【免费下载链接】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.
在开源项目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.ts | 6144 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()生成。两个细节值得注意:
- 水位永远平:水体表面固定在
SEA_LEVEL附近;只在"干地内部"用 smoothstep 收腰下沉,让水面网格在岸边自然退入地下——注释明确写道:在可见岸缘收腰会产生"锯齿轮廓",所以 taper 只发生在干地内。 - 精确重心插值:
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推荐阅读顺序:
- 世界与海平面:world.ts —— 从
SEA_LEVEL和海岸线二分读起 - 河流骨架:coastal-river-candidate.ts → world-river.ts
- 几何与网格:water-surface.ts、river-scene.ts、ocean-coverage.ts
- 着色器与深度:environment-scene.ts、ocean-waves.ts、bathymetry.ts、water-lighting.ts
- 儿童溪流与水花: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.
相关推荐
nodeterm 刘海 HUD 完整揭秘:macBook 上行走的 AI 代理吉祥物是怎么实现的
nodeterm 刘海 HUD 完整揭秘:macBook 上行走的 AI 代理吉祥物是怎么实现的 nodeterm 是一款基于 Node 的 AI 编码代理终端
如何快速实现Creative View Pager的协调滚动效果:5分钟教程
如何快速实现Creative View Pager的协调滚动效果:5分钟教程 想在Android应用中创建令人惊艳的协调滚动效果吗?Creative View
WebGL水面渲染技术:Three.js实现逼真水体效果完整指南
WebGL水面渲染技术:Three.js实现逼真水体效果完整指南 在WebGL开发领域,创建逼真的水面效果一直是开发者面临的技术挑战。Ocean水面着色器基于T
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考