// Geometric family: a field of extruded blocks seen isometrically, rising and // falling on the low end. A solid, lit surface with a light direction — three // faces per block, and the whole grid reads as a landscape rather than a chart. // // Floating Geometry is bodies adrift in space with nothing under them. This is // the opposite proposition: everything is on one grid, everything touches the // ground, and the only freedom any block has is its height. The plan of each // block is the track's signature form, so a hexagonal video gets a honeycomb of // columns rather than a chessboard of them. export const isometricBlocks = { name: 'Isometric Blocks', family: 'geometric', kind: 'fragment', texture: 0.6, consumes: ['cast', 'ink'], traits: ['shape', 'camera', 'style'], params: { grid: { type: 'float', range: [2, 10], default: 4.5, uniform: 'u_grid', bias: 'density', slowAxis: true }, height: { type: 'float', range: [0.1, 0.7], default: 0.35, uniform: 'u_height', bias: 'energy' }, // Displacement: how far each block wanders from its slot on the grid. // // Every block sat exactly on the lattice, so the plan of the field was // the same plan in every song and only the heights moved — which is // what the gallery was reporting as a layout distance of 0.005, near // enough to nothing. Displacing them per cell puts the SONG in the // arrangement rather than only in the elevation. scatter: { type: 'float', range: [0, 0.85], default: 0.3, uniform: 'u_scatter', bias: 'density' }, // Extra disturbance AT the song's focal points, on top of the scatter // everywhere — not instead of it. // // It was a trade at first, spending the scatter budget near the focus // and calming the rest, and that measured worse than no focus at all: // 0.049 down to 0.037. Most of the field went back onto the rigid // lattice and took the orientation variety with it, 0.164 to 0.105. The // focal point has to be something the field gains, not something the // rest of it pays for. gather: { type: 'float', range: [0, 1.6], default: 0.8, uniform: 'u_gather' }, plan: { type: 'float', range: [0.2, 0.5], default: 0.4, uniform: 'u_plan' }, tilt: { type: 'float', range: [0.35, 0.75], default: 0.55, uniform: 'u_isoTilt' }, light: { type: 'float', range: [0, 1.4], default: 0.65, uniform: 'u_light' }, wave: { type: 'float', range: [0, 1], default: 0.5, uniform: 'u_wave' }, roll: { type: 'float', range: [0.01, 0.08], default: 0.035, uniform: 'u_roll', bias: 'motion', rate: true }, palette: { type: 'palette', count: 5 }, }, // The low end deliberately does NOT drive the block heights. Measured at // aggressive settings that read as 4 flashes a second: every tower in the // frame grows and shrinks together, which is a whole-frame luminance cycle // on the kick and exactly what the flash ceiling exists to stop. The beat // lands on a quarter of the tops instead — same reading, local swing. reactive: { light: { feature: 'loudness', amount: 0.25, response: 'smooth' }, }, shader: ` // How tall the block at this cell stands, in screen units. A travelling wave // plus a fixed per-cell character, so the field has a rhythm running through it // and a skyline that is still recognisable between beats. float columnHeight(vec2 cell, float t) { float own = hash12(cell + u_seed); float wave = sin(dot(cell, vec2(0.7, 0.5)) - t * 1.1) * 0.5 + 0.5; // Squared, so most of the field lies flat and the tall ones stand out as // individual towers. A linear height makes every column tall enough to // occlude its neighbours, and the field turns into vertical stripes. return u_height * pow(mix(own, wave, u_wave), 2.4); } vec4 scene(vec2 uv, vec2 p) { float t = u_time * u_roll + u_seed; p = sigCamera(p); float tilt = max(u_isoTilt, 0.1); float cw = 1.0 / u_grid; // one cell across, on screen vec3 col = mix(pal(0) * 0.06, pal(1) * 0.14, sat(p.y * 0.4 + 0.5)); // A block standing on row r covers screen rows ABOVE r's ground line, so a // pixel can only belong to a row at or behind it. Walking a fixed number of // rows from the back forward and overwriting as we go is the painter's // algorithm, and it is the whole of the depth sorting an isometric field // needs — no sorting, no z buffer, just the order of the loop. float colX = floor(p.x / cw); float fx = (p.x / cw - colX - 0.5) * cw; // screen offset within the column // Alternate columns are staggered half a row, which is what interlocks the // field into a lattice instead of leaving it a set of independent stacks. float stagger = mod(colX, 2.0) * 0.5; float rowHere = floor(p.y / (tilt * cw) - stagger); // Rows FURTHER AWAY stand higher up the frame, so a pixel can only belong // to a block whose ground line is at or below it — row <= rowHere. Counting // down from there draws the far ones first and lets the near ones paint // over them. // lint: fixed-cost — eight rows of depth, back to front for (int j = 7; j >= 0; j--) { float row = rowHere - float(7 - j); vec2 cell = vec2(colX, row); // Where this block would stand if nothing disturbed it, so the focus can // be asked about the cell rather than about the pixel. float restY = (row + stagger) * tilt * cw; float focus = focusField(vec2((colX + 0.5) * cw - 0.5, restY)); // Each cell steps off its slot by a fixed amount of its own, and how far // depends on how close it is to what the song is looking at. Spread // evenly this is noise on a grid; concentrated, the lattice stands // except where it is disturbed, and the field acquires a subject. // // Bounded under half a cell: past that blocks cross each other and the // isometric read — which is the whole point of the scene — breaks down. float amount = u_scatter * (1.0 + focus * u_gather * 2.0); vec2 offset = (hash22(cell + u_seed + 3.7) - 0.5) * amount * cw * 0.9; float groundY = restY + offset.y; // where this cell meets the ground // Blocks rise toward the focus as well as scattering around it, so the // disturbance is something you can see in the skyline rather than only // in the plan — and rising changes WHERE the frame's energy is, which // scattering alone did not. float h = columnHeight(cell, t) * (1.0 + offset.x * 2.0) * (1.0 + focus * u_gather * u_focusPull * 1.3); float dy = p.y - groundY; // height up the block face // Side wall: the plan swept up from the ground line to the top face. float inX = smoothstep(u_plan * cw, u_plan * cw * 0.85, abs(fx - offset.x)); float wall = inX * step(0.0, dy) * step(dy, h); float lit = 0.5 + 0.5 * sat(fx / (u_plan * cw) * 0.8 + 0.5); // The wall darkens toward the ground, which is the only shading cue // that tells a tall block from a short one at a glance. // Neither face takes the loudness. Anything that multiplies a face // colour multiplies most of the frame, and a field of blocks brightening // together on the kick measured at 5 flashes a second. The light level // is expressed on the EDGES, which are a few percent of the image. vec3 side = mix(pal(0) * 0.5, pal(2) * 0.8, lit) * 0.75; side *= 0.45 + 0.55 * sat(dy / max(h, 1e-3)); col = mix(col, side, wall); // Top face: the track's signature form, lying flat, lifted by h and // squashed by the tilt — so every block in the field is the same // silhouette as every other subject in the video. vec2 q = vec2(fx, (p.y - groundY - h) / tilt); float dTop = castMain(q / (u_plan * cw)) * (u_plan * cw); float topMask = smoothstep(0.004, -0.004, dTop); vec3 top = mix(pal(2), pal(3), sat(h / max(u_height, 1e-3))) * 0.8; col = mix(col, top, topMask); // A quarter of the blocks answer the beat, chosen by cell rather than // by anything global, so the field has a pulse without the frame having // a flash. float picked = step(0.75, hash12(cell * 3.17 + 5.0)); col += pal(3) * topMask * picked * u_beat * 0.45; col += pal(4) * inkStroke(dTop) * (0.2 + u_light * 1.2) * step(0.0, dy); } col *= 0.75 + 0.25 * exp(-dot(p, p) * 0.3); return vec4(inkValue(col), 1.0); } `, }; export default isometricBlocks;