AI 生成 3D 场景拆解 02:山间铁路
Revio ·
学习 Techartist 的山间铁路,拆解铁路曲线、地形与桥隧、列车建模与停车、建筑植被、河流瀑布、雨夜光照和镜头,并将共享路径与生成方法补进可复用 Skill。

在本站体验山间铁路 Demo · 原作者 GitHub 仓库
Mountain Railway Diorama,也叫 Yamaai,是一个可以旋转、缩放和切换镜头的日本山间铁路微缩场景。两节列车沿着山谷中的环线行驶,经过钢桥、隧道、瀑布和小车站,到站后减速停车,再继续下一圈。我们还可以切换白天、黄昏、雨天与夜晚,调整风、雨量和车速,或让镜头自动跟着旅程切换。
本篇参考学习的 Demo 来自 Techartist(GitHub 用户 iamtechartist),原地址为 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。
| 部分 | 实际使用的技术 | 主要作用 |
|---|---|---|
| 渲染 | 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。
程序化生成
这个场景的模型和纹理由代码构造,没有导入外部 GLTF、FBX 模型,也没有加载外部贴图。车站标牌、时刻表、列车目的地和灯光光晕使用运行时 CanvasTexture;木纹、岩层、水纹、云和星空由着色器计算。运行依赖也随仓库保存,场景不需要在线下载模型或纹理。
但程序化生成并不意味着每次随机生成一座新山谷。铁路的十二个控制点、车站与神社的选址、桥隧区段、主镜头和几棵重要的前景树,都是作者明确写出的布局。随机数主要负责枝叶形态、岩石差异、植被候选点和道砟变化,而且有固定种子。
例如通用几何模块的随机数发生器是一个线性同余序列:
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。
铁路
路线
铁路中心线是一条闭合的 Catmull–Rom 曲线。十二个三维控制点同时给出路线在水平面上的走向和高低变化,曲线类型显式设为 catmullrom,张力为 0.34。
const curve = new THREE.CatmullRomCurve3(
controlPoints, true, 'catmullrom', 0.34
);
curve.arcLengthDivisions = 1800;
curve.updateArcLengths();
这段是将原实现提取后的核心调用。控制点使线路绕过山谷,从前方钢桥转进背后的山体,再回到左侧车站。张力控制各段的切线长度,影响转弯的松紧。
如果展开一段曲线,它可以写成三次 Hermite 插值。以当前段的两个端点 P1、P2 为例,相邻点 P0、P3 决定端点切线:
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 曲线文档。
轨道
轨道没有直接用两根圆管代替。源码定义了钢轨底板、轨腰和轨头的二维截面,再沿中心线扫掠:
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。
地形
山体
地形首先是一个可查询的高度函数 height(x,z)。底层山体由几组高斯山峰组成,再叠加弯曲山脊、峰顶凸起、正弦细节与侵蚀状凹痕:
h_peak(x,z) = A × exp(-((x-cx)²/sx + (z-cz)²/sz))
h_base = 底部高度 + 多个山峰 + 山脊 + 峰顶 + 细节 - 凹痕
A 控制山峰高度,cx、cz 控制位置,sx、sz 控制横向尺度。它给出可直接调整的大形状;几组小幅正弦再打破表面的平整感。这里没有运行真实水力侵蚀模拟。
地形会继续询问铁路和河流的位置。铁路被预采样成 680 个点,每个地表坐标查找最近采样点,得到线路高度、距离与区段。普通路段把近轨地形平滑压到道床下方;桥区不压平峡谷;隧道区则保证轨道上方有山体覆盖。车站平台和神社所在地还使用局部遮罩压出较平的台地。
这些局部修改大量使用 smoothstep。把区间中的坐标归一化成 q 后,其核心是:
q = clamp((x-a)/(b-a), 0, 1)
smoothstep(a,b,x) = q²(3-2q)
h = mix(目标高度, 原高度, smoothstep(近距离,远距离,d))
这样铁路附近先保持平整,离开路基后逐渐恢复山坡,边缘不会出现突然的折线。680 点的最近距离仍然是离散近似,精确净空需要继续检查生成后的几何。
网格与剖面
山谷主体采用极坐标网格,径向 112 段、圆周 216 段。基本轮廓是一块横纵半轴约为 56 和 43 的椭圆,边界再加入不同频率的角向变化:
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) 的单一高度。把山体抬到列车上方,可以形成“屋顶”,但进入山体的那一圈面片仍可能横在洞口,像一张帘子。
项目实际裁切了这部分面片。两端入口共用马蹄形净空轮廓,再加上前后截断平面。对附近每个地形三角形逐平面裁切,留下空腔以外的多边形,最后重新三角化。边与平面的交点由有符号距离计算:
f = da / (da-db)
V_intersection = Va + f × (Vb-Va)
da、db 是边的两个端点到当前平面的有符号值。位置、顶点颜色和法线一起插值,法线重新归一化,因此切开的边缘仍能接上原地形的颜色与光照。它属于逐平面的多边形裁切方法,不是对整座山运行通用 CSG。
洞内衬砌则把另一份马蹄形截面沿路线扫掠 140 段。入口拱门由二维形状挤出,拱石按角度分段,侧翼石块逐层布置。洞口、衬砌和列车共用路线,生成后再用射线检查入口是否通畅。这里的关键是同时处理“山体覆盖”和“入口空腔”,而不是只在山上贴一张黑色洞口。
岩石
普通碎石采用六层截面环和九个周向点,环半径随高度变化,再给角度、半径和中心加入带种子的扰动。峡谷的大岩壁则使用单独的 geology.js:先定义不对称的八边形基底,再沿不同高度连接多层截面,层与层之间错动、外凸和后退,形成劈裂岩体与薄片状峰顶。
这些岩体不是完全均匀散落。峡谷支撑体与峰顶岩片有明确位置,沿河的大岩壁会查询 railDistance 和 railHeight;如果接近铁路,就把岩顶限制在轨道下方,给桥上列车留下净空。
材质使用世界坐标生成低频明暗、细颗粒、倾斜岩层和局部裂缝。比如岩层相位是高度与水平坐标的组合:
bed = 0.78y + 0.135z + 0.043x + 低频噪声偏移
bedCell = floor(bed)
bedPhase = fract(bed)
每层通过哈希决定裂缝在这一周期中的位置,再用另一层噪声打断裂缝,避免变成连续的黑色等高线。朝上的台阶面增加苔藓偏好,河边较低的岩面变冷、变暗,雨天进一步降低粗糙度。几何用 flatShading 保留切面的硬边,材质负责让这些硬边属于同一组岩层。见 geology.js。
列车

