fork a shader
Write or paste an ISF shader (GLSL ES with a /*{ ... }*/ header). It compiles + renders in yer browser. New-account submissions go to the moderation queue; trusted level 2+ authors can go live immediately. Everything is CC0. Got a file first? preview it locally →
A model marks this as witnessed_generation (honest AI provenance). Leave blank if you hand-authored it.
/*{ "DESCRIPTION": "An hourglass in gilt and walnut, tumbling slowly on a random walk, with real sand inside it. Five passes: four automaton steps and a render. The sand is a block cellular automaton, not a particle effect -- the grid is cut into two-by-two blocks whose offset alternates every step, and each block sorts whatever grains it holds toward whichever of its four cells lies furthest along gravity. Sorting inside a block can only move grains, never make or destroy them, so the count is conserved exactly. That structure is not a flourish: a fragment shader cannot move a grain from one cell to another the way you would write it on a CPU, because both cells have to reach the same conclusion independently, and a two-by-two block is the smallest neighbourhood where they can. Alternating the block offset is the only reason a grain can ever leave the block it started in. The glass itself never moves in the simulation -- gravity rotates instead and the finished picture is turned at the end, so no wall ever sweeps through a grain and none is ever lost through the glass. FRICTION is the angle of repose: at zero the sand behaves like water and finds its own level, and turned up it holds a slope and lets go in avalanches. NECK sets how fast it runs, WAIST the shape of the bulbs, SPIN and WANDER the tumble, LAYERS the bands it is packed in. Everything is a solid of revolution, with the surface normal recovered from how far across the radius each pixel sits, and the ornament repeats in that surface angle rather than across the screen, so it crowds toward the silhouette the way real turned work does instead of sitting on the front like a decal. GOLD gilds the fittings, MOTIFS sets how many times the ornament repeats around the axis, GUILLOCHE cuts the interlaced engine-turned frieze into the cap, and GEMS sets cabochons in gold bezels around a gadrooned and scalloped rim. Ten finishes: gilt and walnut, crystal and silver, obsidian and gold, blueprint, x-ray, neon, engraving, candy shop, sand only, and the automaton itself with every grain flat-shaded by its stored value.", "CREDIT": "CC0", "ISFVSN": "2", "CATEGORIES": ["simulation", "generative", "feedback", "automaton"], "INPUTS": [ { "NAME": "uSpin", "TYPE": "float", "DEFAULT": 0.25, "MIN": 0.0, "MAX": 2.0 }, { "NAME": "uWander", "TYPE": "float", "DEFAULT": 0.65, "MIN": 0.0, "MAX": 1.0 }, { "NAME": "uNeck", "TYPE": "float", "DEFAULT": 0.065, "MIN": 0.02, "MAX": 0.16 }, { "NAME": "uWaist", "TYPE": "float", "DEFAULT": 0.62, "MIN": 0.30, "MAX": 1.60 }, { "NAME": "uZoom", "TYPE": "float", "DEFAULT": 0.72, "MIN": 0.45, "MAX": 1.30 }, { "NAME": "uOrnate", "TYPE": "float", "DEFAULT": 0.90, "MIN": 0.0, "MAX": 1.0 }, { "NAME": "uGold", "TYPE": "float", "DEFAULT": 1.0, "MIN": 0.0, "MAX": 1.0 }, { "NAME": "uGuilloche", "TYPE": "float", "DEFAULT": 0.85, "MIN": 0.0, "MAX": 1.0 }, { "NAME": "uGems", "TYPE": "float", "DEFAULT": 0.0, "MIN": 0.0, "MAX": 1.0 }, { "NAME": "uMotifs", "TYPE": "long", "DEFAULT": 2, "VALUES": [0, 1, 2, 3], "LABELS": ["16", "24", "32", "48"] }, { "NAME": "uGrainSize", "TYPE": "float", "DEFAULT": 3.2, "MIN": 2.0, "MAX": 8.0 }, { "NAME": "uFriction", "TYPE": "float", "DEFAULT": 0.34, "MIN": 0.0, "MAX": 0.9 }, { "NAME": "uFill", "TYPE": "float", "DEFAULT": 0.84, "MIN": 0.15, "MAX": 0.95 }, { "NAME": "uLayers", "TYPE": "long", "DEFAULT": 1, "VALUES": [0, 1, 2, 3, 4], "LABELS": ["plain", "bands", "gradient", "stripes", "speckled"] }, { "NAME": "uSandPal", "TYPE": "long", "DEFAULT": 0, "VALUES": [0, 1, 2, 3, 4, 5, 6, 7], "LABELS": ["desert", "rose", "ash", "glacier", "toxic", "iron", "ember", "candy"] }, { "NAME": "uReset", "TYPE": "event" }, { "NAME": "uStyle", "TYPE": "long", "DEFAULT": 0, "VALUES": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], "LABELS": ["gilt and walnut", "crystal and silver", "obsidian and gold", "blueprint", "x-ray", "neon", "engraving", "candy shop", "sand only", "the automaton"] }, { "NAME": "uGlass", "TYPE": "float", "DEFAULT": 0.62, "MIN": 0.0, "MAX": 1.0 }, { "NAME": "uSparkle", "TYPE": "float", "DEFAULT": 0.45, "MIN": 0.0, "MAX": 1.0 }, { "NAME": "uGain", "TYPE": "float", "DEFAULT": 1.0, "MIN": 0.0, "MAX": 3.0 }, { "NAME": "uGamma", "TYPE": "float", "DEFAULT": 1.0, "MIN": 0.4, "MAX": 2.4 }, { "NAME": "uVign", "TYPE": "float", "DEFAULT": 0.45, "MIN": 0.0, "MAX": 1.0 }, { "NAME": "uFilm", "TYPE": "float", "DEFAULT": 0.05, "MIN": 0.0, "MAX": 0.5 } ], "PASSES": [ { "TARGET": "b0", "PERSISTENT": true, "FLOAT": true }, { "TARGET": "b1", "PERSISTENT": true, "FLOAT": true }, { "TARGET": "b2", "PERSISTENT": true, "FLOAT": true }, { "TARGET": "b3", "PERSISTENT": true, "FLOAT": true }, { } ] }*/ // ============================================================================= // hourglass -- a Margolus block automaton of sand in a solid of revolution // ============================================================================= // // Cell state, one grain per texel: // x 1.0 if a grain is here // y the grain's shade, which is what makes the packed layers visible // z a global clock, the same number in every cell // w 1.0 once seeded, so a fresh target can tell it is frame one // // pass 0..3 four automaton steps a frame, each reading the one before it, // with pass 0 reading last frame's b3 // pass 4 render // // WHY A BLOCK AUTOMATON. Falling sand is written on a CPU as "move the grain at // A into the empty cell at B". A fragment shader cannot do that: A and B are // different invocations, each only writes itself, and nothing makes them agree // -- read it naively and a grain either duplicates into both cells or vanishes // from both. Margolus's answer is to partition the grid into two-by-two blocks // and let each block rewrite itself as a unit. All four cells in a block run the // same arithmetic on the same four inputs, so they necessarily agree about where // everything went, and each simply writes its own slot of the answer. Moving // grains around inside a block cannot change how many there are, so the count // is conserved to the bit. The offset of the block grid alternates every step, // which is the only reason a grain can ever leave the block it started in. // // WHY THE GLASS DOES NOT MOVE. It would be natural to rotate the hourglass and // leave gravity pointing down, but then the walls sweep through the cells, and a // grain that finds itself inside glass has nowhere to go -- it has to be deleted // or teleported, and either way the sand slowly disappears. So the simulation // keeps the glass still and rotates GRAVITY, and the render turns the finished // picture instead. The walls are then constant for all time, no grain is ever // overrun, and the sand count is exactly conserved for as long as it runs. // ============================================================================= #define TAU 6.283185307179586 float h11(float n){ return fract(sin(n * 12.9898) * 43758.5453); } float h21(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.x + q.y) * q.z); } vec2 gR; // render size float gAsp; vec2 gCU; // one cell, in uv vec2 gUV; vec2 gCell; // integer cell index of this fragment void setup(){ gR = RENDERSIZE.xy; gAsp = gR.x / gR.y; gCU = uGrainSize / gR; gUV = gl_FragCoord.xy / gR; gCell = floor(gUV / gCU); } // Simulation space: y runs -1..1 with the hourglass upright, x scaled by aspect. vec2 cellToSim(vec2 c){ return ((c + 0.5) * gCU - 0.5) * 2.0 * vec2(gAsp, 1.0); } vec2 simToUV(vec2 q){ return q / (2.0 * vec2(gAsp, 1.0)) + 0.5; } mat2 rot(float a){ float s = sin(a), c = cos(a); return mat2(c, -s, s, c); } // ---- the hourglass, as a profile of revolution ------------------------------ // One function gives the outer radius at every height, and everything else -- // the wall, the cavity, the caps, the shading normal -- is derived from it. #define BODY_H 0.70 #define CAP_H 0.88 #define WALL 0.030 float profile(float y){ float a = clamp(abs(y) / BODY_H, 0.0, 1.0); // A real hourglass curves back IN at the mouth: the bulb swells and then // returns to a narrow shoulder for the cap to grip. Without that the widest // part of the glass IS the rim, so the cap has to be wider still, and the // fitting ends up a slab broader than the vessel it is holding. return uNeck + (0.60 - uNeck) * pow(a, uWaist) * (1.0 - 0.44 * smoothstep(0.68, 1.0, a)); } // The profile's slope, which the surface normal needs. float dProfile(float y){ float e = 0.010; return (profile(y + e) - profile(y - e)) / (2.0 * e); } // Is this point inside the sand cavity? // // The margin is a correctness requirement, not tidiness. The automaton is only // conservative while every live cell's eight neighbours exist: outside the // texture, reads clamp to the edge, the four cells of a block stop agreeing // about what they hold, and grains duplicate or vanish. On a tall narrow canvas // the glass is wider than the simulation domain, and the count measurably // drifted -- 3030 grains became 3075 over eight hundred frames, where a square // canvas held exactly. Confining the cavity to the domain fixes it; the cost is // that at an extreme aspect the glass is cropped rather than wrong. float cavity(vec2 q){ if (abs(q.y) > BODY_H - 0.004) return 0.0; vec2 mrg = vec2(gCU.x * 2.0 * gAsp, gCU.y * 2.0) * 3.0; if (abs(q.x) > gAsp - mrg.x || abs(q.y) > 1.0 - mrg.y) return 0.0; return step(abs(q.x), profile(q.y) - WALL); } // The random tumble. Segment to segment the target angle is a fresh hash, and // the easing between them is smoothstep, so the motion is continuous but never // repeats a heading. WANDER trades a gentle sway against whole rotations. float angleAt(float clk) { // The clock is a phase that wraps every 2048 frames, so anything driving the // rotation has to be continuous ACROSS that wrap or the glass visibly snaps // once every thirty-four seconds -- which the first version did, because // neither term lined up with the seam. Both are whole numbers per wrap now: // the drift is an integer count of full turns, so a wrap advances it by a // multiple of a full circle and changes nothing, and the wander's last // segment eases back into its first. float turns = floor(uSpin * 5.0); float segs = max(1.0, floor(uWander * 5.0 + 0.5)); float seg = floor(clk * segs); float f = smoothstep(0.0, 1.0, fract(clk * segs)); float a0 = (h11(seg * 1.7 + 0.5) - 0.5) * TAU * uWander * 0.5; float a1 = (h11(mod(seg + 1.0, segs) * 1.7 + 0.5) - 0.5) * TAU * uWander * 0.5; return mix(a0, a1, f) + clk * TAU * turns; } // Gravity in the hourglass's own frame. vec2 gravityAt(float clk){ return rot(-angleAt(clk)) * vec2(0.0, -1.0); } // ---- one automaton step ----------------------------------------------------- // Reads four cells, decides the whole block, writes this fragment's slot. vec4 step_(sampler2D src, float phase, float stepId, float clk){ vec4 me = IMG_NORM_PIXEL(src, (gCell + 0.5) * gCU); // block origin, and which of the four slots this cell is vec2 bo = floor((gCell - phase) * 0.5) * 2.0 + phase; vec2 slot = gCell - bo; // the four cells vec4 c0 = IMG_NORM_PIXEL(src, (bo + vec2(0.5, 0.5)) * gCU); vec4 c1 = IMG_NORM_PIXEL(src, (bo + vec2(1.5, 0.5)) * gCU); vec4 c2 = IMG_NORM_PIXEL(src, (bo + vec2(0.5, 1.5)) * gCU); vec4 c3 = IMG_NORM_PIXEL(src, (bo + vec2(1.5, 1.5)) * gCU); // which of them are inside the glass at all float f0 = cavity(cellToSim(bo + vec2(0.0, 0.0))); float f1 = cavity(cellToSim(bo + vec2(1.0, 0.0))); float f2 = cavity(cellToSim(bo + vec2(0.0, 1.0))); float f3 = cavity(cellToSim(bo + vec2(1.0, 1.0))); // FRICTION is the angle of repose. A block that declines to sort is a block // whose grains stay where they are, so slopes survive instead of collapsing // flat -- with this at zero the sand is a liquid and finds its own level. float roll = h21(bo + vec2(stepId * 7.31, stepId * 3.17)); if (roll < uFriction) return vec4(me.x, me.y, clk, me.w); // Depth along gravity. The furthest-along cell is the one sand wants. vec2 g = gravityAt(clk); float d0 = dot(vec2(-0.5, -0.5), g); float d1 = dot(vec2( 0.5, -0.5), g); float d2 = dot(vec2(-0.5, 0.5), g); float d3 = dot(vec2( 0.5, 0.5), g); float n = c0.x * f0 + c1.x * f1 + c2.x * f2 + c3.x * f3; // grains in play // This cell's own numbers, picked out without indexing anything dynamically. float dk = slot.x < 0.5 ? (slot.y < 0.5 ? d0 : d2) : (slot.y < 0.5 ? d1 : d3); float fk = slot.x < 0.5 ? (slot.y < 0.5 ? f0 : f2) : (slot.y < 0.5 ? f1 : f3); float kk = slot.x + 2.0 * slot.y; // Rank among the free cells by depth, deepest first. Ties are broken by slot // index so that all four invocations agree; with gravity exactly axis-aligned // two cells tie every time, and without a rule for it the block would // disagree with itself and grains would double or vanish. #define DEEPER(dj, fj, j) ((fj) > 0.5 && ((dj) > dk + 1e-4 || (abs((dj) - dk) < 1e-4 && (j) < kk)) ? 1.0 : 0.0) float rank = DEEPER(d0, f0, 0.0) + DEEPER(d1, f1, 1.0) + DEEPER(d2, f2, 2.0) + DEEPER(d3, f3, 3.0); float occ = (fk > 0.5 && rank < n - 0.5) ? 1.0 : 0.0; // Shade: a grain that stays keeps its own, and a cell that has just been // filled takes the shade of the shallowest grain in the block, which is the // one that fell into it. Not exact bookkeeping, but it keeps the packed bands // legible instead of averaging them into mud. float sh = me.y; if (occ > 0.5 && me.x < 0.5) { float bd = -1e9; sh = 0.5; if (c0.x * f0 > 0.5 && -d0 > bd) { bd = -d0; sh = c0.y; } if (c1.x * f1 > 0.5 && -d1 > bd) { bd = -d1; sh = c1.y; } if (c2.x * f2 > 0.5 && -d2 > bd) { bd = -d2; sh = c2.y; } if (c3.x * f3 > 0.5 && -d3 > bd) { bd = -d3; sh = c3.y; } } return vec4(occ, sh, clk, me.w); } // ---- seeding ---------------------------------------------------------------- vec4 seed(){ vec2 q = cellToSim(gCell); float inside = cavity(q); // Pack the bulb that is UP, not the one at +y. The tumble starts at a random // heading, so seeding a fixed bulb meant the run often opened with all the // sand already resting at the bottom and nothing to watch until the glass had // turned most of the way over. Seeding against gravity starts it pouring on // frame one, every time. // Pick the BULB that is up, not the half-space that is up. dot(q, -gravity) // is a slab perpendicular to gravity, and on a tilted glass that slab cuts // across both bulbs at once -- the first version opened with sand sprinkled // through both of them instead of one full bulb ready to pour. vec2 g0 = gravityAt(1.0 / 2048.0); float side = (-g0.y > 0.0) ? 1.0 : -1.0; float h = q.y * side; float occ = inside * step(0.02, h) * step(h, mix(0.10, BODY_H, uFill)); float s; if (uLayers == 0) s = 0.5; else if (uLayers == 1) s = fract(q.y * 3.2); else if (uLayers == 2) s = clamp(q.y / BODY_H, 0.0, 1.0); else if (uLayers == 3) s = step(0.5, fract(q.y * 7.0)); else s = h21(gCell * 0.77); return vec4(occ, s, 1.0 / 2048.0, 1.0); } // ---- palettes --------------------------------------------------------------- vec3 cosPal(vec3 a, vec3 b, vec3 c, vec3 d, float t){ return a + b * cos(TAU * (c * t + d)); } vec3 sandCol(float s){ s = clamp(s, 0.0, 1.0); if (uSandPal == 0) return cosPal(vec3(0.62, 0.50, 0.34), vec3(0.26, 0.22, 0.16), vec3(1.0), vec3(0.00, 0.10, 0.20), s * 0.7); if (uSandPal == 1) return cosPal(vec3(0.70, 0.48, 0.48), vec3(0.22, 0.20, 0.18), vec3(1.0), vec3(0.05, 0.20, 0.35), s * 0.7); if (uSandPal == 2) return vec3(0.44, 0.44, 0.47) + 0.16 * cos(TAU * (s * 0.7 + 0.1)); if (uSandPal == 3) return cosPal(vec3(0.64, 0.74, 0.84), vec3(0.20, 0.16, 0.14), vec3(1.0), vec3(0.55, 0.62, 0.70), s * 0.7); if (uSandPal == 4) return cosPal(vec3(0.48, 0.62, 0.36), vec3(0.32, 0.28, 0.24), vec3(1.1, 1.0, 0.8), vec3(0.15, 0.50, 0.30), s * 0.7); if (uSandPal == 5) return vec3(0.30, 0.32, 0.36) + 0.22 * cos(TAU * (s * 0.6 + vec3(0.0, 0.08, 0.16))); if (uSandPal == 6) return cosPal(vec3(0.58, 0.30, 0.16), vec3(0.38, 0.26, 0.14), vec3(1.0), vec3(0.00, 0.12, 0.24), s * 0.7); return cosPal(vec3(0.66, 0.54, 0.66), vec3(0.30, 0.34, 0.30), vec3(1.0), vec3(0.00, 0.28, 0.55), s); } // ---- ornament ------------------------------------------------------------- // The angle around the axis, recovered from how far across the radius we are. // This is the coordinate intricate work has to repeat in: spacing ornament // evenly in x leaves it evenly spaced on SCREEN, which is exactly what a decal // looks like. In the surface angle it crowds toward the silhouette on its own, // which is what a turned object actually does. float surfAngle(float x, float R){ return asin(clamp(x / max(R, 1e-4), -1.0, 1.0)); } float motifCount(){ if (uMotifs == 0) return 16.0; if (uMotifs == 1) return 24.0; if (uMotifs == 2) return 32.0; return 48.0; } // Guilloche: interlaced sine bands, the engine-turned pattern off a watch dial // or a banknote. Three phases at two frequencies is enough to read as engraved // rather than as stripes. float guilloche(float u, float v, float w){ float a = abs(v - (0.5 + 0.30 * sin(TAU * (u * 3.0)))); float b = abs(v - (0.5 + 0.30 * sin(TAU * (u * 3.0) + 0.333))); float c = abs(v - (0.5 + 0.19 * sin(TAU * (u * 5.0) + 0.5))); float d = min(min(a, b), c); return 1.0 - smoothstep(0.0, w, d); } // material colours per style: xyz = metal, and the wood tone after it vec3 metalCol(){ if (uStyle == 1) return mix(vec3(0.78, 0.80, 0.84), vec3(0.86, 0.88, 0.92), uGold * 0.3); if (uStyle == 2) return vec3(0.92, 0.72, 0.28); if (uStyle == 3) return vec3(0.55, 0.72, 0.95); if (uStyle == 5) return vec3(0.35, 0.95, 0.95); if (uStyle == 6) return vec3(0.18, 0.16, 0.14); if (uStyle == 7) return vec3(0.95, 0.55, 0.75); // real gold: a warm, fairly saturated albedo, not orange brass return mix(vec3(0.78, 0.57, 0.24), vec3(1.00, 0.80, 0.36), uGold); } vec3 woodCol(){ if (uStyle == 1) return vec3(0.62, 0.64, 0.70); if (uStyle == 2) return vec3(0.07, 0.07, 0.09); if (uStyle == 3) return vec3(0.10, 0.20, 0.38); if (uStyle == 5) return vec3(0.10, 0.06, 0.18); if (uStyle == 6) return vec3(0.93, 0.90, 0.83); if (uStyle == 7) return vec3(0.55, 0.32, 0.52); return vec3(0.26, 0.14, 0.08); } // ---- render ----------------------------------------------------------------- // The trick that makes a flat picture read as a turned object: for a solid of // revolution seen side on, how far across the radius a pixel sits IS the sine of // the surface angle, so the normal comes back for free -- nx = x/R and // nz = sqrt(1 - nx*nx). Every highlight, rim and rounded cap below is that one // identity, which is also why the sand looks like a heap and not a silhouette. vec3 revNormal(float x, float R){ float nx = clamp(x / max(R, 1e-4), -1.0, 1.0); return vec3(nx, 0.0, sqrt(max(1.0 - nx * nx, 0.0))); } // The same thing done properly. For a surface of revolution r = R(y) the outward // normal at azimuth phi is (cos phi, -R'(y), sin phi), and recovering cos phi // from x/R is only half of it. The -R'(y) term matters just as much: without it // the normal has no vertical component anywhere, so nothing shades along the // length of the object and metal comes out as flat mustard whatever you do to // the highlight. This is the version everything below uses. vec3 revN(float x, float R, float dR){ float nx = clamp(x / max(R, 1e-4), -1.0, 1.0); float nz = sqrt(max(1.0 - nx * nx, 0.0)); return normalize(vec3(nx, -dR, nz)); } // Metals have no diffuse term, and that is the whole reason gold is hard. What // makes it read as gold is the environment it reflects -- a bright sky band, a // dark floor, a small hot key -- not Lambert shading on a yellow albedo, which // can only ever look like mustard plastic. vec3 metalShade(vec3 n, vec3 base, float rough, vec3 key){ vec3 r = reflect(vec3(0.0, 0.0, -1.0), n); vec3 env = mix(vec3(0.020, 0.016, 0.012), vec3(1.00, 0.94, 0.76), smoothstep(-0.50, 0.80, r.y)); env = mix(env, vec3(0.42, 0.30, 0.18), 0.45 * smoothstep(0.15, -0.75, r.y)); float k = max(dot(r, key), 0.0); env += vec3(1.00, 0.97, 0.90) * pow(k, mix(260.0, 26.0, rough)) * 1.7; vec3 c = base * env; c += vec3(1.0) * pow(k, 800.0) * 1.1; // the specular pin c *= 0.86 + 0.50 * pow(1.0 - max(n.z, 0.0), 3.0); // grazing brighten return c; } void main(){ setup(); // A sampler cannot be chosen by a ternary in GLSL ES 1.00 -- it is not a value // -- so each step names its source explicitly. if (PASSINDEX == 0) { vec4 prev = IMG_NORM_PIXEL(b3, (gCell + 0.5) * gCU); if (prev.w < 0.5 || uReset) { gl_FragColor = seed(); return; } float nc = fract(prev.z + 1.0 / 2048.0); // the clock ticks once a frame vec4 o = step_(b3, 0.0, floor(nc * 2048.0) * 4.0, nc); o.z = nc; gl_FragColor = o; return; } if (PASSINDEX == 1) { float clk = IMG_NORM_PIXEL(b0, (gCell + 0.5) * gCU).z; gl_FragColor = step_(b0, 1.0, floor(clk * 2048.0) * 4.0 + 1.0, clk); return; } if (PASSINDEX == 2) { float clk = IMG_NORM_PIXEL(b1, (gCell + 0.5) * gCU).z; gl_FragColor = step_(b1, 0.0, floor(clk * 2048.0) * 4.0 + 2.0, clk); return; } if (PASSINDEX == 3) { float clk = IMG_NORM_PIXEL(b2, (gCell + 0.5) * gCU).z; gl_FragColor = step_(b2, 1.0, floor(clk * 2048.0) * 4.0 + 3.0, clk); return; } // ------------------------------------------------------------------ render float clk = IMG_NORM_PIXEL(b3, vec2(0.5)).z; float th = angleAt(clk); vec2 scr = (gUV - 0.5) * 2.0 * vec2(gAsp, 1.0); vec2 q = rot(-th) * scr / uZoom; // into the hourglass's own frame float R = profile(q.y); float ax = abs(q.x), ay = abs(q.y); vec3 L = normalize(vec3(-0.45, 0.65, 0.62)); // backdrop: a soft studio sweep, level with the world rather than the glass vec3 col = mix(vec3(0.055, 0.050, 0.058), vec3(0.012, 0.011, 0.016), smoothstep(-0.9, 1.1, scr.y)); col += vec3(0.020, 0.017, 0.014) * pow(max(0.0, 1.0 - length(scr * vec2(0.7, 0.9))), 3.0); // The two drawn styles are not lit scenes, they are marks on a ground, so they // need their own ground: a cyanotype is white line on blue, an engraving is // dark line on cream. On the studio sweep both lost the sand entirely -- // blue on blue and grey on grey. vec3 inkC = vec3(1.0); if (uStyle == 3) { col = vec3(0.055, 0.125, 0.295); inkC = vec3(0.88, 0.94, 1.00); float gr = min(abs(fract(scr.x * 12.0) - 0.5), abs(fract(scr.y * 12.0) - 0.5)); col += vec3(0.02, 0.05, 0.09) * (1.0 - smoothstep(0.0, 0.03, gr)); } if (uStyle == 6) { inkC = vec3(0.13, 0.11, 0.09); col = vec3(0.905, 0.875, 0.805) - 0.05 * h21(floor(gl_FragCoord.xy * vec2(0.6, 0.2))) - 0.04 * h21(floor(gl_FragCoord.xy * vec2(0.2, 0.6))); } vec3 met = metalCol(), wud = woodCol(); bool lineArt = (uStyle == 3 || uStyle == 6); // ---- three columns, standing behind the glass -------------------------- if (uStyle != 8) { for (int i = 0; i < 3; i++){ float px = (float(i) - 1.0) * 0.455; float back = (i == 1) ? 1.0 : 0.0; // the middle post is the far one float w = (0.0185 + 0.0090 * uOrnate * abs(sin(q.y * 20.0))) * (1.0 - 0.22 * back); float d = q.x - px; if (abs(d) < w && ay < CAP_H) { vec3 n = revN(d, w, 0.0); float hel = sin(q.y * 24.0 + surfAngle(d, w) * 2.0); n.x += 0.40 * hel * uOrnate; // the helical twist n = normalize(n); vec3 KEYc = normalize(vec3(-0.42, 0.66, 0.62)); vec3 c = metalShade(n, mix(wud * 1.6, met, 0.70 + 0.30 * uGold), 0.26, KEYc); c *= 0.60 + 0.40 * smoothstep(0.0, 0.3, 1.0 - ay / CAP_H); c *= 1.0 - 0.45 * back; if (uStyle == 3 || uStyle == 6) c = mix(col, inkC, 0.55 - 0.30 * back); col = mix(col, c, 0.92); } } } // ---- the sand ---------------------------------------------------------- float inCav = cavity(q); float grain = 0.0, shade = 0.5; if (inCav > 0.5) { // A heap is not a set of grains, it is a region with grains in it. Drawing // one cell per grain gives a 50% packing fraction, which reads as scattered // confetti no matter how small the cells are -- making the grains finer made // it worse, not better. So the mass comes from the local DENSITY over a // three-by-three neighbourhood, and the individual cells are kept only as // texture inside it. Nothing here touches the simulation or the count. vec2 cell = floor(simToUV(q) / gCU); vec4 me = IMG_NORM_PIXEL(b3, (cell + 0.5) * gCU); float dens = 0.0, shSum = 0.0; for (int j = -1; j <= 1; j++){ for (int i = -1; i <= 1; i++){ vec4 v = IMG_NORM_PIXEL(b3, (cell + vec2(float(i) + 0.5, float(j) + 0.5)) * gCU); dens += v.x; shSum += v.x * v.y; } } grain = smoothstep(0.16, 0.52, dens / 9.0); // a softer edge on the heap shade = (dens > 0.5) ? shSum / dens : me.y; // the grain itself, as surface texture rather than as the shape grain *= 0.80 + 0.20 * me.x; } if (grain > 0.02) { vec3 n = revN(q.x, max(R - WALL, 1e-3), dProfile(q.y)); float dif = max(dot(n, L), 0.0); vec3 c = sandCol(shade); // Sand is bright and it bounces light into itself, so it wants a high floor // rather than a lit-from-one-side falloff -- at a low ambient the heap goes // to mud on whichever side is turned away. c *= 0.52 + 0.80 * dif; c += sandCol(shade) * 0.22 * pow(max(1.0 - abs(n.z), 0.0), 2.0); // grains catch the light individually vec2 cell = floor(simToUV(q) / gCU); float sp = h21(cell * 1.37); c += vec3(1.0, 0.96, 0.88) * pow(sp, 14.0) * uSparkle * (0.5 + 0.5 * dif); // darker where the heap meets the glass c *= 1.0 - 0.35 * smoothstep(0.55, 1.0, ax / max(R - WALL, 1e-3)); if (uStyle == 4) c = vec3(0.75, 0.85, 1.0) * (0.25 + 0.6 * dif); if (lineArt) { // stipple: denser marks where it is in shadow, which is how an engraver // renders a tone with one colour of ink float tone = 0.30 + 0.62 * (1.0 - dif); float dot_ = step(h21(cell * 2.11), tone); c = mix(col, inkC, dot_ * 0.92); } if (uStyle == 9) c = vec3(shade, 1.0 - shade, 0.6); col = mix(col, c, clamp(grain * 1.35, 0.0, 1.0)); } else if (inCav > 0.5) { // Empty air behind glass. What sells it is not the tint but the FAR wall: // near the silhouette you are looking through the glass almost edge-on, so // it piles up into a bright band on both sides. Plus one off-centre specular // streak, which is the single cue that reads as "curved glass". float u = ax / max(R - WALL, 1e-3); vec3 n = revN(q.x, max(R - WALL, 1e-3), dProfile(q.y)); vec3 c = vec3(0.055, 0.075, 0.090) * (0.55 + 0.85 * (1.0 - u * u)); c += vec3(0.34, 0.44, 0.54) * smoothstep(0.70, 1.0, u) * 0.75; float streak = exp(-pow((q.x / max(R - WALL, 1e-3) + 0.48) * 5.0, 2.0)); c += vec3(0.75, 0.86, 1.00) * streak * 0.26; c += vec3(0.20, 0.26, 0.32) * pow(max(dot(n, L), 0.0), 6.0) * 0.5; // A drawn style has no lit interior: the bulb is bare ground with a hint of // tone toward the walls, or the dark glass fights the paper it sits on. if (lineArt) c = mix(col, inkC, 0.06 + 0.20 * smoothstep(0.72, 1.0, u)); col = mix(col, c, 0.82 + 0.18 * uGlass); } // ---- the glass wall ---------------------------------------------------- float wallBand = step(ax, R) * step(R - WALL, ax) * step(ay, BODY_H); if (uStyle != 8 && uStyle != 9 && wallBand > 0.5) { vec3 n = revN(q.x, R, dProfile(q.y)); float dif = max(dot(n, L), 0.0); float fres = pow(1.0 - n.z, 3.0); vec3 c = vec3(0.10, 0.13, 0.15) * (0.35 + 0.75 * dif); // body of the glass c += vec3(0.85, 0.95, 1.00) * pow(dif, 30.0) * 1.8; // the hot streak c += vec3(0.40, 0.52, 0.62) * pow(dif, 4.0) * 0.55; // broad sheen c += vec3(0.55, 0.70, 0.80) * fres * 1.1; // rim c += met * 0.08; if (lineArt) c = mix(col, inkC, 0.25 + 0.70 * fres); col = mix(col, c, 0.35 + 0.65 * uGlass); } // a gilded fillet clasping the glass where it enters the cap if (uStyle != 8 && uStyle != 9) { float band = smoothstep(0.045, 0.012, abs(ay - (BODY_H - 0.012))); if (band > 0.01 && ax < profile(q.y) + 0.02) { vec3 n = revN(q.x, profile(q.y) + 0.02, dProfile(q.y)); vec3 c = metalShade(n, met, 0.22, normalize(vec3(-0.42, 0.66, 0.62))); if (lineArt) c = mix(col, inkC, 0.45); col = mix(col, c, band); } } // a bright seam right at the silhouette, which is what sells glass if (uStyle != 8 && uStyle != 9) { float edge = 1.0 - smoothstep(0.0, 0.006, abs(ax - R)); col = mix(col, lineArt ? inkC : vec3(0.6, 0.75, 0.9), edge * step(ay, BODY_H) * (0.35 + 0.65 * uGlass) * (lineArt ? 1.0 : 0.85)); } // ---- the fittings ------------------------------------------------------ // One vocabulary for all of them: a slim fluted gold ring hugging the glass. // The first version made the caps a separate thing -- nine turned features on // a slab wider than the vessel -- and it read as a completely different object // bolted on. The collar at the waist was the part that worked, so the caps are // now the same ring at a different place. vec3 KEY = normalize(vec3(-0.42, 0.66, 0.62)); // a narrow fluted ring: flutes perturb the NORMAL only, never the silhouette, // because a turned ring has a clean circular outline no matter how deeply it // is fluted, and wobbling the outline is what made the caps look chewed. float ringN = motifCount(); if (uStyle != 8 && uStyle != 9 && ay < 0.056) { float cr = profile(q.y) + 0.019; if (ax < cr) { float t = ay / 0.056; float bulge = 1.0 - 0.55 * abs(t - 0.5) * 2.0; vec3 n = revN(q.x, cr, dProfile(q.y) * 0.5); float fu = fract(surfAngle(q.x, cr) * ringN / TAU); n.x += 0.34 * sin(fu * TAU) * uOrnate * bulge; n = normalize(n); vec3 c = metalShade(n, met, 0.25, KEY); c *= 0.55 + 0.55 * bulge; // fillets at both edges if (lineArt) c = mix(col, inkC, 0.30 + 0.45 * (1.0 - max(n.z, 0.0))); col = c; } } if (uStyle != 8 && uStyle != 9 && ay >= BODY_H - 0.015 && ay <= CAP_H + 0.05) { float t = (ay - (BODY_H - 0.015)) / (CAP_H - BODY_H + 0.015); if (t > 1.0) { // a small turned finial float ft = (ay - CAP_H) / 0.05; float fr = 0.085 * sqrt(max(1.0 - ft * ft, 0.0)) + 0.026; if (ax < fr) { vec3 n = revN(q.x, fr, ft * 1.6); vec3 c = metalShade(n, met, 0.18, KEY); if (lineArt) c = mix(col, inkC, 0.25 + 0.5 * (1.0 - max(n.z, 0.0))); col = c; } } else { // four features, not nine: a fluted collar, a recessed engine-turned // band, a thin end plate and a rounded lip. Four fit in the height that // is actually available. float bRing = smoothstep(0.04, 0.13, t) * (1.0 - smoothstep(0.26, 0.36, t)); float bBand = smoothstep(0.40, 0.46, t) * (1.0 - smoothstep(0.60, 0.66, t)); float bPlate = smoothstep(0.68, 0.77, t) * (1.0 - smoothstep(0.90, 0.98, t)); float capR = 0.40 + 0.048 * bRing - 0.014 * bBand + 0.100 * bPlate - 0.300 * smoothstep(0.93, 1.0, t); if (ax < capR) { float th = surfAngle(q.x, capR); float dR = (0.048 * (smoothstep(0.04, 0.13, t + 0.01) - smoothstep(0.04, 0.13, t - 0.01)) + 0.100 * (smoothstep(0.68, 0.77, t + 0.01) - smoothstep(0.68, 0.77, t - 0.01)) - 0.300 * (smoothstep(0.93, 1.0, t + 0.01) - smoothstep(0.93, 1.0, t - 0.01))) / 0.02 / (CAP_H - BODY_H + 0.015); vec3 n = revN(q.x, capR, dR * sign(q.y)); float fu = fract(th * ringN / TAU); n.x += 0.34 * sin(fu * TAU) * uOrnate * bRing; // flutes on the ring n.x += 0.16 * sin(fu * TAU * 2.0) * uOrnate * bPlate; // finer on the plate n = normalize(n); vec3 c = metalShade(n, met, 0.20 + 0.12 * bBand, KEY); c *= 0.95 + 0.09 * h21(floor(vec2(th * 70.0, t * 40.0))); // leaf grain c *= 1.0 - 0.045 * abs(sin(t * 240.0)); // turning marks // the engine-turned band, cut into the recess where it belongs float v = clamp((t - 0.42) / 0.18, 0.0, 1.0); float gl = guilloche(th * ringN / TAU * 0.5, v, 0.07) * bBand * uGuilloche; c *= 1.0 - 0.26 * gl; c += met * gl * 0.30; c *= 1.0 - 0.30 * smoothstep(0.93, 1.0, t); c = mix(c, c * 0.55 + wud * 0.30, (1.0 - uGold) * 0.7 * bBand); // stones, off by default: a ring of them read as sprinkled confetti // rather than as settings, so they are opt-in now and much smaller. float M = max(6.0, floor(ringN * 0.5)); float su = fract(th * M / TAU) - 0.5; float sv = (t - 0.800) / 0.040; float sr = length(vec2(su * 11.0, sv)); if (uGems > 0.01 && sr < 1.0) { float hh = h11(floor(th * M / TAU + 0.5) * 3.7 + 1.3); vec3 gemc = hh < 0.34 ? vec3(0.70, 0.05, 0.13) : (hh < 0.67 ? vec3(0.04, 0.40, 0.19) : vec3(0.07, 0.16, 0.62)); float dome = sqrt(max(1.0 - sr * sr, 0.0)); vec3 gn = normalize(vec3(su * 11.0, sv, dome * 1.2)); float gd = max(dot(gn, KEY), 0.0); vec3 gc = gemc * (0.25 + 1.35 * gd * gd) + vec3(1.0) * pow(gd, 90.0) * 1.5; c = mix(c, gc, smoothstep(1.0, 0.75, sr) * uGems); } if (lineArt) { float tone = 0.20 + 0.55 * (1.0 - max(n.z, 0.0)) + 0.45 * gl; c = mix(col, inkC, clamp(tone, 0.0, 1.0) * 0.9); } col = c; } } } // ---- finish ------------------------------------------------------------ col *= uGain; col = col / (1.0 + col * 0.25); col = max(col, 0.0); if (uGamma != 1.0) col = pow(col, vec3(1.0 / uGamma)); float fg = h21(floor(gl_FragCoord.xy) + floor(clk * 2048.0) * 3.7); col += (fg - 0.5) * uFilm * 0.35; vec2 v = (gUV - 0.5) * vec2(gAsp, 1.0); col *= 1.0 - uVign * smoothstep(0.12, 0.85, dot(v, v) * 1.7); gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0); }
forking #32dae86u — lineage will be recorded.
renders in YOUR browser. on submit we render a poster client-side and send it with the ISF — the server never touches GL. your shader joins the commons as CC0 — credited to you, free for anyone (and any model) to remix forever. that's the whole point. it's permanent: once it's public, CC0 has no take-backs. by submitting you attest you have the right to release it as CC0.