分享一个超级炫酷的字体爆炸效果!

1,047 阅读6分钟

写在开头

01.gif

之前一直学习glsl不可避免的也接触到了three.js,本次给大家分享一个刚学到的字体形变爆炸效果

前期准备


function preload() {

	const loader = new FontLoader();
	loader.load("Archivo_Black_Regular.json", (font) => {
		init(font);
	});

}

function init(font) {

}

window.onload = preload;

首先我们需要加载一个Three.js专用的字体格式:

// 格式如下
{
  "glyphs": {
    "0": {
      "ha": 926,
      "x_min": 57,
      "x_max": 869,
      "o": "m 464 972 q 778 845 688 972 q 869 478 869 718 q 778 110 869 238 q 464 -17 688 -17 q 148 110 239 -17 q 57 478 57 238 q 148 845 57 718 q 464 972 239 972 m 464 785 q 361 729 392 785 q 331 547 331 674 l 331 410 q 361 226 331 282 q 464 171 392 171 q 565 226 536 171 q 594 410 594 282 l 594 547 q 565 730 594 675 q 464 785 536 785 z "
    },
    "1": {
      "ha": 926,
      "x_min": 146,
      "x_max": 867,
      "o": "m 867 214 l 867 0 l 146 0 l 146 214 l 372 214 l 372 646 l 146 646 l 146 807 q 354 856 239 813 q 556 957 469 900 l 649 957 l 649 214 l 867 214 z "
    }
    ...
  }
}

关键信息包括:

  • o 字段:SVG路径格式的字符轮廓
  • ha:字符宽度(horizontal advance)
  • 边界框:用于布局计算
  • 分辨率:单位转换参考

为什么要用这种格式是因为TextGeometry 需要创建3D文字几何体,不是简单的2D文本。它需要:

  • 字符的精确轮廓路径
  • 每个字母的矢量曲线数据
  • 用于生成3D挤出和斜角的几何信息

.ttf字体转换为该格式,可以使用在线Facetype.js工具,该工具会生成一个.typeface.json文件。

添加TextGeometry

有了字体之后我们就可以添加我们的3D字体了!

async function init(font) {
   
    const sizes = {
        width: window.innerWidth,
        height: window.innerHeight,
    };


    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 0.1, 1000);
    const renderer = new THREE.WebGPURenderer({antialias: true});

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

    const textGeometry = new TextGeometry("Every", {
        font: font,
        size: 1.0,
        depth: 0.2,
        bevelEnabled: true,
        bevelThickness: 0.1,
        bevelSize: 0.01,
        bevelOffset: 0,
        bevelSegments: 1
    });

    const mesh = new THREE.Mesh(
        textGeometry,
        new THREE.MeshStandardMaterial({color: "#000000", metalness: 0.8, roughness: 0.8})
    );

    scene.add(mesh);

    renderer.renderAsync(scene, camera);
}

01.png

此时我们能发现,TextGeometry 的默认锚点在左下角,所以此时我们的字体是在原点的右上方向

解决方法,需要把几何体居中:

const mesh = new THREE.Mesh(
        textGeometry,
        new THREE.MeshStandardMaterial({color: "#000000", metalness: 0.8, roughness: 0.8})
    );

    textGeometry.computeBoundingBox();
    textGeometry.center();

02.png

修改顶点

在进行下一步前,我们首先来了解一个关于顶点还有法向的概念。

  1. PlaneGeometry演示
    const geometry = new THREE.PlaneGeometry(1, 1);

    const mesh = new THREE.Mesh(
        geometry,
        new THREE.MeshStandardMaterial({color: "#000000", metalness: 0.8, roughness: 0.8, side: THREE.DoubleSide})
    );

    scene.add(mesh);

    const axesHelper = new THREE.AxesHelper(5); 
    scene.add(axesHelper);

    const normalsHelper = new VertexNormalsHelper(mesh, 0.5, 0xff0000);
    scene.add(normalsHelper);

03.png

