3d-ultra-realistic-water

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

3D Ultra-Realistic Water

3D超写实水面

Use this for an open sea seen by a moving camera, with a horizon and a sun. Reach for something else in these cases:
  • Mirror-flat water seen from the waterline with no mesh: a full-screen analytic water plane is cheaper.
  • Ripples that follow the cursor on a UI surface: use
    shaders-cursor-ripples
    .
  • A small pool that reacts to touch: use a ping-pong height field.
Source: extracted from Pirate Ship Sunset, a single-file Three.js r169 scene in which a three-masted ship sails a rolling sea at sunset under an auto-cycling camera. The water had to stay convincing from 2 m above the waves out to a 12 km horizon.
适用于带有移动相机、具备水天线与太阳的开阔海面场景。以下场景请选用其他方案:
  • 从水线位置观察、无网格的镜面平水面: 使用全屏解析水面平面成本更低。
  • UI界面上跟随光标移动的涟漪: 请使用
    shaders-cursor-ripples
    。
  • 响应触摸的小型泳池: 请使用乒乓高度场。
来源:提取自**《Pirate Ship Sunset》**,这是一个单文件Three.js r169场景,内容为一艘三桅船在日落时分的起伏海面上航行,相机自动循环移动。该水面效果需要在从浪尖上方2米到12公里外的水天线范围内都保持真实感。

The mechanism

实现原理

Shade the sea per pixel from the analytic derivatives of a deep-water Gerstner sum, and fade each wave out once its wavelength falls below the pixel footprint. The per-pixel derivatives keep the glitter and foam sharp on a mesh whose triangles are 30 m wide at the horizon. The footprint fade stops the far sea from boiling into moiré.
基于深水Gerstner波叠加的解析导数对海面进行逐像素着色,当波浪波长低于像素足迹时即淡出该波浪。 逐像素导数确保了在水天线处三角形宽度达30米的网格上,粼波与泡沫依然清晰。足迹淡出机制可避免远处海面出现摩尔纹闪烁的“沸腾”效果。

Reuse the working effect

复用现成效果

Copy assets/ocean.mjs. You pass in the project's own
THREE
, and the module does not import a second copy. Before you integrate it, read references/ocean.md, which covers the full constants, render order, buoyancy and post. The demo runs this same module inside the original scene.
demo/build.mjs
rebuilds it from the reference page.
js
import { createWaves, bakeOceanDetail, createOcean, createWake, createPlanarReflection } from './ocean.mjs';

const waves = createWaves({ direction: 1.22 });                 // wind heading, radians
const ocean = createOcean(THREE, {
  waves,
  detailTexture: bakeOceanDetail(THREE, renderer),              // baked once, 512²
  skyCube,                                                      // cube rendered from YOUR sky shader
  hazeGLSL: SKY_GLSL + 'vec3 oceanHaze(vec3 d){ return hazeColor(d); }',
  hazeUniforms: skyUniforms,                                    // may include uSunDir
  wake: createWake(THREE, renderer, { shipLength: 34 }),        // optional
  hull: { halfWidths, zMin: -18, zMax: 18, bowZ },              // optional, ship-local metres
});
scene.add(ocean.mesh);
const reflection = createPlanarReflection(THREE, renderer, { layer: 1 });
ocean.setReflection(reflection);

// every frame, in this order
waves.update(dt);                                  // CPU phases, before any height() query
ship.y = waves.height(ship.x, ship.z);             // buoyancy uses the same spectrum
ocean.update({ time, camera, ship: { x, z, fx, fz } });
wake.update(x, z, fx, fz, speed * dt);
reflection.render(scene, camera);                  // before the main pass
renderer.render(scene, camera);                    // into a HalfFloat target, then bloom + ACES
复制 assets/ocean.mjs 文件。你只需传入项目自身的
THREE
实例,该模块不会重复导入。在集成之前,请阅读 references/ocean.md,其中涵盖了完整的常量、渲染顺序、浮力与后处理相关内容。演示页面 在原始场景中运行了同一模块,可通过
demo/build.mjs
从参考页面重新构建。
js
import { createWaves, bakeOceanDetail, createOcean, createWake, createPlanarReflection } from './ocean.mjs';

