Files
shaders/10_ice_fish.frag
T
emil28092005 6dbcd630f4 Add frozen fish: dark silhouettes crossing the slab, cold blue eyes
Two of the eight sheets carry fish instead of only plasma. Each fish cuts
straight across the slab once per cycle (thirty seconds, period preserved),
three per sheet, sizes spread by pow(hash, 2.6) so most are small and now
and then one is huge. Sheets sit near the front (0 and 1) so the silhouettes
are not buried under the layers in front of them; their alpha carries
coverage instead of depth.

The fish ride screen space, since the sheets themselves stretch up to four
times towards the frame edges, and are bent by the smooth swell of the
surface only: the crack steps flip the slope within a pixel and would slice
a fish to bits. Eyes are a small cold blue glow, the only light they have.

debug_fish.frag renders the shapes flat for inspection.
2026-10-30 21:54:59 +03:00

155 lines
7.7 KiB
GLSL

// 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);
}