Caustic Study #04: Beach

GLSL shader by guinetik · created 2026-03-01 · 10s loop · 2 passes

Underwater camera between a sandy floor and the water surface. Dual-layer caustic light patterns project onto the sand as bright convergence lines, while the surface seen from below shows the same pattern with Snell's window refraction. Physically-based per-channel water absorption shifts color from clear to deep blue-green with distance.

Tags: Raymarching, Refraction, Caustics, Physics

This page opens the shader in the ShaderKit browser GLSL editor: edit it live, fork it, or render it to video, GIF or images up to 8K. Also available as a full-screen view and an embeddable player.

Shader source (GLSL)

Common

/**
 * Caustic Pattern (joltz0r / David Hoskins)
 * @author joltz0r, David Hoskins (adapted by guinetik)
 * @date 2026-02-16
 *
 * Iterative domain warp for underwater caustic patterns.
 * Each iteration displaces UV coordinates with sin/cos feedback,
 * accumulating inverse distance to create bright convergence lines
 * mimicking refracted light on a pool floor.
 *
 * Two layers at different scales aorbitCameraUpdatere recommended for depth complexity.
 */

#ifndef M_TAU
#define M_TAU 6.28318530718
#endif

/**
 * Compute raw caustic convergence via iterative domain warp.
 *
 * Returns normalized accumulation value -- higher where displaced UV
 * coordinates converge, producing bright caustic lines. Apply
 * post-processing for final brightness curve:
 *   c = base - pow(c, power);   // typical: 1.17 - pow(c, 1.4)
 *   c = pow(abs(c), bright);    // typical: pow(abs(c), 8.0)
 *
 * @param uv        2D sample position
 * @param scale     UV scaling (larger = finer pattern, typical 0.5-3.0)
 * @param time      Animation time (pre-scaled by caller)
 * @param iters     Warp iterations: 3=soft blobs, 5=crisp, 8+=very sharp
 * @param intensity Inverse-distance sensitivity (smaller = tighter lines, typical 0.005)
 * @return Normalized convergence value
 */
float causticWarp(vec2 uv, float scale, float time, int iters, float intensity) {
    vec2 p = mod(uv * scale * M_TAU, M_TAU) - 250.0;
    vec2 i = p;
    float c = 1.0;
    for (int n = 0; n < 8; n++) {
        if (n >= iters) break;
        float tt = time * (1.0 - (3.5 / float(n + 1)));
        i = p + vec2(
            cos(tt - i.x) + sin(tt + i.y),
            sin(tt - i.y) + cos(tt + i.x)
        );
        c += 1.0 / length(vec2(
            p.x / (sin(i.x + tt) / intensity),
            p.y / (cos(i.y + tt) / intensity)
        ));
    }
    return c / float(iters);
}

/**
 * Signed Distance Field Primitives
 * @author guinetik
 * @date 2026-02-15
 *
 * Stateless distance field functions for 2D rendering.
 */

// === LINE SEGMENT ===

/**
 * Compute minimum distance from a point to a line segment.
 *
 * Returns the perpendicular distance from point `p` to the closest
 * point on the segment from `a` to `b`. Handles degenerate
 * zero-length segments gracefully.
 *
 * @param a  Segment start point
 * @param b  Segment end point
 * @param p  Query point
 * @return Distance from `p` to the nearest point on segment (a, b)
 */
float dfLine(vec2 a, vec2 b, vec2 p) {
    vec2 ab = b - a;
    float denom = dot(ab, ab);
    if (denom < 1e-10) return distance(a, p);
    float t = clamp(dot(p - a, ab) / denom, 0.0, 1.0);
    return distance(a + ab * t, p);
}

/**
 * Color Conversion Utilities
 * @author guinetik
 * @date 2026-02-15
 *
 * Stateless color space conversion functions.
 */

// === HSL TO RGB ===

/**
 * Convert HSL color to RGB.
 *
 * @param h  Hue in degrees (0–360, wraps automatically)
 * @param s  Saturation (0.0–1.0)
 * @param l  Lightness (0.0–1.0)
 * @return RGB color in [0, 1] per component
 */