const waves = createWaves({ direction: 1.22 });                 // wind heading, radians
const ocean = createOcean(THREE, {
  waves,
  detailTexture: bakeOceanDetail(THREE, renderer),              // baked once, 512²
  skyCube,                                                      // cube rendered from YOUR sky shader
  hazeGLSL: SKY_GLSL + 'vec3 oceanHaze(vec3 d){ return hazeColor(d); }',
  hazeUniforms: skyUniforms,                                    // may include uSunDir
  wake: createWake(THREE, renderer, { shipLength: 34 }),        // optional
  hull: { halfWidths, zMin: -18, zMax: 18, bowZ },              // optional, ship-local metres
});
scene.add(ocean.mesh);
const reflection = createPlanarReflection(THREE, renderer, { layer: 1 });
ocean.setReflection(reflection);

// every frame, in this order
waves.update(dt);                                  // CPU phases, before any height() query
ship.y = waves.height(ship.x, ship.z);             // buoyancy uses the same spectrum
ocean.update({ time, camera, ship: { x, z, fx, fz } });
wake.update(x, z, fx, fz, speed * dt);
reflection.render(scene, camera);                  // before the main pass
renderer.render(scene, camera);                    // into a HalfFloat target, then bloom + ACES

The stack, with the numbers that shipped

技术栈与上线参数

layerwhat it doesconstants
Spectrum8 Gerstner waves, deep-water dispersion
ω = √(g·k)
L 78 → 3.1 m (×0.63 per step), A 0.8 → 0.02 m, headings 0, +23, −17, +38, −31, +12, −49, +64° off the wind, Q 0.17 → 0.03 (Σ 0.77)
Gridexponential rings centred under the camera
r = 11.92·(e^(0.033·i) − 1)
, 210 rings × 400 segments = 84,001 verts, 167,600 tris, reaches ~12.2 km; spacing
0.033·(r + 12)
Footprint fadeper wave, in both shaders
fade = 1 − smoothstep(0.18·L, 0.5·L, footprint)
; vertex footprint
1.2 × ring spacing
, pixel footprint
2 × (fwidth(x) + fwidth(z))
Normalper pixel,
normalize(cross(dP/dz, dP/dx))
Jacobian
J = dPdx.x·dPdz.z − dPdx.z·dPdz.x
drives crest foam
Micro detailbaked 512² tile: 48 sines with integer wave vectors, amplitude ∝ |k|^−1.35two octaves at 19 m and 7.3 m (the second rotated 90°), strength
0.4·e^(−d/500) + 0.08
ReflectionSchlick with water F0 = 0.02; 256² sky cube re-rendered every 0.25 s; half-res planar pass for the ship and islandsplanar UV nudged by
N.xz · (0.06, 0.03) · clamp(30/d, 0.2, 1)
Bodydeep colour plus sunlit subsurface through the wave crestsdeep (0.006, 0.03, 0.048), scatter (0.035, 0.3, 0.27) linear; scatter × toward-sun² × crest height × (0.35 + 4·(1 − N.y))
GlitterHDR specular, left for the bloom to catch
pow(R·L, 90)·1.2 + pow(R·L, 12)·0.06 + pow(R·L, 1400)·40
, sun colour (4.2, 2.3, 1.1); bloom threshold 1.6, knee 0.8
Foamthresholds a Worley lattice by a mask, so the foam stays lacycrest
smoothstep(0.6, 0.28, J)
;
foam = smoothstep(1 − m, 1.2 − m, pattern·0.88 + m·0.22)
; gone by 1600 m
Wake512² half-float field over 380 m, trailing the shipKelvin arm offset
1.2 + 0.35·a
(atan 0.35 ≈ 19.3°), arm decay 115 m, stern wash 170 m, calm slick 260 m
Hazefogs into the sky's own horizon colour
fog = (1 − e^(−d·2.8e-4))^1.6
层级功能常量参数
波谱8个Gerstner波,深水色散关系
ω = √(g·k)
L 78 → 3.1 m(每步×0.63),A 0.8 → 0.02 m,风向偏角分别为0、+23、−17、+38、−31、+12、−49、+64°,Q值 0.17 → 0.03(总和 0.77)
网格以相机下方为中心的指数环形网格
r = 11.92·(e^(0.033·i) − 1)
,210环 × 400段 = 84,001个顶点,167,600个三角形,覆盖范围约12.2公里;间距
0.033·(r + 12)
足迹淡出逐波浪生效,在顶点与片元着色器中均有实现
fade = 1 − smoothstep(0.18·L, 0.5·L, footprint)
;顶点足迹为
1.2 × 环间距
,像素足迹为
2 × (fwidth(x) + fwidth(z))
法线逐像素计算,公式为
normalize(cross(dP/dz, dP/dx))
Jacobian值
J = dPdx.x·dPdz.z − dPdx.z·dPdz.x
用于驱动浪峰泡沫生成
微观细节烘焙的512²纹理瓦片:48个带整数波矢的正弦波,振幅 ∝ |k|^−1.35两个八度,分别为19米和7.3米(第二个旋转90°),强度
0.4·e^(−d/500) + 0.08
反射采用Schlick近似,水面F0=0.02;256²天空立方体贴图每0.25秒重渲染一次;船舶与岛屿使用半分辨率平面反射通道平面UV偏移量为
N.xz · (0.06, 0.03) · clamp(30/d, 0.2, 1)
水体深水色加上穿过浪峰的日光次表面散射深水色(0.006, 0.03, 0.048),散射色(0.035, 0.3, 0.27)(线性空间);散射强度 × 朝向太阳系数² × 浪峰高度 × (0.35 + 4·(1 − N.y))
粼波HDR高光,交由泛光效果处理
pow(R·L, 90)·1.2 + pow(R·L, 12)·0.06 + pow(R·L, 1400)·40
,太阳颜色(4.2, 2.3, 1.1);泛光阈值1.6,拐点0.8
泡沫通过遮罩对Worley晶格进行阈值处理,保持泡沫的蕾丝状纹理浪峰系数
smoothstep(0.6, 0.28, J)
;
foam = smoothstep(1 − m, 1.2 − m, pattern·0.88 + m·0.22)
;1600米外完全消失
航迹船舶后方380米范围内的512²半浮点场Kelvin臂偏移
1.2 + 0.35·a
(atan 0.35 ≈ 19.3°),臂衰减距离115米,船尾洗流170米,平静滑流区260米
薄雾雾化至天空自身的天际线颜色
fog = (1 − e^(−d·2.8e-4))^1.6