车身
两节列车没有加载外部模型。作者先定义车身的二维截面,再沿车长放置多组截面,让两端逐渐收窄。相邻截面的顶点连接成外壳,车窗区域跳过对应面片,再加入窗框与单层玻璃。
单层玻璃避免封闭薄盒子产生两次相近的透明表面绘制,车窗后面则放置实际座椅、扶杆、窗帘、地板和车内灯。屋顶、通风罩、门、雨刷、车钩和气管都有各自的轮廓。车轮沿车轴回转一个包含轮缘、踏面和轮毂的剖面,悬挂弹簧沿螺旋线扫掠。
车厢涂装也不是叠一块很薄的红色面片:片元着色器根据车身局部高度做色带遮罩,直接改变 diffuseColor。底部积灰、细条纹和湿润后的粗糙度在同一份材质里计算。各部件完成后按材质合批,减少大量小对象的绘制次数。
姿态
列车保存的是沿路线前进的距离 s,路线总长为 L:
u = mod(s/L,1)
s_next = s + v × dt
第一节车厢使用 s,第二节向后偏移 8.3。对每节车厢,源码在车厢参考距离前后各取 2.25 的采样点,也就是两个转向架的位置:
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,原实现保留了一个很小的停车余量:
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。
建筑与道具

车站、神社、鸟居和农舍共用一组建模方法:二维轮廓挤出墙板,带倒角的梁连接两个端点,管状扫掠做栏杆、绳索与排水管,旋转轮廓做花盆、桶、铃铛和石灯笼。
屋顶采用一个可查询的曲面高度,再把屋瓦逐块铺上去。原实现的屋顶横截面为:
roofY(z) = cy - 0.49abs(z) + 0.029z²
+ 0.15(abs(z)/halfWidth)^9
线性项给出屋面坡度,二次项让屋面弯曲,九次项让边缘翘起。每片瓦还加入横向小拱和搭接厚度,檐口曲线、脊瓦、梁柱随后跟着同一形状布置,所以屋顶既有整体轮廓,也有近看的节奏。
建筑落地同样依靠共享地形。车站先按路线切线转向,平台侧墙逐点延伸到实际山坡;前庭不只是浮在地上的水平板,而是把边界密采样后向下补墙。农舍先在旋转后的占地范围内查询最高地面,建立水平基础,再用石墙补齐地面与基础之间的落差。花盆底座也检查多点高度,避免一半埋在坡里、一半悬空。
标牌和时刻表通过 Canvas 绘制后生成纹理。小的有纹理对象保留 UV 与独立网格,其余静态建筑按材质合并。窗口则使用一层随视角偏移的室内图案:把窗面 UV 按观察方向轻微移动,再画出座椅、灯罩和墙面明暗,让凹入的窗户有浅浅的进深;它没有求解完整室内空间的射线交点。
车站旁的自行车、扫帚、水桶、花盆、售货机和神社纸垂,作用是给建筑补充尺度与生活痕迹。它们依然是轮廓、梁、管线和回转体的组合,而非下载的小道具模型。见 architecture.js。
沿线设施也沿共享路线布置。电线杆从轨道向一侧偏移,隧道区不放杆,通信线用 sin(πf) 形成端点之间的下垂。八处山路灯先在目标附近按环搜索候选点,排除轨道、河流和房屋,再按偏离距离与坡度打分;灯脚边界采样实际地形,灯罩、弯曲杆身、古铜套环和小检修盖分别生成后合批。灯具数据还交给植被模块,避免树木直接挡住灯头。见 lanterns.js。
植被
植物模型
植被有杉、松、枫和竹四组树木,以及蕨、灌木、草和野花。树干与枝条沿弯曲中心线逐渐收窄;针叶并不是给树套几个圆锥,而是带折叠脊线和锯齿轮廓的叶簇。枫叶用五裂轮廓,抬起中脉、压低部分边缘;竹子由多根弯曲竹竿和细长叶片组成。
作者先生成六种杉、四种松、四种枫和三种竹的原型,再以实例矩阵改变位置、旋转、比例和明暗。原型负责植物种类的可辨认形态,实例负责个体差异。
分布
种植点先在场景范围内随机采样,再按一组规则拒绝不合适的位置:超出山体边界、离轨道或河流太近、坡度太大、靠近房屋与灯具、挡住车站和瀑布视线,都不会种树。已接受的树之间还要保持最小间距。
这是一种带约束的拒绝采样,带有最小距离检查,并不是完整实现的 Poisson-disk 采样算法。低频正弦场控制树林疏密、树种和枫叶色块,因此相邻枫树会共享相近色系,而不是每一棵独立抽一个红黄颜色。
源码还保留十个 heroSites,用明确坐标摆放主镜头的重要树,前景树的比例、方向和颜色都有参数。也就是说,自动分布负责成片环境,重要轮廓仍由构图决定。
风动
每个植物顶点保存 aFlex 和 aFlutter:前者控制整体弯曲,后者控制叶片细碎摆动。树根的弯曲权重很小,树梢和叶尖较大。每个实例另外保存一组 iWindState,包含共享摆动方向、下压量和局部摆动幅度。
阵风相位与世界位置有关:
gustPhase = 0.082x + 0.049z
gust = max(0, sin(gustPhase-0.55time))^5
五次幂把连续正弦压成较短的阵风脉冲,空间相位让阵风像沿着山坡传播。CPU 每株计算一次这组状态,近景着色器再添加分枝振动和叶片 flutter;远景只保留较缓慢的整体弯曲。
植物的阴影深度材质也调用同一个 botanicalDeform。这一步很容易漏掉:只修改可见材质会让叶片移动,阴影却还停在原位置。植物模型、分布与风动见 vegetation.js。
水体

