js image 获取rgb_[译]使用 Three.js 制作有粘稠感的图像悬停效果

a5baf27cec7ab38ab9a01ce2bc8e4666.png
这是一篇关于glsl在web动效交互中的应用。文章质量比较高,翻译过来大家一起学习借鉴。

原文链接:

Making Gooey Image Hover Effects with Three.js | Codrops​tympanus.net
58d9acf5ac61f6468122f063489d48ec.png

学习如何使用噪声在着色器中创建粘稠的悬停效果。

dc55ccfb7d2248f0a6a794784b0ac7b7.png

查看在线演示or下载源码

作为 Flash 的替代者 webGL 在近几年随着像 Three.js, PIXI.js, OGL.js 这样的库而变得越来越火。它们对于创建空白板非常有用,唯一的限制只有你的想象力。我们看到越来越多的 WebGL 创建的效果微妙地集成到交互界面中,以进行悬停,滚动或显示效果。比如 Hello Monday 或者是 cobosrl.co.

在本教程中,我们将使用 Three.js 创建特殊的粘稠纹理,将其用于在悬停时显示另一幅图像。你现在就可以点击演示链接,去看看真实的效果!对于演示本身,我创建了一个更实际的示例,该示例显示了带有图像的水平可滚动布局,其中每个图像都有不同的效果。你可以单击图像,它将变换为更大的版本,同时显示一些其他内容(Mock 出的内容)。我们将会带你了解这个效果最有趣的部分,这样你就可以知道它是如何工作的,并且可以自己创建更多的效果!

我假设你对 Javascript, Three.js 以及着色器有一定的了解。如果你不了解,那么你可以先看看 Three.js documentation, The Book of Shaders, Three.js Fundamentals 或者 Discover Three.js.

注意:本教程涵盖了许多部分。如果愿意,可以跳过 HTML / CSS / JavaScript 部分,直接转到着色器部分。

在 DOM 中创建场景(scene)

在我们创建有趣的东西之前,需要在 HTML 中插入图片。在 HTML / CSS 中设置初始位置和尺寸,比在 JavaScript 中定位所有内容更容易处理场景大小。此外,样式部分应该只在 CSS 中定义,而不要在 Javascript 中。例如,如果我们的图片在桌面端的比例为 16:9,而在移动设备上的比例为 4:3,我们只应该使用 CSS 来处理。 JavaScript 将仅用于请求更新数据。

// index.html

<section class="container">
    <article class="tile">
        <figure class="tile__figure">
            <img
                src="path/to/my/image.jpg"
                data-hover="path/to/my/hover-image.jpg"
                class="tile__image"
                alt="My image"
                width="400"
                height="300"
            />
        </figure>
    </article>
</section>

<canvas id="stage"></canvas>
// style.css

.container {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    height: 100vh;
    z-index: 10;
}

.tile {
    width: 35vw;
    flex: 0 0 auto;
}

.tile__image {
    width: 100%;
    height: 100%;
    object-fit: cover;
    object-position: center;
}

canvas {
    position: fixed;
    left: 0;
    top: 0;
    width: 100%;
    height: 100vh;
    z-index: 9;
}

正如你在上面看到的,我们已经创建了一个位于在屏幕居中的图像。稍后我们将利用 src 和 data-hover 属性,通过延迟加载在脚本中加载这两个图像。

step 00 - CodeSandbox​codesandbox.io
69c9524794a75d47298fa6b71132a22a.png

在 JavaScript 中创建场景(scene)

让我们从不那么容易但也不算难的部分开始吧!首先,我们将创建场景,灯光和渲染器。

// Scene.js

import * as THREE from "three"

export default class Scene {
    constructor() {
        this.container = document.getElementById("stage")

        this.scene = new THREE.Scene()
        this.renderer = new THREE.WebGLRenderer({
            canvas: this.container,
            alpha: true
        })

        this.renderer.setSize(window.innerWidth, window.innerHeight)
        this.renderer.setPixelRatio(window.devicePixelRatio)

        this.initLights()
    }

    initLights() {
        const ambientlight = new THREE.AmbientLight(0xffffff, 2)
        this.scene.add(ambientlight)
    }
}

这是一个非常基本的场景。但是我们在场景中还需要一个基本的元素:相机。我们有两种可以供选择的相机:正射或透视。如果我们想让图片保持形状不变,我们可以选择第一种。但是对于旋转效果,我们希望在移动鼠标时具有一定的透视效果。

在带有透视相机的 Three.js(或者其他用于 WebGL 的库)中,屏幕上的 10 个单位值并不等于 10px。因此,这里的技巧是使用一些数学运算将 1 单位转换为 1px,并更改视角以增加或减少失真效果。

// Scene.js

const perspective = 800

constructor() {
    // ...
    this.initCamera()
}

