使用Three.js、WebGPU和TSL实现交互式文字爆炸效果

本教程详细介绍了如何利用Three.js、WebGPU和Three Shader Language创建交互式3D文字爆炸效果。从场景设置、字体加载到着色器编程,完整展示了实现动态顶点变形和弹簧物理模拟的技术细节。

交互式文字爆炸效果:Three.js、WebGPU与TSL实战

学习如何使用Three.js、WebGPU和Three Shader Language(TSL)创建交互式3D文字效果,让字母爆炸成动态形状。

项目结构

我们的脚本结构很简单:一个预加载资源的函数,另一个构建场景的函数。

由于我们要处理3D文本,首先需要加载一个.json格式的字体——这种格式适用于Three.js。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const Resources = {
    font: null
};

function preload() {
    const _font_loader = new FontLoader();
    _font_loader.load("../static/font/Times New Roman_Regular.json", (font) => {
        Resources.font = font;
        init();
    });
}

function init() {
    // 初始化场景
}

window.onload = preload;

场景设置与环境

经典的Three.js场景——唯一需要注意的是我们使用Three Shader Language(TSL),这意味着我们的渲染器需要是WebGPURenderer。

1
2
3
4
5
6
7
8
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGPURenderer({ antialias: true });

document.body.appendChild(renderer.domElement);
renderer.setSize(window.innerWidth, window.innerHeight);
camera.position.z = 5;
scene.add(camera);

接下来,我们设置场景环境以添加光照。

为了简化并避免加载更多资源,我们将使用Three.js自带的默认RoomEnvironment。我们还会向场景添加一个DirectionalLight。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const environment = new RoomEnvironment();
const pmremGenerator = new THREE.PMREMGenerator(renderer);
scene.environment = pmremGenerator.fromSceneAsync(environment).texture;
scene.environmentIntensity = 0.8;

const light = new THREE.DirectionalLight("#e7e2ca", 5);
light.position.x = 0.0;
light.position.y = 1.2;
light.position.z = 3.86;
scene.add(light);

文本几何体

我们将使用TextGeometry,它允许我们在Three.js中创建3D文本。

它使用JSON字体文件(我们之前用FontLoader加载)并通过size、depth和letter spacing等参数进行配置。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
const text_geo = new TextGeometry("NUEVOS", {
    font: Resources.font,
    size: 1.0,
    depth: 0.2,
    bevelEnabled: true,
    bevelThickness: 0.1,
    bevelSize: 0.01,
    bevelOffset: 0,
    bevelSegments: 1
});

const mesh = new THREE.Mesh(
    text_geo,
    new THREE.MeshStandardMaterial({
        color: "#656565",
        metalness: 0.4,
        roughness: 0.3
    })
);

scene.add(mesh);

默认情况下,文本的原点位于(0, 0),但我们希望它居中。为此,我们需要计算其BoundingBox并手动对几何体应用平移:

1
2
3
4
text_geo.computeBoundingBox();
const centerOffset = -0.5 * (text_geo.boundingBox.max.x - text_geo.boundingBox.min.x);
const centerOffsety = -0.5 * (text_geo.boundingBox.max.y - text_geo.boundingBox.min.y);
text_geo.translate(centerOffset, centerOffsety, 0);

现在我们有了网格和材质,可以继续实现爆炸效果的功能了💥

Three Shader Language

我真的很喜欢TSL——它在创意和执行之间架起了桥梁,尤其是在着色器这种不太友好的环境中。

我们将实现的效果基于指针位置变形几何体的顶点,并使用弹簧物理以动态方式动画化这些变形。

但在开始之前,我们需要获取一些属性来确保一切正常工作:

1
2
3
4
5
6
7
8
9
// 每个顶点的原始位置——我们将用它作为参考
// 这样未受影响的顶点可以"返回"到它们的原始位置
const initial_position = storage(text_geo.attributes.position, "vec3", count);

// 每个顶点的法线——我们将用它来知道要"推"向哪个方向
const normal_at = storage(text_geo.attributes.normal, "vec3", count);

// 几何体中的顶点数量
const count = text_geo.attributes.position.count;

接下来,我们将创建一个存储缓冲区来保存模拟数据——我们还会编写一个函数。但这不是一个常规的JavaScript函数——这是一个计算函数,在TSL的上下文中编写。

它在GPU上运行,我们将用它为缓冲区设置初始值,为模拟做好准备。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 在这个缓冲区中,我们将存储每个顶点的修改位置——
// 换句话说,它们在模拟中的当前状态。
const position_storage_at = storage(new THREE.StorageBufferAttribute(count, 3), "vec3", count);

