创建HTML结构
作为第一步,我们将使用HTML设置页面。我们将创建一个不指定尺寸的容器,允许其超出页面宽度。然后,我们将主容器的overflow属性设置为hidden,因为页面稍后将通过GSAP的Draggable和ScrollTrigger功能实现交互。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
<main>
<section class="content">
<div class="content__carousel">
<div class="content__carousel-inner-static">
<div class="content__carousel-image">
<img src="/images/01.webp" alt="" role="presentation">
<span>Lorem — 001</span>
</div>
<div class="content__carousel-image">
<img src="/images/04.webp" alt="" role="presentation">
<span>Ipsum — 002</span>
</div>
<div class="content__carousel-image">
<img src="/images/02.webp" alt="" role="presentation">
<span>Dolor — 003</span>
</div>
...
</div>
</div>
</section>
</main>
|
我们将为所有这些元素添加样式,然后继续下一步。
HTML与Canvas同步
我们现在可以通过创建一个Stage类来将Three.js集成到项目中,该类负责管理所有3D引擎逻辑。最初,这个类将设置渲染器、场景和相机。
我们将传递一个HTML节点作为第一个参数,它将作为我们canvas的容器。接下来,我们将更新CSS和主脚本,创建一个全屏canvas,能够响应式调整大小并在每个GSAP帧上渲染。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
export default class Stage {
constructor(container) {
this.container = container;
this.DOMElements = [...this.container.querySelectorAll('img')];
this.renderer = new WebGLRenderer({
powerPreference: 'high-performance',
antialias: true,
alpha: true,
});
this.renderer.setPixelRatio(Math.min(1.5, window.devicePixelRatio));
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.domElement.classList.add('content__canvas');
this.container.appendChild(this.renderer.domElement);
this.scene = new Scene();
const { innerWidth: width, innerHeight: height } = window;
this.camera = new OrthographicCamera(-width / 2, width / 2, height / 2, -height / 2, -1000, 1000);
this.camera.position.z = 10;
}
resize() {
// 更新相机属性以适应canvas尺寸
const { innerWidth: screenWidth, innerHeight: screenHeight } = window;
this.camera.left = -screenWidth / 2;
this.camera.right = screenWidth / 2;
this.camera.top = screenHeight / 2;
this.camera.bottom = -screenHeight / 2;
this.camera.updateProjectionMatrix();
// 同时更新平面尺寸
this.DOMElements.forEach((image, index) => {
const { width: imageWidth, height: imageHeight } = image.getBoundingClientRect();
this.scene.children[index].scale.set(imageWidth, imageHeight, 1);
});
// 使用窗口尺寸更新渲染器
this.renderer.setSize(screenWidth, screenHeight);
}
render() {
this.renderer.render(this.scene, this.camera);
}
}
|
回到我们的main.js文件,我们将首先处理stage的resize事件。之后,我们将通过使用gsap.ticker.add将渲染器的requestAnimationFrame(RAF)与GSAP同步,传递stage的render函数作为回调。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
// 使用stage resize更新resize
function resize() {
...
stage.resize();
}
// 将渲染循环添加到gsap ticker
gsap.ticker.add(stage.render.bind(stage));
<style>
.content__canvas {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100svh;
z-index: 2;
pointer-events: none;
}
</style>
|
现在是时候加载HTML中包含的所有图像了。对于每个图像,我们将创建一个平面并将其添加到场景中。为了实现这一点,我们将通过添加两个新方法来更新类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
setUpPlanes() {
this.DOMElements.forEach((image) => {
this.scene.add(this.generatePlane(image));
});
}
generatePlane(image) {
const loader = new TextureLoader();
const texture = loader.load(image.src);
texture.colorSpace = SRGBColorSpace;
const plane = new Mesh(
new PlaneGeometry(1, 1),
new MeshStandardMaterial(),
);
return plane;
}
|
然后我们可以在Stage类的构造函数中调用setUpPlanes()。结果应该类似于以下内容,具体取决于相机的z位置或平面的放置 - 这两者都可以调整以满足我们的特定需求。
下一步是精确定位平面以与其关联图像的位置相对应,并在每一帧上更新它们的位置。为了实现这一点,我们将实现一个实用函数,将屏幕空间(CSS像素)转换为世界空间,利用已经与屏幕对齐的正交相机。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
const getWorldPositionFromDOM = (element, camera) => {
const rect = element.getBoundingClientRect();
const xNDC = (rect.left + rect.width / 2) / window.innerWidth * 2 - 1;
const yNDC = -((rect.top + rect.height / 2) / window.innerHeight * 2 - 1);
const xWorld = xNDC * (camera.right - camera.left) / 2;
const yWorld = yNDC * (camera.top - camera.bottom) / 2;
return new Vector3(xWorld, yWorld, 0);
};
render() {
this.renderer.render(this.scene, this.camera);
// 对于每个平面和每个图像,更新平面的位置以匹配DOM元素在页面上的位置
this.DOMElements.forEach((image, index) => {
this.scene.children[index].position.copy(getWorldPositionFromDOM(image, this.camera, this.renderer));
});
}
|
通过隐藏原始DOM轮播,我们现在可以仅将图像显示为canvas内的平面。创建一个扩展ShaderMaterial的简单类,并将其用于平面的MeshStandardMaterial。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
const plane = new Mesh(
new PlaneGeometry(1, 1),
new PlanesMaterial(),
);
...
import { ShaderMaterial } from 'three';
import baseVertex from './base.vert?raw';
import baseFragment from './base.frag?raw';
export default class PlanesMaterial extends ShaderMaterial {
constructor() {
super({
vertexShader: baseVertex,
fragmentShader: baseFragment,
});
}
}
// base.vert
varying vec2 vUv;
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
vUv = uv;
}
// base.frag
varying vec2 vUv;
void main() {
gl_FragColor = vec4(vUv.x, vUv.y, 0.0, 1.0);
}
|
然后我们可以用基于UV坐标的纹理采样替换着色器输出,将纹理作为uniform传递给材质和着色器。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
...
const plane = new Mesh(
new PlaneGeometry(1, 1),
new PlanesMaterial(texture),
);
...
export default class PlanesMaterial extends ShaderMaterial {
constructor(texture) {
super({
vertexShader: baseVertex,
fragmentShader: baseFragment,
uniforms: {
uTexture: { value: texture },
},
});
}
}
// base.frag
varying vec2 vUv;
uniform sampler2D uTexture;
void main() {
vec4 diffuse = texture2D(uTexture, vUv);
gl_FragColor = diffuse;
}
|
点击图像实现涟漪和着色效果
这一步分解了创建交互式灰度过渡效果的过程,强调了JavaScript(使用GSAP)和GLSL着色器之间的关系。
步骤1:即时颜色/灰度切换
让我们从最简单的版本开始:点击图像使其在颜色和灰度之间即时切换。
JavaScript(GSAP)
在这个阶段,GSAP的角色是充当简单的"开/关"开关,因此让我们创建一个GSAP Observer来监视鼠标点击交互:
1
2
3
4
5
|
this.observer = Observer.create({
target: document.querySelector('.content__carousel'),
type: 'touch,pointer',
onClick: e => this.onClick(e),
});
|
以下是后续步骤:
- 点击检测:我们使用Observer检测平面上的点击
- 状态管理:一个布尔标志isBw(是黑白)在每次点击时切换
- 着色器更新:我们使用gsap.set()立即更改着色器中的一个uniform,我们将其称为uGrayscaleProgress
1
2
3
4
5
6
7
8
9
10
11
|
onClick(e) {
if (intersection) {
const { material, userData } = intersection.object;
userData.isBw = !userData.isBw;
gsap.set(material.uniforms.uGrayscaleProgress, {
value: userData.isBw ? 1.0 : 0.0
});
}
}
|
着色器(GLSL)
片段着色器非常简单。它接收uGrayscaleProgress并将其用作开关。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
uniform sampler2D uTexture;
uniform float uGrayscaleProgress; // 我们的"开关"(0.0或1.0)
varying vec2 vUv;
vec3 toGrayscale(vec3 color) {
float gray = dot(color, vec3(0.299, 0.587, 0.114));
return vec3(gray);
}
void main() {
vec3 originalColor = texture2D(uTexture, vUv).rgb;
vec3 grayscaleColor = toGrayscale(originalColor);
vec3 finalColor = mix(originalColor, grayscaleColor, uGrayscaleProgress);
gl_FragColor = vec4(finalColor, 1.0);
}
|
步骤2:动画圆形揭示
即时切换很无聊。让我们使过渡成为一个从中心扩展的平滑圆形揭示。
JavaScript(GSAP)
GSAP的角色现在从开关变为动画师。我们使用gsap.to()而不是gsap.set()来在设定的持续时间内将uGrayscaleProgress从0动画到1(或从1到0)。这向着色器发送连续的值流(0.0, 0.01, 0.02, …)。
1
2
3
4
5
|
gsap.to(material.uniforms.uGrayscaleProgress, {
value: userData.isBw ? 1 : 0,
duration: 1.5,
ease: 'power2.inOut'
});
|
着色器(GLSL)
着色器现在使用动画的uGrayscaleProgress来定义圆的半径。
1
2
3
4
5
6
7
8
9
10
|
void main() {
float dist = distance(vUv, vec2(0.5));
// 2. 创建圆形遮罩
float mask = smoothstep(uGrayscaleProgress - 0.1, uGrayscaleProgress, dist);
// 3. 基于每个像素的遮罩值混合颜色
vec3 finalColor = mix(originalColor, grayscaleColor, mask);
gl_FragColor = vec4(finalColor, 1.0);
}
|
smoothstep在这里的工作原理:其中dist小于uGrayscaleProgress - 0.1的像素获得遮罩值0。其中dist大于uGrayscaleProgress的像素获得值1。在两者之间,它是一个平滑过渡,创建了软边缘。
步骤3:从鼠标点击起源
如果效果从确切的点击点开始,效果会更加吸引人。
JavaScript(GSAP)
我们需要告诉着色器点击发生的位置。
- 光线投射:我们使用Raycaster找到网格上点击的精确(u, v)纹理坐标
- uMouse Uniform:我们向材质添加一个uniform vec2 uMouse
- GSAP时间线:在动画开始之前,我们使用GSAP时间线上的.set()用intersection.uv坐标更新uMouse uniform
1
2
3
4
5
6
7
8
9
|
if (intersection) {
const { material, userData } = intersection.object;
material.uniforms.uMouse.value = intersection.uv;
gsap.to(material.uniforms.uGrayscaleProgress, {
value: userData.isBw ? 1 : 0
});
}
|
着色器(GLSL)
我们只需用新的uMouse uniform替换硬编码的中心。
1
2
3
4
5
6
7
8
9
10
|
...
uniform vec2 uMouse; // 来自点击的(u,v)坐标
...
void main() {
...
// 1. 计算距离来自鼠标点击的距离,而不是中心
float dist = distance(vUv, uMouse);
}
|
重要细节:为了确保圆形揭示始终覆盖整个平面,即使在角落点击时也是如此,我们计算从点击点到四个角中任何一个的最大可能距离(getMaxDistFromCorners)并用它归一化我们的dist值:dist / maxDist。这保证了动画完全完成。
步骤4:添加最终涟漪效果
最后一步是添加使平面变形的3D涟漪效果。这需要修改顶点着色器。
JavaScript(GSAP)
我们需要一个更多的动画uniform来控制涟漪的生命周期。
- uRippleProgress Uniform:我们添加一个uniform float uRippleProgress
- GSAP关键帧:在相同的时间线中,我们将uRippleProgress从0动画到1再回到0。这使得波浪升起然后回落
1
2
3
4
5
6
|
gsap.timeline({ defaults: { duration: 1.5, ease: 'power3.inOut' } })
.set(material.uniforms.uMouse, { value: intersection.uv }, 0)
.to(material.uniforms.uGrayscaleProgress, { value: 1 }, 0)
.to(material.uniforms.uRippleProgress, {
keyframes: { value: [0, 1, 0] } // 上升和下降
}, 0)
|
着色器(GLSL)
- 高多边形几何:要看到平滑的变形,Three.js中的PlaneGeometry必须用许多段创建(例如,new PlaneGeometry(1, 1, 50, 50))。这给了顶点着色器更多的点来操作。
1
2
3
4
5
6
7
8
9
|
generatePlane(image) {
...
const plane = new Mesh(
new PlaneGeometry(1, 1, 50, 50),
new PlanesMaterial(texture),
);
return plane;
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
uniform float uRippleProgress;
uniform vec2 uMouse;
varying float vRipple; // 将涟漪强度传递给片段着色器
void main() {
vec3 pos = position;
float dist = distance(uv, uMouse);
float ripple = sin(-PI * 10.0 * (dist - uTime * 0.1));
ripple *= uRippleProgress;
pos.y += ripple * 0.1;
vRipple = ripple;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
|
- 片段着色器:我们可以使用涟漪强度添加最终修饰,比如使波峰更亮。
1
2
3
4
5
6
7
8
9
10
11
|
varying float vRipple; // 从顶点着色器接收
void main() {
// ...(之前的所有颜色和遮罩逻辑)
vec3 color = mix(color1, color2, mask);
// 基于波浪高度添加高光
color += vRipple * 2.0;
gl_FragColor = vec4(color, diffuse.a);
}
|
通过分层这些技术,我们创建了一个丰富的交互式效果,其中JavaScript和GSAP充当木偶大师,告诉着色器要做什么,而着色器处理在GPU上美丽且高效地绘制它的繁重工作。
步骤5:在先前图块上的反向效果
作为最后一步,我们设置当前图块的反向动画,当点击新图块时。让我们从创建重置动画开始,该动画反转uniform的动画:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
resetMaterial(object) {
// 将所有着色器uniform重置为默认值
gsap.timeline({
defaults: { duration: 1, ease: 'power2.out' },
onUpdate() {
object.material.uniforms.uTime.value += 0.1;
},
onComplete() {
object.userData.isBw = false;
}
})
.set(object.material.uniforms.uMouse, { value: { x: 0.5, y: 0.5} }, 0)
.set(object.material.uniforms.uDirection, { value: 1.0 }, 0)
.fromTo(object.material.uniforms.uGrayscaleProgress, { value: 1 }, { value: 0 }, 0)
.to(object.material.uniforms.uRippleProgress, { keyframes: { value: [0, 1, 0] } }, 0);
}
|
现在,在每次点击时,我们需要设置当前图块,以便将其保存在构造函数中,允许我们将当前材质传递给重置动画。让我们像这样修改onClick函数并逐步分析它:
1
2
3
4
5
6
7
8
9
10
11
12
|
if (this.activeObject && intersection.object !== this.activeObject && this.activeObject.userData.isBw) {
this.resetMaterial(this.activeObject)
// 如果活动则停止时间线
if (this.activeObject.userData.tl?.isActive()) this.activeObject.userData.tl.kill();
// 清理时间线
this.activeObject.userData.tl = null;
}
// 设置活动对象
this.activeObject = intersection.object;
|
- 如果this.activeObject存在(最初在构造函数中设置为null),我们继续将其重置为初始黑白状态
- 如果活动图块上有当前动画,我们使用GSAP的kill方法避免冲突和重叠动画
- 我们将userData.tl重置为null(如果再次点击图块,它将被分配新的时间线值)
- 然后我们将this.activeObject的值设置为通过Raycaster选择的对象
通过这种方式,我们将有一个双涟漪动画:一个在点击的图块上,它将变为彩色,另一个在先前活动的图块上,它将重置为其原始黑白状态。
纹理揭示遮罩效果
在本教程中,我们将创建一个交互式效果,当用户悬停或触摸时,在平面上混合两个图像。
步骤1:设置平面
与之前的示例不同,在这种情况下,我们需要为平面使用不同的uniform,因为我们将创建一个可见的前纹理和另一个将通过"切割"第一个纹理的遮罩显示的纹理之间的混合。
让我们从修改index.html文件开始,向所有图像添加一个data属性,我们将在其中指定底层纹理:
1
|
<img src="/images/front-texture.webp" alt="" role="presentation" data-back="/images/back-texture.webp">
|
然后,在我们的Stage.js内部,我们将修改generatePlane方法,该方法用于在WebGL中创建平面。我们将首先通过data属性检索要加载的第二个纹理,并将带有两个纹理和图像纵横比的参数传递给平面材质:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
generatePlane(image) {
const loader = new TextureLoader();
const texture = loader.load(image.src);
const textureBack = loader.load(image.dataset.back);
texture.colorSpace = SRGBColorSpace;
textureBack.colorSpace = SRGBColorSpace;
const { width, height } = image.getBoundingClientRect();
const plane = new Mesh(
new PlaneGeometry(1, 1),
new PlanesMaterial(texture, textureBack, height / width),
);
return plane;
}
|
步骤2:材质设置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
import { ShaderMaterial, Vector2 } from 'three';
import baseVertex from './base.vert?raw';
import baseFragment from './base.frag?raw';
export default class PlanesMaterial extends ShaderMaterial {
constructor(texture, textureBack, imageRatio) {
super({
vertexShader: baseVertex,
fragmentShader: baseFragment,
uniforms: {
uTexture: { value: texture },
uTextureBack: { value: textureBack },
uMixFactor: { value: 0.0 },
uAspect: { value: imageRatio },
uMouse: { value: new Vector2(0.5, 0.5) },
},
});
}
}
|
让我们快速分析传递给材质的uniform:
- uTexture和uTextureBack是显示在前面和通过遮罩显示的两个纹理
- uMixFactor表示遮罩内两个纹理之间的混合值
- uAspect是用于计算圆形遮罩的图像纵横比
- uMouse表示鼠标坐标,更新以在平面内移动遮罩
步骤3:JavaScript(GSAP)
1
2
3
4
5
6
|
this.observer = Observer.create({
target: document.querySelector('.content__carousel'),
type: 'touch,pointer',
onMove: e => this.onMove(e),
onHoverEnd: () => this.hoverOut(),
});
|
快速创建一个GSAP Observer来监视鼠标移动,传递两个函数:
- onMove使用Raycaster检查是否击中平面,以管理揭示遮罩的打开
- onHoverEnd在光标离开目标区域时触发,因此我们将使用此方法将揭示遮罩的扩展uniform值重置回0.0
让我们更详细地了解onMove函数,解释其工作原理:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
onMove(e) {
const normCoords = {
x: (e.x / window.innerWidth) * 2 - 1,
y: -(e.y / window.innerHeight) * 2 + 1,
};
this.raycaster.setFromCamera(normCoords, this.camera);
const [intersection] = this.raycaster.intersectObjects(this.scene.children);
if (intersection) {
this.intersected = intersection.object;
const { material } = intersection.object;
gsap.timeline()
.set(material.uniforms.uMouse, { value: intersection.uv }, 0)
.to(material.uniforms.uMixFactor, { value: 1.0, duration: 3, ease: 'power3.out' }, 0);
} else {
this.hoverOut();
}
}
|
在onMove方法中,第一步是将鼠标坐标从-1归一化到1,以允许Raycaster使用正确的坐标工作。
然后在每一帧上,更新Raycaster以检查场景中是否有任何对象被相交。如果有相交,代码将命中的对象保存在变量中。
当发生相交时,我们继续处理着色器uniform的动画。
具体来说,我们使用GSAP的set方法更新uMouse中的鼠标位置,然后将uMixFactor变量从0.0动画到1.0以打开揭示遮罩并显示底层纹理。
如果Raycaster没有找到指针下的任何对象,则调用hoverOut方法。
1
2
3
4
5
6
7
8
9
10
11
12
|
hoverOut() {
if (!this.intersected) return;
// 停止uMixFactor uniform上任何运行的补间
gsap.killTweensOf(this.intersected.material.uniforms.uMixFactor);
// 将uMixFactor平滑地动画回0.0
gsap.to(this.intersected.material.uniforms.uMixFactor, { value: 0.0, duration: 0.5, ease: 'power3.out' });
// 清除相交引用
this.intersected = null;
}
|
此方法处理一旦光标离开平面就关闭揭示遮罩。
首先,我们依赖killAllTweensOf方法通过停止uMixFactor上所有正在进行的动画来防止遮罩的打开和关闭动画之间的冲突或重叠。
然后,我们通过将uMixFactor uniform设置回0.0来动画遮罩的关闭,并重置正在跟踪当前高亮对象的变量。
步骤4:着色器(GLSL)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
uniform sampler2D uTexture;
uniform sampler2D uTextureBack;
uniform float uMixFactor;
uniform vec2 uMouse;
uniform float uAspect;
varying vec2 vUv;
void main() {
vec2 correctedUv = vec2(vUv.x, (vUv.y - 0.5) * uAspect + 0.5);
vec2 correctedMouse = vec2(uMouse.x, (uMouse.y - 0.5) * uAspect + 0.5);
float distance = length(correctedUv - correctedMouse);
float influence = 1.0 - smoothstep(0.0, 0.5, distance);
float finalMix = uMixFactor * influence;
vec4 textureFront = texture2D(uTexture, vUv);
vec4 textureBack = texture2D(uTextureBack, vUv);
vec4 finalColor = mix(textureFront, textureBack, finalMix);
gl_FragColor = finalColor;
}
|
在main()函数内部,它首先相对于图像的纵横比归一化UV坐标和鼠标位置。应用此校正是因为我们使用的是非方形图像,因此必须调整垂直坐标以保持遮罩的比例正确并确保其保持圆形。因此,修改vUv.y和uMouse.y坐标,使它们根据纵横比"垂直缩放"。
此时,计算当前像素(correctedUv)和鼠标位置(correctedMouse)之间的距离。这个距离是一个数值,指示像素距离表面上的鼠标中心有多近或多远。
然后我们继续实际创建遮罩。uniform influence必须从光标中心的1变化到远离中心的0。我们使用smoothstep函数重新创建此效果并获得两个值之间的软渐变过渡,因此效果自然淡出。
两个纹理之间混合的最终值,即finalMix uniform,由全局因子uMixFactor(传递给着色器的静态数值)和这个局部影响值的乘积给出。因此,像素越接近鼠标位置,其颜色将越受第二个纹理uTextureBack的影响。
最后一部分是实际混合:使用mix()函数混合两种颜色,该函数基于finalMix的值在两个纹理之间创建线性插值。当finalMix为0时,只有前纹理可见。当它为1时,只有背景纹理可见。中间值在两个纹理之间创建逐渐混合。
点击和保持遮罩揭示效果
本文档分解了创建交互式效果的过程,该效果将图像从颜色过渡到灰度。效果从用户的点击开始,向外扩展,带有涟漪 distortion。
步骤1:“移动”(悬停)效果
在这一步中,我们将创建一个效果,当用户将鼠标悬停在其上时,图像会过渡到另一个图像。过渡将从指针的位置开始并向外扩展。
JavaScript(GSAP Observer用于onMove)
GSAP的Observer插件是跟踪指针移动而无需传统事件监听器的样板代码的完美工具。
- 设置Observer:我们创建一个Observer实例,目标是我们