initCamera() {
    const fov = (180 * (2 * Math.atan(window.innerHeight / 2 / perspective))) / Math.PI

    this.camera = new THREE.PerspectiveCamera(fov, window.innerWidth / window.innerHeight, 1, 1000)
    this.camera.position.set(0, 0, perspective)
}

我们将透视值设置为 800,以便在旋转平面时不会产生太大的变形。我们增加的视角越大,我们对扭曲的感知就越少,反之亦然。然后,我们需要做的最后一件事是在每一帧中渲染场景。

// Scene.js

constructor() {
    // ...
    this.update()
}

update() {
    requestAnimationFrame(this.update.bind(this))

    this.renderer.render(this.scene, this.camera)
}

如果你的屏幕不是黑色的,则说明方法正确!

用正确的尺寸创建平面

如上所述,我们必须从 DOM 中的图像上检索一些其他信息,例如其尺寸和在页面上的位置。

// Scene.js

import Figure from './Figure'

constructor() {
    // ...
    this.figure = new Figure(this.scene)
}
// Figure.js

export default class Figure {
    constructor(scene) {
        this.$image = document.querySelector(".tile__image")
        this.scene = scene

        this.loader = new THREE.TextureLoader()

        this.image = this.loader.load(this.$image.dataset.src)
        this.hoverImage = this.loader.load(this.$image.dataset.hover)
        this.sizes = new THREE.Vector2(0, 0)
        this.offset = new THREE.Vector2(0, 0)

        this.getSizes()

        this.createMesh()
    }
}

首先,我们创建另一个类,将场景作为属性传递给该类。我们设置了两个新的矢量,尺寸和偏移,用于存储 DOM 图像的尺寸和位置。

此外,我们将使用 TextureLoader 来“加载”图像并将其转换为纹理。我们需要这样做,因为我们想在着色器中使用这些图片。

我们需要在类中创建一个方法来处理图像的加载并等待回调。我们可以使用异步功能来实现这一目标,但对于本教程而言,我们将其保持简单。请记住,您可能需要出于自身目的对它进行一些重构。

// Figure.js

// ...
    getSizes() {
        const { width, height, top, left } = this.$image.getBoundingClientRect()

        this.sizes.set(width, height)
        this.offset.set(left - window.innerWidth / 2 + width / 2, -(top - window.innerHeight / 2 + height / 2))
    }
// ...

我们在 getBoundingClientRect 对象中获取图像信息。然后,将它们传递给两个变量。这里的偏移量用于计算屏幕中心与页面上的对象之间的距离。(译者:可以补充解释)

// Figure.js

// ...
    createMesh() {
        this.geometry = new THREE.PlaneBufferGeometry(1, 1, 1, 1)
        this.material = new THREE.MeshBasicMaterial({
            map: this.image
        })

        this.mesh = new THREE.Mesh(this.geometry, this.material)

        this.mesh.position.set(this.offset.x, this.offset.y, 0)
        this.mesh.scale.set(this.sizes.x, this.sizes.y, 1)

        this.scene.add(this.mesh)
    }
// ...

之后,我们将在平面上设置值。如您所见,我们在 1px 上创建了一个平面,该平面上有 1 行 1 列。由于我们不想使平面变形,所以我们不需要很多面或顶点。因此,让我们保持简单。

既然我们可以直接设置网格的大小,为什么要用缩放的方式来实现?

其实这么做主要是为了更加便于调整网格的大小。如果我们之后要更改网格的大小,除了用 scale 没有什么更好的方法。虽然更改网格的比例更容易直接实现,但是用来调整尺寸并不太方便。(译者:作者这里其实是一个很巧妙的做法:直接将原来的大小设置为 1x1,然后采用缩放 API 来让网格变换为实际大小,这样缩放的比例也就等于实际的长宽值)

目前,我们设置了 MeshBasicMaterial,看来一切正常。

获取鼠标坐标

现在,我们已经使用网格构建了场景,我们想要获取鼠标坐标,并且为了使事情变得简单,我们将其归一化。为什么要归一化?看看着色器的坐标系统你就明白了。

3fc6af1d509fca6373960be173f31bdc.png

如上图所示,我们已经将两个着色器的值标准化了。为简单起见,我们将转化鼠标坐标以匹配顶点着色器坐标。

如果你在这里觉得理解有困难, 我建议你去看一看 Book of Shaders 和 Three.js Fundamentals的各个章节。 两者都有很好的建议,并提供了许多示例来帮助你理解。

// Figure.js

// ...

this.mouse = new THREE.Vector2(0, 0)
window.addEventListener('mousemove', (ev) => { this.onMouseMove(ev) })

// ...

