写在开头
之前一直学习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);
}
此时我们能发现,TextGeometry 的默认锚点在左下角,所以此时我们的字体是在原点的右上方向
解决方法,需要把几何体居中:
const mesh = new THREE.Mesh(
textGeometry,
new THREE.MeshStandardMaterial({color: "#000000", metalness: 0.8, roughness: 0.8})
);
textGeometry.computeBoundingBox();
textGeometry.center();
修改顶点
在进行下一步前,我们首先来了解一个关于顶点还有法向的概念。
- 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);
再打印一下顶点数据看看
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)的定义:表示表面方向的单位向量,垂直于表面指向外部。如图中红色线段所示~
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。
- 替换回
TextGeomerty
可以看到此时已经有字体形变的效果了,这就是通过改变顶点坐标来实现的!但是此时效果还是比较僵硬没有过渡,所以我们需要在compute_update方法中加上过渡效果!
- 弹簧-阻尼
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);
此时再来看看我们的效果
加入噪声函数
我们目前是按照法向方向推出顶点所以每次的形变都是一致的,我们需要更多的随机性那么就要用到噪声函数
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));
- mx_noise_vec3
mx_noise_vec3 是 Three.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)));
让形变强度产生了更丰富的变化
- rotate
没有旋转时:
- 顶点只是沿法线方向进出移动
- 像"呼吸"或"脉冲"效果
有旋转时:
- 顶点既有法线方向的移动,又有围绕某轴的旋转
- 产生"螺旋"或"涡流"效果
- 在鼠标附近形成扭曲变形
此时效果如下
加入颜色
终于到最后一步啦!
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