Rules, each with the failure it prevents

设计规则及其规避的问题

All of these were A/B tested in the demo scene with the sim frozen, so only the one change differed between frames.
  • Fade every wave at its own footprint, in both the vertex and fragment shaders. Without the fade, the 3–8 m waves are sampled below their wavelength. The far sea turns into dense grain right up to a hard horizon, and the sun's sheen on the far water disappears. Between consecutive frames, the flicker in the far band went from 2.3 to 5.8 grey levels on average, with the 95th percentile rising from 5 to 23. In motion, that reads as boiling.
  • Compute the normal in the fragment shader from the analytic derivatives. If you shade from the interpolated vertex normal, detail disappears past the near field. The far sea turns into long smooth streaks that follow the mesh rings, and the glitter smears instead of breaking into sparkles.
  • Keep ΣQ below 1. Steepness scales the sideways displacement. Past a sum of about 1, the Jacobian goes negative: the surface folds through itself, and the foam mask saturates into flat white slabs on the crests.
    setSeaState()
    lets amplitude keep rising but caps steepness at ΣQ = 0.95.
  • Clamp the reflection vector above the horizon:
    R.y = abs(R.y) + 0.002
    .
    Some wave backs reflect downward. Without the clamp, those faces sample the flat lower half of the sky cube and read as pale grey smears with no glitter. At a grazing shot, 3–4% of the frame changed by as much as 154 levels.
  • Fog into the same haze function the sky draws. One flat fog colour matches the sky only on the sun side. Everywhere else it leaves a hard line where the water meets the sky.
  • Scroll detail and foam textures with the floating-origin offset, using the same divisor as the sampling scale. The reference sampled foam at
    /9
    and
    /3.3
    but offset it by
    /11
    and
    /4.1
    . At every 1200 m rebase, the foam pattern jumped by about 24 tiles. The module fixes this.
  • Wrap wave phases to [0, 2π) on the CPU in float64, including the floating-origin term. If the shader receives raw time,
    ω·t
    passes 16,000 rad for the 3 m wave within an hour. That makes the float32
    sin()
    argument the part that degrades first. This rule is precision reasoning and was not observed in the demo.
  • Query height with the same spectrum you render. Gerstner waves move points sideways by up to 4.5 m here, so
    height(x, z)
    inverts the horizontal displacement with 4 fixed-point steps before it reads y. Reading
    disp(x, z).y
    directly was off by 0.15 m on average and by up to 1.17 m on steep crests, across 20,000 samples. After 4 steps, the worst horizontal miss is 0.18 m.
