关于 threejs-landscape
面向制作实时 3D 背景的开发者,介绍如何用 Three.js 构建环绕摄像机的地形、草地、石块、天空和星空。重点是可复用的地形采样,以及随距离调整的网格和渲染方法。
主要功能
- 01按距离扩展环带的极坐标地形网格
- 02扭曲噪声地形与随距离增强的山脊
- 03无需平铺纹理的顶点地面着色
- 04实例化的着色器驱动草地与石块
- 05渐变天空、星空与插值光照状态
适用场景
- 01为 3D 主体构建景观背景
- 02让散布的草地和石块贴合相同地形
- 03平滑切换昼夜状态,避免状态跳变
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ mengto/ skills --skill threejs-landscape安装后在 Claude Code 中输入/threejs-landscape确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
agents/openai.yamldemo/index.htmlPROMPT.md详细介绍
Three.js Landscape
背景只有一个作用:让主体看起来仿佛置身于某个地方。这里的一切选择,都是为了让人一眼看懂这片风景,然后不再喧宾夺主。
可以使用 threejs-weather 在上面添加雨、暴风雨或雪;如果置身其中的主体是建筑,则使用 threejs-towers。
用极坐标网格环绕摄像机
不要构建方形高度场。长焦镜头看到的范围很窄,因此方形网格的大部分三角形都会落在摄像机后方,而且到地平线时分辨率仍然不够。
在摄像机正下方居中构建极坐标网格,并让径向环随着距离增加而逐渐疏远:
const AN = 900, RN = 52, R0 = 2.0, R1 = 700; // angular, radial, near, far
for (let r = 0; r <= RN; r++) {
const t = r / RN;
const rad = R0 + (R1 - R0) * Math.pow(t, 2.4); // dense near, sparse far
for (let a = 0; a < AN; a++) {
const th = a / AN * Math.PI * 2;
push(Math.cos(th) * rad, landH(x, z), Math.sin(th) * rad);
}
}
主体附近的 2,000 个三角形,胜过均匀铺开的 200,000 个。整个诀窍就在于 pow(t, 2.4):这样每个环覆盖的屏幕像素数大致相同。
注意绕序。 在极坐标网格上,很容易让所有四边形的绕序都反过来,结果只能透过地面看天空。按 (a0, b1, b0, a0, a1, b1) 建立索引,并在叠加其他内容之前,特意从地平线下方绕行检查一次。
先扭曲域,再叠加倍频层
普通的 fBm 看起来像皱巴巴的纸。在评估噪声之前,先用另一个噪声场扭曲采样位置,这是让地形呈现侵蚀感的最大一步:
function landH(x, z) {
const wx = x + fbm(x * 0.012, z * 0.012, 3) * 26; // domain warp
const wz = z + fbm(x * 0.012 + 41, z * 0.012 - 17, 3) * 26;
let h = fbm(wx * 0.0075, wz * 0.0075, 5) * 34; // broad landforms
h += ridged(wx * 0.021, wz * 0.021, 3) * 9; // ridge lines
return h;
}
将解析函数与网格分开。草、石头、雾以及其他任何散布的对象,都必须采样同一个 landH,否则它们就会悬浮或下沉。
山脊层必须随距离缩放。在 40 个单位处看起来合适的山脊振幅,在 600 个单位处就会变得看不见,因此必须增大远处环带的振幅,否则地平线会变得平坦。
根据地面的状态着色,而非使用纹理
平铺的地面纹理总会显得很刻意。根据地形自身的属性计算顶点颜色:
const slope = 1 - normal.y; // steep = rock
const moist = smoothstep(-4, 6, -height); // low = wet, green
const c = rock.clone()
.lerp(grass, (1 - slope * 3.2) * (0.35 + moist * 0.65))
.lerp(sand, Math.max(0, 0.5 - moist) * 0.6);
这样就能免费得到石质峭壁、泛绿的凹地和颜色苍白的山脊,而且不需要 UV,也没有接缝。叠加非常低频的噪声,避免颜色出现条带。
草:用顶点着色器塑形的单条带
10 万根草叶只需一个由五段条带组成的 InstancedMesh。所有弯曲、倾斜、渐细和阵风效果都在顶点着色器中完成,因此不会占用 CPU 处理风效:
const geo = new THREE.BufferGeometry(); // 11 verts: a strip + a tip
const mesh = new THREE.InstancedMesh(geo, mat, 104000);
mat.onBeforeCompile = sh => {
Object.assign(sh.uniforms, grassUni);
sh.vertexShader = `
uniform float uTime, uWindAmp; uniform vec2 uWind;
attribute vec4 aParams; // height, phase, tint, lean
varying float vT; varying float vTint;
` + sh.vertexShader.replace('#include <begin_vertex>', `
float gT = position.y; // 0 at root, 1 at tip
float gBend = uRestBend + sin(uTime * 1.7 + aParams.y) * uWindAmp;
vec2 gRib = ... ; // sweep the blade along an arc
vec3 transformed = vec3(gRib.x, gT * aParams.x, gRib.y);
`);
};
mat.customProgramCacheKey = () => 'grass'; // or every instance recompiles
将草地场锚定在摄像机上。围绕观察者保持固定网格的草叶,只移动网格并按单元格大小对齐,而不是向外扩展草地场。玩家永远不会到达边缘,你也无需为身后的草地付出代价。
不要在片元着色器中硬编码草叶颜色,却指望材质颜色能改变它。 diffuseColor.rgb *= mix(base, tip, t) 会乘上材质提供的颜色,因此白色材质乘以绿色常量,结果仍然是绿色。如果雪的积累、季节变化或夜间调色等效果需要重新给草地着色,这个混合就必须使用自己的 uniform。这个问题排查起来会耗掉一小时,因为每次调试输出都显示材质是白色的。
使用同一个高度函数生成石头
根据坡度进行拒绝采样,然后对 InstancedMesh 调用 setMatrixAt。焊接二十面体并将其压扁缩放,让石头看起来像嵌在地里,而不是掉在地上:
geo.scale(1, 0.62, 1); geo.translate(0, 0.3, 0); // sunk, not resting
几千块石头,搭配三到四种尺寸就够了。它们在主体附近最重要,能为观察者提供衡量尺度的参照物。
天空:在 8×512 画布上设置六个渐变色标
在小画布上绘制垂直渐变,并将其映射到背面球体上。重新绘制的开销极低,因此在过渡过程中每帧都可以重绘:
const grd = ctx.createLinearGradient(0, 0, 0, 512);
[0, 0.30, 0.52, 0.68, 0.84, 1].forEach((s, i) => grd.addColorStop(s, cols[i]));
ctx.fillStyle = grd; ctx.fillRect(0, 0, 8, 512);
skyMat.map.needsUpdate = true;
六个色标正合适。三个看起来像 CSS 渐变;十个则难以调整。将地平线色标略微放在几何地平线下方,这样雾色和天空就能无缝衔接,不会出现可见的分界线。
星星:将其限制在摄像机可见的带状区域内
错误做法是将星星散布在整个球面上。使用约 10° 的镜头,画面只能看到球面约 0.5%,所以放置 1,200 颗星星时,屏幕上大约只有 6 颗,看起来像是出了 bug。
- 将星星限制在摄像机实际允许的仰角范围内。
- 使用三种大小,让点的尺寸各不相同,从而呈现亮度变化。
- 根据
devicePixelRatio缩放PointsMaterial.size,否则在 Retina 屏幕上星星会消失。 - 使用加法混合、
sizeAttenuation: false、fog: false、depthWrite: false。
三种大小共约 39,000 个点,呈现出的天空会像星空,而不是噪点。每种大小只需一次绘制调用。
一天中的时间应通过插值实现,而非切换
将一天中的每个时段保存为完整状态——太阳位置和颜色、半球光、环境光、补光、轮廓光、雾的颜色和范围、地面、草地、六个天空渐变色标、阴影强度、星星不透明度——然后在两者之间进行线性插值:
function applyState(A, B, t) {
key.position.setFromSpherical(lerpAngle(A.sun, B.sun, t));
key.color.copy(A.sunC).clone().lerp(B.sunC, t);
scene.fog.color.copy(A.fog).lerp(B.fog, t);
for (let i = 0; i < 6; i++) CUR.sky[i].copy(A.sky[i]).lerp(B.sky[i], t);
paintSky(CUR.sky);
}
如果用户在过渡期间切换时段,应将当前插值后的状态冻结为新的 A,而不是跳回上一个预设。否则,每次不耐烦的点击都会导致画面跳变。
将天气作为叠加在其上的乘数处理,绝不要把它做成更多预设。四个时段 × 四种天气,是四种状态和四个修正因子,而不是十六种状态。
完成前的检查事项
- 将视角绕到地平线以下一次。如果能透过地面看到天空,说明绕序反了。
- 查看远处环带。如果那里的地形是平的,说明脊线振幅没有随距离缩放。
- 连续两次在过渡期间切换时段。画面不应出现跳变。
- 在 Retina 显示屏上检查。星星和细线几何体最容易暴露 DPR 问题。
- 关注绘制调用次数,而不是三角形数量。1.3M 个三角形分布在 18 次调用中可达到 120fps;200k 个三角形分布在 900 次调用中则不行。