onMouseMove(event) {
    TweenMax.to(this.mouse, 0.5, {
        x: (event.clientX / window.innerWidth) * 2 - 1,
        y: -(event.clientY / window.innerHeight) * 2 + 1,
    })

    TweenMax.to(this.mesh.rotation, 0.5, {
        x: -this.mouse.y * 0.3,
        y: this.mouse.x * (Math.PI / 6)
    })
}

对于补间部分,我将使用 GreenSock 的 TweenMax。这是有史以来最好的库。而且非常适合我们想要达到的目的。我们不需要处理两个状态之间的转换,TweenMax 会为我们完成。每次移动鼠标时,TweenMax 都会平滑更新位置坐标和旋转角度。

step 01 - CodeSandbox​codesandbox.io
71122dfa43d3d52633f378f1bb988452.png

在进行后面的步骤之前还有一件事:我们将材质从 MeshBasicMaterial 更新为 ShaderMaterial,并传递一些值(均匀值)和着色器代码。step 01 - CodeSandbox在进行后面的步骤之前还有一件事:我们将材质从 MeshBasicMaterial 更新为 ShaderMaterial,并传递一些值(均匀值)和着色器代码。

// Figure.js

// ...

this.uniforms = {
    u_image: { type: 't', value: this.image },
    u_imagehover: { type: 't', value: this.hover },
    u_mouse: { value: this.mouse },
    u_time: { value: 0 },
    u_res: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) }
}

this.material = new THREE.ShaderMaterial({
    uniforms: this.uniforms,
    vertexShader: vertexShader,
    fragmentShader: fragmentShader
})

update() {
    this.uniforms.u_time.value += 0.01
}

我们传递了两个纹理,以及鼠标的位置,屏幕的大小和一个名为u_time的变量,该变量将在每一帧进行递增。

但是请记住,这不是最好的方法。我们只需要当我们将鼠标悬停在图形上时增加,而不必在每一帧上增加。出于性能,最好仅在需要时更新着色器。

技巧背后的原理及如何使用噪声

我不会解释什么是噪声以及噪声的来源。如果你有兴趣,请探究《 The Shader of Shaders》中的相关章节,它进行了很好的解释。

长话短说,噪声是一个函数,它根据传递的值为我们提供介于-1 和 1 之间的值。它将输出随机但却又相关的值。

多亏了噪声,我们才能生成许多不同的形状,例如地图,随机图案等。

e9a1f50b141097a71c2adb01773dbf70.png

c994e60c6b98fbfc909b8282063ae894.png

让我们从 2D 噪声开始。仅通过传递纹理的坐标,我们就可以得到类似云的纹理。

66adb10c58266f9f0f185ca781862153.png

但事实上有好几种噪声函数。我们使用 3D 噪声,再给一个参数,例如…时间?噪声图形将随着时间的流逝而变化。通过更改频率和幅度,我们可以进行一些变化并增加对比度。

86cfc771a54856651fb605884921cf38.png

其次,我们将创建一个圆。在片段着色器中构建像圆形这样的简单形状非常容易。我们只是采用了《 The Shader of Shaders:Shapes》中的功能来创建一个模糊的圆,增加对比度和视觉效果!

477b28b1b439d6ceccb414d010de2229.png

最后,我们将这两个加在一起,使用一些变量,让它对纹理进行“切片”:

2d3ba5a28e84b3cdb77fc8e02a592564.png

这个混合之后的结果是不是很让人兴奋,让我们深入到代码层面继续探究!

着色器

我们这里其实不需要顶点着色器,这是我们的代码:

// vertexShader.glsl
varying vec2 v_uv;

