executing-plans
executing-plans
Use when executing an implementation plan in the current session as the implementer yourself — your human partner chose inline execution, or no subagent tool is available
3d-underwater-god-raysAdd volumetric underwater god rays to a Three.js scene, marching one drifting swell field along every view ray at half resolution so the light shafts converge on the sun, parallax with the camera and carry rippling caustic streaks, with a shimmering surface underside, a sun that wobbles through the ripples, and the same light dappling fish as they swim thro…
Independent GitHub snapshot · Automated classification · Original documentation governs
Use this when the camera is under the water and the light comes down through it. Reach for a sibling instead in these cases:
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).3d-ultra-realistic-water.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 ShaderMaterials, one HalfFloat render target, and a hand-written ACES pass. No post-processing library.
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.
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.
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
| layer | constants |
|---|---|
| Swell | 4 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 sun | scale 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 web | the swell again at ×2.6 and 1.35× speed, `(1 − |
| March | 26 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 |
| Buffer | 0.5 × the drawing buffer, HalfFloat, single-sampled (the march is soft, so MSAA buys nothing), upsampled with a 4-tap tent at ½ texel |
| Surface underside | plane at y = 12; network `(1 − |
| Fish | sun key × 0.62 + 1.5 · shaftLight |
| The rest of the frame | sun corona halved to (0.043, 0.095, 0.123)·μ^48; screen-space ray pass cut from 0.82 to 0.25 |
These came from A/B renders of the Blue Hour frame: the same camera, the same frozen fish, one change at a time.
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.exp(−0.06t): the near ones smear least, so they hold the beam edges.mix(total, 0.16, μ^28) closes it.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.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.
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.document.hidden, and reset the frame clock on visibilitychange so the first frame back doesn't integrate the whole pause.