再打印一下顶点数据看看

 console.log('count:', count)
 console.log('array:', geometry.attributes.position.array);
count:4;
array:{
    "0": -0.5,
    "1": 0.5,
    "2": 0,
    "3": 0.5,
    "4": 0.5,
    "5": 0,
    "6": -0.5,
    "7": -0.5,
    "8": 0,
    "9": 0.5,
    "10": -0.5,
    "11": 0
}

很明显能看到,一个PlaneGeometry有四个顶点,顶点坐标存储在geometry.attributes.position中,法向量 (Normal Vector)的定义:表示表面方向的单位向量,垂直于表面指向外部。如图中红色线段所示~

  1. TSL动态修改顶点

既然我们已经知道PlaneGeometry由四个顶点组成,那么如果我们动态修改顶点看看呢!

const initial_position = storage(geometry.attributes.position, "vec3", count);
    const normal_at = storage(geometry.attributes.normal, "vec3", count);

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

     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);

    renderer.computeAsync(compute_init);

    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));

        const pointer_influence = step(distance, 0.5).mul(1.0);

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

        current_position.assign(disorted_pos);

    })().compute(count);

    mesh.material.positionNode = position_storage_at.toAttribute();

  • distance = length(u_input_pos - base_position)

    → 鼠标点到该顶点的距离。

  • pointer_influence = step(distance, 0.5)

    → 如果顶点在 0.5 的范围内 = 1.0,否则 = 0.0。

  • disorted_pos = base_position + normal * influence

    → 如果鼠标在附近,就把顶点往法线方向挤出去。

最终把结果写回 current_position。

02.gif

  1. 替换回TextGeomerty

03.gif

可以看到此时已经有字体形变的效果了,这就是通过改变顶点坐标来实现的!但是此时效果还是比较僵硬没有过渡,所以我们需要在compute_update方法中加上过渡效果!

  1. 弹簧-阻尼
 const velocity_storage_at = storage(new THREE.StorageBufferAttribute(count, 3), "vec3", count);

const compute_init = Fn(() => {
    ...
    // 初始化一下速度
    velocity_storage_at.element(instanceIndex).assign(vec3(0.0, 0.0, 0.0));
    })().compute(count);


 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);

        // 加入mix-鼠标划过Geometry才开始变化
        const disorted_pos = base_position.add(normal.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));
        // 应用摩擦:velocity *= friction
        current_velocity.assign(current_velocity.mul(u_friction));
        // 更新位置:position += velocity
        current_position.addAssign(current_velocity);
        

    })().compute(count);

此时再来看看我们的效果

04.gif

加入噪声函数

我们目前是按照法向方向推出顶点所以每次的形变都是一致的,我们需要更多的随机性那么就要用到噪声函数

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));
  1. mx_noise_vec3

mx_noise_vec3Three.js TSL 中基于 MaterialX 标准 的 3D 噪声函数

函数签名和参数 mx_noise_vec3(position, scale)

参数说明:

  • position: vec3 - 3D 坐标位置,决定噪声的采样点
  • scale: float - 缩放因子,控制噪声的频率/细节程度

返回值: vec3 - 返回三维向量的噪声值,每个分量范围通常在 [-1, 1]

所以我们通过

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

让形变强度产生了更丰富的变化

  1. rotate

没有旋转时:

  • 顶点只是沿法线方向进出移动
  • 像"呼吸"或"脉冲"效果

有旋转时:

  • 顶点既有法线方向的移动,又有围绕某轴的旋转
  • 产生"螺旋"或"涡流"效果
  • 在鼠标附近形成扭曲变形

此时效果如下

05.gif

加入颜色

终于到最后一步啦!

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);

emissiveNode就类似我们之前的glsl中的片段着色器,在每个像素中都会执行,所以我们根据每个片段的速度再用色相旋转让速度越大的片段,色相变化越多!

效果展示

参考文档

# Interactive Text Destruction with Three.js, WebGPU, and TSL