开发日志 · three.js

我们如何用 three.js 做出一片可以漫步的 3D 森林:地形、草地、风与点击行走

一个高度函数撑起整片地面,一个实例化网格画出 25,000 根草叶,一段着色器补丁带来风,一次射线检测把点击变成行走:这就是森林漫步背后的几块拼图。

更新于 作者 胜遇工作室阅读约 9 分钟

森林漫步是一小片可以在浏览器里四处走走的 three.js 森林。这里有起伏的山坡、沿河谷蜿蜒而下的小溪、成千上万在风中摆动的草叶和树叶,还有花、鱼、蝴蝶和一轮低垂的黄昏太阳。一个穿红裙子的小姑娘站在水边;点一下地面,她就走过去。它基于 three.js r160 和 WebGL 2 运行,没有下载任何模型或图片贴图:一切都在加载时由代码生成。

这篇文章结合真实源码的简短片段,讲讲它是怎么拼起来的:地形、草、风、树,以及一个不用物理引擎、也不用导航网格的 three.js 可漫步 3D 场景。

黄昏时分的 three.js 森林:一棵大树、一条蜿蜒的小溪和长满青草与花朵的草地
森林漫步:地形、小溪、草、树和天空,全部在运行时生成。

渲染器、色调映射与黄昏天空

渲染器画进页面的舞台容器里,按基于物理光照的户外场景来配置:

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.75));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.68;

ACES 电影色调映射会让过亮的高光平滑地滚降,而不是直接截断,画面里有天空时这一点很重要;曝光值设得远低于 1,草地才不会被冲淡。

天空用的是 three.js 的 Sky 扩展,一个模拟大气散射的着色器。我们把浑浊度和瑞利散射调到略带薄雾的午后,再把太阳放在地平线上方仅 16° 的位置——整个场景温暖、影子拉长的感觉就来自这里:

const phi = THREE.MathUtils.degToRad(90 - 16);   // sun 16° up: golden hour
const theta = THREE.MathUtils.degToRad(150);
sunDir.setFromSphericalCoords(1, phi, theta);
skyU.sunPosition.value.copy(sunDir);

const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(sky).texture;

PMREMGenerator 那一行是最划算的一笔:它把天空烘焙成一张预滤波环境贴图,于是场景里每个 MeshStandardMaterial 都白得了天空色调的环境光和反射。在此之上,还有一盏沿同一个 sunDir 摆放的暖色 DirectionalLight(这样影子和天空对得上)、一张覆盖林间空地周围 100 × 100 范围的 2048 × 2048 柔和阴影贴图、一盏上蓝下绿的 HemisphereLight 作为补光,以及淡鼠尾草色的指数雾(FogExp2),把远处慢慢化开。

用一个高度函数生成程序化地形

场景里所有接触地面的东西,都去问同一个函数地面在哪。先说小溪:它的中心线是一条曲线 x = cxOf(z),由两个频率不同的正弦波组成,所以它蜿蜒曲折,却看不出明显的重复:

const cxOf = z => 8 + 2.6 * Math.sin(z * 0.045) + 1.3 * Math.sin(z * 0.11 + 1.3);
function terrainH(x, z) {
  const d = Math.abs(x - cxOf(z));
  const rolling = (Math.sin(x * 0.13 + z * 0.07) + Math.sin(x * 0.05 - z * 0.11 + 1.7)) * 0.35
                + Math.sin(x * 0.021 + z * 0.017 + 0.5) * 0.9;
  const flat = ss(3.2, 7.5, d);                 // flatten near the water
  const carve = (1 - ss(0, 4.6, d)) * 1.15;     // dig the valley
  const bank = Math.exp(-((d - 5.6) ** 2) / 3.0) * 0.28;  // slight lip on the bank
  return rolling * flat - carve + bank;
}

把它当成几层叠加来读。rolling 是三个相互叠加的正弦,形成缓和的山丘。d 是到小溪中心线的水平距离,ss 是 smoothstep。山丘乘以 flat,在靠近水的地方逐渐消失,两岸就平了;carve 在小溪正下方最多减去 1.15 个单位,挖出河道;bank 则是离中心约 5.6 个单位处的一个小高斯隆起,看起来就像自然抬高的岸边。