河道与岸线
河流和地形共用 riverX(z) 与 waterLevel(z)。河流中心是一条沿 z 变化的正弦曲线,水位则把三个主要落差连接起来:
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。
波纹与水色
水面的几何位移只用了两组小幅正弦:
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 近似:
F = 0.055 + 0.42(1-clamp(dot(V,N),0,1))³
V 是朝向观察者的方向,N 是水面法线。斜着看水时反射增强,正面看时河床和水色更明显。这里的三次幂与权重是项目调色,不是严格的介电界面 Schlick 参数。
反射每 100ms 最多刷新一次,手动操作时为 125ms,也就是约 10Hz 和 8Hz。主画面继续逐帧渲染,上一张反射纹理与其矩阵一起保留。这种安排把倒影成本与主视图分开,但快速转动时也可能看到反射更新滞后。
泡沫与雨滴
河里的十六块障碍岩石与泡沫共用一组中心、半径数据。泡沫不是另一套随机白点:岩脚用变形后的距离环生成泡沫领口,下游尾迹则用横向高斯衰减和纵向指数衰减近似:
wake ≈ exp(-(侧向距离/宽度)²)
× exp(-下游距离/长度)
× 下游方向遮罩
流动噪声打断这些轮廓,岸边泡沫再用浅水深度遮罩补充。两者叠加后仍有上限,避免整条河铺满白色。
雨滴涟漪把世界坐标划成网格,每格哈希决定循环相位,用逐渐扩大的圆环扰动法线;雨量直接控制幅度。它没有为每一条雨丝追踪真实落水碰撞,而是用统计上相似的水面动效配合雨天。
太阳与月亮的水面亮带也有专门处理。着色器用观察方向与光方向的半角向量,计算高指数的法线响应,分别以暖色和冷色添加细高光,再随流纹闪动。这样夜间能看到窄而冷的水光,黄昏则偏暖。
瀑布
瀑布由 cascade.js 增加的四段水片覆盖三个主要落差。网格仍然读取 waterLevel(z),UV 的纵向坐标则累计水流中心线上的实际距离,使倾斜、陡直和较平的部分都能连续滚动纹理。
每个顶点额外存两项参数:aEnergy 根据局部水位坡度增大,aImpact 根据当前位置与前方的坡度差估计冲击区。水流即将从陡坡转成平缓水潭时,冲击权重上升;水片末端也补充一小段冲击权重。
片元着色器据此控制细流条带、破碎透明度、暗色水膜和撞击泡沫。流纹越密、冲击越强,局部越白;比较平滑的部分保留深色,瀑布才不会变成一块统一的白布。150 个 Points 水雾围绕几个冲击位置循环升起、漂移并消散,河边的碎石、苔藓和倒木补出碰撞环境。
场景边缘还有一段向外折下的出流水片,它使用河流材质。主瀑布的水片、水雾和泡沫是另一组补充,二者共同服务于这一段连续河道。实现见 cascade.js。
与海湾水体的区别
| 部分 | Tideglass Cove | 山间铁路 |
|---|---|---|
| 水体形状 | 带连续侧面的海湾剖面,潮位可变 | 跟随地形岸线的河道,多段预设落差 |
| 水下可见 | 深度折射与光学合成 | 透明浅水显示实际河床 |
| 反射 | 海湾水面的专门反射流程 | 下游局部平面 Reflector |
| 接触效果 | 湿度历史、涟漪事件与入水动效 | 岩脚泡沫、尾迹与雨滴法线涟漪 |
| 湿润状态 | 空间湿度历史纹理 | 全场共享 wetness,缓慢随天气变化 |
| 强调内容 | 潮汐、水下生命与水体剖面 | 流水、瀑布、雨天和列车旅程 |
我们可以复用岸线查询、共享深度、泡沫遮罩和低频反射,但不能把上一篇的屏幕深度折射或湿度 DataTexture 原样解释成这个项目的实现。
天气与动效

