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
Unread comments
×Projects with new comments. Click to open and read.