所有这些规则都在演示场景中进行了A/B测试(模拟冻结状态),确保帧之间仅有一处变量差异。
  • 在顶点与片元着色器中,逐波浪按其自身足迹淡出。 若没有淡出机制,3-8米的波浪会被欠采样(采样率低于波长要求)。远处海面会变成密集颗粒,直到生硬的水天线处,且远处水面的日光光泽会消失。连续帧之间,远处条带的闪烁平均灰度差从2.3升至5.8,第95百分位从5升至23。在动态效果中,看起来就像海面在“沸腾”。
  • 在片元着色器中通过解析导数计算法线。 如果使用插值后的顶点法线进行着色,近场以外的细节会全部消失。远处海面会变成跟随网格环形的平滑长条纹,粼波会模糊成一片而非离散的闪光点。
  • 保持ΣQ低于1。 陡度用于控制横向位移的缩放。当总和超过约1时,Jacobian值会变为负数:水面会发生自相交折叠,泡沫遮罩会饱和,在浪峰处形成平整的白色块。
    setSeaState()
    方法允许振幅持续升高,但会将陡度上限限制在ΣQ = 0.95。
  • 将反射向量限制在水天线以上:
    R.y = abs(R.y) + 0.002
    。
    部分浪背会向下反射。如果没有该限制,这些面会采样天空立方体贴图的下半部分平面,呈现为无粼波的浅灰色污斑。在掠射视角下,3-4%的画面区域颜色变化可达154个色阶。
  • 使用与天空渲染相同的薄雾函数进行雾化。 单一的雾化颜色仅在朝向太阳的一侧与天空匹配,其他区域会在水天相接处留下明显的分界线。
  • 细节纹理与泡沫纹理的滚动使用浮动原点偏移,且采用与采样缩放相同的除数。 参考实现中泡沫的采样除数为
    /9
    和
    /3.3
    ,但偏移除数为
    /11
    和
    /4.1
    。每1200米重设原点时,泡沫图案会跳跃约24个瓦片。本模块修复了该问题。
  • 在CPU端使用float64将波浪相位包裹到[0, 2π)区间,包含浮动原点项。 如果着色器接收原始时间值,3米波浪的
    ω·t
    会在一小时内超过16000弧度。这会导致float32类型的
    sin()
    参数首先出现精度下降。该规则基于精度推导,在演示中未观测到相关问题。
  • 使用与渲染相同的波谱进行高度查询。 此处Gerstner波会使点产生最多4.5米的横向位移,因此
    height(x, z)
    方法会在读取y值前通过4次定点迭代反解水平位移。在20000个采样点中,直接读取
    disp(x, z).y
    的平均误差为0.15米,陡浪峰处最大误差达1.17米。经过4次迭代后,最大水平误差为0.18米。

Colour and pipeline

色彩与渲染管线

  • Every uniform colour is linear HDR. Render the scene into a HalfFloat target, and tone-map once in the composite: ACES, then sRGB. The reference sets
    renderer.toneMapping = NoToneMapping
    and applies ACES itself, at exposure 0.9 and bloom 0.35.
  • The sky cube is rendered from the page's own sky shader, with the tiny sun disc left out. The analytic specular term draws the sun's path on the water.
  • The planar reflection renders only a layer holding the objects that stand in the water. It clips at y > −0.05, and it reuses the frame's shadow map (
    shadowMap.autoUpdate = false
    ).
  • 所有uniform颜色均为线性HDR空间。将场景渲染到HalfFloat目标,在合成阶段进行一次色调映射:先ACES,再转sRGB。参考实现设置
    renderer.toneMapping = NoToneMapping
    ,自行应用ACES,曝光度为0.9,泛光强度为0.35。
  • 天空立方体贴图由页面自身的天空着色器渲染,不包含微小的太阳圆盘。通过解析高光项在水面上绘制太阳光路。
  • 平面反射仅渲染包含水中物体的图层。它在y > −0.05处进行裁剪,并复用当前帧的阴影贴图(
    shadowMap.autoUpdate = false
    )。

