这是一篇关于glsl在web动效交互中的应用。文章质量比较高,翻译过来大家一起学习借鉴。
原文链接:
Making Gooey Image Hover Effects with Three.js | Codropstympanus.net学习如何使用噪声在着色器中创建粘稠的悬停效果。
查看在线演示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 - CodeSandboxcodesandbox.io在 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,看来一切正常。
获取鼠标坐标
现在,我们已经使用网格构建了场景,我们想要获取鼠标坐标,并且为了使事情变得简单,我们将其归一化。为什么要归一化?看看着色器的坐标系统你就明白了。
如上图所示,我们已经将两个着色器的值标准化了。为简单起见,我们将转化鼠标坐标以匹配顶点着色器坐标。
如果你在这里觉得理解有困难, 我建议你去看一看 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 - CodeSandboxcodesandbox.io在进行后面的步骤之前还有一件事:我们将材质从 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 之间的值。它将输出随机但却又相关的值。
多亏了噪声,我们才能生成许多不同的形状,例如地图,随机图案等。
让我们从 2D 噪声开始。仅通过传递纹理的坐标,我们就可以得到类似云的纹理。
但事实上有好几种噪声函数。我们使用 3D 噪声,再给一个参数,例如…时间?噪声图形将随着时间的流逝而变化。通过更改频率和幅度,我们可以进行一些变化并增加对比度。
其次,我们将创建一个圆。在片段着色器中构建像圆形这样的简单形状非常容易。我们只是采用了《 The Shader of Shaders:Shapes》中的功能来创建一个模糊的圆,增加对比度和视觉效果!
最后,我们将这两个加在一起,使用一些变量,让它对纹理进行“切片”:
这个混合之后的结果是不是很让人兴奋,让我们深入到代码层面继续探究!
着色器
我们这里其实不需要顶点着色器,这是我们的代码:
// 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.);
}
创建一些噪噪噪噪声声声
正如我们在上面看到的,噪声函数具有多个参数,并为我们生成了逼真的云图案。那么我们是如何得到的呢?
对于这一部分,我将使用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.);
}
通过更改噪声的幅度和频率(比如于 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.;
但这并时间的函数!它失真了,我们想要出色的效果。因此,我们将改为使用 noise3d 并传递第三个参数:时间。
float n = snoise3(vec3(offx, offy, u_time * .1) * 4.) * .5;
合并纹理
只要将它们叠加在一起,我们就可以看到随时间变化的有趣的形状。
为了解释其背后的原理,让我们假设噪声就像是在-1 和 1 之间浮动的值。但是我们的屏幕无法显示负值或大于 1(纯白色)的像素,因此我们只能看到 0 到 1 之间的值。
我们的圆形则像这样:
相加之后的近似结果:
我们非常白的像素是可见光谱之外的像素。
如果我们减小噪声并减去少量噪声,它将逐渐沿波浪向下移动,直到其消失在可见颜色的范围之内。
float n = snoise(vec3(offx, offy, u_time * .1) * 4.) - 1.;
我们的圆形仍然存在,只是可见度比较低。如果我们增加乘以它的值,它将形成更大的对比。
float c = circle(circlePos, 0.3, 0.3) * 2.5;
我们就实现我们最想要的效果了!但是正如你看到的,仍然缺少一些细节。而且我们的边缘一点也不锐利。
为了解决这个问题,我们将使用 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 - CodeSandboxcodesandbox.io最后,我们可以将混合两个纹理用作遮罩。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.));
// ...
在这里可以找到完整的源码
最后
很高兴你能读到这。这篇教程并不完美,我可能忽略了一些细节,但是我希望你仍然喜欢本教程。基于此,你可以尽情的使用更多变量,尝试其他噪声函数,并尝试使用鼠标方向或滚动发挥你的想象力来实现其他效果!
参考以及感谢
- Images from Unsplash
- Three.js
- GSAP from GreenSock
- Smooth Scrollbar
- glslify
- glsl-noise