Generated banner for 3d Underwater God Rays
Generated logo for 3d Underwater God Rays

3d Underwater God Rays

Meng ToCommunityCreated by Meng To

  • Skill
  • Tool integration
  • Software development
  • MIT
  • Low risk
  • Has scripts

From the author

Add 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 through the beams. Use for underwater scenes, oceans seen from below, aquariums, reefs, kelp forests, diving and shipwreck scenes, schools of fish, sunbeams or light shafts through water, caustics, "rays of light shining through the ocean", or watery ripples on a background. Includes a reusable module and the Blue Hour sardine-school demo.
— the description by Meng To

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

This skill provides a reusable Three.js module for creating volumetric underwater light shafts. It uses a marching integration technique across a drifting swell field to produce light shafts that converge on the sun, parallax with the camera, and include rippling caustic streaks. The implementation is designed to be integrated into existing Three.js scenes, supporting features like shimmering surface undersides and light dappling on objects such as fish.

What it does

  • Integrates volumetric light shafts into Three.js scenes using a marching algorithm.
  • Simulates drifting swell fields to create dynamic light patterns.
  • Provides caustic streaks that ripple and move with the light shafts.
  • Calculates light convergence toward a sun position.
  • Supports light dappling effects on 3D objects within the scene.
  • Includes a reusable module and a demonstration of a school of fish.

When to use it

  • When creating underwater environments like reefs, kelp forests, or shipwrecks.
  • When you need light shafts that behave realistically under water.
  • When you want to add caustic light effects to underwater scenes.
  • When you need to simulate sunbeams shining through water onto moving objects.

Advantages

  • Provides a modular approach for integration into existing Three.js projects.
  • Supports parallax movement relative to the camera.
  • Includes specific logic for handling light convergence and sun positioning.
  • Offers a stable per-pixel jitter for consistent rendering.

Usage examples

“How do I add underwater light shafts to my Three.js scene?”
You import the createUnderwaterLight module, initialize it with your scene's uniforms, and call the render method within your frame loop to integrate the light shafts.
“Can I make fish look like they are swimming through light beams?”
Yes, by applying the underwaterDapple function from the module to your fish material, the light intensity will modulate based on the world position and time.

Install

  1. Download the skill as a ZIP:

    https://mp-staging.autogineer.com/agent-skills/mengto/3d-underwater-god-rays/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip 3d-underwater-god-rays.zip -d .agents/skills

Documentation by Meng To