网格是一个放平的 PlaneGeometry(240, 240, 190, 190),每个顶点的高度由 terrainH 设定。同一个循环还根据 d 写入顶点颜色:3 个单位以内是深色河床,3.6 到 5.4 个单位之间是沙地渐变到草地,再往外是草地,色相和明度带一点基于正弦的变化。调用一次 computeVertexNormals(),地形就完成了——不需要高度图,也不需要噪声库。

因为 terrainH 又便宜又确定,它还负责摆放溪床里的 160 颗卵石、岸边的 70 块石头、每一根草、每一朵花、每一棵树,以及小姑娘的双脚。没有东西悬空,也没有东西下陷。

沿着小溪铺开的水面条带

水面并不是一块被地形裁掉的大平面,而是一个自定义的 BufferGeometry:一条 240 行的带子,每行两个顶点,分别放在 cxOf(z) 两侧,于是这条带子和河道弯得严丝合缝:

for (let i = 0; i < rows; i++) {
  const z = waterZ0 + (waterZ1 - waterZ0) * (i / (rows - 1));
  const cx = cxOf(z);
  wPos.set([cx - WATER_HALF, WATER_Y, z, cx + WATER_HALF, WATER_Y, z], i * 6);
}

它的材质是 MeshPhysicalMaterial,transmission: 0.92,ior: 1.33(水的折射率),加一点厚度和青绿色的衰减颜色。透射会让 three.js 把不透明场景额外渲染进一个缓冲区,再透过水面去采样,于是卵石和河床透过水面显现出来,带着轻微的弯折和色调。它比普通透明更耗性能,但只用很少的代码就能做出令人信服的浅水。

涟漪在 CPU 上完成:每一帧根据位置和时间的三个正弦之和,设定这条带子 480 个顶点的高度,再重新计算法线。开销几乎可以忽略。我们那篇风暴海面的文章用的是同样的思路,只是规模大得多。

一次绘制调用画出 25,000 根草

草是大家问得最多的部分,而它基本上就是一个 InstancedMesh。单根草叶起初是一个细长的 PlaneGeometry(0.085, 1.05, 1, 3),移动到根部位于原点,然后逐顶点塑形:

const yy = p.getY(i) / 1.05;
p.setX(i, p.getX(i) * (1 - yy * 0.85));       // taper to a point
p.setZ(i, p.getZ(i) + yy * yy * 0.32);        // curve forward
const v = 0.5 + yy * 0.65;                    // dark root, bright tip

由暗到亮的顶点渐变,是茂密草坪里环境光遮蔽的一个廉价替身。材质是开启了 vertexColors 的双面 MeshStandardMaterial。

接着把 25,000 个实例撒在半径 58 的圆盘上。用 r = Math.sqrt(Math.random()) * 58 能让整个圆盘上的密度保持均匀(不开平方根的话,草会挤在中间),离小溪 4.6 个单位以内的点一律丢弃,好让水面保持干净。每个实例都有随机的朝向、轻微的倾斜、0.6 到 1.7 之间的缩放,以及通过 setColorAt 设置的各自颜色。three.js 会把实例颜色和顶点渐变相乘,于是每根草的绿色都不一样,但由暗到亮的过渡是一致的。

结果:25,000 根草,一个几何体,一个材质,一次绘制调用,矩阵只在启动时计算一次。

用 onBeforeCompile 做风

我们没有自己写草的着色器,而是用一个小工具函数,在 three.js 算出局部位置之后,给标准材质的顶点着色器打个补丁,这样内置的光照、阴影和雾都照常工作:

material.onBeforeCompile = (shader) => {
  shader.uniforms.uTime = uTime;
  shader.vertexShader = 'uniform float uTime;\n' + shader.vertexShader.replace(
    '#include <begin_vertex>',
    `#include <begin_vertex>
     float wph = instanceMatrix[3].x * 0.8 + instanceMatrix[3].z * 0.6;
     float wh = max(position.y, 0.0);
     transformed.x += (sin(uTime * 1.7 + wph) + sin(uTime * 2.9 + wph * 1.7) * 0.4) * 0.17 * wh;
     transformed.z += cos(uTime * 1.3 + wph * 1.2) * 0.102 * wh;`);
};