const compute_init = Fn(() => {
    position_storage_at.element(instanceIndex).assign(initial_position.element(instanceIndex));
})().compute(count);

// 在GPU上运行函数。这会对每个顶点运行一次compute_init。
renderer.computeAsync(compute_init);

现在我们将创建另一个这样的函数——但与上一个不同,这个函数将在动画循环内运行,因为它负责在每一帧更新模拟。

这个函数在GPU上运行,需要从外部接收值——比如指针位置。

为了将这类数据发送到GPU,我们使用所谓的uniforms。它们就像我们"常规"代码和在GPU着色器中运行的代码之间的桥梁。

它们是这样定义的:

1
2
const u_input_pos = uniform(new THREE.Vector3(0, 0, 0));
const u_input_pos_press = uniform(0.0);

有了这些,我们可以计算指针位置和几何体每个顶点之间的距离。

然后我们钳制这个值,使变形只影响特定半径内的顶点。为此,我们使用step函数——它像一个阈值,让我们只在距离低于定义值时应用效果。

最后,我们使用顶点法线作为向外推的方向。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const compute_update = Fn(() => {
    // 顶点的原始位置——也是其静止位置
    const base_position = initial_position.element(instanceIndex);

    // 顶点法线告诉我们推的方向
    const normal = normal_at.element(instanceIndex);

    // 顶点的当前位置——我们将在每一帧更新这个
    const current_position = position_storage_at.element(instanceIndex);

    // 计算指针和顶点基础位置之间的距离
    const distance = length(u_input_pos.sub(base_position));

    // 限制效果范围:只在距离小于0.5时应用
    const pointer_influence = step(distance, 0.5).mul(1.0);

    // 沿着法线计算新的位移位置。
    // 当pointer_influence为0时,不会有变形。
    const disorted_pos = base_position.add(normal.mul(pointer_influence));

    // 分配新位置以更新顶点
    current_position.assign(disorted_pos);
})().compute(count);

为了让这个工作,我们缺少两个关键步骤:我们需要将带有修改位置的缓冲区分配给材质,我们需要确保渲染器在动画循环内的每一帧运行计算函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 将带有修改位置的缓冲区分配给材质
mesh.material.positionNode = position_storage_at.toAttribute();

// 动画循环
function animate() {
    // 运行计算函数
    renderer.computeAsync(compute_update_0);

    // 渲染场景
    renderer.renderAsync(scene, camera);
}

现在这个函数还没有产生太令人兴奋的东西——几何体以一种有点笨拙的方式移动。我们将引入弹簧,情况会变得好很多。

1
2
3
4
5
6
7
// 弹簧——我们施加多少力来达到目标值
velocity += (target_value - current_value) * spring;

// 摩擦控制阻尼,这样运动不会无限振荡
velocity *= friction;

current_value += velocity;

但在此之前,我们需要为每个顶点存储另一个值,速度,所以让我们创建另一个存储缓冲区。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const position_storage_at = storage(new THREE.StorageBufferAttribute(count, 3), "vec3", count);

// 新的速度缓冲区
const velocity_storage_at = storage(new THREE.StorageBufferAttribute(count, 3), "vec3", count);

const compute_init = Fn(() => {
    position_storage_at.element(instanceIndex).assign(initial_position.element(instanceIndex));
    
    // 我们也初始化它
    velocity_storage_at.element(instanceIndex).assign(vec3(0.0, 0.0, 0.0));
})().compute(count);

我们还会添加两个uniforms:spring和friction。

1
2
const u_spring = uniform(0.05);
const u_friction = uniform(0.9);

现在我们在更新中实现了弹簧:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const compute_update = Fn(() => {
    const base_position = initial_position.element(instanceIndex);
    const current_position = position_storage_at.element(instanceIndex);

    // 获取当前速度
    const current_velocity = velocity_storage_at.element(instanceIndex);

    const normal = normal_at.element(instanceIndex);

    const distance = length(u_input_pos.sub(base_position));
    const pointer_influence = step(distance, 0.5).mul(1.5);

    const disorted_pos = base_position.add(normal.mul(pointer_influence));
    disorted_pos.assign((mix(base_position, disorted_pos, u_input_pos_press)));
  
    // 弹簧实现
    // velocity += (target_value - current_value) * spring;
    current_velocity.addAssign(disorted_pos.sub(current_position).mul(u_spring));
    // velocity *= friction;
    current_velocity.assign(current_velocity.mul(u_friction));
    // value += velocity
    current_position.addAssign(current_velocity);
})().compute(count);

