☰
水文系统揭秘:prehistoric-animal-museum 海岸河流与水面渲染是怎么实现的(完整指南)
2026/10/15 16:58:43 网站建设 项目流程

【免费下载链接】prehistoric-animal-museum

A free, open-source bilingual 3D museum where young children and their grown-ups explore prehistoric animals across land, sea, and sky.

项目地址:https://gitcode.com/gh_mirrors/pr/prehistoric-animal-museum
点击查看免费下载

在开源项目prehistoric-animal-museum(一个免费、双语的 3D 史前动物博物馆)中,水面从来不只是"一块蓝色平面"。它的**水文系统(Hydrology)**横跨海岸河流、开阔海洋与儿童身高的浅溪,用确定性程序化几何 + 自定义 GLSL 着色器,实现了从 3 公里长的入海河流到 12 公里见方的洋面波纹的完整水面渲染。本文带你逐层拆解这套 3D 水面渲染方案的核心思路。

prehistoric-animal-museum 海洋展区水面渲染场景,沧龙游弋在 3D 海洋中

两套水面系统:一张"地形高度函数"贯穿始终

整个项目的水体渲染建立在同一个关键常量上:

SEA_LEVEL = -0.7—— 全局海平面高度,定义于 world.ts

export const SEA_LEVEL = -.7

所有水面——海洋、河流、浅溪——的水位都与它对齐。水面的"边界"不靠贴图,而是靠同一套高度查询函数切割:地面和水面共用一个高度场,谁高谁露出来。这是整个水文系统最优雅的地方。

系统位置规模用途
海岸河流系统src/flight-experience/hydrology/3 km 河流 + 12 km 洋面飞行体验中的入海河流
环境水面着色器environment-scene.ts6144 m 半径海洋波浪、阳光波光
尺度对比溪流src/viewer/480 m 河道儿童与恐龙同框的浅溪

海岸河流的"骨架":一条 3000 米长的程序化河流

河流并不是画出来的,而是算出来的。核心工厂函数是 coastal-river-candidate.ts 中的createCoastalRiverCandidate():

  • 7 个控制点勾勒出一条从源头到入海口的走向,用Catmull-Rom 样条采样成 121 个节点的折线,并缩放归一到精确的3000 米长度(L27-L38)。
  • 7 个水文断面站(stations)定义水位、水深与河道半宽:上游窄而浅(2.5 m),中下游的池潭处(pool)河宽 14 m,入海口(estuary)扩到 28 m(L22-L24)。
  • 横断面公式保证"岸线就是河床与水面相交的精确位置":中心最深处waterLevel - depth*(1-u²),外侧岸坡以 2.2 m 坡度连续抬升(crossSection, L53-L58)。
  • 空间哈希分桶(256 m 的格网 bin)让任意世界坐标的(x, z)查询都能在常数次计算内找到最近的河道断面,返回链长(chainage)、横向距离、水深与湿润度(query, L63-L74)。
  • 确定性哈希(Math.imul种子扰动)保证同一 seed 永远生成同一条河,seed 不同则河道不同——可复现、可测试。

让河流"自己找到"山谷

真正的世界集成在 world-river.ts 的createWorldRiver()中完成。它沿出海口的上游方向取 301 个断面,每一步用二分搜索在地形高度场上找到"目标水位首次上坡的交点",让河道天然贴合海岸低谷,而不是硬拖穿山脊(L14-L17)。水位从源头约 18 m 平滑降到海平面 -0.7 m,中段插入一段**平潭(pool)**打断坡度——水位曲线保证只降不升,水永远只往海里流(L10-L12)。

海平面与海岸线的交点也在 world.ts 里用 24 次二分求出:

for (let i = 0; i < 24; i++) { const mid = (lo + hi) / 2 if (terrainAt(mid, z).height < SEA_LEVEL) lo = mid else hi = mid }

