3d-underwater-god-rays

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

3D Underwater God Rays

3D 水下体积光束

Use this when the camera is under the water and the light comes down through it. Reach for a sibling instead in these cases:
  • Sun shafts in air, cut by trees and rooflines: use
    3d-sky-rays
    . Its screen-space radial blur of an occlusion mask is the right tool there. It cannot parallax or carry ripples, so under water keep it only as a low companion for fish shadows (Blue Hour runs it at 0.25).
  • The ocean surface seen from above, with a horizon: use
    3d-ultra-realistic-water
    .
  • Authored shaft planes through one window in a lit interior: use
    3d-wood-lighting-scorecard
    .
Source: extracted from Blue Hour, the living-ocean wallpaper of a Three.js iPhone Duo study. A school of 2,200 Blender sardines turns behind a glass device. The light had to read as sun through water across the whole 38 s camera dolly and on the device's own screen.
Verified stack: Three.js r170, WebGL2, GLSL
ShaderMaterial
s, one HalfFloat render target, and a hand-written ACES pass. No post-processing library.
当相机位于水下、光线从水面上方照下时,使用此效果。在以下情况中,应改用同类的 skill:
  • 空气中的太阳光束,被树木和屋顶轮廓切割: 使用
    3d-sky-rays
    。在那种场景中,适合使用它对遮挡遮罩进行屏幕空间径向模糊。它无法产生视差或承载波纹,因此在水中只应将其作为鱼影的低强度辅助效果(Blue Hour 将其强度设为 0.25)。
  • 从上方观察且带有地平线的海洋表面: 使用
    3d-ultra-realistic-water
    。
  • 明亮室内穿过单个窗户的手工编排光束平面: 使用
    3d-wood-lighting-scorecard
    。
来源:提取自 Blue Hour,这是一项 Three.js iPhone Duo 研究中的动态海洋壁纸。2,200 条 Blender 沙丁鱼在玻璃设备后方游动。光线必须在整个 38 秒的相机推轨过程中,以及设备自身屏幕上,都呈现为穿过水面的阳光。
已验证技术栈:Three.js r170、WebGL2、GLSL
ShaderMaterial
、一个 HalfFloat render target,以及手写的 ACES pass。未使用后处理库。

The mechanism

实现机制

Sample one drifting swell field across the sun direction, and integrate it along every view ray. Each point in the water is lit by the swell value at its position measured perpendicular to the sun. The shafts are therefore parallel columns in the world. They converge on the sun the way rails converge on a vanishing point, and they slide past the fish when the camera moves. Paint the same shafts in screen space and they stay locked to the lens.
沿太阳方向横向采样同一个漂移的涌浪场,并沿每条视线对其进行积分。 水中的每个点都由该点位置处的涌浪值决定亮度,而该位置的测量方向与太阳方向垂直。因此,这些光束在世界空间中是彼此平行的柱体。它们像铁轨汇聚于灭点一样汇聚到太阳;相机移动时,光束会从鱼群旁滑过。若在屏幕空间中绘制同样的光束,它们则会固定在镜头上。

Reuse the working effect

复用该效果

Copy assets/underwater-light.mjs. You pass in your own
THREE
, and the module does not import a second copy. The demo runs this same module inside Blue Hour's renderer.
demo/build.mjs
rebuilds it from the Blue Hour sources.
js
import { createUnderwaterLight, LIGHT_GLSL, WATER_GLSL } from './underwater-light.mjs';

// One uniforms object shared by the water, fish and particle materials.
const uniforms = {
  uSunDirection: { value: new THREE.Vector3(.25, .40, -1).normalize() },   // toward the sun
  uTime: { value: 0 },
  uCameraPosition: { value: camera.position },
  uCameraWorld: { value: camera.matrixWorld },
  uInverseProjection: { value: camera.projectionMatrixInverse },
};
const light = createUnderwaterLight(THREE, { uniforms });   // before creating the materials below
light.setSize(innerWidth * dpr, innerHeight * dpr);          // drawing-buffer pixels, again on resize

// Water: a full-screen quad. Declare uSunDirection, uCameraPosition, uTime,
// uShafts and uShaftTexel, then add ${LIGHT_GLSL}${WATER_GLSL} and in main():
//   vec3 sunRay = ray;
//   color += surfaceUnderside(ray, sunRay);     // also bends sunRay through the ripples
//   ...draw your sun with sunRay...
//   color += underwaterShafts(vUv, ray);
// Fish: declare uSunDirection, then add ${LIGHT_GLSL} and multiply the sun key
// by underwaterDapple(worldPosition, uTime).

