diff --git a/10_ice_fish.frag b/10_ice_fish.frag new file mode 100644 index 0000000..8985f1e --- /dev/null +++ b/10_ice_fish.frag @@ -0,0 +1,154 @@ +// Pass 1: eight plasma sheets, one atlas tile each. Alpha stores linear depth, +// except on the three sheets that carry the fish: there it stores how much of the +// pixel the fish covers, so the ice pass can draw them as solid dark shapes. +// Density field lit as a height surface: billowing volumes with a real terminator. +precision highp float; +uniform vec2 u_res; // size of one tile +uniform float u_time; + +float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } +float noise(vec2 p) { + vec2 i = floor(p), f = fract(p); + f = f*f*(3.0-2.0*f); + return mix(mix(hash(i), hash(i+vec2(1.0,0.0)), f.x), + mix(hash(i+vec2(0.0,1.0)), hash(i+vec2(1.0,1.0)), f.x), f.y); +} +float fbm(vec2 p) { + float sum = 0.0, amp = 0.5; + for (int i = 0; i < 4; i++) { + sum += amp * noise(p); + p = mat2(1.62, 1.17, -1.17, 1.62) * p; + amp *= 0.5; + } + return sum; +} +float fbm2(vec2 p) { + return 0.5 * noise(p) + 0.25 * noise(mat2(1.62, 1.17, -1.17, 1.62) * p); +} + +// Silhouette of a fish: pointed nose, deepest just behind the head, a narrow +// peduncle, then a tail fan with a fork cut out of it. One width function, so the +// whole animal is guaranteed to be a single connected shape. +float smin(float a, float b, float k) { + float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); + return mix(b, a, h) - k * h * (1.0 - h); +} +float sdTriangle(vec2 p, vec2 p0, vec2 p1, vec2 p2) { + vec2 e0 = p1 - p0, e1 = p2 - p1, e2 = p0 - p2; + vec2 v0 = p - p0, v1 = p - p1, v2 = p - p2; + vec2 q0 = v0 - e0 * clamp(dot(v0, e0) / dot(e0, e0), 0.0, 1.0); + vec2 q1 = v1 - e1 * clamp(dot(v1, e1) / dot(e1, e1), 0.0, 1.0); + vec2 q2 = v2 - e2 * clamp(dot(v2, e2) / dot(e2, e2), 0.0, 1.0); + float s = sign(e0.x * e2.y - e0.y * e2.x); + vec2 d = min(min(vec2(dot(q0, q0), s * (v0.x * e0.y - v0.y * e0.x)), + vec2(dot(q1, q1), s * (v1.x * e1.y - v1.y * e1.x))), + vec2(dot(q2, q2), s * (v2.x * e2.y - v2.y * e2.x))); + return -sqrt(d.x) * sign(d.y); +} +// Half-height of the body at x: pointed nose, deepest a quarter of the way back, +// then down to a narrow peduncle. Sweeping a circle along it keeps the body one piece. +float fishWidth(float x) { + float t = clamp((1.15 - x) / 2.30, 0.0, 1.0); // 0 at the nose, 1 at the peduncle + return 0.34 * pow(sin(3.14159 * pow(t, 0.55)), 0.8) + 0.05 * smoothstep(0.70, 1.0, t); +} +float sdFish(vec2 p) { + float d = length(vec2(max(abs(p.x) - 1.15, 0.0), p.y)) - fishWidth(p.x); // body + // tail: two lobes fanning back from the peduncle, with a notch between them + d = smin(d, sdTriangle(p, vec2(-1.02, 0.03), vec2(-1.62, 0.09), vec2(-1.46, 0.52)), 0.08); + d = smin(d, sdTriangle(p, vec2(-1.02, -0.03), vec2(-1.62, -0.09), vec2(-1.46, -0.52)), 0.08); + // dorsal and pectoral fins + d = smin(d, sdTriangle(p, vec2(0.22, 0.20), vec2(-0.06, 0.46), vec2(-0.40, 0.16)), 0.05); + d = smin(d, sdTriangle(p, vec2(0.46, -0.16), vec2(0.26, -0.08), vec2(0.10, -0.28)), 0.04); + return d; +} + +const float EXTENT = 3.8; // half-width of a sheet in world units; must match the ice pass + +void main() { + vec2 tile = floor(gl_FragCoord.xy / u_res); + float layer = tile.x + tile.y * 4.0; + vec2 uv = fract(gl_FragCoord.xy / u_res); + vec2 p = (uv - 0.5) * 2.0 * vec2(EXTENT * u_res.x / u_res.y, EXTENT); + p *= 0.85 + 0.10 * layer; // far sheets carry finer detail, which reads as distance + float angle = hash(vec2(layer, 3.1)) * 6.2831853; + p = mat2(cos(angle), sin(angle), -sin(angle), cos(angle)) * p; + + // Periodic time: every field returns exactly after 30 seconds. + float phase = u_time * 6.2831853 / 30.0; + vec2 o = vec2(cos(phase), sin(phase)); + vec2 seed = vec2(layer * 17.3, layer * 9.1); + + // Two nested warp stages, the marbled-plasma motion. + vec2 q = vec2(fbm(p * 0.50 + seed + o * 0.55), + fbm(p * 0.50 + seed + 23.1 - o * 0.55)); + vec2 r = vec2(fbm(p * 0.85 + 2.0 * q + seed + vec2(o.y, -o.x) * 0.40), + fbm(p * 0.85 + 2.0 * q + seed + 31.7 - vec2(o.y, -o.x) * 0.40)); + vec2 w = p * 1.10 + 2.6 * r + seed; // warped coords of the density field + + // The silhouette is shared by all sheets (slid a little per depth), so the gaps + // stay open right through the stack instead of filling in with overlap. + float mass = smoothstep(0.520, 0.548, fbm((p + o * 0.30 + vec2(0.10, -0.07) * layer) * 0.62 + r * 1.1 + 13.7)); + + // Light the density as a height surface: $cheap 2-octave gradient, strong terminator. + float h = 0.05; + vec2 slope = vec2(fbm2(w + vec2(h, 0.0)) - fbm2(w - vec2(h, 0.0)), + fbm2(w + vec2(0.0, h)) - fbm2(w - vec2(0.0, h))) * (10.0 / (2.0 * h)); + vec3 nrm = normalize(vec3(-slope, 1.0)); + float lit = clamp(dot(nrm, normalize(vec3(-0.38, 0.52, 0.76))), 0.0, 1.0); + + // Folded planes across the volume, feathered so they never read as a grid. + vec2 g = (p + r * 2.0) * 1.4; + vec2 cell = floor(g), f = fract(g); + float feather = smoothstep(0.0, 0.10, min(min(f.x, 1.0 - f.x), min(f.y, 1.0 - f.y))); + float facet = mix(1.0, mix(0.70, 1.25, hash(cell + seed + step(1.0, f.x + f.y) * 37.0)), feather); + + float shade = 0.05 + 1.10 * lit * lit; // lit tops, near-black undersides + float e = mass * facet * shade * 0.085 + + mass * (1.0 - mass) * 4.0 * 0.040 // thin torn silhouette + + mass * pow(lit, 14.0) * 0.110; // glint where a fold faces the light + vec3 col = e * mix(vec3(0.02, 0.42, 0.60), vec3(0.08, 0.64, 0.88), facet * 0.5); + // Faint dye in the open gaps so darkness reads as depth, not as a hole. + col += smoothstep(0.40, 0.85, fbm(p * 0.30 + seed * 0.4 + o * 0.3)) * vec3(0.0003, 0.0011, 0.0015); + + + // Two sheets carry fish. Each one cuts straight across the slab once per cycle, + // with long gaps and a wide spread of sizes: mostly small, now and then something + // far too big. They are almost pure black, so all you ever see is the silhouette + // eating into the glow behind them, bent by the cracks in the ice. + float cover = (0.42 + layer * 0.26) / 2.6; // the sheet's own opacity, except where a fish is + if (layer == 0.0 || layer == 1.0) { + vec2 sp = (uv - 0.5) * 2.0 * vec2(EXTENT * u_res.x / u_res.y, EXTENT); + float t01 = u_time / 30.0; + for (int k = 0; k < 2; k++) { + float id = float(k); + float lt = fract(t01 + hash(vec2(layer, id))); + if (lt < 0.02 || lt > 0.98) continue; + float dir = hash(vec2(id, layer + 5.0)) > 0.5 ? 1.0 : -1.0; + float y0 = (hash(vec2(id, layer + 13.0)) - 0.5) * 4.6; + float y1 = y0 + (hash(vec2(id, layer + 17.0)) - 0.5) * 1.8; + vec2 pos = mix(vec2(-dir * 8.6, y0), vec2(dir * 8.6, y1), lt); + float tilt = atan((y1 - y0) / 17.2); + float size = 0.30 + 1.30 * pow(hash(vec2(id, layer + 3.0)), 2.2); + vec2 q = (mat2(cos(tilt), -sin(tilt), sin(tilt), cos(tilt)) * (sp - pos) * vec2(dir, 1.0)) / size; + float d = sdFish(q); + float px = 0.019 / size; + float cov = clamp(0.5 - d / px, 0.0, 1.0); + if (cov >= 0.0) { + // A dark body, a faint halo where the ice is lit around it, and two small + // eyes that burn cold blue: that is the only thing that says "alive". + float halo = max(clamp(0.5 - (d - 0.16) / (px * 6.0), 0.0, 1.0) - cov, 0.0); + col += vec3(0.006, 0.020, 0.032) * halo; + if (cov > 0.0) { + col = mix(col, vec3(0.0010, 0.0014, 0.0018), cov); + float eye = smoothstep(0.045, 0.012, length(q - vec2(0.80, 0.095))); + eye += 0.22 * smoothstep(0.11, 0.0, length(q - vec2(0.80, 0.095))); + eye += 0.07 * smoothstep(0.26, 0.0, length(q - vec2(0.76, 0.05))); // the head catches it + col += vec3(0.06, 0.44, 0.80) * eye * cov; + cover = 1.0; + } + } + } + } + float alpha = (layer == 0.0 || layer == 1.0) ? cover : (0.42 + layer * 0.26) / 2.6; + gl_FragColor = vec4(col, alpha); +} diff --git a/11_ice_over_fish.frag b/11_ice_over_fish.frag new file mode 100644 index 0000000..8a52868 --- /dev/null +++ b/11_ice_over_fish.frag @@ -0,0 +1,137 @@ +// Pass 2: clean ice. Refraction, chromatic shear, absorption by true path length. +// Nothing is added to empty space, so the gaps between sheets stay black. +precision highp float; +uniform vec2 u_res; +uniform float u_time; +uniform vec2 u_mouse; // normalized, bottom-left origin +uniform sampler2D u_scene; +uniform vec2 u_tile_res; + +const float EXTENT = 3.8; // must match the plasma pass +const float FRONT = -2.0; // ice surface, camera at the origin looking down -z +const vec3 SIGMA = vec3(0.66, 0.26, 0.11); // absorption per unit: red dies first, so depth turns blue +const float IOR = 1.31; +// The fish sheets store coverage in alpha, so their depth comes from the index. + +float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } +float noise(vec2 p) { + vec2 i = floor(p), f = fract(p); + f = f*f*(3.0-2.0*f); + return mix(mix(hash(i), hash(i+vec2(1.0,0.0)), f.x), + mix(hash(i+vec2(0.0,1.0)), hash(i+vec2(1.0,1.0)), f.x), f.y); +} +float fbm(vec2 p) { + float sum = 0.0, amp = 0.5; + for (int i = 0; i < 3; i++) { sum += amp * noise(p); p = mat2(1.62, 1.17, -1.17, 1.62) * p; amp *= 0.5; } + return sum; +} +float fbm2(vec2 p) { + return 0.5 * noise(p) + 0.25 * noise(mat2(1.62, 1.17, -1.17, 1.62) * p); +} +vec2 atlasUV(vec2 world, float layer) { + vec2 uv = world / (2.0 * EXTENT) + 0.5; + uv = clamp(uv, 0.5 / u_tile_res, 1.0 - 0.5 / u_tile_res); // keep bilinear taps inside the tile + return (vec2(mod(layer, 4.0), floor(layer / 4.0)) + uv) / vec2(4.0, 2.0); +} +// The slab's fracture lines: two families of long, slightly wandering cracks. Shared +// by the height field and the sheen, so each crack is both a bright seam and a step. +vec2 fracture(vec2 x) { + float d = fbm(x * 0.62 + 3.0) - 0.5; // zero set: long winding cracks + float e = fbm(x * 1.35 - 7.0) - 0.5; // a sparser second family + return vec2(d, e); +} +// Frozen surface: fine ridged grain over the plates the cracks have lifted apart. +// Static in world space; only the plasma behind it moves. +float heightSmooth(vec2 x) { + return 0.090 * (1.0 - abs(2.0 * fbm2(x * 2.2 + 2.0) - 1.0)) + + 0.055 * (1.0 - abs(2.0 * fbm2(x * 6.4 - 5.0) - 1.0)); +} +float height(vec2 x) { + vec2 fr = fracture(x); + return heightSmooth(x) + + 0.014 * clamp(fr.x * 110.0, -1.0, 1.0) // the slab sits at a different level + + 0.009 * clamp(fr.y * 110.0, -1.0, 1.0); // on each side of a crack +} +void main() { + vec2 screen = gl_FragCoord.xy / u_res; + vec2 p = (gl_FragCoord.xy - 0.5 * u_res) / u_res.y; + vec2 look = clamp(u_mouse - 0.5, -0.35, 0.35); // orbiting the slab centre + vec3 ray = normalize(vec3(p - look * 2.2, -1.6)); + + // Front surface along the view ray; two fixed-point steps settle on the swell. + float t = FRONT / ray.z; + for (int i = 0; i < 2; i++) t = (FRONT + height(ray.xy * t)) / ray.z; + vec3 hit = ray * t; + + float e = 0.008; + vec2 slope = vec2(height(hit.xy + vec2(e, 0.0)) - height(hit.xy - vec2(e, 0.0)), + height(hit.xy + vec2(0.0, e)) - height(hit.xy - vec2(0.0, e))) / (2.0 * e); + float bevel = smoothstep(0.14, 0.0, min(min(screen.x, 1.0 - screen.x), min(screen.y, 1.0 - screen.y))); + slope += bevel * (screen - 0.5) * 0.55; // the slab turns away at the frame edge + // The cracks are steps in the surface, so their slope flips sign within a pixel. + // That is what tears the plasma beautifully, and what would slice a fish to bits, + // so the fish are bent by the smooth swell alone. + vec2 slopeSmooth = vec2(heightSmooth(hit.xy + vec2(e, 0.0)) - heightSmooth(hit.xy - vec2(e, 0.0)), + heightSmooth(hit.xy + vec2(0.0, e)) - heightSmooth(hit.xy - vec2(0.0, e))) / (2.0 * e); + vec3 normal = normalize(vec3(-slope, 1.0)); + vec3 normalSmooth = normalize(vec3(-slopeSmooth, 1.0)); + float facing = max(dot(-ray, normal), 0.0); + + // Refracted ray; red and blue bend a little differently, which is what makes ice ring. + vec3 inside = refract(ray, normal, 1.0 / IOR); + vec2 shearR = (refract(ray, normal, 1.0 / (IOR - 0.012)) - inside).xy; + vec2 shearB = (refract(ray, normal, 1.0 / (IOR + 0.019)) - inside).xy; + inside = normalize(inside); + inside.z = min(inside.z, -0.40); // bound grazing rays at the crack steps + + // Recentre the parallax on the middle of the sheet stack: near and far sheets + // then slide past each other at different rates as the pointer moves. + vec2 par = look * 4.54; + + vec3 acc = vec3(0.0); + float trans = 1.0; + for (int i = 0; i < 8; i++) { + float layer = float(i); + bool solid = layer == 0.0 || layer == 1.0; // the two sheets that hold fish + float depth = solid ? 0.42 + layer * 0.26 + : texture2D(u_scene, atlasUV(hit.xy + par, layer)).a * 2.6; + float path = (hit.z - (FRONT - depth)) / -inside.z; // ice crossed to reach this sheet + // The slab is seen at a wide angle: the sheets themselves stretch up to four times + // towards the frame edges, which a fish silhouette cannot hide. So the fish ride + // on screen space and the ice bends them through its own slope instead. + vec2 at = solid ? (screen - 0.5) * 2.0 * EXTENT + normalSmooth.xy * path * 1.8 + par * 0.6 + : (hit + inside * path).xy + par; + vec3 plasma; + plasma.r = texture2D(u_scene, atlasUV(at + shearR * path, layer)).r; + plasma.g = texture2D(u_scene, atlasUV(at, layer)).g; + plasma.b = texture2D(u_scene, atlasUV(at + shearB * path, layer)).b; + vec3 c = plasma * exp(-SIGMA * path); + // Near plumes are nearly opaque, so the sheets behind them show through the gaps + // instead of averaging into a wash. That is what makes the stack read as depth. + // The shard is a solid, so its coverage comes straight from pass 1. + float alpha = solid ? texture2D(u_scene, atlasUV(at, layer)).a * 0.92 + : clamp(max(max(c.r, c.g), c.b) * 1.8, 0.0, 0.80); + acc += trans * c; + trans *= 1.0 - alpha; + } + + // Gentle shoulder: overlapping glow stays coloured instead of clipping to white. + vec3 col = vec3(1.0) - exp(-acc * 1.5); + + // Glass: reflection and a rim only where the surface turns away. Empty space stays dark. + float fresnel = pow(1.0 - facing, 5.0); + vec3 sky = mix(vec3(0.004, 0.016, 0.030), vec3(0.075, 0.220, 0.320), normal.y * 0.5 + 0.5); + col += sky * (fresnel * 1.2 + bevel * 0.03); + // Steep faces of the crests catch a thin sheen: the edge of the frozen surface, + // painted only where the ice actually tilts, so the open water stays black. + // Only the crack walls are this steep, so this sheen outlines the seams alone + // instead of washing the whole grain. + float tilt = smoothstep(1.00, 2.60, length(slope)); + col += vec3(0.022, 0.062, 0.078) * tilt; + vec2 fr = fracture(hit.xy); + float crack = (1.0 - smoothstep(0.0, 0.004, abs(fr.x))) + + (1.0 - smoothstep(0.0, 0.004, abs(fr.y))); + col += vec3(0.020, 0.055, 0.068) * crack * smoothstep(0.58, 0.80, fbm2(hit.xy * 0.30 + 21.0)); + + gl_FragColor = vec4(pow(clamp(col, 0.0, 1.0), vec3(0.4545)), 1.0); +} diff --git a/debug_fish.frag b/debug_fish.frag new file mode 100644 index 0000000..7a8ffcd --- /dev/null +++ b/debug_fish.frag @@ -0,0 +1,61 @@ +// Debug view: the fish silhouette alone, on flat grey, big enough to judge the +// geometry. Not part of the pages; build with +// python3 build_plasma.py 6_ice_plasma.frag debug_fish.frag debug_fish.html +precision highp float; +uniform vec2 u_res; +uniform float u_time; + +float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } +float smin(float a, float b, float k) { + float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); + return mix(b, a, h) - k * h * (1.0 - h); +} +float sdCapsule(vec2 p, vec2 a, vec2 b, float r) { + vec2 pa = p - a, ba = b - a; + float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); + return length(pa - ba * h) - r; +} +float sdTriangle(vec2 p, vec2 p0, vec2 p1, vec2 p2) { + vec2 e0 = p1 - p0, e1 = p2 - p1, e2 = p0 - p2; + vec2 v0 = p - p0, v1 = p - p1, v2 = p - p2; + vec2 q0 = v0 - e0 * clamp(dot(v0, e0) / dot(e0, e0), 0.0, 1.0); + vec2 q1 = v1 - e1 * clamp(dot(v1, e1) / dot(e1, e1), 0.0, 1.0); + vec2 q2 = v2 - e2 * clamp(dot(v2, e2) / dot(e2, e2), 0.0, 1.0); + float s = sign(e0.x * e2.y - e0.y * e2.x); + vec2 d = min(min(vec2(dot(q0, q0), s * (v0.x * e0.y - v0.y * e0.x)), + vec2(dot(q1, q1), s * (v1.x * e1.y - v1.y * e1.x))), + vec2(dot(q2, q2), s * (v2.x * e2.y - v2.y * e2.x))); + return -sqrt(d.x) * sign(d.y); +} +// Half-height of the outline at x: pointed nose, deepest just behind the head, +// a narrow peduncle, then the tail fan opening toward the rear. One function, so +// the whole animal is guaranteed to be one connected shape. +// Half-height of the body at x: pointed nose, deepest a quarter of the way back, +// then down to a narrow peduncle. Sweeping a circle along it keeps the body one piece. +float fishWidth(float x) { + float t = clamp((1.15 - x) / 2.30, 0.0, 1.0); // 0 at the nose, 1 at the peduncle + return 0.34 * pow(sin(3.14159 * pow(t, 0.55)), 0.8) + 0.05 * smoothstep(0.70, 1.0, t); +} +float sdFish(vec2 p) { + float d = length(vec2(max(abs(p.x) - 1.15, 0.0), p.y)) - fishWidth(p.x); // body + // tail: two lobes fanning back from the peduncle, with a notch between them + d = smin(d, sdTriangle(p, vec2(-1.02, 0.03), vec2(-1.62, 0.09), vec2(-1.46, 0.52)), 0.08); + d = smin(d, sdTriangle(p, vec2(-1.02, -0.03), vec2(-1.62, -0.09), vec2(-1.46, -0.52)), 0.08); + // dorsal and pectoral fins + d = smin(d, sdTriangle(p, vec2(0.22, 0.20), vec2(-0.06, 0.46), vec2(-0.40, 0.16)), 0.05); + d = smin(d, sdTriangle(p, vec2(0.46, -0.16), vec2(0.26, -0.08), vec2(0.10, -0.28)), 0.04); + return d; +} + +void main() { + vec2 p = (gl_FragCoord.xy - 0.5 * u_res) / u_res.y * 3.0; + vec3 col = vec3(0.16, 0.17, 0.18); + for (int i = 0; i < 3; i++) { + float id = float(i); + float size = mix(1.15, 0.28, id / 2.0); + vec2 at = vec2(0.0, 0.75 - 0.72 * id); + float d = sdFish((p - at) / size); + col = mix(col, vec3(0.01), clamp(0.5 - d / (0.005 / size), 0.0, 1.0)); + } + gl_FragColor = vec4(col, 1.0); +} diff --git a/debug_fish.html b/debug_fish.html new file mode 100644 index 0000000..7208324 --- /dev/null +++ b/debug_fish.html @@ -0,0 +1,112 @@ + + +
+ + +