void main() {
    v_uv = uv;

    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

Three.js 的 ShaderMaterial 提供了一些有用的默认变量,便于初学者使用:

  • 位置(vec3):网格每个顶点的坐标
  • uv(vec2):纹理的坐标
  • 法线(vec3):网格中每个顶点的法线。

在这里,我们只是将 UV 坐标从顶点着色器传递到片段着色器。

创建圆形

让我们使用 The Book of Shaders中的函数来构建圆并添加一个变量来控制边缘的模糊性。

此外,我们将用鼠标位置来同步圆心坐标。这样,只要我们将鼠标移到图像上,圆就会跟随鼠标移动。

// fragmentShader.glsl
uniform vec2 u_mouse;
uniform vec2 u_res;

float circle(in vec2 _st, in float _radius, in float blurriness){
    vec2 dist = _st;
    return 1.-smoothstep(_radius-(_radius*blurriness), _radius+(_radius*blurriness), dot(dist,dist)*4.0);
}

void main() {
    vec2 st = gl_FragCoord.xy / u_res.xy - vec2(1.);
    // tip: use the following formula to keep the good ratio of your coordinates
    st.y *= u_res.y / u_res.x;

    vec2 mouse = u_mouse;
    // tip2: do the same for your mouse
    mouse.y *= u_res.y / u_res.x;
    mouse *= -1.;

    vec2 circlePos = st + mouse;
    float c = circle(circlePos, .03, 2.);

    gl_FragColor = vec4(vec3(c), 1.);
}
step 02 - CodeSandbox​codesandbox.io
e164ac40eb94797a41d24678fb340c79.png

创建一些噪噪噪噪声声声

正如我们在上面看到的,噪声函数具有多个参数,并为我们生成了逼真的云图案。那么我们是如何得到的呢?

对于这一部分,我将使用glslify和glsl-noise,以及两个 npm 包来包含其他功能。它使我们的着色器更具可读性,并且隐藏了很多我们根本不会使用的显示函数。

// fragmentShader.glsl
#pragma glslify: snoise2 = require('glsl-noise/simplex/2d')

//...

varying vec2 v_uv;

uniform float u_time;

void main() {
    // ...

    float n = snoise2(vec2(v_uv.x, v_uv.y));

    gl_FragColor = vec4(vec3(n), 1.);
}

d67243610fd6ccf355200f6b30d23900.png

通过更改噪声的幅度和频率(比如于 sin / cos 函数),我们可以更改渲染。

// fragmentShader.glsl

float offx = v_uv.x + sin(v_uv.y + u_time * .1);
float offy = v_uv.y - u_time * 0.1 - cos(u_time * .001) * .01;

float n = snoise2(vec2(offx, offy) * 5.) * 1.;

4a2062f40828d7a79560ac68850ab28b.png

但这并时间的函数!它失真了,我们想要出色的效果。因此,我们将改为使用 noise3d 并传递第三个参数:时间。

float n = snoise3(vec3(offx, offy, u_time * .1) * 4.) * .5;

合并纹理

只要将它们叠加在一起,我们就可以看到随时间变化的有趣的形状。

e872ce6d554ff5dea05fa51508916ace.png

为了解释其背后的原理,让我们假设噪声就像是在-1 和 1 之间浮动的值。但是我们的屏幕无法显示负值或大于 1(纯白色)的像素,因此我们只能看到 0 到 1 之间的值。

bdefdc83c37d66e4cdd11562a3394679.png

我们的圆形则像这样:

94da27112f67c183c3950ae352c5caa2.png

相加之后的近似结果:

3477e3faf725c569f23298240d8e0ec4.png

我们非常白的像素是可见光谱之外的像素。

如果我们减小噪声并减去少量噪声,它将逐渐沿波浪向下移动,直到其消失在可见颜色的范围之内。

dabb30d6b57e042c6357db34f7fcd99d.png
float n = snoise(vec3(offx, offy, u_time * .1) * 4.) - 1.;

我们的圆形仍然存在,只是可见度比较低。如果我们增加乘以它的值,它将形成更大的对比。

float c = circle(circlePos, 0.3, 0.3) * 2.5;

cac5e92a230182b5542df5cd3ee6a658.png

我们就实现我们最想要的效果了!但是正如你看到的,仍然缺少一些细节。而且我们的边缘一点也不锐利。

为了解决这个问题,我们将使用 built-in smoothstep function。

float finalMask = smoothstep(0.4, 0.5, n + c);

gl_FragColor = vec4(vec3(finalMask), 1.);

借助此功能,我们将在 0.4 到 0.5 之间切出一部分图案。这些值之间的间隔越短,边缘越锐利。

step 03 - CodeSandbox​codesandbox.io
5ae826ec952618d049036a3f6d25fef2.png

最后,我们可以将混合两个纹理用作遮罩。step 03 - CodeSandbox最后,我们可以将混合两个纹理用作遮罩。

uniform sampler2D u_image;
uniform sampler2D u_imagehover;

// ...

vec4 image = texture2D(u_image, uv);
vec4 hover = texture2D(u_imagehover, uv);

vec4 finalImage = mix(image, hover, finalMask);

gl_FragColor = finalImage;

我们可以更改一些变量以产生更强的粘粘效果:

// ...

float c = circle(circlePos, 0.3, 2.) * 2.5;

float n = snoise3(vec3(offx, offy, u_time * .1) * 8.) - 1.;

float finalMask = smoothstep(0.4, 0.5, n + pow(c, 2.));

// ...
step 04 - CodeSandbox​codesandbox.io
76a7750e48b1c17d62b839278816b531.png

在这里可以找到完整的源码

最后

很高兴你能读到这。这篇教程并不完美,我可能忽略了一些细节,但是我希望你仍然喜欢本教程。基于此,你可以尽情的使用更多变量,尝试其他噪声函数,并尝试使用鼠标方向或滚动发挥你的想象力来实现其他效果!

参考以及感谢

  • Images from Unsplash
  • Three.js
  • GSAP from GreenSock
  • Smooth Scrollbar
  • glslify
  • glsl-noise

c7bb8f5c00ed3e08e62ec9e0787ca5bf.png
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值