// Every frame, in this order:
uniforms.uTime.value = time;
camera.updateMatrixWorld();
light.render(renderer);          // the march, into its own half-resolution target
// then the water, the fish, and one tone map at the end
复制
assets/underwater-light.mjs
。传入你自己的
THREE
,模块不会再次导入另一个副本。演示 在 Blue Hour 的 renderer 中运行同一个模块。
demo/build.mjs
可从 Blue Hour 源码重新构建它。
js
import { createUnderwaterLight, LIGHT_GLSL, WATER_GLSL } from './underwater-light.mjs';

// One uniforms object shared by the water, fish and particle materials.
const uniforms = {
  uSunDirection: { value: new THREE.Vector3(.25, .40, -1).normalize() },   // toward the sun
  uTime: { value: 0 },
  uCameraPosition: { value: camera.position },
  uCameraWorld: { value: camera.matrixWorld },
  uInverseProjection: { value: camera.projectionMatrixInverse },
};
const light = createUnderwaterLight(THREE, { uniforms });   // before creating the materials below
light.setSize(innerWidth * dpr, innerHeight * dpr);          // drawing-buffer pixels, again on resize

// Water: a full-screen quad. Declare uSunDirection, uCameraPosition, uTime,
// uShafts and uShaftTexel, then add ${LIGHT_GLSL}${WATER_GLSL} and in main():
//   vec3 sunRay = ray;
//   color += surfaceUnderside(ray, sunRay);     // also bends sunRay through the ripples
//   ...draw your sun with sunRay...
//   color += underwaterShafts(vUv, ray);
// Fish: declare uSunDirection, then add ${LIGHT_GLSL} and multiply the sun key
// by underwaterDapple(worldPosition, uTime).

// Every frame, in this order:
uniforms.uTime.value = time;
camera.updateMatrixWorld();
light.render(renderer);          // the march, into its own half-resolution target
// then the water, the fish, and one tone map at the end

The numbers that shipped

最终采用的参数