(这里略作简化:真实的工具函数把 instanceMatrix 那一行包在 #ifdef USE_INSTANCING 里,并把振幅作为参数传入。)让它奏效的有三个细节:

  • 相位取自位置。instanceMatrix[3] 是实例的平移量,所以 wph 是一个在整片草地上平滑变化的相位。相邻的草几乎同步摆动,摆动像一阵阵风那样滚过草地,而不是每根草整齐划一地抽动。
  • 按高度加权。乘以 position.y(根部为 0),每根草的根就牢牢扎住,而草尖摆得最远。
  • 共享一个 uniform。每个打过补丁的材质拿到的都是同一个 uTime 对象,所以每帧只需一次赋值 uTime.value = t,就能同时驱动草地和全部七棵树的树叶。

对树叶调用的是同一个工具函数,只是振幅更小(0.07),并关闭了高度加权,于是每张叶片卡整体平移,而不是弯曲。

用圆柱和叶片卡搭树

每根树干由两三段逐渐变细的 CylinderGeometry 叠成,带一点随机倾斜;根部围着五段外张的根圆柱,再加几根朝树冠斜伸的树枝,全都使用启动时画进 canvas 的树皮贴图(棕色底上画竖向的贝塞尔条纹)。

树冠是另一个 InstancedMesh:一个边长 0.62 单位的普通正方形,贴上用 canvas 画的叶子,再用 alphaTest: 0.45 抠出形状。用透明度测试而不是真正的透明,意味着不用排序、深度也正确,这在成千上万张叶片卡相互重叠时至关重要。叶片卡分布在每棵树的几个球形簇上,按簇的体积分配数量,并且大多放在簇的外侧,这样树冠看起来饱满却不是实心一团:

const rr = c[3] * (0.55 + 0.45 * Math.cbrt(Math.random()));
dmy.position.set(c[0] + dir.x * rr, c[1] + dir.y * rr * 0.85, c[2] + dir.z * rr);

簇顶部的叶子比底部的更亮、更暖,以此假装阳光从叶间筛落。主角大树挂着 9,000 张叶片卡,六棵小树各 2,200 张,总共约 22,200 张。在它们后面,离中心 62 到 90 个单位处围着一圈 90 个实例化圆锥;在雾里它们就是远处的林线,几乎不花成本就给林间空地带来了纵深。

点击行走:射线检测、限位、漫步

相机用的是 OrbitControls,鼠标左右键都映射为旋转(触屏没有右键)。所以第一件事就是区分点击和拖动。在 pointerup 时,如果指针移动超过 6 像素或按下时间超过 700 毫秒,就忽略这次事件;否则就从相机穿过点击位置发出一条射线:

raycaster.setFromCamera(ndc, camera);
const hit = raycaster.intersectObject(ground, false)[0];
if (hit) setGirlTarget(hit.point.x, hit.point.z);

只检测地形网格,所以射线检测很快,而且你总能“穿过”草点到地面上。

然后 setGirlTarget 应用两条规则,完全不需要导航网格。如果目标点离小溪中心线不到 4.4 个单位,就把它推回她当前所在的那一岸,所以点对岸时她会走到近岸边,而不是蹚进水里。如果目标点离中心超过 26 个单位,就把它拉回这个圆上,好让她留在森林里。最终落点上会放一个圆环标记,在 0.9 秒内扩散并淡出。

行走只是动画循环里的几行代码。她以每秒 2.6 个单位的速度径直走向目标,并沿较短的方向转身:

const want = Math.atan2(dx, dz);
let turn = (want - g.heading + Math.PI * 3) % (Math.PI * 2) - Math.PI;
g.heading += turn * Math.min(1, dt * 7);

把角度差折回 −π…π 之间,正是为了防止目标就在她左肩后方时,她原地转上一整圈。一个 gait 值在 0(站立)和 1(行走)之间缓动,所有动作都按它缩放:腿和手臂的摆动、由 abs(sin(phase)) 带来的小跳步、轻微的前倾,以及双马尾的弹动。她起步和停下都很平滑,闲下来时则换成细微的呼吸起伏。她的高度就是她所在位置的 terrainH,所以每一处坡她都能跟上。

鱼、蝴蝶和飘落的叶子

那些会动的小东西全都是廉价的参数化运动,没有任何模拟:

  • 鱼。十条鱼,每条是一个压扁的球体加上 ShapeGeometry 做的鳍,沿着 cxOf(z) 带着各自的泳道偏移游动,游到小溪尽头就绕回来。每条鱼都会沿曲线往前看一小段来朝向下游,尾巴按正弦摆动。
  • 蝴蝶。六对贴着花瓣贴图的翅膀,沿利萨茹式的轨迹绕圈飞(x 和 z 上的正弦速度不同),面朝飞行方向、快速扇翅,离地形始终不低于 0.8 个单位。
  • 落叶。一个 InstancedMesh 里的六十张叶片卡,以 11 个单位为周期从主角大树上飘落,一边横向漂移一边翻滚,矩阵每帧重写。
  • 空气。500 颗叠加混合的花粉微粒、七片飘动的云朵精灵,以及四个沿 sunDir 倾斜、穿过大树的光束平面。

后期处理:辉光与调色

每一帧都要经过一条 EffectComposer 链:先是 RenderPass,然后是 UnrealBloomPass,强度适中(0.32)、阈值较高,所以只有最亮的天空和被阳光照亮的叶片边缘会发光;接着是负责色调映射和 sRGB 转换的 OutputPass,最后是一个自定义的 ShaderPass。因为调色在输出通道之后进行,它处理的是可以直接显示的颜色,参数也就很好推敲:

col = mix(col, col * col * (3.0 - 2.0 * col), 0.22);           // gentle S-curve
col += vec3(0.035, 0.018, -0.012) * smoothstep(0.55, 1.0, luma); // warm highlights
col += vec3(-0.018, 0.004, 0.022) * (1.0 - smoothstep(0.0, 0.45, luma)); // cool shadows

它还把饱和度提高 10%,并加了一圈暗角。暖色的高光叠在略带青色的阴影上,让一片绿上加绿的场景有了傍晚的味道,而不像默认渲染。

保持轻量

对一个网页来说,这是个很重的场景,所以它得懂礼貌:

  • 凡是重复的东西都用实例化:草、树叶、落叶、卵石、石头和远处的林线,各自都只是一个 InstancedMesh。
  • 像素比上限设为 1.75。否则高 DPI 手机要让三倍的像素走一遍辉光和透射。
  • 离开屏幕就暂停。当舞台可见部分不足 15% 时,IntersectionObserver 会停掉 requestAnimationFrame 循环;标签页隐藏时,visibilitychange 监听器也会让它停下。页面上还有一个暂停按钮。
  • 单帧时间上限 50 毫秒,所以暂停之后,世界会平静地继续,而不是一下子跳到前面。
  • 廉价的射线检测:只检测地面网格,从不检测实例化的植被。
  • 尊重“减弱动态效果”。对于在系统里开启了减弱动态效果的访客,相机缓慢的空闲环绕是关闭的;而且你第一次左键点击、轻点或用左键拖动之后,它就永久停下。

要点回顾

  • 让一个高度函数成为唯一的事实来源。只要地形、道具和角色都采样它,整个场景就能严丝合缝。
  • 先做小溪,再围着它做地形。一条中心线曲线一次性给了你河道、水面网格、鱼的路径,以及“别走进水里”的规则。
  • 草 = 实例化 + 顶点补丁。一根塑好形的草叶、25,000 个矩阵,再加上 onBeforeCompile 里的几行代码,就能走很远。
  • 开阔场景的点击行走不需要导航网格:对地面做射线检测,用简单的规则限制目标,再给转身和步态加上缓动。
  • 光照和调色撑起氛围。低垂的太阳、PMREM 环境、雾、轻柔的辉光和冷暖调色,作用不亚于任何模型。

场景就在森林漫步:在草地上四处点点、转转镜头,看风从草间吹过。如果你喜欢这篇,还可以看看我们如何用 three.js 做风暴海面,或者如何用两骨 IK 做火柴人动画。