mcpField
T
threejs-landscape

threejs-landscape

github.com/mengto创意与媒体7.1k1.2k 次安装3 天前更新
代码仓库

关于 threejs-landscape

面向制作实时 3D 背景的开发者,介绍如何用 Three.js 构建环绕摄像机的地形、草地、石块、天空和星空。重点是可复用的地形采样,以及随距离调整的网格和渲染方法。

主要功能

  1. 01按距离扩展环带的极坐标地形网格
  2. 02扭曲噪声地形与随距离增强的山脊
  3. 03无需平铺纹理的顶点地面着色
  4. 04实例化的着色器驱动草地与石块
  5. 05渐变天空、星空与插值光照状态

适用场景

  1. 01为 3D 主体构建景观背景
  2. 02让散布的草地和石块贴合相同地形
  3. 03平滑切换昼夜状态,避免状态跳变

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/mengto/skills --skill threejs-landscape

安装后在 Claude Code 中输入/threejs-landscape确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

agents/
openai.yaml
demo/
index.html
PROMPT.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 次调用中则不行。

评论

支持 Markdown