雨丝由一个 LineSegments 绘制,包含 8400 条线段。每条有随机种子与长度,顶点着色器用循环取模让雨丝从上方持续落下,再按风力倾斜。雨量通过种子阈值控制可见比例,所以调整雨量不需要不断创建和删除对象。
局部雾使用柔边 Points,主要放在河谷、隧道口和瀑布落点。片元颜色与透明度随夜色和雨量变化,点大小随观察距离变化。靠近列车前方的雾还根据方向夹角与距离变暖、变亮,与车头光锥配合,形成灯光穿过潮湿空气的观感。它是局部粒子近似,没有做全场体积光积分。
站台有七块不规则积水,雨檐有少量循环下落的水线。全场 wetness 用比光照更慢的插值变化,岩石、建筑、列车和叶片分别接收同一个值:颜色变暗、粗糙度降低,高光更集中;积水透明度也跟着增加。因此天气切回晴天后,表面不会立即恢复完全干燥。
其他动效保持局部而克制:54 片漂浮枫叶共用一个受光照的网格,从实际枫树林附近出发,循环飘落;85 个萤火虫分布在几个固定区域,以不同相位缓慢运动和闪烁。它们没有独立的动物行为系统。雨、雾、落叶和萤火虫见 weather.js。
光照与风格

画面的风格来自几何、色板、光照和构图共同控制。桥梁是赭色,列车是米白与红色,枫树采用成片的暖色,屋顶、河流和远山则偏灰绿与蓝灰。强轮廓用明确几何,细纹理控制得较弱,重要主体也保留了周围空隙。
岩石切面、折叠针叶和具体屋瓦让近景有结构,PBR 材质又保留了阴影、湿润与角度高光。细颗粒纹理通过 fwidth 估计屏幕上的变化率,距离远时降低幅度,减少细纹闪烁。材质能够补丰富度,但画面的大色块和视线走向已经由布局确定。
昼夜
四种天气是四套协调参数。它们同时控制主光颜色、位置与强度,半球光天空与地面颜色,补光,雾颜色与密度,背景天空,雨量,湿度,环境反射强度和曝光。夜间还提高车内灯、车头灯、尾灯、站台灯与沿路灯的贡献。
| 模式 | 主光 | 环境反射强度 | 主要画面关系 |
|---|---|---|---|
| 白天 | 高位暖白太阳,强度 3.35 | 0.27 | 山坡受光与阴影清楚,树叶保持暖色 |
| 黄昏 | 较低的暖橙主光,强度 3.6 | 0.16 | 铁轨和树冠出现暖高光,暗面偏冷 |
| 雨天 | 较弱的冷色主光,强度 0.5 | 0.09 | 山谷变冷、湿,车窗与灯具成为亮点 |
| 夜晚 | 后方冷色月光,强度 0.52 | 0.03 | 保留深黑与远山轮廓,局部暖灯突出旅程 |
环境反射来自 RoomEnvironment 经 PMREM 预过滤后的固定纹理,模式变化调整其强度,并没有实时重生成整个场景的环境探针。
各参数采用指数方式平滑靠近目标:
alpha = 1-exp(-1.5dt)
value_next = value + (target-value) × alpha
dt 是以秒计的帧间隔。相同的时间常数让不同帧率下的过渡接近一致;湿度使用更慢的系数。主光、雾和材质会一起变化,切换模式时画面不只是背景换色。
远山与天空
背景有三层封闭山脊,半径约为 145、235、370,分别有自己的高度变化和雾化比例。远山着色器降低饱和度与局部对比,再向天空颜色混合;谷底额外增加雾感,黄昏时朝向太阳的坡肩增加少量暖色。这让远景后退,同时保留山脊的立体面。
天空用全屏三角形绘制。着色器通过相机逆投影矩阵与方向基,把屏幕位置还原为世界观察方向,再按仰角计算天空渐变。云是沿方向采样的多频噪声,太阳和月亮是观察方向与光方向夹角的圆盘,星星是球面方向网格中的稀疏哈希点;月面加入噪声明暗。
这是一套有针对性的天空与空气透视近似,没有完整的大气散射 LUT,也没有光线步进的体积云。它的优势是成本较低,而且从不同镜头观察时,太阳、月亮和天空结构仍然与方向相符。见 atmosphere.js。
灯光与泛光
一盏灯的可见形状、照亮周围的光,以及空气里的光晕,分别由不同对象承担。灯罩用发光材质;PointLight 或 SpotLight 照亮场景;Canvas 渐变光晕、局部雾和透明光锥补出空气中的可见光。
阴影也按作用分配:主光用 2048×2048 阴影,只有一侧车头聚光灯使用 1024×1024 阴影,站台有一份 512×512 的缓存阴影;其余不少局部灯只提供照明。这样不用让每一盏小灯重复绘制完整场景。
luminous.js 还做了一条小型 HDR 泛光链。场景先渲染到 HalfFloat 纹理,提取高于普通表面亮度的区域,在宽高都缩到四分之一的纹理上横向、纵向过滤,再与清晰的主图合成。四分之一宽高意味着像素数量约为原图的十六分之一。
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。
镜头
场景使用透视相机,垂直视场角为 46°,OrbitControls 提供拖动、缩放和平移。最初创建相机的位置只是初始化值,实际开场会被主镜头替换:横屏时主镜头位置为 (22,13,60),目标为 (6,9,12)。
这个位置把列车放在主画面偏左的钢桥上,桥面向右延伸到瀑布与隧道,车站留在边缘,前景树框住山谷。开场还有一次约 2.7 秒的轻推近,让画面稳定进入这个构图。
除了主镜头,界面提供车站、神社、桥梁、河流、瀑布、隧道、出洞、灯具细节、列车跟随和全景等视角。镜头的目标大多来自对象位置,而不是另外复制一套世界坐标。竖屏时根据 max(1,1/aspect) 拉远相机与目标之间的距离,尽量保住主画面。
自动镜头由列车所在区段选择。过桥时看桥,进入山体后可以看瀑布或河流,接近出口时切到出洞镜头,回到站台时看车站;部分镜头还根据圈数与夜色变化。每个镜头至少驻留约 7.2 秒,避免在区段边缘频繁切换。
转场使用五次 smootherstep:
e(t) = 6t⁵-15t⁴+10t³
camera = mix(from,to,e)
target = mix(fromTarget,toTarget,e)
这个函数在两端的一阶、二阶导数都为零,起步与收尾较柔和。转场开始前还会采样路线中的地形高度,按需要增加一段弧形抬升:
y_camera += sin(πe)^0.35 × lift
lift 由途中最高净空需求与路程长度共同决定,避免镜头直线穿过山坡。固定镜头落定后只保留很慢的漂移,把主要运动交给列车、流水和落叶。手动拖动会退出自动镜头与列车跟随,操作结束不会立即被系统抢回。见 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。
还有几项看似很小、但会直接影响近景的处理:车窗有实际开孔,玻璃与窗框保留间隙;凹形屋顶截面用轮廓三角化封口,避免中心扇形造成重叠三角形;建筑合批后保留需要的标牌和窗口 UV;相机近裁面随观察距离调整;入口有加载提示和 WebGL 2 错误说明,控制面板兼顾键盘与窄屏,系统要求减少动态效果时默认暂停场景。
学习总结
山间铁路最适合复用的经验,是让路线成为全场景共同的空间依据。我们先决定旅程,再用代码生成铁路、路基、桥隧、车站和列车;构图与净空规则限制植被,昼夜与雨量统一影响灯光、材质和水体,镜头再跟着旅程观察这些变化。
如果只让 AI 分别“生成山、生成铁路、生成火车”,各模块很容易各用一套坐标和高度。把共享接口与生成顺序交代清楚,会更容易得到能一起修改的场景。
| 本项目的经验 | 应整理进 Skill 的内容 | 可以迁移到哪里 |
|---|---|---|
| 路线共享 | 中心线、弧长、切线、最近距离、桥隧区段 | 铁路、道路、运河、巡逻路径 |
| 设施与地形联动 | 普通区压平、桥区留空、隧道保留覆盖与净空 | 山路、桥梁、洞穴入口 |
| 车辆姿态 | 前后支承点决定车体,局部转向架独立沿线 | 长车辆、观光车、路径运输 |
| 停靠节奏 | 距离制动、加减速上限、停留计时、子步 | 站点交通、移动平台、机械演示 |
| 约束式分布 | 坡度、海拔、建筑与路径排除,主体树明确布局 | 林地、营地、村落和街道 |
| 风与阴影一致 | 顶点权重、实例阵风、深度材质同一位移 | 草、树、旗帜等受风对象 |
| 环境统一状态 | 天气参数表、指数过渡、湿度滞后 | 昼夜、雨雪、季节演示 |
| 路径事件镜头 | 区段触发、驻留时间、平滑转场、地形避让 | 演示漫游、交通动画、旅程叙事 |
| 分层渲染成本 | 迟滞 LOD、反射与阴影节流、低分辨率泛光 | 有大量细节的浏览器 3D 场景 |
这些内容已经补进现有的 procedural-3d-scene Skill:入口保留通用方法,新增 references/mountain-railway.md 说明本项目的路线、建模、运动、环境和渲染做法;上一篇海湾案例仍然保留。三份文件可以直接查看:Skill 正文、山间铁路案例、完整下载包。
复用时,我们可以这样交代任务:
使用 $procedural-3d-scene,参考 references/mountain-railway.md。
用 Three.js 制作一座可交互的山间铁路微缩场景。
先给出主镜头、色板、路线控制点及桥/隧道/车站区段,
建立共享的地形高度、河流水位、路线弧长采样与净空接口。
先让两节占位列车完成转弯、进站减速、停车与循环,
再依次生成轨道、桥梁、真实洞口、车站、植物、河流与瀑布。
关键主体明确布局;植被使用带种子的约束采样,避免挡住视线。
昼夜和雨量共用一套状态,材质湿度稍慢变化。
可见植物和阴影使用同一风动,近远景保留相同位置与色板。
自动镜头按列车事件切换,转场检查地形,手动操作能退出。
交付可运行页面、可调整参数、实际截图与来源许可,
检查车厢姿态、桥隧净空、建筑落脚、四种天气和窄屏构图。
这个 Skill 要保存的是模块如何共享数据、怎样生成形状、怎样检查连接和运动。路线点、车速、镜头坐标与颜色则留在案例中按新场景调整。我们由此学到的,是把一段明确的场景体验拆成可执行的生成步骤,再让这些步骤共享同一套空间与时间。