现在我们有了所需的一切——是时候开始微调了。

我们将添加两样东西。首先,我们将使用TSL函数mx_noise_vec3为每个顶点生成一些噪声。这样,我们可以稍微调整方向,让感觉不那么僵硬。

我们还将使用另一个TSL函数旋转顶点——惊喜,它叫做rotate。

这是我们更新后的compute_update函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const compute_update = Fn(() => {
    const base_position = initial_position.element(instanceIndex);
    const current_position = position_storage_at.element(instanceIndex);
    const current_velocity = velocity_storage_at.element(instanceIndex);

    const normal = normal_at.element(instanceIndex);

    // 新增:添加噪声,使顶点"爆炸"的方向不会与法线完全对齐
    const noise = mx_noise_vec3(current_position.mul(0.5).add(vec3(0.0, time, 0.0)), 1.0).mul(u_noise_amp);

    const distance = length(u_input_pos.sub(base_position));
    const pointer_influence = step(distance, 0.5).mul(1.5);

    const disorted_pos = base_position.add(noise.mul(normal.mul(pointer_influence)));

    // 新增:旋转顶点,给动画带来更混乱的感觉
    disorted_pos.assign(rotate(disorted_pos, vec3(normal.mul(distance)).mul(pointer_influence)));

    disorted_pos.assign(mix(base_position, disorted_pos, u_input_pos_press));

    current_velocity.addAssign(disorted_pos.sub(current_position).mul(u_spring));
    current_position.addAssign(current_velocity);
    current_velocity.assign(current_velocity.mul(u_friction));
})().compute(count);

现在运动感觉对了,是时候稍微调整材质颜色并为场景添加一些后期处理了。

我们将处理自发光颜色——这意味着它不会受到光线的影响,并且总是看起来明亮和爆炸性。特别是当我们添加一些bloom效果时。(是的,给所有东西都加bloom。)

我们将从一个基础颜色开始(你喜欢的任何颜色),作为uniform传入。为了确保每个顶点获得稍微不同的颜色,我们将使用缓冲区中的值稍微偏移其色调——在这种情况下是速度缓冲区。

hue函数接受一个颜色和一个值来移动其色调,有点像THREE.Color中的offsetHSL工作方式。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 基础自发光颜色
const emissive_color = color(new THREE.Color("0000ff"));

const vel_at = velocity_storage_at.toAttribute();
const hue_rotated = vel_at.mul(Math.PI * 10.0);

// 乘以速度缓冲区的长度——这意味着运动越多,
// 顶点颜色偏移越多
const emission_factor = length(vel_at).mul(10.0);

// 将颜色分配给自发光节点并随意增强
mesh.material.emissiveNode = hue(emissive_color, hue_rotated).mul(emission_factor).mul(5.0);

最后!让我们改变场景背景颜色并添加雾效:

1
2
scene.fog = new THREE.Fog(new THREE.Color("#41444c"), 0.0, 8.5);
scene.background = scene.fog.color;

现在,让我们用一些后期处理来为场景增添趣味——多亏了TSL,这些东西实现起来容易多了。

我们将包含三种效果:环境光遮蔽、bloom和噪声。我总是喜欢在我做的事情中添加一些噪声——它有助于打破像素的平坦感。

我不会深入讨论这部分——我从Three.js示例中获取了AO设置。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
const composer = new THREE.PostProcessing(renderer);
const scene_pass = pass(scene, camera);

scene_pass.setMRT(mrt({
    output: output,
    normal: normalView
}));

const scene_color = scene_pass.getTextureNode("output");
const scene_depth = scene_pass.getTextureNode("depth");
const scene_normal = scene_pass.getTextureNode("normal");

const ao_pass = ao(scene_depth, scene_normal, camera);
ao_pass.resolutionScale = 1.0;

const ao_denoise = denoise(ao_pass.getTextureNode(), scene_depth, scene_normal, camera).mul(scene_color);
const bloom_pass = bloom(ao_denoise, 0.3, 0.2, 0.1);
const post_noise = (mx_noise_float(vec3(uv(), time.mul(0.1)).mul(sizes.width), 0.03)).mul(1.0);

composer.outputNode = ao_denoise.add(bloom_pass).add(post_noise);

好了,就这样了,朋友们——非常感谢阅读,希望这对你有用!

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计