vec3 hsl2rgb(float h, float s, float l) {
    h = mod(h, 360.0) / 60.0;
    float c = (1.0 - abs(2.0 * l - 1.0)) * s;
    float x = c * (1.0 - abs(mod(h, 2.0) - 1.0));
    float m = l - c * 0.5;
    vec3 rgb;
    if      (h < 1.0) rgb = vec3(c, x, 0.0);
    else if (h < 2.0) rgb = vec3(x, c, 0.0);
    else if (h < 3.0) rgb = vec3(0.0, c, x);
    else if (h < 4.0) rgb = vec3(0.0, x, c);
    else if (h < 5.0) rgb = vec3(x, 0.0, c);
    else              rgb = vec3(c, 0.0, x);
    return rgb + m;
}

// === HSV CONVERSIONS ===

/**
 * Convert RGB color to HSV.
 *
 * @param c  RGB color in [0, 1] per component
 * @return   HSV where H is in [0, 1] (not degrees), S and V in [0, 1]
 */
vec3 rgb2hsv(vec3 c) {
    vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
    vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
    vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
    float d = q.x - min(q.w, q.y);
    float e = 1.0e-10;
    return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}

/**
 * Convert HSV color to RGB.
 *
 * @param c  HSV where H is in [0, 1] (not degrees), S and V in [0, 1]
 * @return   RGB color in [0, 1] per component
 */
vec3 hsv2rgb(vec3 c) {
    vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
    vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
    return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}

// === SPHERE PROJECTION ===

/**
 * Compute responsive UV coordinates for unit-sphere rendering.
 *
 * Maps fragment coordinates to a centered coordinate system where the
 * unit sphere fills most of the viewport. On portrait screens, applies an
 * additional scale boost to shrink the sphere from ~91% to ~68% of viewport
 * width, preventing the sphere from dominating mobile displays.
 *
 * Portrait boost: linearly increases with portrait-ness (1 - aspect),
 * scaled by 0.7 for a natural feel. On landscape/square screens the boost
 * is zero and behavior is identical to the original formula.
 *
 * | Device             | Aspect | uvScale | Sphere width fill |
 * |--------------------|--------|---------|-------------------|
 * | Phone portrait     | 0.46   | 3.04    | ~68%              |
 * | iPad portrait      | 0.75   | 1.72    | ~78%              |
 * | Desktop 16:9       | 1.78   | 1.1     | ~51% (unchanged)  |
 *
 * @param fragCoord  Pixel coordinates (gl_FragCoord.xy)
 * @param resolution Viewport resolution (iResolution.xy)
 * @param baseScale  Base UV scale — larger zooms out (typically 1.1)
 * @return Centered UV coordinates where unit sphere has radius 1.0
 */
vec2 sphereUV(vec2 fragCoord, vec2 resolution, float baseScale) {
    float aspect = resolution.x / resolution.y;
    // Boost effective scale on portrait screens to shrink sphere from 91% → ~68% width fill
    float portraitBoost = max(0.0, 1.0 - aspect) * 0.7;
    float uvScale = (baseScale + portraitBoost) / min(1.0, aspect);
    return uvScale * (2.0 * fragCoord - resolution) / resolution.y;
}

// === RAY-SPHERE INTERSECTION ===

// Guard PI/TAU defines to avoid conflicts with shader-local constants
#ifndef M_PI
#define M_PI 3.14159265359
#endif
#ifndef M_TAU
#define M_TAU 6.28318530718
#endif

/**
 * Ray-sphere intersection via quadratic discriminant.
 *
 * Solves |ro + t*rd - center|^2 = radius^2 for the nearest positive t.
 * Returns -1.0 on miss (discriminant < 0 or both roots behind the ray).
 *
 * @param ro      Ray origin
 * @param rd      Ray direction (must be normalized)
 * @param center  Sphere center in world space
 * @param radius  Sphere radius
 * @return Nearest positive t, or -1.0 if no hit
 */
float intersectSphere(vec3 ro, vec3 rd, vec3 center, float radius) {
    vec3 oc = ro - center;
    float b = dot(oc, rd);
    float c = dot(oc, oc) - radius * radius;
    float h = b * b - c;

    if (h < 0.0) return -1.0;

    h = sqrt(h);
    float t = -b - h;

    if (t < 0.0) t = -b + h;
    if (t < 0.0) return -1.0;

    return t;
}

/**
 * Compute surface normal and spherical UV at a hit point on a sphere.
 *
 * Normal points outward from center. UV maps longitude to [0,1] on x
 * and latitude to [0,1] on y (0 = south pole, 1 = north pole).
 *
 * @param hitPoint  World-space intersection point
 * @param center    Sphere center
 * @param normal    (out) Unit surface normal
 * @param uv        (out) Spherical UV in [0,1]^2
 */
