diff --git a/8_ice_scene.frag b/8_ice_scene.frag new file mode 100644 index 0000000..ccb468a --- /dev/null +++ b/8_ice_scene.frag @@ -0,0 +1,135 @@ +// Pass 1: eight sheets, one atlas tile each; sheet 5 holds a shard frozen in the ice. +// Alpha stores linear depth, so pass 2 absorbs it like any other sheet. +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); +} +const float EXTENT = 3.8; // half-width of a sheet in world units; must match the ice pass + +// Signed distance to an elongated octahedron turning once per cycle. Sharp edges, +// cheap to march: this is the one thing in the slab that is a real 3D object. +vec3 shardLocal(vec3 p, float phase) { + float c1 = cos(phase), s1 = sin(phase); + p.xz = mat2(c1, -s1, s1, c1) * p.xz; + float c2 = cos(phase * 2.0 + 1.0), s2 = sin(phase * 2.0 + 1.0); + p.yz = mat2(c2, -s2, s2, c2) * p.yz; + p.yz /= vec2(1.0, 1.45); + return abs(p); +} +const float SHARD = 0.70; // how big the shard is in sheet units +// Cross two octahedra: the intersection is an eight-sided gem, so the shard still +// reads as a cut stone from any angle instead of a flat kite. +vec3 gemAxes(vec3 l, out vec2 r) { + r = mat2(0.70711, -0.70711, 0.70711, 0.70711) * l.xz; + return vec3(abs(r.x), l.y, abs(r.y)); +} +float sdShard(vec3 p, float phase) { + vec3 l = shardLocal(p / SHARD, phase); + vec2 r; + vec3 m = gemAxes(l, r); + float a = (l.x + l.y * 0.86 + l.z - 1.0) * 0.45; + float b = (m.x * 1.34 + m.y * 0.86 + m.z * 1.34 - 1.0) * 0.40; + return max(a, b) * SHARD; +} + +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); + + float solid = 0.45; // sheet 1 keeps its plasma, but its alpha becomes coverage + if (layer > 0.5 && layer < 1.5) { + vec3 ro = vec3(0.0, 0.0, 6.0); + vec3 rd = normalize(vec3((uv - 0.5) * 2.0 * vec2(u_res.x / u_res.y, 1.0), -1.0)); + float b = dot(ro, rd); + float disc = b * b - dot(ro, ro) + 2.4 * 2.4; + if (disc > 0.0) { + float near = max(-b - sqrt(disc), 0.0); + float t = near, d = 1.0; + for (int k = 0; k < 40; k++) { + d = sdShard(ro + rd * t, phase); + if (d < 0.003 || t > near + 5.0) break; + t += d; + } + if (d < 0.003) { + solid = 1.0; + vec3 pos = ro + rd * t; + float e2 = 0.004; + vec3 nrm = normalize(vec3( + sdShard(pos + vec3(e2, 0.0, 0.0), phase) - sdShard(pos - vec3(e2, 0.0, 0.0), phase), + sdShard(pos + vec3(0.0, e2, 0.0), phase) - sdShard(pos - vec3(0.0, e2, 0.0), phase), + sdShard(pos + vec3(0.0, 0.0, e2), phase) - sdShard(pos - vec3(0.0, 0.0, e2), phase))); + // A key light and a cool fill from the plasma side: flat faces take one tone + // each, which is what makes the shard read as a cut stone rather than a blob. + float key = max(dot(nrm, normalize(vec3(-0.45, 0.72, 0.52))), 0.0); + float fill = max(dot(nrm, normalize(vec3(0.60, -0.20, -0.75))), 0.0); + float rim = pow(1.0 - max(dot(nrm, -rd), 0.0), 4.0); + col = mix(vec3(0.004, 0.016, 0.026), vec3(0.060, 0.290, 0.340), key); + col += vec3(0.010, 0.060, 0.080) * fill; + col += vec3(0.20, 0.62, 0.72) * rim * 0.55; + } + } + } + float alpha = (layer > 0.5 && layer < 1.5) ? solid : (0.42 + layer * 0.26) / 2.6; + gl_FragColor = vec4(col, alpha); +} diff --git a/9_ice_over_obj.frag b/9_ice_over_obj.frag new file mode 100644 index 0000000..fef1b4f --- /dev/null +++ b/9_ice_over_obj.frag @@ -0,0 +1,126 @@ +// 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; +const float OBJECT_DEPTH = 0.68; // sheet 1 holds the shard, at a fixed depth + +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 height(vec2 x) { + vec2 fr = fracture(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)) + + 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 + vec3 normal = normalize(vec3(-slope, 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 = i == 1; // sheet 1 is the shard: its alpha is coverage, not depth + float depth = solid ? OBJECT_DEPTH + : 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 shard keeps its shape: it is sampled without the refraction offset, so it + // stays one solid piece instead of being cut up by the crack steps. + vec2 at = solid ? hit.xy + par * 0.5 : (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/build_plasma.py b/build_plasma.py index 5adbea0..8fa8123 100644 --- a/build_plasma.py +++ b/build_plasma.py @@ -1,11 +1,17 @@ #!/usr/bin/env python3 -"""Embed the two GLSL passes into the standalone ice_plasma.html page.""" +"""Embed two GLSL passes into a standalone page. + + build_plasma.py [scene.frag] [ice.frag] [out.html] + +Defaults to the plasma wall: 6_ice_plasma.frag + 7_ice_over.frag -> ice_plasma.html.""" import json +import sys from pathlib import Path here = Path(__file__).resolve().parent -scene = (here / '6_ice_plasma.frag').read_text() -ice = (here / '7_ice_over.frag').read_text() +args = (sys.argv[1:] + ['6_ice_plasma.frag', '7_ice_over.frag', 'ice_plasma.html'])[:3] +scene = (here / args[0]).read_text() +ice = (here / args[1]).read_text() html = r''' @@ -119,5 +125,5 @@ if (shot) frame(0); else requestAnimationFrame(frame); ''' -(here / 'ice_plasma.html').write_text(html.replace('__SCENE__', json.dumps(scene)).replace('__ICE__', json.dumps(ice))) -print('ice_plasma.html written') +(here / args[2]).write_text(html.replace('__SCENE__', json.dumps(scene)).replace('__ICE__', json.dumps(ice))) +print(args[2], 'written') diff --git a/ice_object.html b/ice_object.html new file mode 100644 index 0000000..1318490 --- /dev/null +++ b/ice_object.html @@ -0,0 +1,112 @@ + + + + + +Сквозь лёд + + + + +
Сквозь лёд
Двигайте мышь · скорость − / =
+ + +