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

AI 生成 3D 场景拆解 01:Tideglass Cove

学习 Techartist 的 Tideglass Cove,按地形、岩石、植物、房车、水体、动物、光照和镜头拆解程序化实现与关键公式,并将共享空间规则、建模方法和动效经验整理为可直接调用的 Skill。