/*{ "DESCRIPTION": "A slab of cast dielectric under rising electrical stress. Charge accumulates until the medium fails; the discharge branches through it at the speed of a decision and burns a permanent Lichtenberg figure into the material. The white-hot channel lasts an instant. The tree it leaves is dark, fine and permanent — old burns anneal slowly while new ones cut across them, so the slab is a record of every discharge it has ever taken. It fails on its own on a slow irregular clock; FIRE triggers one now, on top of that. ERASE anneals the slab clean.", "CREDIT": "", "INPUTS": [ { "NAME": "fire", "TYPE": "event" }, { "NAME": "erase", "TYPE": "event" }, { "NAME": "stress", "TYPE": "float", "DEFAULT": 1.0, "MIN": 0.25, "MAX": 3.0 }, { "NAME": "reach", "TYPE": "float", "DEFAULT": 1.0, "MIN": 0.35, "MAX": 1.7 }, { "NAME": "branch", "TYPE": "float", "DEFAULT": 1.0, "MIN": 0.3, "MAX": 2.0 }, { "NAME": "anneal", "TYPE": "float", "DEFAULT": 0.60, "MIN": 0.0, "MAX": 1.0 }, { "NAME": "conduct", "TYPE": "float", "DEFAULT": 0.30, "MIN": 0.0, "MAX": 1.0 }, { "NAME": "grain", "TYPE": "float", "DEFAULT": 1.0, "MIN": 0.0, "MAX": 2.0 }, { "NAME": "warmth", "TYPE": "float", "DEFAULT": 0.62, "MIN": 0.0, "MAX": 1.0 } ], "PASSES": [ { "TARGET": "tre", "FLOAT": true }, { "TARGET": "mem", "PERSISTENT": true, "FLOAT": true }, { "TARGET": "mem2", "PERSISTENT": true, "FLOAT": true }, { } ] }*/ #ifdef GL_FRAGMENT_PRECISION_HIGH precision highp float; #endif #define TAU 6.28318531 #define GROWT 0.115 // seconds for the discharge to reach full extent #define EVLIFE 1.25 // seconds of life for a fired (event) discharge // ---------------------------------------------------------------- hashes float h11(float p){ p = fract(p*0.1031); p *= p + 33.33; p *= p + p; return fract(p); } float h21(vec2 p){ vec3 q = fract(vec3(p.xyx)*0.1031); q += dot(q, q.yzx+33.33); return fract((q.x+q.y)*q.z); } vec2 h22(vec2 p){ vec3 q = fract(vec3(p.xyx)*vec3(0.1031,0.1030,0.0973)); q += dot(q, q.yzx+33.33); return fract((q.xx+q.yz)*q.zy); } float vnoise(vec2 p){ vec2 i = floor(p), f = fract(p); f = f*f*(3.0-2.0*f); return mix(mix(h21(i), h21(i+vec2(1.0,0.0)), f.x), mix(h21(i+vec2(0.0,1.0)), h21(i+vec2(1.0,1.0)), f.x), f.y); } float sdSeg(vec2 p, vec2 a, vec2 b){ vec2 pa = p-a, ba = b-a; float h = clamp(dot(pa,ba)/max(dot(ba,ba),1e-9), 0.0, 1.0); return length(pa - ba*h); } // ------------------------------------------------------- breakdown potential // U = "how easily can the discharge reach here". // * a fixed material grain (this slab's own striae) alternating with a per-strike term // * angular lobes so a few directions win big and most stall — that is what keeps the // figure spiky instead of a disc // * a strong radial fall-off, so U descends away from the entry point; that guarantees // a live cell's whole chain back to the seed is live too (no floating fragments). // The gradient falls out analytically, which is what makes this cheap enough to run per // pixel with no marching and no state. void ufield(vec2 x, vec2 sp, float sid, float B, float lob, float f0, float gn, out float U, out vec2 G){ vec2 rv = x - sp; float r = max(length(rv), 1e-4); vec2 rh = rv / r; float v = 0.0; vec2 g = vec2(0.0); for (int i = 0; i < 6; i++){ float fi = float(i); float per = mod(fi, 2.0); // alternate: material grain / this strike float oc = floor(fi*0.5); // octaves 0,0,1,1,2,2 float sd = fi*7.317 + per*(sid*3.113 + 1.7); float an = TAU*h11(sd); float fr = f0 * pow(2.13, oc); float am = gn / (6.0*fr); // gn is the TOTAL gradient budget over all 6 vec2 k = vec2(cos(an), sin(an))*fr; float s = dot(k, x) + TAU*h11(sd + 11.0); v += am*sin(s); g += am*cos(s)*k; } // low-order lobes: two or three directions take almost everything, the rest stall float th = atan(rv.y, rv.x); float L = 0.0, dL = 0.0; for (int j = 0; j < 4; j++){ float fj = float(j); float n = floor(3.0 + fj*fj*1.35 + fj*0.35); // 3, 4, 8, 16 lobes float aj = 0.44*(1.0 - 0.17*fj)/pow(1.30, fj); float p2 = TAU*h11(sid*5.71 + fj*13.13); L += aj*sin(n*th + p2); dL += aj*n*cos(n*th + p2); } // Carbonised channels conduct, so a new discharge is drawn along old damage — but // only damage that has HAD time to carbonise. mem2.g is burn recency, so weighting by // (1-recency) excludes this strike own fresh deposit; without that the tree would // steer into itself mid-strike and the scar depth would depend on the step count. vec2 uvx = x*RENDERSIZE.y/RENDERSIZE.xy + 0.5; vec4 old = IMG_NORM_PIXEL(mem2, uvx); float cnd = conduct*3.2*old.r*(1.0 - clamp(old.g, 0.0, 1.0)); float fd = smoothstep(0.0, 0.10, r); // lobes fade in past the entry point // the tangential pull is what makes neighbouring tips merge into a winning arm float tg = clamp(0.135*dL*lob*fd/r, -0.55*B, 0.55*B); U = v + L*lob*fd + cnd - B*r; G = g + tg*vec2(-rh.y, rh.x) - B*rh; } // ------------------------------------------------------------- the figure // Every cell of a jittered grid picks a parent: the neighbour that lies uphill in U, i.e. // one step back toward the entry point, deflected by the material grain. Together those // links are a spanning tree — many tips, converging as they run inward, so the channel // count falls and the surviving ones carry more. A cell exists only where U > T; T drops // fast during the strike, which is the tree growing. Thickness tracks U-T: cells that // joined early are channels, cells that only just made it are hair. // returns x=core y=burn weight z=leading front w=corona vec4 lich(vec2 P, vec2 sp, float sid, float T, float B, float lob, float cs, float f0, float gn, float tks, float wmax, float pw){ vec2 c0 = floor(P/cs); float d = 1e3, wb = 0.0; for (int j = 0; j < 9; j++){ vec2 c = c0 + vec2(mod(float(j),3.0)-1.0, floor(float(j)/3.0)-1.0); vec2 fp = (c + 0.5 + 0.92*(h22(c + sid*17.31) - 0.5))*cs; float U; vec2 G; ufield(fp, sp, sid, B, lob, f0, gn, U, G); float w = U - T; // thin tips are culled at random so the hair spacing is not the grid pitch; trunk // and mid cells always survive, so nothing that carries a subtree is cut if (w > -0.085*wmax && (w > 0.20*wmax || h21(c*1.7 + sid*3.13) < 0.72)){ vec2 gg = G / max(max(abs(G.x), abs(G.y)), 1e-5); vec2 c2 = c + floor(gg + 0.5); // one step uphill, 8-neighbourhood vec2 fq = (c2 + 0.5 + 0.92*(h22(c2 + sid*17.31) - 0.5))*cs; float tk = tks*pow(clamp(w/wmax, 0.0, 1.0), 2.7); float ds = sdSeg(P, fp, fq) - tk; if (ds < d){ d = ds; wb = w; } } } if (d > 0.9e3) return vec4(0.0); // cells just BELOW threshold still draw, very faintly: the long thin outrunners that // shoot past the body of the figure and then stop float lin = (1.0 - smoothstep(0.0, pw, d))*smoothstep(-0.085*wmax, 0.015*wmax, wb); float thk = clamp(wb/wmax, 0.0, 1.0); float dc = max(d, 0.0); // the corona has to die INSIDE the 3x3 cell window it was measured over, or the // neighbourhood boundary shows up as square blocks float cor = exp(-dc/0.013)*smoothstep(0.9*cs, 0.32*cs, dc); return vec4(lin*(0.09 + 0.91*thk*thk), lin, lin*exp(-4.2*thk), cor); } // ------------------------------------------------------------- the clock // One breakdown per slot, at a jittered phase inside it, so intervals are irregular. // Every term is a closed form of TIME — nothing is integrated — so a poster rendered at // any --time lands exactly where live playback would be at that moment. void schedule(out float sid, out float age, out float mag, out vec2 sp, out vec2 nsp, out float chg){ float slot = 4.4 / max(stress, 0.05); // run the schedule on a clock that is already part-way charged, so the slab fails // within a second of TIME=0 instead of opening on four seconds of nothing float tt = TIME + 2.73/max(stress, 0.05); float sl = floor(tt/slot); float fp = 0.16 + 0.68*h11(sl*7.13 + 0.5); float ft = (sl + fp)*slot; if (tt < ft){ sl -= 1.0; fp = 0.16 + 0.68*h11(sl*7.13 + 0.5); ft = (sl + fp)*slot; } float nf = (sl + 1.16 + 0.68*h11((sl+1.0)*7.13 + 0.5))*slot; sid = sl; age = tt - ft; mag = 0.26 + 0.74*pow(h11(sl*3.771 + 2.2), 1.55); // mostly minor failures float asp = RENDERSIZE.x/RENDERSIZE.y; // golden-angle placement: consecutive entry points never cluster float ga = 2.39996323*sl + 1.1; float rr2 = 0.06 + 0.31*sqrt(h11(sl*2.393 + 0.9)); sp = vec2(cos(ga)*rr2*asp, sin(ga)*rr2); float nga = 2.39996323*(sl+1.0) + 1.1; float nrr = 0.06 + 0.31*sqrt(h11((sl+1.0)*2.393 + 0.9)); nsp = vec2(cos(nga)*nrr*asp, sin(nga)*nrr); chg = clamp((tt - ft)/max(nf - ft, 0.001), 0.0, 1.0); } void main(){ vec2 uv = isf_FragNormCoord; vec2 P = (gl_FragCoord.xy - 0.5*RENDERSIZE.xy)/RENDERSIZE.y; float asp = RENDERSIZE.x/RENDERSIZE.y; float sid, age, mag, chg; vec2 sp, nsp; schedule(sid, age, mag, sp, nsp, chg); // ============================================================ PASS 0: the discharge if (PASSINDEX == 0){ float ev = IMG_THIS_PIXEL(mem).a; bool evOn = ev > 0.002; if (age > 0.9 && !evOn){ gl_FragColor = vec4(0.0); return; } float pw = 1.30/RENDERSIZE.y; vec4 acc = vec4(0.0); for (int s = 0; s < 2; s++){ float a2, m2, i2; vec2 p2; if (s == 0){ a2 = age; m2 = mag; i2 = sid; p2 = sp; } else { float ft = TIME - (1.0 - ev)*EVLIFE; i2 = 400.0 + floor(ft*6.0); a2 = evOn ? (1.0 - ev)*EVLIFE : 99.0; m2 = 0.86; float ga2 = 2.39996323*i2 + 1.1; float rr3 = 0.06 + 0.31*sqrt(h11(i2*2.393 + 0.9)); p2 = vec2(cos(ga2)*rr3*asp, sin(ga2)*rr3); } if (a2 > 0.9) continue; float gr = clamp(a2/GROWT, 0.0, 1.0); float B = 6.1/(max(reach, 0.2)*(0.55 + 0.45*m2)); // a minor failure stays small float T = mix(0.95, -0.30 - 0.98*m2, pow(gr, 0.5)); float ec = exp(-a2/0.50); float wmx = 0.32 + 0.98*m2; // Two scales through ONE call site, because compile cost is driven by call sites, // not by file size. L=0 is the structure: a coarse graph, few channels, real // bifurcations. L=1 is filigree: a much finer graph on a much rougher field, run // only where L=0 already found a channel to hang it off. vec4 C = vec4(0.0); for (int L = 0; L < 2; L++){ float fn = float(L); if (L == 1 && C.w <= 0.14) break; vec4 X = lich(P, p2, i2, T + 0.10*fn, B, branch, mix(0.0240, 0.0086, fn), mix(16.0, 54.0, fn), mix(0.93, 1.75, fn)*B, mix(0.0108, 0.0017, fn)*m2, wmx, pw); // filigree is a BURN feature, not a bright one — at full brightness it fills the // interior and the live discharge stops reading as a tree if (L == 1) X *= smoothstep(0.14, 0.62, C.w)*vec4(0.40, 0.80, 0.60, 0.45); C = (L == 0) ? X : max(C, X); } // the BURN is not multiplied by ec. The glow fades with age, but how deep the // scar goes must not depend on which sim steps happened to land in the window — // deposit is idempotent, so a coarse poster step burns exactly as deep as live. acc = max(acc, vec4(C.x*ec, C.y, C.z*ec, C.w*ec)); } gl_FragColor = acc; return; } // ================================================ PASS 1: the slab's memory of it if (PASSINDEX == 1){ vec4 M = IMG_THIS_PIXEL(mem); vec4 Tr = IMG_THIS_PIXEL(tre); // carry our own clock — TIMEDELTA disagrees with TIME on the render path float nowc = mod(TIME, 1.0); float dt = nowc - M.b; if (dt < 0.0) dt += 1.0; if (dt <= 0.0 || dt > 0.50) dt = 0.0167; float sc = M.r; float tau = 70.0*exp(-2.7*anneal)*(0.30 + 1.7*sc); // deep burns anneal slower sc *= exp(-dt/max(tau, 0.30)); float dep = Tr.y; sc = min(1.4, max(sc, dep*(1.0 + 0.35*sc))); // idempotent: step count can't matter if (erase) sc = 0.0; float ht = max(M.g*exp(-dt/0.28), Tr.x); float ev = fire ? 1.0 : max(0.0, M.a - dt/EVLIFE); gl_FragColor = vec4(sc, ht, nowc, ev); return; } // ============================ PASS 2: what the slab never gets back // mem.r anneals on a scale of tens of seconds — that is the visible ageing. This pass // holds the part that does NOT come back: a permanent floor under every burn, plus a // recency term so a fresh char and a decade-old ghost can be coloured differently. if (PASSINDEX == 2){ vec4 M2 = IMG_THIS_PIXEL(mem2); float dep = IMG_THIS_PIXEL(tre).y; float nowc = mod(TIME, 1.0); float dt = nowc - M2.b; if (dt < 0.0) dt += 1.0; if (dt <= 0.0 || dt > 0.50) dt = 0.0167; float perm = max(M2.r*exp(-dt/600.0), dep*0.38); float rec = max(M2.g*exp(-dt/9.0), dep); if (erase){ perm = 0.0; rec = 0.0; } gl_FragColor = vec4(perm, rec, nowc, 1.0); return; } // ================================================ PASS 3: look into the material vec4 M = IMG_THIS_PIXEL(mem); vec4 M2 = IMG_THIS_PIXEL(mem2); vec4 Tr = IMG_THIS_PIXEL(tre); vec2 e = min(uv, 1.0-uv); float ed = min(e.x*asp, e.y); float bev = smoothstep(0.0, 0.048, ed); // the figure sits INSIDE the slab: displaced, softening copies read as depth vec2 par = vec2(0.0058, -0.0042); float sD = ( IMG_NORM_PIXEL(mem2, uv + par*2.4).r + IMG_NORM_PIXEL(mem2, uv + par*2.4 + vec2(0.004,0.003)).r + IMG_NORM_PIXEL(mem2, uv + par*2.4 - vec2(0.004,0.003)).r ) * 0.72; float sM = max(IMG_NORM_PIXEL(mem, uv + par).r, IMG_NORM_PIXEL(mem2, uv + par).r); float s = clamp(max(M.r, M2.r), 0.0, 1.4); float rec = clamp(M2.g, 0.0, 1.0); vec3 warmS = vec3(0.888, 0.822, 0.708); vec3 coolS = vec3(0.735, 0.775, 0.812); vec3 base = mix(coolS, warmS, warmth); float lite = 1.06 - 0.40*length(P - vec2(-0.30, 0.32)); float striae = 0.5 + 0.5*sin(P.x*19.0 + 5.5*vnoise(P*vec2(1.5,5.0))); float tooth = vnoise(P*430.0); float bub = smoothstep(0.88, 1.0, vnoise(P*34.0 + 11.0)); vec3 col = base*lite; col *= 1.0 - 0.035*striae*grain; col *= 1.0 - 0.050*(tooth-0.5)*grain; col *= 1.0 - 0.085*bub*grain; col += vec3(0.05,0.03,0.00)*striae*grain*0.25; // faint warm cast in the striae // Photoelastic fringes: stress concentrating where the slab is ABOUT to fail. Keyed // to the next entry point and to charge, so it telegraphs the strike a second early. float rr = length(P - nsp); float fr = sin(30.0*rr - 2.0)*exp(-2.0*rr); col += vec3(0.10,0.15,0.26)*fr*pow(chg, 3.0)*0.55; col = mix(col, base*vec3(0.62,0.55,0.47), clamp(sD,0.0,1.0)*0.26); col = mix(col, base*vec3(0.42,0.33,0.26), clamp(sM,0.0,1.0)*0.32); col = mix(col, vec3(0.325,0.237,0.170), smoothstep(0.0,0.34,s)*0.54); // scorch col = mix(col, vec3(0.044,0.033,0.030), smoothstep(0.55,1.15,s)*(0.62+0.38*rec)); // char col += vec3(1.00,0.38,0.09)*M.g*0.62; // cooling ember // while a channel is conducting it lights the material around it: a cheap two-ring // bloom straight off the live pass, skipped entirely when nothing is burning float blo = 0.0; if (age < 0.9 || M.a > 0.002){ float rot = TAU*h21(gl_FragCoord.xy); // per-pixel ring rotation: replicas -> grain for (int i = 0; i < 10; i++){ float fi8 = float(i); float a8 = rot + 2.39996323*fi8; float r8 = 0.010 + 0.050*fi8/9.0; blo += IMG_NORM_PIXEL(tre, uv + vec2(cos(a8), sin(a8))*r8).x*(0.115 - 0.008*fi8); } } col += vec3(0.47,0.58,1.00)*blo*0.95; // the slab glowing around it col += vec3(0.46,0.58,1.00)*Tr.w*0.72*mag; // corona in the material col += vec3(1.00,0.98,0.99)*Tr.x*1.55; // white-hot channel col += vec3(0.78,0.85,1.00)*Tr.z*1.20; // advancing tips float flash = exp(-age/0.070)*mag; col += vec3(0.40,0.46,0.72)*flash*0.52*(0.45 + 0.55*bev); // The polished FACE, composited over the burns rather than under them — this is the // cue that sells the figures as being inside something rather than drawn on it. float sheen = smoothstep(0.30, 1.35, 1.05 - (P.x*0.5 + P.y*0.95)); col += vec3(0.84,0.88,0.97)*sheen*0.052; vec2 sq = vec2(P.x*0.70 - P.y*0.71, P.x*0.71 + P.y*0.70); float scr = smoothstep(0.968, 1.0, vnoise(vec2(sq.x*2.2, sq.y*300.0))); col += vec3(0.92,0.94,1.0)*scr*0.085*grain; col += vec3(0.95)*smoothstep(0.93, 1.0, vnoise(P*185.0 + 41.0))*0.05*grain; // the polished chamfer on the block: this is a solid object, not a picture plane float cham = smoothstep(0.009, 0.046, ed); col = mix(col*vec3(0.30,0.325,0.395), col, cham); col = mix(col, col*vec3(1.10,1.06,0.98), smoothstep(0.046, 0.020, ed)*0.6); float q1 = (ed - 0.0455)/0.0042; float q2 = (ed - 0.0105)/0.0030; col += vec3(0.95,0.96,1.00)*exp(-q1*q1)*0.20; col += vec3(0.90,0.94,1.00)*exp(-q2*q2)*0.10; vec3 hot = max(col - 1.0, 0.0); col = min(col, vec3(1.0)) + hot/(1.0 + hot); col += (h21(gl_FragCoord.xy + fract(TIME)*13.0) - 0.5)*0.009; gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0); }
A slab of cast dielectric under rising electrical stress. Charge accumulates until the medium fails; the discharge branches through it at the speed of a decision and burns a permanent Lichtenberg figure into the material. The white-hot channel lasts an instant. The tree it leaves is dark, fine and permanent — old burns anneal slowly while new ones cut across them, so the slab is a record of every discharge it has ever taken. It fails on its own on a slow irregular clock; FIRE triggers one now, on top of that. ERASE anneals the slab clean.
export clip · render a video in your browser
Renders to video in your browser (no upload), stepping the real renderer frame by frame so multipass + feedback come out right. motion auto-animates the shader's own knobs — each export rolls a fresh take (🎲 re-roll, 🔒 to pin one you like). GIF is slower with a tighter length cap; MP4/WebM are faster. Some devices record in real time (noted below if so) — keep the tab visible.
embed · drop this shader into any page
Renders client-side in the reader's browser (CC0 — no attribution required, but the backlink is appreciated).