void getSphereInfo(vec3 hitPoint, vec3 center, out vec3 normal, out vec2 uv) {
    normal = normalize(hitPoint - center);
    float latitude = 0.5 + asin(normal.y) / M_PI;
    float longitude = 0.5 + atan(normal.x, normal.z) / M_TAU;
    uv = vec2(longitude, latitude);
}

/**
 * Orbit Camera Commons
 * @author guinetik
 * @date 2026-02-20
 *
 * Reusable orbit camera with mouse-drag inertia, friction decay, and idle
 * auto-rotation. Split into two parts:
 *
 * 1. **Buffer-A side** — `orbitCameraUpdate()` runs the state machine:
 *    drag detection, velocity blending, friction, idle orbit blend.
 *    Stores yaw/pitch/velocities in pixel (0,0), prev mouse in pixel (1,0).
 *
 * 2. **Image side** — `orbitCameraRay()` reads buffer state and computes
 *    a spherical orbit camera with `cameraLookAt()` view matrix.
 *
 * === STATE LAYOUT (buffer-a → iChannel0) ===
 * Pixel (0, 0): yaw (x), pitch (y), yawVel (z), pitchVel (w)
 * Pixel (1, 0): prevMouseX (x), prevMouseY (y), unused (zw)
 *
 * TECHNIQUE: Drag detection via mouse-delta dead zone
 * Instead of relying on iMouse.z (which stays positive after first click
 * in some renderers), we compare current vs previous mouse position.
 * If the squared delta exceeds DRAG_DEAD_ZONE², we're dragging.
 *
 * TECHNIQUE: Inertia with idle orbit blend
 * On release, velocity decays by FRICTION per frame. When yaw velocity
 * drops below IDLE_THRESHOLD, it blends toward IDLE_ORBIT_SPEED so the
 * camera never fully stops. Pitch always decays to zero (no idle tilt).
 */

// Guard TAU define to avoid conflicts with shader-local constants
#ifndef _CAM_TAU
#define _CAM_TAU 6.28318530718
#endif

// -------------------------------------------------------
// Configuration struct — all tunable camera parameters
// -------------------------------------------------------

/**
 * Orbit camera tuning parameters. Create via orbitCameraDefaultConfig()
 * and override individual fields as needed.
 *
 * friction       — velocity multiplier per frame when not dragging (0.99 = slow decay, 0.9 = fast)
 * dragSensitivity — horizontal drag-to-velocity scale
 * pitchSensitivity — vertical drag-to-velocity scale (typically < dragSensitivity)
 * velocitySmooth  — blend factor for new drag velocity (0 = ignore drag, 1 = instant)
 * idleOrbitSpeed  — yaw velocity target when coasting below threshold (rad/frame)
 * idleThreshold   — velocity magnitude below which idle blend kicks in
 * idleBlend       — blend rate toward idle orbit speed (0 = never, 1 = instant)
 * dragDeadZone    — minimum mouse delta to register as drag (normalized coords)
 * pitchMin        — minimum pitch angle in radians (negative = look down)
 * pitchMax        — maximum pitch angle in radians (positive = look up)
 */
struct OrbitCameraConfig {
    float friction;
    float dragSensitivity;
    float pitchSensitivity;
    float velocitySmooth;
    float idleOrbitSpeed;
    float idleThreshold;
    float idleBlend;
    float dragDeadZone;
    float pitchMin;
    float pitchMax;
};

/**
 * Sensible defaults matching the caustics-pool camera behavior.
 * Override pitchMin/pitchMax per shader for different viewing angles.
 */
OrbitCameraConfig orbitCameraDefaultConfig() {
    OrbitCameraConfig cfg;
    cfg.friction         = 0.993;
    cfg.dragSensitivity  = 2.0;
    cfg.pitchSensitivity = 0.3;
    cfg.velocitySmooth   = 0.35;
    cfg.idleOrbitSpeed   = 0.003;
    cfg.idleThreshold    = 0.0003;
    cfg.idleBlend        = 0.015;
    cfg.dragDeadZone     = 0.0001;
    cfg.pitchMin         = -0.35;
    cfg.pitchMax         =  0.18;
    return cfg;
}

// -------------------------------------------------------
// Buffer-A: full camera state machine
// -------------------------------------------------------



// -------------------------------------------------------
// Image side: view matrix + orbit ray
// -------------------------------------------------------