入河口坐标就取自这条搜索的收敛中点(world.ts#L129-L131)。

水面网格:4 米一格,岸缘"收腰"防锯齿

几何网格由 water-surface.ts 的waterVertex()生成。两个细节值得注意:

  1. 水位永远平:水体表面固定在SEA_LEVEL附近;只在"干地内部"用 smoothstep 收腰下沉,让水面网格在岸边自然退入地下——注释明确写道:在可见岸缘收腰会产生"锯齿轮廓",所以 taper 只发生在干地内。
  2. 精确重心插值:visibleWaterHeight()(L10-L15)对已发布的水面三角形做严格的重心坐标查询,保证地形采样时"谁在水下"的判断与网格渲染逐位一致,岸线不会错位一个像素。

网格本身由 river-scene.ts 的RiverScene构建:在河流包围盒内按4 m 步长(WATER_GRID_STEP)铺设顶点,每个顶点附带waterFlow(流向 + 流速)属性;构建过程每 8 行让出一帧(setTimeout分片),完成后一次性同步交接所有权,避免半成品的网格出现在画面中。

海洋平面上"挖洞":几何级隔离消灭 1px 缝隙

海洋是一个跟随相机的巨大平面,半径 6144 m(OCEAN_HALF_EXTENT)。它和河流网格如何共存而不露缝?ocean-coverage.ts 的oceanCoverage()给出了答案:在几何层面切割。

"Cut the ocean in geometry, sharing the finite water's flat border. This avoids different interpolated world coordinates making a one-pixel discard seam."(在几何上切割海洋,与有限水面共享同一条平直边界,避免不同插值坐标造成 1 像素的 discard 缝隙。)

实现上只需把海洋大矩形减去河流包围盒,得到 4 个矩形(上、右、左、下),拼成网格——没有任何 epsilon 比较,缝隙在拓扑上就不可能存在。EnvironmentScene 每帧只在布局变化时重建该几何。

着色器洋面:6 道波浪 + 8192 米周期噪声 + GGX 阳光

真正的"海洋感"来自 environment-scene.ts 中的水面片元着色器,配合两个辅助模块:

① 固定波长的波列(ocean-waves.ts):

{ length:137, angle:-.44, amplitude:.56, speed:.57 }, { length:83, angle: .82, amplitude:.37, speed:.71 }, // …直到 4.3 m 的短波

6 个波长的波矢与相位由 CPU 每帧计算后传入 shader;天气只改变振幅,永远不改变波长——这让海面形态在不同天气间连续可变。

② 周期值噪声包络:着色器内的oceanNoise以8192 m 逻辑周期生成带解析导数的值噪声(L37-L40),为每道波浪叠加"风带"畸变,并生成细密流动斜率与厘米级毛细涟漪。

③ 屏幕导数抗锯齿:用dFdx/dFdy测量波形在屏幕上的足迹,短波只在近处解析、远处平滑淡出(L58-L62)——远处海面自然变成大涌浪,避免摩尔纹。

④ GGX 太阳波光:water-lighting.ts 实现了标准 GGX 各向异性高光(D·G·F,水的 F0 取 0.02),未解析的斜率方差会转成粗糙度,让阳光在水面上洒出一串碎金。

BathymetryField:按"帧预算"逐像素烘出的深度贴图

水面着色器要知道脚下有多深,靠的是一张130×130、8 m/像素的深度贴图,由 bathymetry.ts 的BathymetryField维护,这套设计是性能工程的重头戏:

  • RG 双通道打包高度(-128 m ~ 896 m,encodeHeight/decodeHeight,L30-L31),CPU 端一次采样、shader 端解码双线性插值;
  • 16×16 页(page)分块生成:每帧最多处理 520 个像素,并受budgetMs预算与canStart()门控——低端设备上水深贴图"慢慢长出来",但绝不卡渲染帧(L81-L92);
  • 脏页增量上传:页面内容不变则复用,发布时只把变化矩形加入addUpdateRange上传(environment-scene.ts#L210-L219);
  • 双版本交叉淡入:previousData与data两张贴图在 shader 中按depthBlend混合,相机移动带来的水深更新毫无闪烁;另有一张 32 m 步长的粗分辨率场做兜底(L88-L94)。

最终着色器按水深算出"浅水度"shallow:浅处透出沙色,深处沉入蓝绿,再叠加阴影与距离雾化——这就是展区里那条从沙滩渐变到深海的水体。

尺度对比模式:会"走路带浪花"的儿童溪流

第二套水面服务于"和孩子比身高"的尺度对比场景,目标完全不同:近景、儿童身高、交互水花。

  • 按动物缩放河道:scale-encounter-forest-water.ts 为每种动物定义河道比例——给 4.5 m 的巨型蜻蜓配 1 倍溪流,给霸王龙配 2.3 倍;河道最大深度只有半米,且中央区域保证干地,动物与儿童的起始位永远站在水岸上(L26-L36)。
  • 反射式水面:scale-encounter-river-water.ts 基于 three.js 的Reflector走单次反射 pass——岸线用 16 次二分从同一高度函数切出(L35-L39),"只有反射法线在动,水线永远不动",省掉了折射 pass 与屏幕深度管线;
  • 访客水花系统:scale-encounter-water-interaction.ts 维护一个 8 个涟漪、3 秒寿命的共享池。孩子踩进水里,脚印涟漪以波前扩散;持续涉水时,一道马蹄形压力脊紧紧跟随双腿移动(而非原地扩散的圆环),空中坠落则触发更强的入水冲击——着色器里甚至加了"浪尖高光照",让挤在腿边的水花清晰可读(fragment, L118-L145)。溅水用的切图是 footstep-water-splash-v1.webp,且被裁剪到只露出水面以上的部分。

测试保证:"水只能往海里流"

水文系统最重要的性质都写进了单元测试 hydrology-candidate.test.ts:

for (let s = 0; s <= 3000; s += 2) { const p = river.station(s) expect(p.waterLevel).toBeLessThanOrEqual(previous) // 水位只降不升 expect(p.bedLevel).toBeLessThan(p.waterLevel) // 河床永远在水下 }

此外还验证了:池潭段水位严格恒定(不倾斜)、岸缘处水深精确为 0、断面在岸线两侧连续、同 seed 结果完全一致且查询顺序无关、边界外返回null。配套测试还包括 shoreline-r7.test.ts 与 scale-encounter-water-interaction.test.ts 等,让"岸线不抖、水只下流"成为回归基线。

如何动手探索这套水文代码

本地跑起来只需要 Node.js 20.19+:

npm ci npm run dev

推荐阅读顺序:

  1. 世界与海平面:world.ts —— 从SEA_LEVEL和海岸线二分读起
  2. 河流骨架:coastal-river-candidate.ts → world-river.ts
  3. 几何与网格:water-surface.ts、river-scene.ts、ocean-coverage.ts
  4. 着色器与深度:environment-scene.ts、ocean-waves.ts、bathymetry.ts、water-lighting.ts
  5. 儿童溪流与水花:scale-encounter-river-water.ts、scale-encounter-water-interaction.ts

小结

prehistoric-animal-museum 的水面渲染方案可以浓缩为三句话:

  • 一份高度函数定乾坤——地面与水面共用同一套高度采样,岸线天然是"精确相交线",没有贴图、没有缝隙;
  • 几何级隔离替代像素级修补——海洋切洞、水面收腰、双版本交叉淡入,处处用拓扑和预算取代 epsilon 技巧;
  • 物理直觉驱动细节——水位只降不升、压力脊跟随双腿、涟漪 3 秒消散,每一个参数都来自对"真实水应该怎样"的观察,并有测试锁死。

这套从 3 km 河流到 12 km 洋面的完整水文系统,是学习"程序化地形 + 预算化 GPU 更新 + 交互水面"三个主题难得的开源范例,值得每个想做 3D 水面渲染的开发者逐行精读。

【免费下载链接】prehistoric-animal-museum

A free, open-source bilingual 3D museum where young children and their grown-ups explore prehistoric animals across land, sea, and sky.

项目地址:https://gitcode.com/gh_mirrors/pr/prehistoric-animal-museum
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询