Add ice_object.html: a faceted crystal shard frozen in the slab

Pass 1 draws one raymarched gem on sheet 1 instead of plasma; its alpha
carries coverage rather than depth, so pass 2 can light it as a solid
while every other sheet still absorbs by path length.
This commit is contained in:
emil28092005
2026-10-30 21:30:09 +03:00
parent 366ff39662
commit 01123b8bac
4 changed files with 384 additions and 5 deletions
+135
View File
@@ -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);
}
+126
View File
@@ -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);
}
+11 -5
View File
@@ -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'''<!doctype html>
<html lang="ru">
<head>
@@ -119,5 +125,5 @@ if (shot) frame(0); else requestAnimationFrame(frame);
</body>
</html>
'''
(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')
+112
View File
File diff suppressed because one or more lines are too long