layerconstants
Swell4 sines along (.86, .51), (−.42, .91), (.13, −.99), (−.97, −.24) at ×1, 1.23, 0.87, 1.41; drift 0.29, 0.24, 0.19, 0.15 rad/s; domain warp 0.62
Across the sunscale 1.3, so the main period is about 4.8 world units against a frame about 22 units wide at the school
Shaft focus
smoothstep(−0.05, 1, swell)²
, faded by
exp(−0.012 · distance to the surface along the sun)
Caustic webthe swell again at ×2.6 and 1.35× speed, `(1 −
March26 steps,
t = 4 + 50·f²
, weight
exp(−0.06t)·(0.08 + f)
, divided by the weight sum; a stable per-pixel jitter (interleaved gradient noise) instead of temporal noise
Sun blend
total = mix(total, 0.16, μ^28)
, where μ = ray·sun
Phase
0.7 + 1.1μ^5 + 0.9μ^40
; rays below the horizon ×
mix(0.45, 1, smoothstep(−0.45, 0.35, ray.y))
Gain(0.16, 0.40, 0.47) × 3.6, linear HDR, tone-mapped once at the end
Buffer0.5 × the drawing buffer, HalfFloat, single-sampled (the march is soft, so MSAA buys nothing), upsampled with a 4-tap tent at ½ texel
Surface undersideplane at y = 12; network `(1 −
Fishsun key ×
0.62 + 1.5 · shaftLight
The rest of the framesun corona halved to (0.043, 0.095, 0.123)·μ^48; screen-space ray pass cut from 0.82 to 0.25
层次参数
涌浪沿 (.86, .51)、(−.42, .91)、(.13, −.99)、(−.97, −.24) 方向使用 4 个正弦函数,倍频依次为 ×1、1.23、0.87、1.41;漂移速度分别为 0.29、0.24、0.19、0.15 rad/s;domain warp 为 0.62
垂直于太阳方向缩放为 1.3,因此相对于鱼群所在处约 22 单位宽的画面,主周期约为 4.8 个世界单位
光束聚焦
smoothstep(−0.05, 1, swell)²
,再按
exp(−0.012 · distance to the surface along the sun)
衰减
焦散网纹再次以 ×2.6 的频率和 1.35× 的速度采样涌浪,`(1 −
步进26 步,
t = 4 + 50·f²
;权重为
exp(−0.06t)·(0.08 + f)
,再除以权重总和;采用稳定的逐像素抖动(interleaved gradient noise),而非时间噪声
太阳混合
total = mix(total, 0.16, μ^28)
,其中 μ = ray·sun
相位
0.7 + 1.1μ^5 + 0.9μ^40
;地平线以下的光束再乘以
mix(0.45, 1, smoothstep(−0.45, 0.35, ray.y))
增益(0.16, 0.40, 0.47) × 3.6,采用线性 HDR,最后统一进行一次 tone mapping
缓冲区drawing buffer 的 0.5 倍,HalfFloat,单次采样(步进效果柔和,MSAA 没有收益),使用 4-tap tent 以 ½ texel 偏移进行上采样
水面下侧位于 y = 12 的平面;网纹为 `(1 −
鱼群太阳主光 ×
0.62 + 1.5 · shaftLight
画面其余部分太阳日冕减半至 (0.043, 0.095, 0.123)·μ^48;screen-space ray pass 从 0.82 降至 0.25

Rules, each with the failure it prevents

规则:每条规则都对应一个需要避免的问题

These came from A/B renders of the Blue Hour frame: the same camera, the same frozen fish, one change at a time.
  • Shape the swell into sparse peaks, not ridges.
    1 − |swell|
    makes a web of thin sheets, and a ray that crosses many sheets averages them into flat haze. On a frozen frame the ridge web raised the mean brightness of the water by 20% and halved the contrast between pixel columns, from 0.247 to 0.118. The demo's Ridge web switch shows it.
  • Start the march 4 units out. Starting at 1.5 units, a shaft passing over the camera filled a quarter of the frame with one bright wedge. Weight the samples by
    exp(−0.06t)
    : the near ones smear least, so they hold the beam edges.
  • Merge the shafts into the sun's glow as the ray points at it. Looking straight up one column, the integral is a single swell value. Where the camera sat between shafts, that left a dark hole on the sun.
    mix(total, 0.16, μ^28)
    closes it.
  • Integrate the caustic web inside the march. Sampled 4 times at full resolution instead, the web reads as a pool floor painted over the water. Inside the 26-step march at half resolution, it becomes fine streaks that run along each beam. The demo's Painted on screen switch shows it.
  • Turn down any uniform glow already in the frame. Blue Hour's corona and screen-space ray pass lit the top of the frame evenly. The first shafts barely showed in the composite at about the gain that later shipped. They read only after the corona was halved and the radial pass cut from 0.82 to 0.25, so the shafts carry the light and the gaps stay dark.
  • Judge the gain on the composed page, not the raw canvas. Gain 2.2 looked right on the canvas. On the page, where the canvas sits at 82% opacity under a darkening CSS shade, the same frame showed almost nothing. 3.6 survives the page.
  • Lift the sun above the top edge for full-frame beams. At 21° the sun sits in frame: the convergence point is a glow and the beams are short. Between 30° and 44° it clears the top edge, and the beams fan down the whole frame. The demo's Sun slider sweeps it.
  • Declare each uniform once per shader.
    LIGHT_GLSL
    declares its own uniforms (
    uSurfaceY
    ,
    uShaftScale
    ,
    uShaftFloor
    ,
    uCompare
    ) and expects the host to declare
    uSunDirection
    before it. Declaring one twice is a compile error, and the material quietly draws nothing.
以下规则来自对 Blue Hour 画面进行的 A/B 渲染测试:相机相同、鱼群帧冻结,每次只改一项。
  • 将涌浪塑造成稀疏的峰,而不是连续的脊线。
    1 − |swell|
    会形成由细薄片层构成的网格;若光线穿过许多片层,它们会被平均成平坦的雾霭。在冻结帧上,脊线网格将水体平均亮度提高 20%,并使像素列之间的对比度从 0.247 降至 0.118。演示中的 Ridge web 开关可展示这一效果。
  • 从距相机 4 个单位处开始步进。 若从 1.5 个单位处开始,一束从相机上方掠过的光会让画面四分之一被一个明亮楔形填满。按
    exp(−0.06t)
    对采样加权:近处样本扩散最少,因此能保持光束边缘。
  • 当视线指向太阳时,让光束融入太阳辉光。 沿一根光柱直视正上方时,积分结果只是单个涌浪值。当相机处于光束之间时,这会在太阳处留下一个暗洞。
    mix(total, 0.16, μ^28)
    可将其填补。
  • 在步进内部积分焦散网纹。 如果改为在全分辨率下采样 4 次,网纹看起来就像铺在水面上的池底纹样。将其置于半分辨率的 26 步步进内部后,就会变成沿每束光线延伸的细条纹。演示中的 Painted on screen 开关可展示这一效果。
  • 降低画面中已有的任何均匀辉光。 Blue Hour 的日冕和 screen-space ray pass 会均匀照亮画面顶部。在后来采用的增益下,最初的光束在合成结果中几乎看不出来。只有将日冕亮度减半,并把径向 pass 从 0.82 降至 0.25 后,光束才承担起照明作用,而光束间隙仍保持黑暗。
  • 应在最终合成后的页面上判断增益,而不是只看原始 canvas。 增益为 2.2 时,在 canvas 上看起来合适;但在页面中,canvas 以 82% 不透明度置于一层 CSS 暗化遮罩下方,同一画面几乎看不见。3.6 才能在页面中保留效果。
  • 将太阳抬高到画面上边缘之外,以获得贯穿全画面的光束。 在 21° 时,太阳位于画面内:汇聚点只是一团辉光,光束也很短。介于 30° 到 44° 时,太阳会移出上边缘,光束向下贯穿整个画面。演示中的 Sun 滑块可扫描这一范围。
  • 每个 uniform 在每个 shader 中只声明一次。
    LIGHT_GLSL
    会自行声明
    uSurfaceY
    、
    uShaftScale
    、
    uShaftFloor
    和
    uCompare
    ,并要求宿主提前声明
    uSunDirection
    。重复声明会导致编译错误,材质会静默地不渲染任何内容。

Cost

性能开销

Under heavy machine load (load average about 60), headless Chrome on ANGLE Metal rendered the whole Blue Hour frame in 6–10 ms at 2048×1440. That frame includes 2,200 instanced sardines, the water, the 1024×720 march, the screen-space ray pass and the tone map. Skipping the march changed the median by less than the run-to-run noise (about ±2 ms) across 5 alternating rounds of 20 frames, so it is not the bottleneck here. If it shows up in your profile, lower the buffer scale or the step count. The shafts are low frequency, so both mostly cost softness, but measure that before you rely on it.
在机器负载较重(load average 约 60)时,运行于 ANGLE Metal 上的 headless Chrome 以 2048×1440 渲染完整 Blue Hour 画面只需 6–10 ms。该画面包含 2,200 条 instanced sardines、水体、1024×720 的步进、screen-space ray pass 和 tone map。在 5 轮交替测试中(每轮 20 帧),跳过步进使中位耗时变化小于运行间噪声(约 ±2 ms),因此它并非此处的性能瓶颈。如果性能分析显示该步骤占用明显,可降低缓冲区缩放比例或步数。光束属于低频效果,因此这两项调整主要影响柔和度,但应先测量再作决定。

Lifecycle and accessibility

生命周期与无障碍

  • Step the simulation at 30 fps, with each step capped at 0.1 s. Blue Hour renders the aquarium into a fixed 2048×1440 canvas (1600×1125 below 800 px) and cover-fits it, so the march cost doesn't scale with the screen.
  • Under
    prefers-reduced-motion: reduce
    , hold the frame at t = 0 and re-render only when a control changes. The jitter is stable per pixel, so a still frame doesn't crawl.
  • Stop on
    document.hidden
    , and reset the frame clock on
    visibilitychange
    so the first frame back doesn't integrate the whole pause.
  • 将模拟以 30 fps 步进,并将每步时长上限设为 0.1 s。Blue Hour 将水族箱渲染到固定尺寸的 2048×1440 canvas(视口宽度低于 800 px 时为 1600×1125),再以 cover 方式适配,因此步进开销不会随屏幕尺寸变化。
  • 当
    prefers-reduced-motion: reduce
    生效时,将画面固定在 t = 0,仅在控件发生变化时重新渲染。抖动对每个像素保持稳定,因此静止画面不会出现像素游移。
  • 当
    document.hidden
    为 true 时停止处理,并在
    visibilitychange
    时重置帧时钟,避免恢复显示后的第一帧积分整个暂停时段。

Verify

验证

  • Orbit the camera: the shafts slide against the fish and still converge on the sun. If they move with the lens, they are painted in screen space.
  • With Ridge web on, the water turns to haze. Back on sparse peaks, you see distinct beams with dark water between them.
  • With Painted on screen on, a pool-floor web covers the water. Inside the march, fine streaks run along each beam.
  • Looking straight at the sun, there is no dark hole.
  • Sweep the sun from 21° to 44°: the convergence point moves off the top edge, and the beams lengthen across the frame.
  • A fish crossing a beam brightens and dims again past it.
  • Reduced motion holds a composed still, the controls still re-render it, and the console is clean at 1440×900 and 390×844.
  • 环绕移动相机:光束应掠过鱼群,同时仍汇聚到太阳。如果它们随镜头一起移动,就说明它们是在屏幕空间中绘制的。
  • 开启 Ridge web 后,水体应变成雾霭。切回稀疏峰值后,应看到清晰分离的光束以及光束之间的暗水。
  • 开启 Painted on screen 后,池底网纹会覆盖水体;置于步进内部时,细条纹会沿每束光线延伸。
  • 直视太阳时,不应出现暗洞。
  • 将太阳从 21° 扫描到 44°:汇聚点应移出上边缘,光束也应贯穿并延长至整个画面。
  • 鱼穿过光束时,应先变亮,掠过后再变暗。
  • 减少动态效果模式下应保持合成后的静止画面,控件仍能触发重新渲染,并且在 1440×900 和 390×844 分辨率下控制台无报错。