# AI 生成 3D 场景拆解 02：山间铁路

规范网页：https://devtookit.com/article/15

作者：Revio

发布时间：2026-10-12T12:33:45Z

更新时间：2026-10-12T12:33:45Z

摘要：学习 Techartist 的山间铁路，拆解铁路曲线、地形与桥隧、列车建模与停车、建筑植被、河流瀑布、雨夜光照和镜头，并将共享路径与生成方法补进可复用 Skill。

---

![山间铁路黄昏全景，列车、钢桥与瀑布共同构成主画面](/articles/mountain-railway/hero.jpg)

[在本站体验山间铁路 Demo](https://devtookit.com/demos/mountain-railway-diorama) · [原作者 GitHub 仓库](https://github.com/iamtechartist/mountain-railway-diorama)

Mountain Railway Diorama，也叫 Yamaai，是一个可以旋转、缩放和切换镜头的日本山间铁路微缩场景。两节列车沿着山谷中的环线行驶，经过钢桥、隧道、瀑布和小车站，到站后减速停车，再继续下一圈。我们还可以切换白天、黄昏、雨天与夜晚，调整风、雨量和车速，或让镜头自动跟着旅程切换。

本篇参考学习的 Demo 来自 **Techartist（GitHub 用户 iamtechartist）**，原地址为 [github.com/iamtechartist/mountain-railway-diorama](https://github.com/iamtechartist/mountain-railway-diorama)。场景设计和原始实现归原作者，项目采用 MIT 许可证，Copyright (c) 2026 Techartist。配图为本篇运行该版本 Demo 时的实际截图。

上一篇 Tideglass Cove 让我们看到地形、水面、植物和动物怎样共享空间规则。这次我们顺着铁路继续学习：一条路线怎样决定地形和设施的位置，列车怎样转弯与停车，天气、灯光和镜头又怎样围绕这段旅程变化。原仓库没有记录作者使用的 AI 工具和提示词，文末的 AI 复用方法来自我们的源码学习。

## 技术栈

项目入口是静态 `index.html`，使用 import map 把 Three.js 和 addons 映射到本地 `vendor/`。场景由原生 JavaScript ES Modules 运行，源码自带 **Three.js r185（0.185.0）**。本篇分析固定在提交 [6e8b0fbb](https://github.com/iamtechartist/mountain-railway-diorama/tree/6e8b0fbb806e486d7819b6d297b92f4ecf3d258b)。

| 部分 | 实际使用的技术 | 主要作用 |
| --- | --- | --- |
| 渲染 | Three.js WebGLRenderer、WebGL 2 | 场景、灯光、阴影与离屏渲染 |
| 几何 | BufferGeometry、Shape、ExtrudeGeometry、TubeGeometry | 地形、轨道、车身、建筑、植物和道具 |
| 材质 | MeshStandardMaterial、MeshPhysicalMaterial、ShaderMaterial | PBR 表面、玻璃、水体和粒子 |
| 着色器扩展 | GLSL、onBeforeCompile | 在标准材质中加入纹理、湿润、风动和高光 |
| 路径与运动 | CatmullRomCurve3、弧长采样、矩阵与四元数 | 铁路生成、列车姿态、到站减速 |
| 重复对象 | InstancedMesh、InstancedBufferAttribute | 树、地被与道砟的批量绘制 |
| 水面反射 | Reflector、HalfFloat 离屏纹理 | 下游水面的场景倒影 |
| 后处理 | WebGLRenderTarget、亮区过滤与合成 | 灯光泛光、色调映射与最终输出 |
| 交互 | OrbitControls、HTML 控件 | 手动视角、天气、风、车速、暂停和镜头选择 |

这里与上一篇有一个明确区别：它使用 `WebGLRenderer` 和 GLSL，并没有用 WebGPU 或 TSL。`onBeforeCompile` 会在 Three.js 生成标准着色器时插入额外表达式，因此我们既能保留标准材质的光照与阴影，又能改变表面颜色、粗糙度和顶点位置。入口、环境参数和更新顺序见 [main.js](https://github.com/iamtechartist/mountain-railway-diorama/blob/6e8b0fbb806e486d7819b6d297b92f4ecf3d258b/main.js)。

## 程序化生成

这个场景的模型和纹理由代码构造，没有导入外部 GLTF、FBX 模型，也没有加载外部贴图。车站标牌、时刻表、列车目的地和灯光光晕使用运行时 CanvasTexture；木纹、岩层、水纹、云和星空由着色器计算。运行依赖也随仓库保存，场景不需要在线下载模型或纹理。

但程序化生成并不意味着每次随机生成一座新山谷。铁路的十二个控制点、车站与神社的选址、桥隧区段、主镜头和几棵重要的前景树，都是作者明确写出的布局。随机数主要负责枝叶形态、岩石差异、植被候选点和道砟变化，而且有固定种子。

例如通用几何模块的随机数发生器是一个线性同余序列：

```text
seed_next = (1664525 × seed + 1013904223) mod 2^32
random = seed_next / 2^32
```

相同种子会得到相同序列。植被模块使用另一套带整数混合的生成器；各模块独立管理随机状态，所以我们可以修改树木而不让列车车身一起发生变化。这里适合学的是“明确的构图参数 + 可重复的细节变化”，而不是把整座场景交给无约束随机数。

源码也按对象组织：`world.js` 管地形、路线、桥隧，`train.js` 管列车，`architecture.js` 管建筑，`vegetation.js` 管植物，`river.js` 和 `cascade.js` 管河流与瀑布，`weather.js`、`atmosphere.js` 管环境，`cinematography.js` 管镜头。通用的挤出、扫掠、材质和合批工具放在 [geometry.js](https://github.com/iamtechartist/mountain-railway-diorama/blob/6e8b0fbb806e486d7819b6d297b92f4ecf3d258b/geometry.js)。

## 铁路

### 路线

铁路中心线是一条闭合的 Catmull–Rom 曲线。十二个三维控制点同时给出路线在水平面上的走向和高低变化，曲线类型显式设为 `catmullrom`，张力为 `0.34`。

```js
const curve = new THREE.CatmullRomCurve3(
  controlPoints, true, 'catmullrom', 0.34
);
curve.arcLengthDivisions = 1800;
curve.updateArcLengths();
```

这段是将原实现提取后的核心调用。控制点使线路绕过山谷，从前方钢桥转进背后的山体，再回到左侧车站。张力控制各段的切线长度，影响转弯的松紧。

如果展开一段曲线，它可以写成三次 Hermite 插值。以当前段的两个端点 P1、P2 为例，相邻点 P0、P3 决定端点切线：

```text
m1 = tension × (P2-P0)
m2 = tension × (P3-P1)
P(t) = (2t³-3t²+1)P1 + (t³-2t²+t)m1
     + (-2t³+3t²)P2 + (t³-t²)m2
```

`t` 是这一段内的参数。这个公式解释了为什么移动一个控制点也会影响相邻段的形状。

列车和轨道使用的是 `getPointAt(u)`，它会通过累计弧长表把 `u` 映射回普通曲线参数。于是 `u` 的等间隔对应近似等距离，而 `getPoint(t)` 的等间隔未必如此。源码把弧长细分从默认值提高到 1800，减小这个映射的误差。参见 [Three.js 曲线文档](https://threejs.org/docs/pages/Curve.html)。

### 轨道

轨道没有直接用两根圆管代替。源码定义了钢轨底板、轨腰和轨头的二维截面，再沿中心线扫掠：

```text
P_track(u,x,y) = C(u) + N(u) × (x+offset) + UP × y
N(u) = (Tz, 0, -Tx)
```

`C` 是线路中心，`T` 是切线，`offset` 是左右钢轨偏移，`x、y` 是截面坐标。左右偏移分别为 `-0.75` 和 `0.75`，纵向采用 1100 段细分。这里的 `N` 是项目直接使用的水平侧向向量，未在这段扫掠中归一化；坡度较小时影响较小，迁移到陡坡路线时应重新检查截面宽度。

枕木按约 `0.76` 个场景单位的距离间隔布置，朝向来自切线的水平角；钢轨夹具、道床和排水槽也跟着同一条路线生成。2200 个碎石实例被分为八个轨道扇区，近看用较完整的碎石轮廓，远看用简化的封闭楔形，位置与材质保持一致。

因此修改路线后，轨道、枕木、排水设施和列车可以共同更新。它们共享的并非一张位置表，而是中心线、弧长和桥隧区段。实现见 [world.js](https://github.com/iamtechartist/mountain-railway-diorama/blob/6e8b0fbb806e486d7819b6d297b92f4ecf3d258b/world.js)。

## 地形

### 山体

地形首先是一个可查询的高度函数 `height(x,z)`。底层山体由几组高斯山峰组成，再叠加弯曲山脊、峰顶凸起、正弦细节与侵蚀状凹痕：

```text
h_peak(x,z) = A × exp(-((x-cx)²/sx + (z-cz)²/sz))
h_base = 底部高度 + 多个山峰 + 山脊 + 峰顶 + 细节 - 凹痕
```

`A` 控制山峰高度，`cx、cz` 控制位置，`sx、sz` 控制横向尺度。它给出可直接调整的大形状；几组小幅正弦再打破表面的平整感。这里没有运行真实水力侵蚀模拟。

地形会继续询问铁路和河流的位置。铁路被预采样成 680 个点，每个地表坐标查找最近采样点，得到线路高度、距离与区段。普通路段把近轨地形平滑压到道床下方；桥区不压平峡谷；隧道区则保证轨道上方有山体覆盖。车站平台和神社所在地还使用局部遮罩压出较平的台地。

这些局部修改大量使用 `smoothstep`。把区间中的坐标归一化成 `q` 后，其核心是：

```text
q = clamp((x-a)/(b-a), 0, 1)
smoothstep(a,b,x) = q²(3-2q)
h = mix(目标高度, 原高度, smoothstep(近距离,远距离,d))
```

这样铁路附近先保持平整，离开路基后逐渐恢复山坡，边缘不会出现突然的折线。680 点的最近距离仍然是离散近似，精确净空需要继续检查生成后的几何。

### 网格与剖面

山谷主体采用极坐标网格，径向 112 段、圆周 216 段。基本轮廓是一块横纵半轴约为 56 和 43 的椭圆，边界再加入不同频率的角向变化：

```text
B(a) = 1 + 0.065 sin(3a+0.3)
         + 0.045 cos(5a-0.5) + 0.025 sin(8a)
x = 56 × r × B(a) × cos(a)
z = 43 × r × B(a) × sin(a)
y = height(x,z)
```

最外一圈沿着相同边界向下连接到 `-17.8`，中间分成十一层，并加入小幅凹凸和不同岩层颜色。顶面与侧面共用边界的高度采样，所以剖面能够沿着山体轮廓闭合。

地表颜色还依赖海拔、坡度、林地区域和河岸距离：陡坡更接近岩石色，河流附近偏向潮湿苔藓色，低坡保留暖色落叶土壤。颜色变化先解释地形结构，再用纹理补充细节。

## 桥梁与隧道

钢桥沿铁路的桥区间生成。两侧主梁使用 I 形截面扫掠，横梁、交叉斜撑、检修步道和护栏按区段采样连接。桥墩从桥面下方向实际地表延伸，不用一个固定长度贯穿所有位置。赭色桥梁也成为画面中连接列车和瀑布的醒目线条。

隧道比桥梁多一个问题：高度场只能保存每个 `(x,z)` 的单一高度。把山体抬到列车上方，可以形成“屋顶”，但进入山体的那一圈面片仍可能横在洞口，像一张帘子。

项目实际裁切了这部分面片。两端入口共用马蹄形净空轮廓，再加上前后截断平面。对附近每个地形三角形逐平面裁切，留下空腔以外的多边形，最后重新三角化。边与平面的交点由有符号距离计算：

```text
f = da / (da-db)
V_intersection = Va + f × (Vb-Va)
```

`da、db` 是边的两个端点到当前平面的有符号值。位置、顶点颜色和法线一起插值，法线重新归一化，因此切开的边缘仍能接上原地形的颜色与光照。它属于逐平面的多边形裁切方法，不是对整座山运行通用 CSG。

洞内衬砌则把另一份马蹄形截面沿路线扫掠 140 段。入口拱门由二维形状挤出，拱石按角度分段，侧翼石块逐层布置。洞口、衬砌和列车共用路线，生成后再用射线检查入口是否通畅。这里的关键是同时处理“山体覆盖”和“入口空腔”，而不是只在山上贴一张黑色洞口。

## 岩石

普通碎石采用六层截面环和九个周向点，环半径随高度变化，再给角度、半径和中心加入带种子的扰动。峡谷的大岩壁则使用单独的 `geology.js`：先定义不对称的八边形基底，再沿不同高度连接多层截面，层与层之间错动、外凸和后退，形成劈裂岩体与薄片状峰顶。

这些岩体不是完全均匀散落。峡谷支撑体与峰顶岩片有明确位置，沿河的大岩壁会查询 `railDistance` 和 `railHeight`；如果接近铁路，就把岩顶限制在轨道下方，给桥上列车留下净空。

材质使用世界坐标生成低频明暗、细颗粒、倾斜岩层和局部裂缝。比如岩层相位是高度与水平坐标的组合：

```text
bed = 0.78y + 0.135z + 0.043x + 低频噪声偏移
bedCell = floor(bed)
bedPhase = fract(bed)
```

每层通过哈希决定裂缝在这一周期中的位置，再用另一层噪声打断裂缝，避免变成连续的黑色等高线。朝上的台阶面增加苔藓偏好，河边较低的岩面变冷、变暗，雨天进一步降低粗糙度。几何用 flatShading 保留切面的硬边，材质负责让这些硬边属于同一组岩层。见 [geology.js](https://github.com/iamtechartist/mountain-railway-diorama/blob/6e8b0fbb806e486d7819b6d297b92f4ecf3d258b/geology.js)。

## 列车

![列车跟随镜头，可以观察两节车厢、窗框和底部转向架](/articles/mountain-railway/train.jpg)

### 车身

两节列车没有加载外部模型。作者先定义车身的二维截面，再沿车长放置多组截面，让两端逐渐收窄。相邻截面的顶点连接成外壳，车窗区域跳过对应面片，再加入窗框与单层玻璃。

单层玻璃避免封闭薄盒子产生两次相近的透明表面绘制，车窗后面则放置实际座椅、扶杆、窗帘、地板和车内灯。屋顶、通风罩、门、雨刷、车钩和气管都有各自的轮廓。车轮沿车轴回转一个包含轮缘、踏面和轮毂的剖面，悬挂弹簧沿螺旋线扫掠。

车厢涂装也不是叠一块很薄的红色面片：片元着色器根据车身局部高度做色带遮罩，直接改变 `diffuseColor`。底部积灰、细条纹和湿润后的粗糙度在同一份材质里计算。各部件完成后按材质合批，减少大量小对象的绘制次数。

### 姿态

列车保存的是沿路线前进的距离 `s`，路线总长为 `L`：

```text
u = mod(s/L,1)
s_next = s + v × dt
```

第一节车厢使用 `s`，第二节向后偏移 `8.3`。对每节车厢，源码在车厢参考距离前后各取 `2.25` 的采样点，也就是两个转向架的位置：

```text
A = routePointAt(s_car-2.25)
B = routePointAt(s_car+2.25)
center = (A+B)/2
T = normalize(B-A)
R = normalize(UP cross T)
N = normalize(T cross R)
```

用 `R、N、T` 组成旋转基，再转为四元数。车厢的姿态因此跟随前后支承点之间的弦，既能转弯，也会随线路坡度俯仰。

每个转向架还独立读取自身位置的曲线切线。把它在世界中的旋转乘上车厢旋转的逆，就得到挂在车厢下面的局部姿态。于是长车厢在弯道中保持整体方向，前后转向架又能分别沿轨道偏转。

这里没有刚体轮轨接触、车厢关节或真实悬挂模拟。车轮已经合在转向架的静态几何里，没有独立的滚动更新；源码声明的 `wheelGroups` 也未参与动画。我们应该把这部分理解为路径运动和姿态控制，而不是完整铁路物理。

### 停车

列车到站前会根据剩余距离限制速度。基础关系来自匀减速运动 `v²=2ad`，原实现保留了一个很小的停车余量：

```text
v_brake = sqrt(max(0, 2 × 0.57 × (ahead-0.06)))
v_target = min(v_cruise, v_brake)
v_next = v + clamp(v_target-v, -0.68dt, 0.34dt)
```

`ahead` 是到下一个停车点的剩余距离。弯道巡航速度还会参考前后切线夹角，转弯更急时适当放慢；用户车速倍率作用于巡航目标。加速度与减速度分别限制，避免速度瞬间跳变。

当距离和速度都进入停车阈值，列车对齐停车点，停留 `6.2` 秒，然后将下一次停车距离增加一个线路总长。主循环把更新拆成不超过 `0.05` 秒的子步，减少低帧率时跨过停车点的概率。这些数值服务于微缩场景的节奏，单位是场景单位，没有按真实列车标定。车身和服务运动见 [train.js](https://github.com/iamtechartist/mountain-railway-diorama/blob/6e8b0fbb806e486d7819b6d297b92f4ecf3d258b/train.js)。

## 建筑与道具

![车站近景，屋瓦、站台、时刻表、长椅和售货机由几何与运行时纹理组成](/articles/mountain-railway/station.jpg)

车站、神社、鸟居和农舍共用一组建模方法：二维轮廓挤出墙板，带倒角的梁连接两个端点，管状扫掠做栏杆、绳索与排水管，旋转轮廓做花盆、桶、铃铛和石灯笼。

屋顶采用一个可查询的曲面高度，再把屋瓦逐块铺上去。原实现的屋顶横截面为：

```text
roofY(z) = cy - 0.49abs(z) + 0.029z²
               + 0.15(abs(z)/halfWidth)^9
```

线性项给出屋面坡度，二次项让屋面弯曲，九次项让边缘翘起。每片瓦还加入横向小拱和搭接厚度，檐口曲线、脊瓦、梁柱随后跟着同一形状布置，所以屋顶既有整体轮廓，也有近看的节奏。

建筑落地同样依靠共享地形。车站先按路线切线转向，平台侧墙逐点延伸到实际山坡；前庭不只是浮在地上的水平板，而是把边界密采样后向下补墙。农舍先在旋转后的占地范围内查询最高地面，建立水平基础，再用石墙补齐地面与基础之间的落差。花盆底座也检查多点高度，避免一半埋在坡里、一半悬空。

标牌和时刻表通过 Canvas 绘制后生成纹理。小的有纹理对象保留 UV 与独立网格，其余静态建筑按材质合并。窗口则使用一层随视角偏移的室内图案：把窗面 UV 按观察方向轻微移动，再画出座椅、灯罩和墙面明暗，让凹入的窗户有浅浅的进深；它没有求解完整室内空间的射线交点。

车站旁的自行车、扫帚、水桶、花盆、售货机和神社纸垂，作用是给建筑补充尺度与生活痕迹。它们依然是轮廓、梁、管线和回转体的组合，而非下载的小道具模型。见 [architecture.js](https://github.com/iamtechartist/mountain-railway-diorama/blob/6e8b0fbb806e486d7819b6d297b92f4ecf3d258b/architecture.js)。

沿线设施也沿共享路线布置。电线杆从轨道向一侧偏移，隧道区不放杆，通信线用 `sin(πf)` 形成端点之间的下垂。八处山路灯先在目标附近按环搜索候选点，排除轨道、河流和房屋，再按偏离距离与坡度打分；灯脚边界采样实际地形，灯罩、弯曲杆身、古铜套环和小检修盖分别生成后合批。灯具数据还交给植被模块，避免树木直接挡住灯头。见 [lanterns.js](https://github.com/iamtechartist/mountain-railway-diorama/blob/6e8b0fbb806e486d7819b6d297b92f4ecf3d258b/lanterns.js)。

## 植被

### 植物模型

植被有杉、松、枫和竹四组树木，以及蕨、灌木、草和野花。树干与枝条沿弯曲中心线逐渐收窄；针叶并不是给树套几个圆锥，而是带折叠脊线和锯齿轮廓的叶簇。枫叶用五裂轮廓，抬起中脉、压低部分边缘；竹子由多根弯曲竹竿和细长叶片组成。

作者先生成六种杉、四种松、四种枫和三种竹的原型，再以实例矩阵改变位置、旋转、比例和明暗。原型负责植物种类的可辨认形态，实例负责个体差异。

### 分布

种植点先在场景范围内随机采样，再按一组规则拒绝不合适的位置：超出山体边界、离轨道或河流太近、坡度太大、靠近房屋与灯具、挡住车站和瀑布视线，都不会种树。已接受的树之间还要保持最小间距。

这是一种带约束的拒绝采样，带有最小距离检查，并不是完整实现的 Poisson-disk 采样算法。低频正弦场控制树林疏密、树种和枫叶色块，因此相邻枫树会共享相近色系，而不是每一棵独立抽一个红黄颜色。

源码还保留十个 `heroSites`，用明确坐标摆放主镜头的重要树，前景树的比例、方向和颜色都有参数。也就是说，自动分布负责成片环境，重要轮廓仍由构图决定。

### 风动

每个植物顶点保存 `aFlex` 和 `aFlutter`：前者控制整体弯曲，后者控制叶片细碎摆动。树根的弯曲权重很小，树梢和叶尖较大。每个实例另外保存一组 `iWindState`，包含共享摆动方向、下压量和局部摆动幅度。

阵风相位与世界位置有关：

```text
gustPhase = 0.082x + 0.049z
gust = max(0, sin(gustPhase-0.55time))^5
```

五次幂把连续正弦压成较短的阵风脉冲，空间相位让阵风像沿着山坡传播。CPU 每株计算一次这组状态，近景着色器再添加分枝振动和叶片 flutter；远景只保留较缓慢的整体弯曲。

植物的阴影深度材质也调用同一个 `botanicalDeform`。这一步很容易漏掉：只修改可见材质会让叶片移动，阴影却还停在原位置。植物模型、分布与风动见 [vegetation.js](https://github.com/iamtechartist/mountain-railway-diorama/blob/6e8b0fbb806e486d7819b6d297b92f4ecf3d258b/vegetation.js)。

## 水体

![瀑布近景，水片、撞击泡沫、岩石和局部水雾沿实际落差布置](/articles/mountain-railway/cascade.jpg)

### 河道与岸线

河流和地形共用 `riverX(z)` 与 `waterLevel(z)`。河流中心是一条沿 z 变化的正弦曲线，水位则把三个主要落差连接起来：

```text
riverX(z) = 16 + 6.2sin(0.060z) + 1.7sin(0.16z)
waterLevel(z) = 10.08 - 0.0035z
              - 2.35smoothstep(-0.3,2.4,z)
              - 17.60smoothstep(4.5,8.5,z)
              - 1.20smoothstep(11.8,14.8,z)
```

这些落差明确写在场景参数里，不是水流自动侵蚀出的河床。地形先按同一条河流中心压出河谷，水面再查询实际高度，决定哪里有水。

水面沿河纵向分成 300 段，横向分成 22 段。每一横断面都从中心向左右粗步进，找到地面高于水位的位置，再二分九次逼近岸线。这样河流宽度能随地形变化，而不必给整条河统一宽度。

每个顶点保存 `aDepth=max(0,水位-地形高度)`。如果相邻纵向行的河中心没有浸水，就不连接这一段水面三角形。岸线、深度、河床与岩石的关系都来自同一个高度函数。见 [river.js](https://github.com/iamtechartist/mountain-railway-diorama/blob/6e8b0fbb806e486d7819b6d297b92f4ecf3d258b/river.js)。

### 波纹与水色

水面的几何位移只用了两组小幅正弦：

```text
wave = 0.018sin(2.2x+1.1z-1.5time)
     + 0.009sin(2.8z-0.7x-2.2time)
wave *= smoothstep(0.025,0.22,depth)
```

深度遮罩让靠岸的位移变小，减少水面在浅岸反复穿出地表。片元着色器另加较明显的法线扰动，让微小波纹在高光和倒影里可见。法线扰动的系数并不是上述位移的严格导数，所以它属于视觉强化，不能直接当作 CPU 接触计算的水面坡度。

水色按深度从薄薄的暖色边缘过渡到矿物绿浅水，再到深色水潭。透明度也随深度增加，浅水处能看见真实生成的河床碎石。多频 value noise 负责大块水色变化和细纹；噪声坐标沿时间移动，形成沿河流动的观感。

这里的 value noise 是给网格角点做哈希，经过平滑插值后得到连续值。三层频段以 `0.65、0.25、0.10` 混合，频率逐层提高。它提供流纹，并没有求解水的速度场或压力场。

### 反射

反射用 Three.js 的 `Reflector` 辅助镜像相机，绘制到一张 `384×384` 的 HalfFloat 纹理。辅助几何不显示在场景里，只负责计算镜像视角、裁切和投影矩阵。反射前暂时隐藏水体和天气所在的组，避免把水和其附属效果再反射进去。

镜像平面放在下游 `z=22` 对应的水位上。由于整条河有多个落差，一张平面纹理不可能准确反映所有高度，因此源码只在接近下游水位、表面接近水平的区域提高反射权重。

反射纹理以投影坐标采样，再用波纹扰动 UV。混合系数包含一项风格化的 Fresnel 近似：

```text
F = 0.055 + 0.42(1-clamp(dot(V,N),0,1))³
```

`V` 是朝向观察者的方向，`N` 是水面法线。斜着看水时反射增强，正面看时河床和水色更明显。这里的三次幂与权重是项目调色，不是严格的介电界面 Schlick 参数。

反射每 `100ms` 最多刷新一次，手动操作时为 `125ms`，也就是约 10Hz 和 8Hz。主画面继续逐帧渲染，上一张反射纹理与其矩阵一起保留。这种安排把倒影成本与主视图分开，但快速转动时也可能看到反射更新滞后。

### 泡沫与雨滴

河里的十六块障碍岩石与泡沫共用一组中心、半径数据。泡沫不是另一套随机白点：岩脚用变形后的距离环生成泡沫领口，下游尾迹则用横向高斯衰减和纵向指数衰减近似：

```text
wake ≈ exp(-(侧向距离/宽度)²)
       × exp(-下游距离/长度)
       × 下游方向遮罩
```

流动噪声打断这些轮廓，岸边泡沫再用浅水深度遮罩补充。两者叠加后仍有上限，避免整条河铺满白色。

雨滴涟漪把世界坐标划成网格，每格哈希决定循环相位，用逐渐扩大的圆环扰动法线；雨量直接控制幅度。它没有为每一条雨丝追踪真实落水碰撞，而是用统计上相似的水面动效配合雨天。

太阳与月亮的水面亮带也有专门处理。着色器用观察方向与光方向的半角向量，计算高指数的法线响应，分别以暖色和冷色添加细高光，再随流纹闪动。这样夜间能看到窄而冷的水光，黄昏则偏暖。

### 瀑布

瀑布由 `cascade.js` 增加的四段水片覆盖三个主要落差。网格仍然读取 `waterLevel(z)`，UV 的纵向坐标则累计水流中心线上的实际距离，使倾斜、陡直和较平的部分都能连续滚动纹理。

每个顶点额外存两项参数：`aEnergy` 根据局部水位坡度增大，`aImpact` 根据当前位置与前方的坡度差估计冲击区。水流即将从陡坡转成平缓水潭时，冲击权重上升；水片末端也补充一小段冲击权重。

片元着色器据此控制细流条带、破碎透明度、暗色水膜和撞击泡沫。流纹越密、冲击越强，局部越白；比较平滑的部分保留深色，瀑布才不会变成一块统一的白布。150 个 Points 水雾围绕几个冲击位置循环升起、漂移并消散，河边的碎石、苔藓和倒木补出碰撞环境。

场景边缘还有一段向外折下的出流水片，它使用河流材质。主瀑布的水片、水雾和泡沫是另一组补充，二者共同服务于这一段连续河道。实现见 [cascade.js](https://github.com/iamtechartist/mountain-railway-diorama/blob/6e8b0fbb806e486d7819b6d297b92f4ecf3d258b/cascade.js)。

### 与海湾水体的区别

| 部分 | Tideglass Cove | 山间铁路 |
| --- | --- | --- |
| 水体形状 | 带连续侧面的海湾剖面，潮位可变 | 跟随地形岸线的河道，多段预设落差 |
| 水下可见 | 深度折射与光学合成 | 透明浅水显示实际河床 |
| 反射 | 海湾水面的专门反射流程 | 下游局部平面 Reflector |
| 接触效果 | 湿度历史、涟漪事件与入水动效 | 岩脚泡沫、尾迹与雨滴法线涟漪 |
| 湿润状态 | 空间湿度历史纹理 | 全场共享 wetness，缓慢随天气变化 |
| 强调内容 | 潮汐、水下生命与水体剖面 | 流水、瀑布、雨天和列车旅程 |

我们可以复用岸线查询、共享深度、泡沫遮罩和低频反射，但不能把上一篇的屏幕深度折射或湿度 DataTexture 原样解释成这个项目的实现。

## 天气与动效

![雨天主镜头，冷色山谷与车站、列车的暖光形成对比](/articles/mountain-railway/rain.jpg)

雨丝由一个 `LineSegments` 绘制，包含 8400 条线段。每条有随机种子与长度，顶点着色器用循环取模让雨丝从上方持续落下，再按风力倾斜。雨量通过种子阈值控制可见比例，所以调整雨量不需要不断创建和删除对象。

局部雾使用柔边 Points，主要放在河谷、隧道口和瀑布落点。片元颜色与透明度随夜色和雨量变化，点大小随观察距离变化。靠近列车前方的雾还根据方向夹角与距离变暖、变亮，与车头光锥配合，形成灯光穿过潮湿空气的观感。它是局部粒子近似，没有做全场体积光积分。

站台有七块不规则积水，雨檐有少量循环下落的水线。全场 `wetness` 用比光照更慢的插值变化，岩石、建筑、列车和叶片分别接收同一个值：颜色变暗、粗糙度降低，高光更集中；积水透明度也跟着增加。因此天气切回晴天后，表面不会立即恢复完全干燥。

其他动效保持局部而克制：54 片漂浮枫叶共用一个受光照的网格，从实际枫树林附近出发，循环飘落；85 个萤火虫分布在几个固定区域，以不同相位缓慢运动和闪烁。它们没有独立的动物行为系统。雨、雾、落叶和萤火虫见 [weather.js](https://github.com/iamtechartist/mountain-railway-diorama/blob/6e8b0fbb806e486d7819b6d297b92f4ecf3d258b/weather.js)。

## 光照与风格

![夜间山谷，局部暖光保留列车与车站，远山退成冷色轮廓](/articles/mountain-railway/night.jpg)

画面的风格来自几何、色板、光照和构图共同控制。桥梁是赭色，列车是米白与红色，枫树采用成片的暖色，屋顶、河流和远山则偏灰绿与蓝灰。强轮廓用明确几何，细纹理控制得较弱，重要主体也保留了周围空隙。

岩石切面、折叠针叶和具体屋瓦让近景有结构，PBR 材质又保留了阴影、湿润与角度高光。细颗粒纹理通过 `fwidth` 估计屏幕上的变化率，距离远时降低幅度，减少细纹闪烁。材质能够补丰富度，但画面的大色块和视线走向已经由布局确定。

### 昼夜

四种天气是四套协调参数。它们同时控制主光颜色、位置与强度，半球光天空与地面颜色，补光，雾颜色与密度，背景天空，雨量，湿度，环境反射强度和曝光。夜间还提高车内灯、车头灯、尾灯、站台灯与沿路灯的贡献。

| 模式 | 主光 | 环境反射强度 | 主要画面关系 |
| --- | --- | --- | --- |
| 白天 | 高位暖白太阳，强度 3.35 | 0.27 | 山坡受光与阴影清楚，树叶保持暖色 |
| 黄昏 | 较低的暖橙主光，强度 3.6 | 0.16 | 铁轨和树冠出现暖高光，暗面偏冷 |
| 雨天 | 较弱的冷色主光，强度 0.5 | 0.09 | 山谷变冷、湿，车窗与灯具成为亮点 |
| 夜晚 | 后方冷色月光，强度 0.52 | 0.03 | 保留深黑与远山轮廓，局部暖灯突出旅程 |

环境反射来自 `RoomEnvironment` 经 PMREM 预过滤后的固定纹理，模式变化调整其强度，并没有实时重生成整个场景的环境探针。

各参数采用指数方式平滑靠近目标：

```text
alpha = 1-exp(-1.5dt)
value_next = value + (target-value) × alpha
```

`dt` 是以秒计的帧间隔。相同的时间常数让不同帧率下的过渡接近一致；湿度使用更慢的系数。主光、雾和材质会一起变化，切换模式时画面不只是背景换色。

### 远山与天空

背景有三层封闭山脊，半径约为 145、235、370，分别有自己的高度变化和雾化比例。远山着色器降低饱和度与局部对比，再向天空颜色混合；谷底额外增加雾感，黄昏时朝向太阳的坡肩增加少量暖色。这让远景后退，同时保留山脊的立体面。

天空用全屏三角形绘制。着色器通过相机逆投影矩阵与方向基，把屏幕位置还原为世界观察方向，再按仰角计算天空渐变。云是沿方向采样的多频噪声，太阳和月亮是观察方向与光方向夹角的圆盘，星星是球面方向网格中的稀疏哈希点；月面加入噪声明暗。

这是一套有针对性的天空与空气透视近似，没有完整的大气散射 LUT，也没有光线步进的体积云。它的优势是成本较低，而且从不同镜头观察时，太阳、月亮和天空结构仍然与方向相符。见 [atmosphere.js](https://github.com/iamtechartist/mountain-railway-diorama/blob/6e8b0fbb806e486d7819b6d297b92f4ecf3d258b/atmosphere.js)。

### 灯光与泛光

一盏灯的可见形状、照亮周围的光，以及空气里的光晕，分别由不同对象承担。灯罩用发光材质；PointLight 或 SpotLight 照亮场景；Canvas 渐变光晕、局部雾和透明光锥补出空气中的可见光。

阴影也按作用分配：主光用 `2048×2048` 阴影，只有一侧车头聚光灯使用 `1024×1024` 阴影，站台有一份 `512×512` 的缓存阴影；其余不少局部灯只提供照明。这样不用让每一盏小灯重复绘制完整场景。

`luminous.js` 还做了一条小型 HDR 泛光链。场景先渲染到 HalfFloat 纹理，提取高于普通表面亮度的区域，在宽高都缩到四分之一的纹理上横向、纵向过滤，再与清晰的主图合成。四分之一宽高意味着像素数量约为原图的十六分之一。

```text
brightWeight = smoothstep(1.35,2.7,max(R,G,B))
finalLinear = (sharp + bloom × strength) × exposure
strength = 0.018 + 0.17night
filmic(x) = clamp((x(2.51x+0.03))/(x(2.43x+0.59)+0.14),0,1)
```

亮区阈值保留黑位，夜色增加泛光强度，最后的 ACES 近似映射压缩高亮，再进行颜色空间输出。它不是把整张图模糊一遍，所以窗灯会发亮，暗山仍然保持清楚的轮廓。见 [luminous.js](https://github.com/iamtechartist/mountain-railway-diorama/blob/6e8b0fbb806e486d7819b6d297b92f4ecf3d258b/luminous.js)。

## 镜头

场景使用透视相机，垂直视场角为 `46°`，OrbitControls 提供拖动、缩放和平移。最初创建相机的位置只是初始化值，实际开场会被主镜头替换：横屏时主镜头位置为 `(22,13,60)`，目标为 `(6,9,12)`。

这个位置把列车放在主画面偏左的钢桥上，桥面向右延伸到瀑布与隧道，车站留在边缘，前景树框住山谷。开场还有一次约 `2.7` 秒的轻推近，让画面稳定进入这个构图。

除了主镜头，界面提供车站、神社、桥梁、河流、瀑布、隧道、出洞、灯具细节、列车跟随和全景等视角。镜头的目标大多来自对象位置，而不是另外复制一套世界坐标。竖屏时根据 `max(1,1/aspect)` 拉远相机与目标之间的距离，尽量保住主画面。

自动镜头由列车所在区段选择。过桥时看桥，进入山体后可以看瀑布或河流，接近出口时切到出洞镜头，回到站台时看车站；部分镜头还根据圈数与夜色变化。每个镜头至少驻留约 `7.2` 秒，避免在区段边缘频繁切换。

转场使用五次 smootherstep：

```text
e(t) = 6t⁵-15t⁴+10t³
camera = mix(from,to,e)
target = mix(fromTarget,toTarget,e)
```

这个函数在两端的一阶、二阶导数都为零，起步与收尾较柔和。转场开始前还会采样路线中的地形高度，按需要增加一段弧形抬升：

```text
y_camera += sin(πe)^0.35 × lift
```

`lift` 由途中最高净空需求与路程长度共同决定，避免镜头直线穿过山坡。固定镜头落定后只保留很慢的漂移，把主要运动交给列车、流水和落叶。手动拖动会退出自动镜头与列车跟随，操作结束不会立即被系统抢回。见 [cinematography.js](https://github.com/iamtechartist/mountain-railway-diorama/blob/6e8b0fbb806e486d7819b6d297b92f4ecf3d258b/cinematography.js)。

## 性能与细节

这个场景的小部件很多，源码同时在几何、光照和离屏渲染上控制成本。

| 方法 | 项目里的具体处理 | 可以复用的原因 |
| --- | --- | --- |
| 静态合批 | 建筑、桥梁和车厢按材质合并，纹理标牌保留独立 UV | 减少小部件 draw calls，同时保住必要材质信息 |
| 实例化 | 树木、地被和 2200 个道砟共享原型 | 保留数量感，减少重复网格对象 |
| 植物 LOD | 普通树进入近景阈值 46、退出阈值 54，重要树用 90 | 近看保留枝叶，边界不反复跳档 |
| 道砟 LOD | 八个区段，进入近景 60、退出 72 | 用同位置的简化几何替换远处碎石 |
| 分开刷新 | 阴影最多约 20Hz，反射约 8～10Hz | 主视图持续运动，不必逐帧重画所有附加 pass |
| 泛光降采样 | 两张宽高为主图四分之一的纹理 | 降低过滤成本，保留清晰主图 |
| 自适应像素比 | 异步 GPU 计时后，在 1～1.5 内调整 | 只在持续压力下改分辨率，避免频繁振荡 |
| 编译预热 | 临时启用近远几何，再 compileAsync | 减少第一次进入近景时才编译材质的停顿 |

树木 LOD 的两级距离形成迟滞：已经处于近景的树，要更远才退出；远景树则必须更近才进入。它不是每帧判断一个固定阈值，因此镜头在边界轻微晃动时不容易来回替换。简化仍保留树冠外轮廓、实例位置与色板，减少突然跳形和跳色。

阴影还有一个针对性处理：大批量植物保留太阳的正交阴影，也能接收车灯照明，但在狭窄的透视灯光阴影绘制时暂时跳过这些大批次。灯光存在不等于每份阴影都必须包含整片森林。

GPU 计时使用 `EXT_disjoint_timer_query_webgl2`，异步读取已完成查询，动画循环不等待 GPU 或读回像素。只有 GPU 时间持续高于 `25ms` 才逐步降像素比，恢复也要求更长的稳定时间。性能统计把主视图、反射、阴影和后处理实际调用一起累计；因此读 draw calls 时不能只把它当成场景里网格的数量。实现见 [render-budget.js](https://github.com/iamtechartist/mountain-railway-diorama/blob/6e8b0fbb806e486d7819b6d297b92f4ecf3d258b/render-budget.js)。

还有几项看似很小、但会直接影响近景的处理：车窗有实际开孔，玻璃与窗框保留间隙；凹形屋顶截面用轮廓三角化封口，避免中心扇形造成重叠三角形；建筑合批后保留需要的标牌和窗口 UV；相机近裁面随观察距离调整；入口有加载提示和 WebGL 2 错误说明，控制面板兼顾键盘与窄屏，系统要求减少动态效果时默认暂停场景。

## 学习总结

山间铁路最适合复用的经验，是让路线成为全场景共同的空间依据。我们先决定旅程，再用代码生成铁路、路基、桥隧、车站和列车；构图与净空规则限制植被，昼夜与雨量统一影响灯光、材质和水体，镜头再跟着旅程观察这些变化。

如果只让 AI 分别“生成山、生成铁路、生成火车”，各模块很容易各用一套坐标和高度。把共享接口与生成顺序交代清楚，会更容易得到能一起修改的场景。

| 本项目的经验 | 应整理进 Skill 的内容 | 可以迁移到哪里 |
| --- | --- | --- |
| 路线共享 | 中心线、弧长、切线、最近距离、桥隧区段 | 铁路、道路、运河、巡逻路径 |
| 设施与地形联动 | 普通区压平、桥区留空、隧道保留覆盖与净空 | 山路、桥梁、洞穴入口 |
| 车辆姿态 | 前后支承点决定车体，局部转向架独立沿线 | 长车辆、观光车、路径运输 |
| 停靠节奏 | 距离制动、加减速上限、停留计时、子步 | 站点交通、移动平台、机械演示 |
| 约束式分布 | 坡度、海拔、建筑与路径排除，主体树明确布局 | 林地、营地、村落和街道 |
| 风与阴影一致 | 顶点权重、实例阵风、深度材质同一位移 | 草、树、旗帜等受风对象 |
| 环境统一状态 | 天气参数表、指数过渡、湿度滞后 | 昼夜、雨雪、季节演示 |
| 路径事件镜头 | 区段触发、驻留时间、平滑转场、地形避让 | 演示漫游、交通动画、旅程叙事 |
| 分层渲染成本 | 迟滞 LOD、反射与阴影节流、低分辨率泛光 | 有大量细节的浏览器 3D 场景 |

这些内容已经补进现有的 **procedural-3d-scene Skill**：入口保留通用方法，新增 `references/mountain-railway.md` 说明本项目的路线、建模、运动、环境和渲染做法；上一篇海湾案例仍然保留。三份文件可以直接查看：[Skill 正文](/articles/mountain-railway/procedural-3d-scene/SKILL.md)、[山间铁路案例](/articles/mountain-railway/procedural-3d-scene/references/mountain-railway.md)、[完整下载包](/articles/mountain-railway/procedural-3d-scene.zip)。

复用时，我们可以这样交代任务：

```text
使用 $procedural-3d-scene，参考 references/mountain-railway.md。

用 Three.js 制作一座可交互的山间铁路微缩场景。
先给出主镜头、色板、路线控制点及桥/隧道/车站区段，
建立共享的地形高度、河流水位、路线弧长采样与净空接口。

先让两节占位列车完成转弯、进站减速、停车与循环，
再依次生成轨道、桥梁、真实洞口、车站、植物、河流与瀑布。
关键主体明确布局；植被使用带种子的约束采样，避免挡住视线。

昼夜和雨量共用一套状态，材质湿度稍慢变化。
可见植物和阴影使用同一风动，近远景保留相同位置与色板。
自动镜头按列车事件切换，转场检查地形，手动操作能退出。

交付可运行页面、可调整参数、实际截图与来源许可，
检查车厢姿态、桥隧净空、建筑落脚、四种天气和窄屏构图。
```

这个 Skill 要保存的是模块如何共享数据、怎样生成形状、怎样检查连接和运动。路线点、车速、镜头坐标与颜色则留在案例中按新场景调整。我们由此学到的，是把一段明确的场景体验拆成可执行的生成步骤，再让这些步骤共享同一套空间与时间。