/**
 * Construct a right-handed view matrix (camera-to-world).
 * Named cameraLookAt to avoid clashes with shader-local lookAt functions.
 *
 * @param ro  Camera position (ray origin)
 * @param ta  Look-at target point
 * @return 3x3 view matrix [right, up, forward]
 */
mat3 cameraLookAt(vec3 ro, vec3 ta) {
    vec3 fwd = normalize(ta - ro);
    vec3 right = normalize(cross(fwd, vec3(0.0, 1.0, 0.0)));
    vec3 up = cross(right, fwd);
    return mat3(right, up, fwd);
}

/**
 * Result of orbit camera ray computation.
 * ro    — ray origin (camera position in world space)
 * rd    — ray direction (normalized, per-pixel)
 * yaw   — current yaw angle from buffer state
 * pitch — current pitch angle from buffer state
 */
struct OrbitCameraRay {
    vec3 ro;
    vec3 rd;
    float yaw;
    float pitch;
};

/**
 * Compute orbit camera ray from buffer state.
 *
 * Reads yaw/pitch from pixel (0,0) of the state buffer, converts to a
 * spherical orbit position at the given distance and height from the
 * target, and builds a per-pixel ray direction.
 *
 * TECHNIQUE: Spherical orbit via base elevation
 * The base elevation angle is derived from CAM_HEIGHT and CAM_DIST,
 * then pitch is added on top. This keeps the camera at approximately
 * the right height regardless of the orbit distance.
 *
 * @param stateSampler  Buffer containing camera state (pixel 0,0)
 * @param fragCoord     Fragment coordinates
 * @param resolution    Viewport resolution (iResolution.xy)
 * @param dist          Horizontal orbit distance from target
 * @param height        Base camera height above target
 * @param target        Look-at target point
 * @param fov           Field of view (focal length inverse — lower = telephoto)
 * @return OrbitCameraRay with ro, rd, yaw, pitch
 */
OrbitCameraRay orbitCameraRay(
    in sampler2D stateSampler,
    in vec2 fragCoord,
    in vec2 resolution,
    float dist,
    float height,
    vec3 target,
    float fov
) {
    OrbitCameraRay cam;

    vec2 uv = (fragCoord * 2.0 - resolution) / min(resolution.x, resolution.y);

    // Camera angles from buffer state (pixel 0,0)
    vec4 camState = texelFetch(stateSampler, ivec2(0, 0), 0);
    cam.yaw   = camState.x;
    cam.pitch = camState.y;

    // Spherical camera: pitch tilts elevation around the base height
    float baseElev = atan(height, dist);
    float elev     = baseElev + cam.pitch;
    float camR     = length(vec2(dist, height));

    cam.ro = vec3(
        cos(elev) * cos(cam.yaw) * camR,
        sin(elev) * camR,
        cos(elev) * sin(cam.yaw) * camR
    );

    mat3 viewMat = cameraLookAt(cam.ro, target);
    cam.rd = viewMat * normalize(vec3(uv, fov));

    return cam;
}

Buffer A (iChannel0)

/**
 * Caustic Study #03: Crystal — Buffer A: Camera state
 *
 * @author guinetik
 * @date 2026-02-18
 *
 * Orbit camera with mouse-drag inertia, powered by camera commons.
 * Crystal view: wider pitch range for looking down at the gem from above.
 *
 * === STATE LAYOUT (buffer-a → iChannel0) ===
 * Pixel (0, 0): yaw (x), pitch (y), yawVel (z), pitchVel (w)
 * Pixel (1, 0): prevMouseX (x), prevMouseY (y), unused (zw)
 */

// -- Pitch limits (radians) --
// Slightly elevated default view to look down at the crystal
#define PITCH_MIN -0.5    // Max downward tilt — allows steep overhead view
#define PITCH_MAX  0.3    // Max upward tilt — sees crystal from below

void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
    OrbitCameraConfig cfg = orbitCameraDefaultConfig();
    cfg.pitchMin = PITCH_MIN;
    cfg.pitchMax = PITCH_MAX;
    //orbitCameraUpdate(fragColor, fragCoord, iChannel0, cfg, iFrame, iMouse, iResolution);
}

Image

Not used

More shaders by guinetik

Browse all public shaders · All shaders by guinetik · ShaderKit home

Vibe Mode BETA
💰 ~0 credits
Uniforms
FPS: 0
Time: 0
Resolution: 0 x 0

Account

FPS: -- Time: -- --×-- 1x