










仓库:https://github.com/VictorZakharov/beautiful-water
作者:Victor Zakharov
语言:JavaScript(ESM)
星标 / Fork:9 / 5(截至解读时)
许可证:MIT
主题标签:ocean、water、threejs、webgpu、webgl、shader、procedural-generation、playwright、github-pages
在线 Demo:https://victorzakharov.github.io/beautiful-water/
解读基准:main分支 commit830c7f3(2026-08-23,Merge PR #15「fix/gpu-aware-adaptive-quality」)
依赖:three@0.185.1;开发依赖@playwright/test@^1.62.1、vite@8.2.0;运行引擎要求bun >= 1.3.11
beautiful-water 是一个全屏电影级浅海(shallow ocean)程序化三维环境,使用 Three.js 在浏览器中实时渲染。它把一整套「海洋—天空—浮标—水下生态」整合进一个可交互场景:
与作者前两个 model-x-studio / human-atlas(我都解读过)不同,本项目不依赖任何外部模型 / 贴图素材:几何、行为、着色器、天空、噪声、栖息地细节全部由代码生成,以 MIT 发布。这一点在 README 与代码中反复强调("No downloaded scene assets")。
项目有三个相互叠加的「卖点」:
| 维度 | 选型 |
|---|---|
| 构建 / 开发服务器 | Vite 8(bun run dev / bun run build) |
| 3D 引擎 | Three.js 0.185.1(含 three/webgpu 的 WebGPURenderer、TSL 节点材质、three/addons 的 Reflector/Refractor/OrbitControls) |
| WebGL 着色器 | 手写 GLSL(顶点 / 片元,含 #include <tonemapping_fragment> 等 Three 块) |
| WebGPU 着色器 | Three.js TSL(Fn/If/uniform/reflect/refract/reflector 等节点式) |
| 控制 | OrbitControls,枢轴锁定浮标;禁用平移与光标缩放 |
| UI | 原生 DOM + 内联 SVG(性能面板)/ CSS(src/style.css,设计系统) |
| 测试 / 回归 | Playwright(@playwright/test)+ 自研 bun test 性能单测 |
| 部署 | GitHub Pages(vite build --base=/beautiful-water/,官方 Actions,PR 预览) |
| 包管理 / 运行 | bun(要求 >=1.3.11) |
index.html ──▶ src/main.js (编排器 / 状态机 / 动画循环)
│
├─ core/ 性能与运行态
│ ├─ classifyGpu / inspectGpu / createAdaptiveQuality (逐帧质量预算)
│ ├─ createGpuFrameTimer (WebGPU/WebGL 时间戳查询 → 滚动 GPU 帧时分布)
│ ├─ createPresentationMonitor / formatPerformanceReport (FPS 历史 / 诊断报告)
│ ├─ createRenderPacer / readRenderCapPreference (FPS 上限节流)
│ ├─ createRenderer / readRendererPreference (WebGPU↔WebGL 选择+降级)
│ └─ describeRuntimeIdentity (浏览器/平台指纹,填入诊断报告)
│
├─ scene/ 场景对象(双后端成对出现)
│ ├─ waves.js (CPU 波采样,浮标/鱼共用;数据被 GPU 着色器复用)
│ ├─ pipeline.js(按 rendererMode 动态 import 对应后端模块)
│ ├─ ocean.js + ocean-webgpu.js
│ ├─ sky.js + sky-webgpu.js
│ ├─ underwater-rays.js + underwater-rays-webgpu.js
│ ├─ environment.js(海床/岩石/水草/粒子/光照/云影焦散)
│ ├─ buoy.js(浮标+尾迹+水面阴影)
│ ├─ fish.js(三群 Boids 鱼群 AI)
│ └─ noise-texture.js(确定性噪声 DataTexture)
│
├─ shaders/
│ ├─ water/{vertex,fragment,functions}.js (GLSL 水体)
│ ├─ sky.js(GLSL 天空)
│ └─ (WebGPU 对应物位于 scene/webgpu-*.js,用 TSL 改写同一套算法)
│
└─ ui/ loading.js / render-cap.js / renderer-toggle.js
scripts/ 质量/部署脚本(性能剖析、Pages 校验、冒烟测试、线性历史检查)
tests/ visual/(Playwright 回归 + 性能 HUD/可持续 规格)
performance/(bun 单测:自适应质量、GPU 计时、呈现监测…)
.github/ CI / 部署 Pages / PR 预览 三条工作流
渲染主循环(每帧)(main.js 的 renderFrame):
updateFrameState(t) // 更新相机枢轴、海底/相机地板钳制、水面高程采样、underwaterMix 插值
└─ 各 scene 对象的 update(t, underwaterMix, camera)
renderOceanCaptures() // 若 WebGL:隐藏水面/太阳后离屏渲染反射/折射 RT(共享波形)
renderScene() // renderer.render(scene, camera) + 可选叠加水下光柱
└─ readRendererFrameDiagnostics() // 记录 drawCalls / triangles
关键设计:共享波谱单源。 scene/waves.js 导出 OCEAN_WAVES、OCEAN_DOMAIN_WARP、OCEAN_ENERGY_WAVES、OCEAN_DOMAIN_VARIANTS 等常量;sampleOceanSurface(x,z,time) 在 CPU 端计算结果,供浮标浮沉、鱼群避面、相机入水判定复用;同一份常量被 GLSL 顶点着色器(通过 functions.js 的模板字符串注入)与 TSL webgpu-wave-nodes.js 复用,保证两个后端波形完全一致(视觉回归「renderer-parity」门即验证此点)。
| 功能 | 说明 |
|---|---|
| 方向性涌浪 | 14 条波宽窄不一、无公倍周期的波;域扭曲弯曲/分裂涌浪;波包让波峰弯曲/合并/消散;波峰锐化 |
| 反射 / 折射 | 投影式(Reflector/Refractor)离屏 RT + 屏幕空间扭曲;WebGPU 用 TSL reflector 节点 + viewportSharedTexture 屏采折射 |
| 浅水透射 | 光学深度随视角/深度变化;青→蓝随 opticalDepth 渐变;远距离吸收至地平色 |
| 太阳闪光 | Fresnel + GGX distributionGGX + Smith 可见项 + 随风速平流的多尺度遮罩 → 风成闪片 |
| 输送式泡沫 | foamLifecycle(出生—扩散—破碎)+ 卷曲位移(无散度场)+ 多孔丝带,跟随高能波峰且非整片移动 |
| 浮标 | 程序化船体/灯/系泊锚链;随浪浮沉倾斜;尾迹+水面阴影;相机环绕枢轴 |
| 鱼群 AI | 分离/对齐/聚合 + 避顶/避底 + 好奇心环绕 + 习惯化(静止相机降低恐惧)+ 逃逸(移动相机逼近) |
| 跨介质 | 俯冲穿过动态海面连续进入水下;underwaterMix 在 0/1 间插值混合天空/水下色 |
| 性能面板 | 实时 GPU PASS P50/P95、渲染 FPS 15 秒历史、1% 低帧;点击复制完整诊断 |
| 渲染上限 / 后端切换 | 30/60/120/144/240 FPS 上限(本地持久化);WebGPU↔WebGL 一键切换(URL/存储) |
| 自适应质量 | 按 GPU 等级(software/integrated/unknown/discrete)定像素预算,帧率或 GPU 时间戳驱动上下调 |
| 视觉回归 | Playwright 冻结时钟,20 命名视角抓图;WebGL/WebGPU 并排对比,像素误差+感知相似度限 |
| 4K 持续门 | 原生 4K 3840×2160 @ 目标刷新率,测量表面/水下各 30s,验证吞吐≥99.5% 目标 |
本项目无 shadcn/自动生成脚手架(与
model-x-studio/human-atlas不同),几乎全部手写。下面把文件分成「产品核心源码(逐行注释)」与「工程/测试支撑(模块级说明)」两档。
| 文件 | 角色 | 注释粒度 |
|---|---|---|
src/main.js |
编排器:装配渲染器→场景→对象→动画循环→HUD→诊断 | 逐段(行号解析) |
src/core/adaptive-quality.js |
GPU 分级 + 像素预算 + 帧率/GPU 时间戳驱动的质量自适应 | 逐行重点 |
src/core/gpu-frame-timer.js |
滚动 GPU 帧时分布(WebGPU resolveTimestampsAsync / WebGL EXT_disjoint_timer_query_webgl2) |
逐行重点 |
src/core/presentation-monitor.js |
渲染 FPS 历史、刷新率估算、CPU 帧时、诊断报告格式化 | 逐行重点 |
src/core/render-cap.js |
FPS 上限节流器 + 偏好读写(URL/localStorage) | 逐行 |
src/core/renderer.js |
WebGPU↔WebGL 创建与降级 + 偏好读写 | 逐行 |
src/core/runtime-identity.js |
UA / userAgentData 指纹 → 浏览器/平台可读名 |
逐行 |
src/core/renderer-diagnostics.js |
从 renderer.info 抽取 drawCalls/triangles |
逐行 |
src/scene/waves.js |
波谱常量 + sampleOceanSurface(CPU 采样,多端复用) |
逐行 |
src/scene/pipeline.js |
按后端动态 import 场景模块 | 逐行 |
src/scene/ocean.js |
WebGL 水体:ShaderMaterial + Reflector/Refractor 离屏捕获 | 逐行 |
src/scene/ocean-webgpu.js |
WebGPU 水体:TSL 材质 + 节点反射器(无手动捕获) | 逐行 |
src/scene/sky.js |
WebGL 天空:球壳着色器 + 太阳精灵 | 逐行 |
src/scene/sky-webgpu.js |
WebGPU 天空:烘焙云纹理 + TSL 色节点 + 镜像球壳兼容 WARP | 逐行 |
src/scene/underwater-rays.js |
WebGL 体积太阳光柱(全屏 quad 叠加) | 逐行 |
src/scene/underwater-rays-webgpu.js |
WebGPU 光柱:半分辨率 RT + QuadMesh 合成 | 逐行 |
src/scene/environment.js |
海床几何/焦散着色、岩石/水草 InstancedMesh、粒子、光照、云影 | 逐行 |
src/scene/buoy.js |
浮标几何、尾迹/水面阴影着色、随浪运动 | 逐行 |
src/scene/fish.js |
三群 Boids 鱼群 AI + 尾摆 + 诊断 | 逐行 |
src/scene/noise-texture.js |
确定性噪声 DataTexture(WebGL/WebGPU 共用) | 逐行 |
src/scene/webgpu-water-functions.js |
TSL 工具:valueNoise/fbm/GGX/Fresnel/微梯度(与 GLSL 对应) | 逐行 |
src/scene/webgpu-wave-nodes.js |
TSL 波位置节点(与 GLSL addWave 对应) |
逐行 |
src/scene/webgpu-water-material.js |
TSL 水体材质(与 GLSL waterFragmentShader 对应,最长后端文件) |
逐段 |
src/shaders/water/vertex.js |
GLSL 顶点着色器:域扭曲/能量/逐波位移,模板化注入波常量 | 逐行 |
src/shaders/water/functions.js |
GLSL 工具函数:噪声/GGX/泡沫生命周期/微梯度/天空反射 | 逐行 |
src/shaders/water/fragment.js |
GLSL 片元着色器:完整水体光照合成(~400 行) | 逐段 |
src/shaders/sky.js |
GLSL 天空着色器:大气/云/太阳/水下光柱 | 逐行 |
src/shaders/water.js |
重导出 waterVertexShader/waterFragmentShader |
逐行 |
src/ui/loading.js / render-cap.js / renderer-toggle.js |
加载屏、FPS 上限控件、后端切换按钮 | 逐行 |
index.html |
入口 HTML:canvas、性能面板、渲染器切换、加载屏、启动脚本 | 逐行 |
package.json |
脚本与依赖 | 逐行 |
playwright.config.js |
视觉回归配置(视口、浏览器路径、webServer) | 逐行 |
AGENTS.md |
给编码 Agent 的协作备注 | 逐行 |
scripts/*.mjs / *.ps1 |
性能剖析、Pages 校验、冒烟、线性历史检查 | 模块级 |
tests/visual/*.spec.js |
Playwright 回归/性能/HUD/可持续规格 | 模块级 + 关键结构 |
tests/visual/{screenshot-metrics,wave-field}.js |
像素差、波场相关性工具 | 逐行 |
tests/performance/*.test.js |
自适应质量、GPU 计时、呈现监测等单测 | 模块级 |
src/style.css |
设计系统(CSS 变量、布局、加载动画、面板) | 模块级(非逻辑) |
docs/effort-hours-*.md |
工作量估算报告 | 不注释(数据文档) |
waves.js 是单源真相:
export const OCEAN_WAVES = [ /* 14 条 wave(...) */ ];
export const OCEAN_DOMAIN_WARP = [ /* 3 条域扭曲 */ ];
export const OCEAN_ENERGY_WAVES = [ /* 3 条能量波 */ ];
export function sampleOceanSurface(x, z, time) { /* CPU 采样,返回 height + 法线 */ }
water/vertex.js 在构建时把 OCEAN_WAVES 等以 toFixed(8) 硬编码进 addWave(...) 调用模板(见 waveCalls 数组),运行时在 GPU 顶点着色器逐顶点复现同一位移。webgpu-wave-nodes.js 用 OCEAN_WAVES 等常量在 createWebGpuWavePositionNode 中生成 positionNode,算法与 GLSL addWave 逐字段对应(注释中明确「此 orientation 须与 GLSL mat2(0.80,-0.60,0.60,0.80) 一致」)。buoy.js、fish.js、main.js 的入水判定都直接调用 sampleOceanSurface,保证浮标浮在 GPU 画出的同一海面上。pipeline.js 是切换点:
export async function loadScenePipeline(rendererMode) {
if (rendererMode === 'webgpu') {
const [sky, ocean, rays] = await Promise.all([
import('./sky-webgpu.js'), import('./ocean-webgpu.js'), import('./underwater-rays-webgpu.js'),
]);
return { createSky: sky.createWebGpuSky, createOcean: ocean.createWebGpuOcean,
createUnderwaterRays: rays.createWebGpuUnderwaterRays };
}
// … 否则 import('./sky.js') 等 GLSL 版本
}
后端专属代码按需动态加载,因此 WebGL 兼容包不会拖慢 WebGPU 初始渲染。
index.html + src/main.jsindex.html(入口骨架)<!doctype html>
<html lang="en">
<head>
<!-- 主题色 #071b2b,description 点明 Three.js 海洋 -->
<meta name="theme-color" content="#071b2b" />
<link rel="icon" href="data:image/svg+xml,..." /> <!-- 内联 SVG favicon,零请求 -->
<link rel="stylesheet" href="/src/style.css" /> <!-- 设计系统 -->
<style> /* 内联兜底:满屏、#app 相对定位、canvas 初始透明、.loader 全屏遮罩 */ </style>
<title>Open Water</title>
</head>
<body>
<main id="app">
<canvas id="ocean-canvas" aria-label="Animated open ocean with a floating red navigation buoy"></canvas>
<div class="vignette" aria-hidden="true"></div> <!-- 暗角 -->
<!-- 性能面板:GPU PASS P50/P95、渲染 FPS 历史 SVG、CAP 状态 -->
<button class="fps" data-performance-panel …> … </button>
<!-- 渲染器切换 + 渲染上限控件(初始 hidden,JS 揭示) -->
<div class="renderer-toggle" data-renderer-toggle hidden> … </div>
<!-- 加载屏:太阳/地平线/波浪/浮标 动画 + 进度条 -->
<div class="loader" …> … </div>
<noscript>本场景需要 JavaScript 与 WebGPU 或 WebGL 2</noscript>
</main>
<script type="module">
// 双 rAF 后在下一帧 import 主模块;失败则改写加载状态文案
const start = () => import('/src/main.js').catch(() => { /* … */ });
requestAnimationFrame(() => requestAnimationFrame(start));
</script>
</body>
</html>
要点:加载屏、性能面板、渲染器切换 UI 全部预置于 HTML,JS 仅在就绪后揭示(app.classList.add('is-ready'))或填充数据属性——避免「白屏 → 突然闪现」。
src/main.js(编排器,699 行)— 段级行号解析main.js 是「状态机 + 装配 + 动画循环 + HUD」。按职责分段:
① 模块导入与 DOM 句柄(1–42)
document.querySelector('[data-xxx]') 抓取性能面板、FPS/ GPU P50/P95 数值、HUD 历史 SVG 等。const query = new URLSearchParams(...)、runtimeIdentity = describeRuntimeIdentity(navigator) 解析运行环境指纹。② 启动期模式判定(44–61)
harnessMode = query.has('harness'):测试/截图模式(冻结时钟、只显式渲染)。nativeSustainProfile:仅当非 harness 且 ?sustain=native-4k 时启用原生 4K 持续证明模式(锁定像素比=1)。preferredRenderCap、renderPacer = createRenderPacer(...)、adaptiveTargetFrameRate = min(cap??60, 60)。requestedAntialias = shouldUseAntialias({...}):依据视口与 DPR 决定是否用 MSAA。antialias = preferredRenderer==='webgl' && requestedAntialias——WebGPU 路径主动关 MSAA。③ 创建渲染器与质量基线(67–108)
createRenderer({ canvas, antialias, preferredMode }) → rendererInfo={renderer,pipeline,backend,adapterName,fallbackReason}。outputColorSpace=SRGB、toneMapping=ACESFilmic、exposure=0.90、shadowMap.enabled、PCFShadowMap。shadowMap.autoUpdate=false(捕获与主渲染复用同一张图集,性能档可每两帧刷新)。gpu = inspectGpu(renderer, {...}):拿到 GPU 名称/等级;harness 可用 ?gpuClass= 强制 software/integrated/unknown/discrete。adaptiveQuality = createAdaptiveQuality({... gpuTimingEnabled: !harnessMode && gpuFrameTimer.supported, ...})。④ 场景与对象装配(109–183)
loadScenePipeline(rendererInfo.pipeline)。scene(FogExp2(0x063c48, 0.00001))、相机(PerspectiveCamera(51,1,0.08,520),初始位置 7.8,3.65,10.8)。OrbitControls:枢轴 (0,0.54,0)、阻尼、禁用平移、min/max 距离 2.7–46、极角夹紧避免翻转;zoomToCursor=false。sunDirection = normalize(-0.58,0.10,-0.81)。surfaceSegments 按 GPU 等级与后端选择(discrete 300 / unknown 240 / webgpu 180 / 其他 210)。createSky/createOcean/createEnvironment/createFishSchools/createBuoy/createUnderwaterRays。underwaterWorld = [...environment.underwaterObjects, ...fish, ...buoy](供反射捕获时临时隐藏)。reducedMotion → 时间缩放 0.35(无障碍)。⑤ 质量应用与生命周期(190–275)
applyRenderQuality():把 adaptiveQuality.getState() 映射到 renderer.setPixelRatio/setSize、ocean.setCaptureResolution、environment.setShadowMapResolution、underwaterRays.resize、相机投影。disposeRenderer():页面 pagehide 时释放;WebGPU 4K 目标在 Chromium 拆文档时 dispose() 可能无限阻塞,故只对非 WebGPU 调 renderer.dispose()。resetPerformanceSampling():清空所有监测器与 HUD 文本。⑥ 每帧状态更新 updateFrameState(elapsed)(304–339)
controls.update()。seabedHeight/sampleOceanSurface 钳制相机与枢轴不穿海底。cameraSurface = sampleOceanSurface(camX,camZ,elapsed);underwaterTarget = camY < surface.height ? 1 : 0;cameraIsUnderwater。underwaterMix:harness 用固定值,否则 lerp(underwaterMix, underwaterTarget, 0.085) 平滑过渡(避免跨水面一帧闪烁)。ocean/sky/environment/fishSchools/buoy/underwaterRays.update(...)。underwaterMix 插值;app.classList.toggle('is-underwater', ...)。⑦ 渲染捕获与渲染(341–382)
renderOceanCaptures():WebGL 下先隐藏水面/太阳/隐藏物,再 reflector.onBeforeRender + 更新纹理矩阵;underwaterMix<0.65 才捕获(深水不反射)。harness 可用 harnessUnderwaterRaysEnabled 控制。renderFrame(elapsed):renderer.info.reset() → gpuFrameTimer.beginFrame() → updateFrameState → renderOceanCaptures → renderScene → finally gpuFrameTimer.endFrame()。renderedFrames%shadowFrameInterval==0 才刷阴影。⑧ HUD 与诊断(384–528)
updateFps():每 ≥500ms 采样一次,平滑显示;填 GPU P50/P95、渲染 FPS 平均/1% 低、历史 SVG 路径(createHistoryPath)。buildPerformanceReport():汇总呈现/FPS/渲染/GPU/渲染器/画布/质量/场景/三角面/页面状态/运行环境,调 formatPerformanceReport。明确标注「渲染 FPS 非物理面板刷新率」「多屏时回调节奏可能跟随另一屏」——诚实披露测量局限。createRenderCapControl(...):FPS 上限变更时写 URL(?renderCap=)与 localStorage,并重置采样。⑨ 启动与动画循环 start()(550–693)
renderer.compileAsync + ocean.compileCaptures;依次渲染反射/折射捕获;renderScene()。window.__WATER_HARNESS__(setView/advance/getDiagnostics/setUnderwaterRaysEnabled/samplePerformance),供 Playwright 确定性渲染与诊断。renderer.setAnimationLoop(t => { … renderFrame(timer.getElapsed()); updateFps(t); … })。每帧:presentationMonitor.recordFrame、adaptiveQuality.sampleGpuTiming/observeFrame → applyAdaptiveQualityChange、renderPacer.shouldRender 节流、渲染、记录 CPU 帧时。亮点:整个动画循环把「呈现帧率(浏览器回调)」与「渲染帧率(实际画了多少)」与「GPU 帧时(真实 GPU 耗时)」三条指标分开计量并都呈现给用户——这是该项目工程成熟的标志(见 §3.2/§3.3)。
src/core/adaptive-quality.js(422 行)这是最值得读的「质量自适应」核心。要点:
GPU_PIXEL_BUDGETS 对 software/integrated/unknown/discrete 各给 {minimum,initial,maximum}(单位百万像素)。例如 discrete 初始 6.0、上限 8.3;software 仅 1.45/1.85。deviceMemory/hardwareConcurrency 推断。shouldUseAntialias 在原生像素 ≤7MP 时才开 MSAA,否则让浏览器上采样平滑即可,避免带宽爆炸。qualityTier(high/balanced/performance 按 renderScale)、captureResolutionFor(软件 384、性能 512、平衡 640、高 768)、shadowResolutionFor(512/1024/1536/2048)。state()(180–235):把当前 pixelBudget 换算成 pixelRatio、renderScale、drawingBufferWidth/Height、captureResolution、shadowMapResolution、shadowFrameInterval,以及 GPU 计时预算(gpuFrameBudgetMs=1000/target、gpuP95BudgetMs×0.90)。applyFrameRate(fps)(237–284):平滑 FPS;<39 计慢窗、>56(且仅浏览器计时时)计快窗;严重变慢(<27)或连续 2 慢窗 → 预算 ×0.72/0.84(带 2 窗冷却);连续 4 快窗 → ×1.12 回升。改动超 1 像素才 revision++ 触发应用。applyGpuTiming(timing)(286–363):若有 GPU 时间戳,用 p95 帧时 vs 预算判定 overloaded/severeOverload/headroom;过载时记下 learnedMaximumPixelBudget(安全上限),防止在昂贵的「捕获/阴影档位边界」反复横跳;有余量且 p95≤预算×0.70 且中值≤预算×0.60 才谨慎回升(×1.08)。这是「GPU 感知自适应」PR #15 的核心。getState/resize/observeFrame/sampleFrameRate/sampleGpuTiming/setTargetFrameRate/resetFrameSampling。observeFrame 内部累积 ≥900ms 才出一次 FPS,避免抖动。src/core/gpu-frame-timer.js(183 行)把「GPU 查询缓冲解析」封装为滚动 10 秒 p50/p95 分布:
renderer.backend.trackTimestamp + resolveTimestampsAsync('render');WebGL 用扩展 EXT_disjoint_timer_query_webgl2(含 beginQuery/endQuery/getQueryParameter)。supported 决定后续是否启用 GPU 计时。pollWebGlQueries()(88–100):逐个轮询挂起查询,跳过 GPU_DISJOINT_EXT 异常帧,结果入 record()。requestNodeTimestamp()(102–114):WebGPU 异步解析,用 generation 防竞态(requestedGeneration===generation 才记录)。getState()(152–171):裁剪窗口、算 windowElapsedMs、用 summarizeFrameTimes 出 p50/p95;ready = 窗口满 && 样本≥2。src/core/presentation-monitor.js(381 行)createPresentationMonitor(111–271):记录每帧时间戳 → 估算「渲染 FPS」、刷新率(estimateRefreshRate 取最快 25% 区间中位数再吸附到常见刷新率列表)、CPU 帧时 p50/p95/p99、缺失刷新率(missedRefreshes)、1% 低帧、最坏 1 秒。createHistoryPath(series,{width,height,targetFps}):把 FPS 序列转成 SVG 路径 M/L 命令(性能面板的 15 秒历史曲线)。formatPerformanceReport(308–380):拼出多行可读报告,含「Timing caveat: rendered FPS … are not physical panel measurements」——再次诚实声明测量边界。render-cap.js / renderer.js / runtime-identity.jsrender-cap.js:
RENDER_CAP_OPTIONS=[30,60,120,144,240];parseRenderCap 只接受这些值或 off/null。createRenderPacer(initialCap):核心 shouldRender(t) 用 nextRenderAt 累加 1000/cap,带 0.5ms 容差与「跨槽补帧」逻辑,使非整除刷新率下仍稳定节流;cap=null 时每回调都渲染。renderer.js:
readRendererPreference(query):优先 ?renderer=,其次 localStorage,默认 webgpu。createRenderer({canvas,antialias,preferredMode}):若为 webgl 直接建 WebGLRenderer;否则 import('three/webgpu') 动态导入 WebGPURenderer(trackTimestamp:true、outputBufferType:UnsignedByteType 省 4K 带宽),await init();失败则 candidate?.dispose() 并降级 WebGL 2,记录 fallbackReason。返回 {pipeline,backend,adapterName,fallbackReason}。runtime-identity.js:从 userAgent / userAgentData.brands 解析浏览器名(Edge/Opera/Firefox/Chrome 兼容/Safari…),normalizePlatform 处理 Windows/Android/iOS/iPadOS/macOS/Linux,并区分 maxTouchPoints>1 的 iPadOS。用于诊断报告的「Runtime」行。
renderer-diagnostics.js:薄封装,从 renderer.info.render 取 drawCalls/triangles(兼容 calls 旧字段)。
src/scene/waves.js(256 行)wave(...)/domainWarp(...)/energyWave(...):构造助手,返回带方向/陡度/波长/速度/弯曲/波包/波峰锐化/LOD 区间的对象。OCEAN_DOMAIN_WARP(3 条):超长非共周期波「弯曲坐标场」而非加可见正弦——几百米尺度弯曲/分裂涌浪系统而不改局部波峰尺度(注释 51–53)。OCEAN_ENERGY_WAVES(3 条):宽风能量区,使某些波峰系统增强、另一些终止;频率刻意无公共平铺周期(64–65)。OCEAN_WAVES(14 条):宽方向谱,避免从高处看时的完美平行 Gerstner 行;含 lodStart/lodEnd 距离 LOD(76–94)。sampleOceanDomain(x,z,time,variant)(96–149):计算 warpedX/Z 及雅可比导数、energy 及其梯度。sampleOceanSurface(x,z,time)(151–255):对每条波算 along/across、弯曲相位(双频)、波包包络、波数、锐化高度(sine - crestSharpness*cos(2θ))、用解析导数累积法线与梯度。返回 {height, normal}——CPU 与 GPU 共用同一数学,保证浮标/鱼/入水判定与渲染波形一致。src/scene/ocean.js(204 行)ShaderMaterial(双面、不透明、写深度),uniforms 含 uTime/uSunDirection/深浅色/地平色/uWaterDepth/uUnderwater/反射折射纹理矩阵/三张纹理。PlaneGeometry(420,420,surfaceSegments×surfaceSegments) 旋转到 XZ;renderOrder=5。Reflector/Refractor(各 captureResolution,multisample:0——因水着色器会立即过滤扭曲,reflector/refractorRT 单采样即可,主画布仍抗锯齿)。把它们的getRenderTarget().texture` 绑定到 uniforms。updateReflection/RefractionTextureMatrix():用反射相机纹理矩阵 × 世界矩阵逆,把捕获投影到平均水面平面(注释 267–272:投影平均平面而非位移波峰,否则掠射角处齐次 w 跨零会暴露整片无效纹理)。renderCaptures(hiddenObjects):先 hideCaptureScene(藏水面/太阳/隐藏物)→ 调 reflector/refractor.onBeforeRender → 还原。供 main.js 在每帧前调用。getDiagnostics():回报 captureResolution、captureStrategy:'reflector-refractor'。update(t,underwaterMix):仅推进 uTime/uUnderwater。src/scene/ocean-webgpu.js(86 行)与 WebGL 版对比:
createWebGpuOcean:同样建平面几何,但 mesh.material = createWebGpuWaterMaterial({...}).material(TSL,见 §3.16)。usesManualCaptures:false:WebGPU 路径用 TSL reflector 节点 + viewportSharedTexture 在着色器内直接采屏,无需 main.js 每帧手动离屏渲染(因此 compileCaptures/renderReflectionCapture/... 全是空操作)。setCaptureResolution:改为调 reflectionSampler.reflector.resolutionScale = clamp(res/画布宽, 0.14, 0.42)——分辨率随画布缩放。update:只推进 timeNode/underwaterNode。sky.js / sky-webgpu.jssky.js(76 行):球壳 BackSide 着色器(深度不写、无雾)+ 太阳 Sprite(加色混合、不色调映射)。update:天空跟随相机、太阳按 sunDirection*315 定位、水下时淡出太阳透明度。
sky-webgpu.js(299 行):
createSkyTexture/createSunTexture/createCloudTexture:把云场烘焙进一张 768×384 纹理(多尺度 directionalFbm + 侵蚀 + 海拔淡入),避免每个天空片元与反射 pass 都跑程序化噪声(注释 141–143)。colorNode = Fn(() => { ... }) 用 TSL:水下色作为底,underwaterNode<0.98 时按方向算云光、采样云纹理、与天空纹理混合;If 分支在节点图里实现介质切换(注释 287–289:连续淡入而非切换,避免一帧平色地平线)。BackSide 节点材质裁剪不一致,故把球壳 scale(-1,1,1) 翻转成普通正面渲染,使原生适配与软件 WebGPU 走同一可移植光栅路径。underwater-rays.js / underwater-rays-webgpu.jsunderwater-rays.js(141 行):全屏 quad 叠加(OrthographicCamera(-1,1,1,-1)),片段用 fbm 噪声造细/宽光束、rayDensity、径向包络、原点淡入、破碎;以折射太阳方向投影到屏幕 UV 定位光柱源。折射按 eta=1/1.333(水折射率)算 refractedSun。render(renderer):autoClear=false 后叠加,强度 <0.002 跳过。
underwater-rays-webgpu.js(191 行):半分辨率 RT(宽高 ×0.5)再 QuadMesh 合成——八次噪声读取在半分辨率跑可砍掉 75% 重 pass(注释 104–106)。用 TSL rayFbm/valueNoiseNode 复刻 GLSL 算法;render 先渲到 raysTarget 再 compositeQuad.render 合成。注释 132–135:QuadMesh 用渲染目标 UV 约定,普通 PlaneGeometry 会把 WebGPU RT 上下翻转、把光源从视口上方移到下方。
src/scene/environment.js(428 行)seabedHeight(x,z):确定性海床高度(正弦叠加,-3.55 基准)。createSeabedTexture(WebGL 用):用 xorshift 伪随机 + 焦散 pow(1-|a-b|,12) 在 DataTexture 烘焙沙地;repeat=420/20 保持 20 世界单位颗粒尺度跨全海域(避免掠射角露平背景)。#include <tonemapping_fragment>)。MeshStandardMaterial + 烘焙 createSeabedTexture(带 emissive),性能路径提高水下 emissive 强度(注释 418–420)。InstancedMesh(34 岩 / 150 水草),用 xorshift 种子随机布点;水草聚成 7 簇。Points(确定性噪声纹理、加色、水下淡入)。注释 339–345:WebGPU 的 PointsMaterial 即便 pointUV 已提供也校验 UV 输入,故补一个中性 uv 属性避免主/反射 pass 警告。HemisphereLight + DirectionalLight(投影);WebGPU 下 shadow.autoUpdate=false(其 ShadowNode 无 WebGL 式 autoUpdate,否则每帧重绘图集)。update 随 underwaterMix 插值光强、粒子透明度。src/scene/buoy.js(298 行)createRadialTexture:尾迹环(青色描边)与水面阴影(深色径向)两版 Canvas 纹理。createWake/createSurfaceShadow:各含 GLSL 着色器版本与 WebGPU MeshBasicMaterial 版本(rendererMode==='webgpu' 走后者)。尾迹用 hash21 噪声做向外涟漪;阴影用噪声边缘柔化。createBuoy:用圆柱/圆锥/圆环/十二面体拼出船体、颈圈、灯(emissive 橙)、系泊锚(沉海床)、系泊绳(随浮标高度伸缩)。update(t,underwaterMix):采样 sampleOceanSurface 得目标高度,浮标位置/四元数向波面法线 slerp;尾迹/阴影随波法线并令其偏移到太阳投影处;系泊绳随浮标上下伸缩;水下时隐藏尾迹/阴影。src/scene/fish.js(407 行)三群(中心 + 数量 + 色调)共 45 条鱼,身体 SphereGeometry + 尾巴 BufferGeometry 两个 InstancedMesh;逐鱼存位置/速度/加速度/规模/巡航速度/相位/好奇心。
update(t, underwaterMix, camera)(193–365):
separation(反距离平方)、alignment、cohesion;加向所属「学校中心」(随时间缓慢漂移)的吸引。CAMERA_NOTICE_RADIUS + min(speed*0.45,3) 内产生 panic;cameraStillTime),calmness/curiosityCalmness 越高,好奇鱼恐惧下降(注释 292–294:好奇鱼习惯化 0.97,普通鱼 0.72);max(personalSpace, panic*(1-habituation)),驱动远离相机;curiosityCalmness>0.1):在距相机 orbitRadius 处切向环绕 + 略上抬。sampleOceanSurface/seabedHeight 算距顶/底,过近加相应加速度。clampVectorLength 限制加速度/速度;panic 时提速;下限保持巡航速度的 72%(鱼不停)。updateMatrices:实体化位置/四元数(朝速度方向)、尾摆频率随速度、尾摆角随速度 smoothstep。getDiagnostics(camera):汇报附近/逃逸/好奇鱼数、径向速度、平均尾频、相机速度——供视觉回归的「calm/curious/startled」行为门。
noise-texture.js / pipeline.jsnoise-texture.js(44 行):xorshift 生成 512×512 RGBA 噪声 DataTexture(NoColorSpace,供着色器当 valueNoise 晶格)。WebGL/WebGPU 双端共用(WebGPU 用 valueNoiseNode 采样 .r)。
pipeline.js(26 行):见 §2.4,按 rendererMode 动态 import 对应后端场景模块。
water/vertex.js + water/functions.js + water/fragment.jswater/vertex.js(277 行):构建时把 OCEAN_WAVES 等以 toFixed(8) 注入 addDomainWarp/addEnergyWave/addWave 调用模板。main() 逐顶点复现 sampleOceanSurface 的位移与法线(用 out 参数累积导数),并把平均水面平面投影到反射/折射纹理空间(vReflectionCoord/vRefractionCoord)。
water/functions.js(315 行):GLSL 工具库——
hash21/hash22/valueNoise(采样噪声纹理,带 -0.65 LOD 偏置保留远处细节)、fbm(4 倍频)、directionalFbm(球面方向 5 倍频,无经度缝);distributionGGX/visibilitySmithGGXCorrelated/fresnelSchlick(水 F0=0.02037)——物理朝向的太阳闪光;addRipple/microHeight/microGradient——多尺度毛细波(按距离/屏幕足迹淡出,避免远处条纹与近处闪烁);skyReflection——程序化天空反射(含风转云);cloudShadowMask——海面云影;foamCurlDisplacement(无散度卷曲场,使泡沫随材料流动而非直线尾迹)+ foamLifecycle(出生—扩散—破碎,cell 噪声驱动)+ foamPacketMask(揭示/侵蚀用空间 AA 而非整片淡出)。water/fragment.js(417 行):完整水体光照合成,是项目最复杂的单文件。主干:
macroGradient(顶点法线)+ detailGradient(微梯度)合成 surfaceUp;0.025+0.975*(1-viewFacing)^5;反射方向夹 y≥0.015;1-exp(-column*0.052) + 深度/清晰度变化 → 水body 青→蓝;vReflectionCoord.xy/w 投影 UV + 梯度扭曲;projectiveValidity(w) 防掠射无效;reflectedScene 混合捕获与程序天空;refractedScene 透射染色;microfacetAlpha = 粗糙度² + min(法线方差*0.32,0.055)(屏幕方差只加宽亚像素高光,防远处闪烁);broadAlpha 宽波瓣定位闪光场,advected 多尺度遮罩拆成风成闪片;refract(...,1.333) 算透射天空 + 水下雾;color = mix(surfaceColor, underwaterColor, uUnderwater)(注释 46–51:两侧各用物理法线着色再混色,绝不在 50% 处插值/翻转对映法线——此前跨水面会整帧跳变)。webgpu-water-functions.js + webgpu-wave-nodes.js + webgpu-water-material.jswebgpu-water-functions.js(183 行):TSL 版 valueNoiseNode/fbmNode/distributionGGX/visibilitySmith/fresnelSchlick/microGradientNode。其中 microGradientNode 用 dFdx/dFdy 从屏幕空间导数反解世界梯度,把 5 采样微高度只算一次而非四次中心差分,省 15 次纹理读取/片元(注释 160–162)。
webgpu-wave-nodes.js(181 行):createWebGpuWavePositionNode 用 TSL Fn 复刻 GLSL addWave——域扭曲、能量、逐波位移/梯度/LOD、锐化,并把 normal/surfacePosition/waveHeight/waveSlope 写入 varyingProperty 供片元用。注释强调矩阵朝向须与 GLSL mat2(0.80,-0.60,0.60,0.80) 一致。
webgpu-water-material.js(511 行,最长后端文件):MeshBasicNodeMaterial,positionNode = createWebGpuWavePositionNode(...);colorNode = Fn(() => {...}):
reflector({bounces:false,samples:0}) 节点反射器(target.rotation.x=-π/2 暴露 +Y 平面法线);viewportSharedTexture(viewportSafeUV(screenUV - gradient*...)) 直接屏采,无需离屏 RT;waterFragmentShader 逐段对应:水body、云影、透射、反射混合、距离色阶、波峰透射、GGX 闪光(grazingResolutionCompensation 按相机高度补偿 WebGL MSAA 保留更多亚像素太阳面元)、泡沫、地平吸收;If(underwaterNode<0.98) 与 If(underwaterNode>0.02) 两支各自着色,最后 mix(surfaceOutput, underwaterOutput, mediumBlend)——与 GLSL 的「两侧各着色再混」一致,规避零向量归一化。注释 96–99/342–347/382–386 多处点明「与 WebGL 路径完全对齐」。ui/loading.js / render-cap.js / renderer-toggle.jsloading.js:加载控制器,setStage 单调推进 --loader-progress CSS 变量与状态文案;paint 用 requestAnimationFrame 让出以刷新 UI;reveal 加 is-ready;fail 标记错误态。render-cap.js:绑定 <select data-render-cap-select>,change 时 persistRenderCapPreference 并回调 onChange;揭示控件。renderer-toggle.js:两个按钮绑定 webgpu/webgl,点击写 ?renderer= 并 location.assign 重载(不改 URL 历史);状态文案区分 WebGPU 直连 / WebGPU 管线 WebGL2 降级 / WebGL2。package.json:type:module;脚本 dev/build/build:pages/check:pages/quality:test/performance:profile/performance:sustain-4k/visual:test;依赖仅 three,开发依赖 playwright+vite;engines.bun>=1.3.11。
playwright.config.js:视觉测试目录 ./tests/visual;自动探测 Chrome/Edge 可执行路径;--use-angle=d3d11(Win)/ swiftshader(其他);CI 视口 960×540、本地 1600×900;webServer 启动 bun run dev --port 4173,timeout 60s,workers:1、fullyParallel:false(每用例独立浏览器进程,防 GPU 状态泄漏)。
scripts/profile-performance.mjs:对 1080p/1440p/4K × webgl/webgpu(可按环境变量筛选)各起全新浏览器跑「profile live renderer timings」,合并 10 秒时序报告到 visual-results/runtime-timings.json。
scripts/profile-native-4k-sustain.mjs:原生 4K 持续门(PERFORMANCE_TARGET_FPS 默认 144)。对每个 view 起独立进程跑「sustains native 4K at target refresh」,轮询 native-4k-sustain.json 直到 complete;失败/卡死时用 taskkill /T /F(Win)或进程组 SIGTERM/SIGKILL 清理。判定:画布/质量修订不变、吞吐 ≥99.5% 目标、缺失回调 <0.5%、帧间隔尾/CPU/GPU 预算不超、无浏览器错误。
scripts/check-pages-build.mjs:校验 dist/index.html 不引用 /src/、所有资源路径以 /beautiful-water/ 为作用域且文件存在——保证 Pages 部署自包含。
scripts/smoke-pages.mjs:部署后最多 30 次、间隔 10s 探活 PAGES_URL,校验 <title>Open Water</title> 与 JS bundle 可 HEAD 访问。
scripts/check-linear-history.ps1:检查 Git 线性历史(Windows 沙箱 apply_patch 失败时 AGENTS.md 提及的兜底)。
tests/视觉回归(tests/visual/ocean.visual.spec.js,~1100 行结构):定义 20 个命名视角(views:sun-facing / 各种 grazing / foam-detail 及其 forming/transition/replaced / cross-sun / opposite-sun / top-down / wide / repetition / underwater / 鱼 calm/startled),冻结 time=11.75 后 setView 渲染并截 20 张命名回归图;4 个相机/时间预设经 WebGL 与 WebGPU 各渲,写单帧 + 并排合成图,强制像素误差与感知相似度上限;CI 用 ci-scene 子集(7 视图)硬 2 分钟上限。screenshot-metrics.js 的 measureNormalizedImageDifference 用 Canvas 2D 算归一化绝对像素误差;wave-field.js 的 measureWaveFieldCorrelation 在 81×81 网格采样 sampleOceanSurface 并做互相关移位搜索,防止周期性平铺在三个仿真时刻悄悄回归(README 所述「displacement-field correlation」)。
性能/HUD/可持续规格:performance-hud.spec.js(点性能面板复制 15s 报告内容)、runtime.performance.spec.js、runtime.sustain.spec.js(原生 4K 持续门,写 native-4k-sustain.json 含各判定 criteria)。
性能单测(tests/performance/*.test.js,bun:adaptive-quality/gpu-frame-timer/presentation-monitor/render-cap/renderer-preference/runtime-diagnostics/workflow-budget):例如 renderer-preference.test.js 验证默认 WebGPU、接受 ?renderer=webgpu|webgl、忽略非法值回退 WebGPU。这些单测让「GPU 感知自适应」等核心逻辑可独立验证,不依赖浏览器。
renderer-parity 视觉门 + wave-field 相关性检测直接守住「WebGPU 与 WebGL 看起来一样、且波形不_periodic tiling_」这一最易回归的点。learnedMaximumPixelBudget 防在档位边界反复横跳;multisample:0、WebGPU 折射单采样 + 半分辨率光柱(省 75% pass)、microGradientNode 用导数反解省 15 次纹理读、距离/屏幕足迹 LOD 淡出毛细波;underwaterMix 混合(特意规避 50% 处法线翻转跳变);泡沫为输送式多孔丝带而非整片白。prefers-reduced-motion 降速;WebGPU 失败自动降级 WebGL2 并记录原因;dispose 在 WebGPU 4K 下避免无限阻塞;多屏/隐藏页/页面卸载均有采样重置。webgpu-water-material.js 511 行基本是 water/fragment.js 的翻版)。任何水体算法改动都需改两处并靠视觉回归兜底——一旦回归门松弛,两端就会悄悄分叉。breakingZone 多重 sin 组合、泡沫 lifecycle/curl/filaments、GLSL 内大量魔法常数(0.02037 F0、0.052 吸收、0.65 云光等)均为作者经验性手调,注释虽解释「为什么」但没有系统性的物理标定或可配置参数表。换一组海域/天气需大量试错。reflector、viewportSharedTexture、varyingProperty、QuadMesh 等属 three/webgpu 较新的节点系统,版本敏感(three@0.185.1 锁定);升级 Three 风险高。sunDirection 写死。作为「作品」足够,作为「可配置海洋 SDK」尚早。native-4k-sustain 与 performance:profile 明确标注「hardware-dependent, local only」,CI 仅跑降分辨率视觉矩阵,4K 吞吐不进 CI——生产环境的 4K 性能未经持续集成保证。core/ 纯逻辑,GLSL/TSL 正确性完全靠视觉回归(阈值若调宽易漏回归)。docs/ 是工作量报告而非开发文档。| 领域 | 具体用途 |
|---|---|
| 游戏 / 实时渲染 | 作为开放世界浅海、港口、湖泊的水体与水下渲染参考实现;Boids 鱼群可直接用于生态点缀 |
| 图形教学 | 极佳的「GLSL ↔ TSL 双后端对照」教材;GGX 闪光、投影反射/折射、泡沫生命周期、体积光柱均有可运行范例 |
| 数字孪生 / 仿真 | 海岸、码头、海上风电/钻井平台周边水面可视化;浮标/系泊可作监测浮标原型 |
| 产品展示 / 官网 | 全屏电影感背景(汽车/旅游/地产落地页),零素材易嵌入 |
| AI / Agent 可视化 | window.__WATER_HARNESS__ 把场景暴露为确定性渲染接口,可被 AI 智能体驱动做「讲解/截图/对比」 |
| 性能工程样本 | GPU 感知自适应、帧率上限、三级 FPS 计量、4K 持续门可作为 WebGL/WebGPU 应用的性能基准范式 |
| 工作量估算研究 | 与 EffortHours 配合,作为「AI 辅助 vs 传统人力」产能标定的可复现案例 |
beautiful-water 是一个以极致工程纪律打磨的电影级程序化浅海 WebGL/WebGPU 演示:它在零外部素材的前提下,用一套共享波谱驱动 GLSL 与 TSL 双后端,覆盖海面反射折射、浅水透射、太阳闪光、输送式泡沫、浮标、三群具「好奇心/习惯化/逃逸」的鱼群,以及连续跨水面的水下世界;并围绕它建立了「视觉回归 + GPU 感知自适应 + 4K 持续门 + Pages 部署 + 工作量标定」的完整闭环。其最值得借鉴的是把性能做成可证伪测试、把测量局限写进产品文案的诚实工程文化;主要代价是双后端算法需手工对齐的高维护成本与大量经验性魔法常数带来的可复现难度。
解读基准:
main分支 commit830c7f3(2026-08-23,Merge PR #15「fix/gpu-aware-adaptive-quality」)。
范围声明:已逐行/逐段覆盖全部产品核心源码(src/main.js、src/core/*、src/scene/*、src/shaders/*、src/ui/*、入口与配置);工程测试与脚本(scripts/*、tests/*、src/style.css、docs/*)按模块级说明,并对最关键的工具函数(screenshot-metrics.js、wave-field.js)与结构(视觉回归视图矩阵、性能单测)做了引用级拆解。未运行项目(无 bun/Chromium 执行环境),所有分析基于静态源码与仓库元数据。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。