3D Underwater God Rays

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 ShaderMaterials, one HalfFloat render target, and a hand-written ACES pass. No post-processing library.

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.

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 focussmoothstep(−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 blendtotal = mix(total, 0.16, μ^28), where μ = ray·sun
Phase0.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

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.

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.

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.

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.

Files

demo/src/ui.js

// Demo controller. The aquarium is created and stepped exactly as Blue Hour
// does it: a fixed 2048×1440 render (1600×1125 below 800px), cover-fitted,
// stepped at 30 fps, with a hovered camera orbit.
(() => {
'use strict';
const motion = matchMedia('(prefers-reduced-motion: reduce)');
const $ = (id) => document.getElementById(id);
const loading = $('loading'), status = $('status'), play = $('play'), sun = $('sun'), sunAngle = $('sun-angle');
let aquarium = null, playing = !motion.matches, pending = 0, dirty = true, last = 0;
let hoverX = 0, hoverY = 0, targetHoverX = 0, targetHoverY = 0;

// The page's sun: (.25, .40, -1), about 21° above the horizon. The slider
// changes elevation and keeps the heading.
const heading = (() => { const x = .25, z = -1, l = Math.hypot(x, z); return { x: x / l, z: z / l }; })();
function setSun(degrees) {
  const e = degrees * Math.PI / 180;
  aquarium.sunDirection.set(heading.x * Math.cos(e), Math.sin(e), heading.z * Math.cos(e)).normalize();
  sunAngle.value = Math.round(degrees) + '°';
  sun.style.setProperty('--sun-progress', ((degrees - sun.min) / (sun.max - sun.min) * 100).toFixed(1) + '%');
  sun.setAttribute('aria-valuetext', Math.round(degrees) + ' degrees above the horizon');
  dirty = true;
}

const messages = {
  pattern: ['Sparse peaks: the march keeps distinct shafts with dark water between them.',
            'Ridge web: the same march averages the ridges into flat haze.'],
  ripples: ['Ripples inside the march: fine streaks run along each shaft.',
            'Ripples painted on screen: the water reads as a pool floor.'],
};
function setCompare(kind, value) {
  const compare = aquarium.light.uniforms.uCompare.value;
  if (kind === 'pattern') compare.x = value; else compare.y = value;
  document.querySelectorAll(`[data-compare="${kind}"]`).forEach((b) => b.setAttribute('aria-pressed', String(+b.dataset.value === value)));
  status.textContent = messages[kind][value];
  dirty = true;
}

function updatePlay() {
  play.setAttribute('aria-label', playing ? 'Pause animation' : 'Play animation');
  play.querySelector('svg').innerHTML = playing ? '<path d="M8 5v14M16 5v14"/>' : '<path d="M7 4.5v15l12-7.5z" fill="currentColor"/>';
  $('play-label').textContent = playing ? 'Living ocean' : 'Paused';
}

function frame(now) {
  requestAnimationFrame(frame);
  const dt = last ? Math.min((now - last) / 1000, .06) : 0;
  last = now;
  if (document.hidden || !aquarium) return;
  const ease = motion.matches ? 1 : 1 - Math.exp(-dt * 4.5);
  hoverX += ((motion.matches ? 0 : targetHoverX) - hoverX) * ease;
  hoverY += ((motion.matches ? 0 : targetHoverY) - hoverY) * ease;
  const settling = Math.abs(hoverX - targetHoverX) + Math.abs(hoverY - targetHoverY) > .0005;
  aquarium.setPointer(hoverX, hoverY);
  if (playing) {
    pending += dt;
    if (pending < 1 / 30) return;
    aquarium.render(Math.min(pending, .1));
    pending = 0;
  } else if (dirty || settling) {
    aquarium.render(0);
  }
  dirty = false;
}

// A rounded lens displacement field bends the backdrop at the glass edge.
function buildLens() {
  const canvas = document.createElement('canvas'); canvas.width = 320; canvas.height = 96;
  const ctx = canvas.getContext('2d'); if (!ctx) return;
  const pixels = ctx.createImageData(320, 96);
  for (let y = 0; y < 96; y++) for (let x = 0; x < 320; x++) {
    const px = x - 159.5, py = y - 47.5, ox = Math.max(Math.abs(px) - 112, 0), oy = Math.max(Math.abs(py), 0);
    const length = Math.hypot(ox, oy), t = Math.min(Math.max(-(length - 47.5) / 17, 0), 1), bend = Math.sin(t * Math.PI) * .25;
    const nx = length ? Math.sign(px) * ox / length : 0, ny = length ? Math.sign(py) * oy / length : 0, i = (y * 320 + x) * 4;
    pixels.data[i] = Math.round((.5 + nx * bend) * 255); pixels.data[i + 1] = Math.round((.5 + ny * bend) * 255);
    pixels.data[i + 2] = 128; pixels.data[i + 3] = 255;
  }
  ctx.putImageData(pixels, 0, 0);
  $('glass-displacement').setAttribute('href', canvas.toDataURL());
}

// Short, eased pointer response on the glass rims; no idle animation.
function bindGlassRims() {
  document.querySelectorAll('.liquid-glass').forEach((glass) => {
    const reset = () => ['--mx', '--my', '--glass-angle'].forEach((name) => glass.style.removeProperty(name));
    glass.addEventListener('pointermove', (e) => {
      if (e.pointerType === 'touch' || motion.matches) return;
      const r = glass.getBoundingClientRect();
      const x = Math.min(Math.max((e.clientX - r.left) / r.width, 0), 1), y = Math.min(Math.max((e.clientY - r.top) / r.height, 0), 1);
      glass.style.setProperty('--mx', (x * 100).toFixed(2) + '%');
      glass.style.setProperty('--my', (y * 100).toFixed(2) + '%');
      glass.style.setProperty('--glass-angle', (115 + x * 85 + y * 22).toFixed(2) + 'deg');
    });
    glass.addEventListener('pointerleave', reset);
    glass.addEventListener('pointercancel', reset);
  });
}

function bindControls() {
  document.querySelectorAll('[data-compare]').forEach((b) => b.addEventListener('click', () => setCompare(b.dataset.compare, +b.dataset.value)));
  sun.addEventListener('input', () => setSun(+sun.value));
  play.addEventListener('click', () => { playing = !playing; pending = 0; updatePlay(); dirty = true; });
  const focusView = (clean) => {
    document.body.classList.toggle('clean', clean);
    (clean ? $('restore-ui') : $('focus-view')).focus();
    status.textContent = clean ? 'Controls hidden. Press H to show them.' : 'Controls shown.';
  };
  $('focus-view').addEventListener('click', () => focusView(true));
  $('restore-ui').addEventListener('click', () => focusView(false));
  document.addEventListener('keydown', (e) => {
    if (e.metaKey || e.ctrlKey || e.altKey || e.target.matches('input')) return;
    if (e.code === 'Space' && !e.target.matches('button')) { e.preventDefault(); play.click(); }
    if (e.key.toLowerCase() === 'h') focusView(!document.body.classList.contains('clean'));
  });
  // Hover orbits with the mouse; on touch, dragging across the water does the same.
  const aim = (e) => {
    if (motion.matches) return;
    if (e.pointerType !== 'mouse' && e.buttons === 0) return;
    targetHoverX = Math.min(Math.max(e.clientX / innerWidth * 2 - 1, -1), 1);
    targetHoverY = Math.min(Math.max(1 - e.clientY / innerHeight * 2, -1), 1);
  };
  document.addEventListener('pointermove', aim, { passive: true });
  document.addEventListener('pointerdown', aim, { passive: true });
  document.documentElement.addEventListener('pointerleave', () => { targetHoverX = targetHoverY = 0; });
  motion.addEventListener('change', (e) => { playing = !e.matches; targetHoverX = targetHoverY = 0; updatePlay(); dirty = true; });
  document.addEventListener('visibilitychange', () => { last = 0; pending = 0; });
}

function boot() {
  try {
    const wide = (innerWidth || document.documentElement.clientWidth || 1440) > 800;
    aquarium = window.createAquariumWallpaper({ manual: true, width: wide ? 2048 : 1600, height: wide ? 1440 : 1125, pixelRatio: 1, count: 2200, immersiveStudio: true });
    if (!aquarium || !aquarium.canvas) throw new Error('WebGL2 is unavailable.');
    $('ocean-backdrop').appendChild(aquarium.canvas);
    if (aquarium.foregroundCanvas) {
      const front = document.createElement('div');
      front.id = 'ocean-foreground'; front.setAttribute('aria-hidden', 'true');
      front.appendChild(aquarium.foregroundCanvas);
      document.body.insertBefore(front, document.querySelector('main'));
    }
    aquarium.canvas.setAttribute('aria-hidden', 'true');
    document.querySelector('main').setAttribute('aria-describedby', 'mechanism');
    setSun(+sun.value);
    aquarium.render(0);                       // a composed frame before the loop starts
    loading.classList.add('done');
    window.__demo = { aquarium, setCompare, setSun, render: (dt = 0) => aquarium.render(dt) };
  } catch (error) {
    console.error('Underwater God Rays could not start', error);
    loading.textContent = 'This demo needs WebGL2. Try a current browser.';
    return;
  }
  buildLens(); bindGlassRims(); bindControls(); updatePlay();
  requestAnimationFrame(frame);
}
boot();
})();

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Needs Node.js

Security

What our scanner noticed· Low risk

  • Scripts that your agent may run

This skill includes scripts that your agent may run. Read them before you use it.

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Created by
Meng To
Publisher
Meng To (Community)
Source
MengTo/Skills · agent-skills/3d/3d-underwater-god-rays · commit 5ea1661
Licence
MIT License (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Meng To.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download 3d-underwater-god-rays.zip