Cost

性能开销

The ocean mesh is 167,600 triangles. Its fragment shader runs the 8-wave loop once per pixel, plus 6 texture taps, 1 cube tap and 1 planar tap. The planar pass re-renders the reflection layer at half resolution. Under heavy machine load, headless Chrome ran the whole Pirate Ship Sunset scene in 10–13 ms per frame at 1440×900. Hiding the ocean or skipping the reflection pass each changed that by less than the run-to-run noise (about ±2 ms), so neither is the bottleneck. The reference's lever is adaptive resolution: DPR is capped at 1.6, and a resolution scale of 0.6–1.0 steps down when the 60-frame average passes 26 ms and back up below 14.5 ms.
海洋网格包含167,600个三角形。其片元着色器每个像素执行一次8波循环,外加6次纹理采样、1次立方体贴图采样和1次平面反射采样。平面反射通道以半分辨率重渲染反射层。在高机器负载下,无头版Chrome运行完整《Pirate Ship Sunset》场景,1440×900分辨率下每帧耗时10-13毫秒。隐藏海洋或跳过反射通道带来的耗时变化都低于运行间的噪声波动(约±2毫秒),因此两者都不是性能瓶颈。参考实现的调节手段是自适应分辨率:DPR上限为1.6,当60帧平均耗时超过26毫秒时,分辨率缩放会在0.6-1.0区间逐级下调,低于14.5毫秒时则逐级上调。

Lifecycle and accessibility

生命周期与无障碍适配

  • Clamp
    dt
    to 1/20 s. The reference also rebases the world every 1200 m, shifting the ship, wake, particles, camera and
    waves.offX/offZ
    together.
  • Under reduced motion, stop simulation time and keep rendering until the intro fade finishes, then hold the frame. Re-render only when a control changes. The demo's
    __OD.hold()
    implements this.
  • Pause with the tab:
    setAnimationLoop
    stops when the page is hidden, and the
    dt
    clamp absorbs the gap when it resumes.
  • 将
    dt
    限制为1/20秒。参考实现还会每1200米重设世界原点,同步移动船舶、航迹、粒子、相机和
    waves.offX/offZ
    。
  • 在“减少动态效果”模式下,停止模拟时间,继续渲染直到入场淡入完成,然后保持帧静止。仅当控件变化时才重新渲染。演示中的
    __OD.hold()
    实现了该功能。
  • 随标签页暂停:页面隐藏时
    setAnimationLoop
    会停止,恢复时
    dt
    限制会吸收时间差。

Verify

验证项

  • With the camera 2 m above the water facing the sun, the far sea resolves into haze and sheen, not grain. Toggling the footprint fade off makes the far band flicker between frames.
  • Per-vertex normals visibly lose the far micro detail. If they don't, the normal is still being taken from the vertices.
  • Raise the sea state to 1.6: the crests stay lacy, with no flat white slabs.
  • At a grazing view, no pale grey smears appear on the wave backs.
  • Anti-sun horizon: the water blends into the sky with no line.
  • Floating objects ride the crests (
    height()
    inverted), and the wake arms open at about 19°.
  • Reduced motion holds a composed still, controls still re-render, and the console is clean at 1440×900 and 390×844.
  • 相机位于水面上方2米、朝向太阳时,远处海面应呈现薄雾与光泽,而非颗粒感。关闭足迹淡出功能会导致远处条带在帧间闪烁。
  • 逐顶点法线会明显丢失远处的微观细节。如果没有出现该情况,说明法线仍然是从顶点采样的。
  • 将海况等级提升至1.6:浪峰仍保持蕾丝状,不会出现平整的白色块。
  • 掠射视角下,浪背不会出现浅灰色污斑。
  • 背向太阳的水天线:水天融合自然,无明显分界线。
  • 漂浮物会随浪峰起伏(
    height()
    反解正确),航迹臂张开角度约为19°。
  • “减少动态效果”模式下会保持合成后的静止画面,控件操作仍会触发重渲染,且在1440×900和390×844分辨率下控制台无报错。