• 01 - Critter Models 02
  • 02 - VantaJS Shaders
  • 03 - App Bundle 02
  • 04 - Cathedrals
  • 05 - App Bundle 01
  • 06 - Retroscopic
  • 07 - Games
  • 08 - Voronoi
  • 09 - Terrain
  • 10 - Critter Models 01
  • 11 - Chromatophores
  • 12 - Conformal Maps
  • 13 - Echo Logix
  • 14 - Music
  • 15 - Colour Theory
MIKE'S NOTEBOOK
Notes
Number:
Project:
01Critter Models 02
└ Locust└ Butterfly└ Cockroach└ Spider└ Scorpion└ Dragon└ Brazilian Treehopper└ Tardigrade└ Diatoms└ Bumblebee└ Wasp└ Hammerhead Worm└ Giraffe Weevil└ Trilobite Beetle
02VantaJS Shaders
└ Clouds└ Waves└ Cells└ Rings└ Halo└ Globe└ Net└ Dots└ Birds└ Trunk└ Topology└ About VantaJS
03App Bundle 02
└ Gutindex└ Insect Builder└ OpenLayers
04Cathedrals
└ Lattice└ Lost└ Lucent└ Lumen└ Glass└ Disco└ Hyperion└ Latent
05App Bundle 01
└ Robohash└ PrismJS└ Horoscope└ Binary Jazz└ Morse Code└ URL Extractor└ Entity Escaper└ Phonetic Alphabet└ Toki Pona└ Semaphore└ Cut Up Machine└ Text Mixer└ Currency Freaks└ Host IO└ CrUX API└ Logo Dev└ Web Speech API└ GEO DB
06Retroscopic
└ Galaxy└ Ocean└ Slime└ 24-Cell└ Unicorn└ Polyp└ Oneiric 1└ Oneiric 2└ Node└ Torus Knot└ GLSL Grid└ Liquid Crystal└ Silky└ Butterfly└ Ocean 2└ Icosahedron└ Luma└ Swarm└ Broken Solid└ Hypersphere└ Constraints└ Psycho Killer
01 / 03

24 CELL - RETROSCOPIC CRT SHADER SOURCE CODE

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>24-Cell</title>
<style>
  html, body { margin:0; padding:0; overflow:hidden; background:#000904; height:100%; width:100%; cursor: crosshair; }
  canvas { display:block; width:100vw; height:100vh; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
const canvas = document.getElementById('c');
const gl = canvas.getContext('webgl2', { antialias:false, depth:false, stencil:false, alpha:false, powerPreference:'high-performance' });
if (!gl) { document.body.innerHTML = '<div style="color:#fff;font-family:sans-serif;padding:40px;">WebGL2 is required for this piece.</div>'; }

// Rendering to a floating-point framebuffer (needed for the trail buffer)
// requires this extension in WebGL2. Fall back to an 8-bit target if it's
// not available, rather than silently failing to draw anything.
let HAS_FLOAT_FBO = false;
if (gl) {
  HAS_FLOAT_FBO = !!gl.getExtension('EXT_color_buffer_float');
}

// Internal render scale is kept low (independent of display size) — this is
// the single biggest lever for performance, since every pixel runs a raymarch.
const RENDER_SCALE = 0.42;

let rw = 1, rh = 1; // internal (low-res) render target size

function resize(){
  const dpr = Math.min(window.devicePixelRatio || 1, 1.0);
  const w = Math.max(1, Math.floor(window.innerWidth * dpr));
  const h = Math.max(1, Math.floor(window.innerHeight * dpr));
  if (canvas.width !== w || canvas.height !== h) {
    canvas.width = w; canvas.height = h;
  }
  const nrw = Math.max(1, Math.floor(w * RENDER_SCALE));
  const nrh = Math.max(1, Math.floor(h * RENDER_SCALE));
  if (nrw !== rw || nrh !== rh){
    rw = nrw; rh = nrh;
    rebuildTargets();
  }
}

const vsSrc = `#version 300 es
precision highp float;
const vec2 verts[3] = vec2[3](vec2(-1.0,-1.0), vec2(3.0,-1.0), vec2(-1.0,3.0));
out vec2 vUv;
void main(){
  vec2 p = verts[gl_VertexID];
  vUv = p;
  gl_Position = vec4(p, 0.0, 1.0);
}`;

// ---------------------------------------------------------------------------
// PASS 1: raymarch the 24-cell + blend with the previous frame using a
// max-decay "phosphor" trail. This is cheap and numerically stable (unlike
// additive accumulation) because old trail energy can only ever shrink.
// ---------------------------------------------------------------------------
const fsMainSrc = `#version 300 es
precision highp float;
in vec2 vUv;
out vec4 fragColor;

uniform vec2 uRes;
uniform float uTime;
uniform vec2 uMouse;
uniform vec2 uMouseVel;
uniform float uBloomState;
uniform float uClickEnergy[8];
uniform vec2 uClickPos[8];
uniform float uClickTime[8];
uniform sampler2D uPrevFrame;
uniform float uTrailDecay;

#define PI 3.14159265359
#define TAU 6.28318530718

float hash21(vec2 p){
  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
  p3 += dot(p3, p3.yzx + 33.33);
  return fract((p3.x + p3.y) * p3.z);
}

mat2 rot2(float a){ float s=sin(a), c=cos(a); return mat2(c,-s,s,c); }
mat3 rotAxis(vec3 axis, float angle){
  axis = normalize(axis);
  float s = sin(angle), c = cos(angle), oc = 1.0-c;
  return mat3(
    oc*axis.x*axis.x+c,        oc*axis.x*axis.y-axis.z*s, oc*axis.z*axis.x+axis.y*s,
    oc*axis.x*axis.y+axis.z*s, oc*axis.y*axis.y+c,        oc*axis.y*axis.z-axis.x*s,
    oc*axis.z*axis.x-axis.y*s, oc*axis.y*axis.z+axis.x*s, oc*axis.z*axis.z+c
  );
}

vec3 closestPt; // nearest point on the nearest edge — filled in by sdCapsule,
                 // reused later for a cheap analytic normal (avoids extra SDF taps)

float sdCapsule(vec3 p, vec3 a, vec3 b, float r, bool track){
  vec3 pa = p-a, ba = b-a;
  float h = clamp(dot(pa,ba)/dot(ba,ba),0.0,1.0);
  vec3 closest = a + ba*h;
  float dd = length(p - closest) - r;
  if (track) closestPt = closest;
  return dd;
}

// ---------- 24-cell (icositetrachoron, a regular 4D polytope) machinery ----------
// Vertices: all permutations of (±1, ±1, 0, 0) — 24 of them, each with
// squared length 2. This is the standard "permutohedral" construction.
const vec4 CELL_V[24] = vec4[24](
  vec4( 1.0, 1.0, 0.0, 0.0), vec4( 1.0,-1.0, 0.0, 0.0),
  vec4(-1.0, 1.0, 0.0, 0.0), vec4(-1.0,-1.0, 0.0, 0.0),
  vec4( 1.0, 0.0, 1.0, 0.0), vec4( 1.0, 0.0,-1.0, 0.0),
  vec4(-1.0, 0.0, 1.0, 0.0), vec4(-1.0, 0.0,-1.0, 0.0),
  vec4( 1.0, 0.0, 0.0, 1.0), vec4( 1.0, 0.0, 0.0,-1.0),
  vec4(-1.0, 0.0, 0.0, 1.0), vec4(-1.0, 0.0, 0.0,-1.0),
  vec4( 0.0, 1.0, 1.0, 0.0), vec4( 0.0, 1.0,-1.0, 0.0),
  vec4( 0.0,-1.0, 1.0, 0.0), vec4( 0.0,-1.0,-1.0, 0.0),
  vec4( 0.0, 1.0, 0.0, 1.0), vec4( 0.0, 1.0, 0.0,-1.0),
  vec4( 0.0,-1.0, 0.0, 1.0), vec4( 0.0,-1.0, 0.0,-1.0),
  vec4( 0.0, 0.0, 1.0, 1.0), vec4( 0.0, 0.0, 1.0,-1.0),
  vec4( 0.0, 0.0,-1.0, 1.0), vec4( 0.0, 0.0,-1.0,-1.0)
);
// Edges connect vertices at squared-distance 2 apart — every vertex has
// degree 8, giving the 24-cell's 96 edges total.
const ivec2 CELL_E[96] = ivec2[96](
  ivec2(0,4), ivec2(0,5), ivec2(0,8), ivec2(0,9), ivec2(0,12), ivec2(0,13), ivec2(0,16), ivec2(0,17),
  ivec2(1,4), ivec2(1,5), ivec2(1,8), ivec2(1,9), ivec2(1,14), ivec2(1,15), ivec2(1,18), ivec2(1,19),
  ivec2(2,6), ivec2(2,7), ivec2(2,10), ivec2(2,11), ivec2(2,12), ivec2(2,13), ivec2(2,16), ivec2(2,17),
  ivec2(3,6), ivec2(3,7), ivec2(3,10), ivec2(3,11), ivec2(3,14), ivec2(3,15), ivec2(3,18), ivec2(3,19),
  ivec2(4,8), ivec2(4,9), ivec2(4,12), ivec2(4,14), ivec2(4,20), ivec2(4,21),
  ivec2(5,8), ivec2(5,9), ivec2(5,13), ivec2(5,15), ivec2(5,22), ivec2(5,23),
  ivec2(6,10), ivec2(6,11), ivec2(6,12), ivec2(6,14), ivec2(6,20), ivec2(6,21),
  ivec2(7,10), ivec2(7,11), ivec2(7,13), ivec2(7,15), ivec2(7,22), ivec2(7,23),
  ivec2(8,16), ivec2(8,18), ivec2(8,20), ivec2(8,22),
  ivec2(9,17), ivec2(9,19), ivec2(9,21), ivec2(9,23),
  ivec2(10,16), ivec2(10,18), ivec2(10,20), ivec2(10,22),
  ivec2(11,17), ivec2(11,19), ivec2(11,21), ivec2(11,23),
  ivec2(12,16), ivec2(12,17), ivec2(12,20), ivec2(12,21),
  ivec2(13,16), ivec2(13,17), ivec2(13,22), ivec2(13,23),
  ivec2(14,18), ivec2(14,19), ivec2(14,20), ivec2(14,21),
  ivec2(15,18), ivec2(15,19), ivec2(15,22), ivec2(15,23),
  ivec2(16,20), ivec2(16,22), ivec2(17,21), ivec2(17,23),
  ivec2(18,20), ivec2(18,22), ivec2(19,21), ivec2(19,23)
);

vec4 rot4(vec4 v, int a, int b, float ang){
  float s = sin(ang), c = cos(ang);
  float va = (a==0?v.x:(a==1?v.y:(a==2?v.z:v.w)));
  float vb = (b==0?v.x:(b==1?v.y:(b==2?v.z:v.w)));
  float na = va*c - vb*s;
  float nb = va*s + vb*c;
  if (a==0) v.x=na; else if (a==1) v.y=na; else if (a==2) v.z=na; else v.w=na;
  if (b==0) v.x=nb; else if (b==1) v.y=nb; else if (b==2) v.z=nb; else v.w=nb;
  return v;
}

vec3 CELL_VERTS[24];
int edgeHit; // which edge the closest surface point belongs to (for color)

void cell24Project(float size, float t, float wake){
  for(int i=0;i<24;i++){
    vec4 v = CELL_V[i] * size;
    v = rot4(v, 0,3, t*0.22);
    v = rot4(v, 1,3, t*0.15 + wake*0.5);
    v = rot4(v, 2,3, t*0.11);
    v = rot4(v, 0,1, t*0.06);
    float wPersp = 2.6;
    float fac = wPersp / max(wPersp - v.w, 0.6);
    CELL_VERTS[i] = v.xyz * fac;
  }
}

// hard-min union of capsule edges — cheaper than smooth-min and gives
// crisp, precisely defined line geometry instead of a melted blob.
float cell24SDF(vec3 p, float radius){
  float d = 1e5;
  int bestEdge = 0;
  for(int e=0;e<96;e++){
    ivec2 ed = CELL_E[e];
    float seg = sdCapsule(p, CELL_VERTS[ed.x], CELL_VERTS[ed.y], radius, false);
    if (seg < d){ d = seg; bestEdge = e; }
  }
  edgeHit = bestEdge;
  // one extra cheap call to recover the closest point, only for the winner —
  // far cheaper than tracking/overwriting it on every one of the 96 candidates
  ivec2 ed = CELL_E[bestEdge];
  sdCapsule(p, CELL_VERTS[ed.x], CELL_VERTS[ed.y], radius, true);
  return d;
}

vec3 clickField(vec3 p, float t){
  vec3 disp = vec3(0.0);
  for(int i=0;i<8;i++){
    float e = uClickEnergy[i];
    if (e <= 0.001) continue;
    float age = t - uClickTime[i];
    vec3 cpos = vec3(uClickPos[i]*4.5, 0.0);
    float d = length(p - cpos);
    float wave = sin(d*3.2 - age*5.5) * exp(-age*0.55) * exp(-d*0.32);
    disp += normalize(p - cpos + 0.0001) * wave * e * 0.3;
  }
  return disp;
}
float clickGlowField(vec3 p, float t){
  float g = 0.0;
  for(int i=0;i<8;i++){
    float e = uClickEnergy[i];
    if (e <= 0.001) continue;
    float age = t - uClickTime[i];
    vec3 cpos = vec3(uClickPos[i]*4.5, 0.0);
    float d = length(p - cpos);
    float ring = exp(-pow((d - age*2.1)*2.0, 2.0)) * exp(-age*0.4);
    g += ring * e;
  }
  return g;
}

float colorPhase;

// Overall scale of the 24-cell — bumped up substantially so it reads as
// a large, dominant presence rather than a small distant object.
const float STRUCT_SCALE = 6.5;

// Conservative bounding sphere for the whole structure (constant, independent
// of the 4D rotation since rotation preserves vertex length). Letting the
// raymarcher use this single cheap distance while far away — instead of
// projecting 24 vertices and testing 96 capsules every step — is the single
// biggest cost reduction here, since most steps along most rays are spent
// travelling through empty space before ever reaching the structure.
const float BOUND_RADIUS = 1.55 * STRUCT_SCALE * 0.42 * 1.45;

vec3 lastTransformedP; // the warped/breathed point actually tested against the
                       // capsules — needed so calcNormal compares apples to apples
                       // with closestPt, which lives in this same transformed space

float deScene(vec3 p, float t){
  vec3 mInfluence = vec3(uMouse*2.4, 0.5*sin(t*0.11)) * STRUCT_SCALE * 0.4;
  float distToMouse = length(p - mInfluence);
  vec3 warp = (p - mInfluence) * (0.14 * exp(-distToMouse*(0.65/STRUCT_SCALE))) * (1.0 + length(uMouseVel)*5.0);
  p -= warp;
  p += clickField(p, t) * STRUCT_SCALE * 0.4;

  float wake = uBloomState;
  float breathe = 1.0 + 0.04*sin(t*0.12) + 0.02*sin(t*0.07+1.7);
  p /= breathe;
  lastTransformedP = p;

  float boundDist = length(p) - BOUND_RADIUS;
  if (boundDist > 0.01){
    return boundDist * breathe * 0.95;
  }

  cell24Project(1.55 * STRUCT_SCALE * 0.42, t, wake);
  float d = cell24SDF(p, (0.035 + 0.012*wake) * STRUCT_SCALE * 0.42);
  colorPhase = length(p)*0.4 + float(edgeHit)*0.18;
  return d * 0.95;
}

vec3 calcNormal(vec3 p, float t){
  // deScene() was already called for this point by the raymarch loop, but we
  // need closestPt to reflect *this* position freshly, so call once more —
  // one call instead of the old 4-tap finite-difference (4 extra full
  // 96-edge evaluations). The normal then falls straight out of the capsule
  // geometry: point-on-edge to surface-point, no extra sampling needed.
  deScene(p, t);
  vec3 n = lastTransformedP - closestPt;
  float len = length(n);
  if (len < 0.0001) return vec3(0.0, 1.0, 0.0);
  return n / len;
}

// vivid, rotating-hue palette — far more saturated than a muted gradient
vec3 hsv2rgb(vec3 c){
  vec3 rgb = clamp(abs(mod(c.x*6.0 + vec3(0.0,4.0,2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0);
  return c.z * mix(vec3(1.0), rgb, c.y);
}
vec3 vividPalette(float phase, float wake, float fres, float t){
  float hue = fract(phase*0.10 + t*0.035 + wake*0.1);
  float sat = 0.82 + 0.15*fres;
  float val = 0.85 + 0.5*fres;
  vec3 col = hsv2rgb(vec3(hue, sat, val));
  col = mix(col, vec3(1.0,0.96,1.0), fres*0.35);
  return col;
}

void main(){
  vec2 uv = vUv;
  float aspect = uRes.x/uRes.y;
  vec2 p = uv;
  p.x *= aspect;

  float t = uTime;

  float camAngle = t*0.065 + uMouse.x*0.45;
  float camHeight = sin(t*0.045)*0.55 + uMouse.y*0.7;
  float camRadius = 6.0 + sin(t*0.03)*0.6;

  vec3 ro = vec3(cos(camAngle)*camRadius, camHeight + 0.8, sin(camAngle)*camRadius);
  vec3 target = vec3(sin(t*0.02)*0.5, 0.15*sin(t*0.05), cos(t*0.018)*0.35);

  vec3 fwd = normalize(target - ro);
  vec3 right = normalize(cross(fwd, vec3(0.0,1.0,0.0)));
  vec3 up = cross(right, fwd);

  float fov = 1.22;
  vec3 rd = normalize(fwd*fov + right*p.x + up*p.y);

  float dist = 0.0;
  float totalDist = 0.0;
  bool hit = false;
  vec3 pos = ro;

  // Reduced step budget — the single biggest cost lever besides resolution.
  for(int i=0;i<26;i++){
    pos = ro + rd*totalDist;
    dist = deScene(pos, t);
    if (dist < 0.002){ hit = true; break; }
    if (totalDist > 30.0) break;
    totalDist += dist * 0.92;
  }

  vec3 bgA = vec3(0.01, 0.035, 0.015);
  vec3 bgB = vec3(0.005, 0.02, 0.01);
  float bgGrad = 0.5 + 0.5*rd.y;
  vec3 bg = mix(bgA, bgB, bgGrad);

  vec3 col;
  if (hit){
    vec3 n = calcNormal(pos, t);
    vec3 viewDir = -rd;
    float fres = pow(1.0 - clamp(dot(n,viewDir),0.0,1.0), 3.0);

    vec3 lightP1 = vec3(sin(t*0.19)*3.0, cos(t*0.16)*2.0+1.0, cos(t*0.12)*3.0);
    vec3 l1 = normalize(lightP1 - pos);
    float diff1 = clamp(dot(n,l1),0.0,1.0);

    vec3 baseCol = vividPalette(colorPhase, uBloomState, fres, t);

    vec3 lit = baseCol * (0.3 + diff1*0.85);
    lit += fres * vec3(1.0,0.95,1.0) * (0.7 + 0.4*uBloomState);

    float cg = clickGlowField(pos, t);
    lit += cg * vec3(1.1,0.6,0.95) * 1.5;

    float fog = 1.0 - exp(-totalDist*0.06);
    col = mix(lit, bg*1.2, fog*0.45);
  } else {
    col = bg;
  }

  // ---- phosphor trail: combine with previous frame, never additive-blow-up ----
  vec3 prev = texture(uPrevFrame, uv*0.5+0.5).rgb;
  vec3 trailed = max(col, prev * uTrailDecay);

  fragColor = vec4(trailed, 1.0);
}`;

// ---------------------------------------------------------------------------
// PASS 2: cheap present pass — tonemap, vignette, grain. No raymarching here,
// so this pass is essentially free regardless of internal render scale.
// ---------------------------------------------------------------------------
const fsPresentSrc = `#version 300 es
precision highp float;
in vec2 vUv;
out vec4 fragColor;
uniform sampler2D uTex;
uniform vec2 uRes;
uniform float uTime;

float hash21(vec2 p){
  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
  p3 += dot(p3, p3.yzx + 33.33);
  return fract((p3.x + p3.y) * p3.z);
}

void main(){
  vec2 uv = vUv*0.5+0.5;
  vec3 rawCol = texture(uTex, uv).rgb;

  rawCol = rawCol / (1.0 + rawCol*0.4);
  float vig = 1.0 - 0.25*dot(vUv,vUv);
  rawCol *= vig;

  // Retroscopic Green CRT Phosphor overlay & grading
  float lum = dot(rawCol, vec3(0.299, 0.587, 0.114));
  vec3 phosphor = vec3(0.15, 1.0, 0.45);
  vec3 phosphor_dim = vec3(0.015, 0.08, 0.035);
  vec3 col = phosphor_dim + phosphor * pow(lum, 0.82) * 1.6;
  col += phosphor * pow(lum, 2.5) * 0.45;

  // CRT scanline pattern
  float scan = sin(uv.y * uRes.y * 3.14159265) * 0.5 + 0.5;
  col *= mix(0.72, 1.0, scan);

  // Vertical beam sweep
  float sweepY = fract(uTime * 0.12);
  float beam = smoothstep(0.03, 0.0, abs(uv.y - sweepY));
  col += phosphor * beam * 0.2;

  // Analog phosphor grain
  float grain = (hash21(uv*uRes.xy*0.5 + uTime*60.0) - 0.5) * 0.035;
  col += grain * (phosphor + 0.5);

  fragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
}`;

function compile(type, src){
  const sh = gl.createShader(type);
  gl.shaderSource(sh, src);
  gl.compileShader(sh);
  if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)){
    console.error(gl.getShaderInfoLog(sh));
  }
  return sh;
}
function makeProgram(vs, fs){
  const prog = gl.createProgram();
  gl.attachShader(prog, compile(gl.VERTEX_SHADER, vs));
  gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, fs));
  gl.linkProgram(prog);
  if (!gl.getProgramParameter(prog, gl.LINK_STATUS)){
    console.error(gl.getProgramInfoLog(prog));
  }
  return prog;
}

const progMain = makeProgram(vsSrc, fsMainSrc);
const progPresent = makeProgram(vsSrc, fsPresentSrc);
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);

// uniforms - main pass
const m_uRes = gl.getUniformLocation(progMain, 'uRes');
const m_uTime = gl.getUniformLocation(progMain, 'uTime');
const m_uMouse = gl.getUniformLocation(progMain, 'uMouse');
const m_uMouseVel = gl.getUniformLocation(progMain, 'uMouseVel');
const m_uBloomState = gl.getUniformLocation(progMain, 'uBloomState');
const m_uClickEnergy = gl.getUniformLocation(progMain, 'uClickEnergy[0]');
const m_uClickPos = gl.getUniformLocation(progMain, 'uClickPos[0]');
const m_uClickTime = gl.getUniformLocation(progMain, 'uClickTime[0]');
const m_uPrevFrame = gl.getUniformLocation(progMain, 'uPrevFrame');
const m_uTrailDecay = gl.getUniformLocation(progMain, 'uTrailDecay');

// uniforms - present pass
const p_uTex = gl.getUniformLocation(progPresent, 'uTex');
const p_uRes = gl.getUniformLocation(progPresent, 'uRes');
const p_uTime = gl.getUniformLocation(progPresent, 'uTime');

// ---------------------------------------------------------------------------
// Ping-pong framebuffers for the trail feedback
// ---------------------------------------------------------------------------
let fboA = null, fboB = null;
function makeTarget(w,h){
  const tex = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, tex);
  if (HAS_FLOAT_FBO){
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, w, h, 0, gl.RGBA, gl.HALF_FLOAT, null);
  } else {
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
  }
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  const fb = gl.createFramebuffer();
  gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
  const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
  if (status !== gl.FRAMEBUFFER_COMPLETE){
    console.error('Framebuffer incomplete:', status);
  }
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
  return { fb, tex, w, h };
}
function rebuildTargets(){
  fboA = makeTarget(rw, rh);
  fboB = makeTarget(rw, rh);
  // clear both to black so the first frames don't show garbage trails
  for (const t of [fboA, fboB]){
    gl.bindFramebuffer(gl.FRAMEBUFFER, t.fb);
    gl.viewport(0,0,t.w,t.h);
    gl.clearColor(0,0,0,1);
    gl.clear(gl.COLOR_BUFFER_BIT);
  }
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
}

resize();
window.addEventListener('resize', resize);

// ---------- interaction state ----------
let mouseTarget = [0,0];
let springPos = [0,0];
let springVel = [0,0];
const SPRING_STIFFNESS = 38.0;
const SPRING_DAMP = 2.0 * Math.sqrt(SPRING_STIFFNESS);
let mouseVelSmooth = [0,0];

const MAX_CLICKS = 8;
let clickEnergy = new Float32Array(MAX_CLICKS);
let clickPos = new Float32Array(MAX_CLICKS*2);
let clickTime = new Float32Array(MAX_CLICKS);
let clickSlot = 0;

let wakefulness = 0.05;
let interactionScore = 0;

window.addEventListener('mousemove', (e)=>{
  const x = (e.clientX / window.innerWidth)*2 - 1;
  const y = -((e.clientY / window.innerHeight)*2 - 1);
  mouseTarget = [x,y];
  interactionScore += 0.0008;
}, {passive:true});

window.addEventListener('touchmove', (e)=>{
  if (e.touches.length>0){
    const t0 = e.touches[0];
    const x = (t0.clientX / window.innerWidth)*2 - 1;
    const y = -((t0.clientY / window.innerHeight)*2 - 1);
    mouseTarget = [x,y];
    interactionScore += 0.0008;
  }
}, {passive:true});

function spawnClick(nx, ny){
  clickEnergy[clickSlot] = 1.0;
  clickPos[clickSlot*2] = nx;
  clickPos[clickSlot*2+1] = ny;
  clickTime[clickSlot] = performance.now()/1000.0;
  clickSlot = (clickSlot+1) % MAX_CLICKS;
  interactionScore += 0.06;
}

window.addEventListener('mousedown', (e)=>{
  const x = (e.clientX / window.innerWidth)*2 - 1;
  const y = -((e.clientY / window.innerHeight)*2 - 1);
  spawnClick(x,y);
});
window.addEventListener('touchstart', (e)=>{
  if (e.touches.length>0){
    const t0 = e.touches[0];
    const x = (t0.clientX / window.innerWidth)*2 - 1;
    const y = -((t0.clientY / window.innerHeight)*2 - 1);
    spawnClick(x,y);
  }
}, {passive:true});

const startTime = performance.now();
let lastFrame = startTime;
let flip = false; // ping-pong toggle

function frame(){
  resize();
  const now = performance.now();
  const dt = Math.min((now - lastFrame)/1000.0, 0.05);
  lastFrame = now;
  const t = (now - startTime)/1000.0;

  for (let axis=0; axis<2; axis++){
    const x = springPos[axis];
    const v = springVel[axis];
    const targetX = mouseTarget[axis];
    const accel = SPRING_STIFFNESS*(targetX - x) - SPRING_DAMP*v;
    springVel[axis] = v + accel*dt;
    springPos[axis] = x + springVel[axis]*dt;
  }
  mouseVelSmooth[0] += (springVel[0]*0.02 - mouseVelSmooth[0])*0.25;
  mouseVelSmooth[1] += (springVel[1]*0.02 - mouseVelSmooth[1])*0.25;

  for (let i=0;i<MAX_CLICKS;i++){
    if (clickEnergy[i] > 0.0){
      clickEnergy[i] -= dt*0.18;
      if (clickEnergy[i] < 0.0) clickEnergy[i] = 0.0;
    }
  }

  interactionScore *= 0.999;
  const targetWake = Math.min(0.15 + interactionScore*1.4, 1.0);
  wakefulness += (targetWake - wakefulness) * Math.min(dt*0.5, 1.0);

  const readTarget = flip ? fboB : fboA;
  const writeTarget = flip ? fboA : fboB;
  flip = !flip;

  // ---- pass 1: raymarch + trail feedback, into low-res target ----
  gl.bindFramebuffer(gl.FRAMEBUFFER, writeTarget.fb);
  gl.viewport(0,0,writeTarget.w, writeTarget.h);
  gl.useProgram(progMain);
  gl.uniform2f(m_uRes, writeTarget.w, writeTarget.h);
  gl.uniform1f(m_uTime, t);
  gl.uniform2f(m_uMouse, springPos[0], springPos[1]);
  gl.uniform2f(m_uMouseVel, mouseVelSmooth[0], mouseVelSmooth[1]);
  gl.uniform1f(m_uBloomState, wakefulness);
  gl.uniform1fv(m_uClickEnergy, clickEnergy);
  gl.uniform2fv(m_uClickPos, clickPos);
  gl.uniform1fv(m_uClickTime, clickTime);
  gl.uniform1f(m_uTrailDecay, 0.90);
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D, readTarget.tex);
  gl.uniform1i(m_uPrevFrame, 0);
  gl.drawArrays(gl.TRIANGLES, 0, 3);

  // ---- pass 2: cheap present to screen ----
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
  gl.viewport(0,0,canvas.width, canvas.height);
  gl.useProgram(progPresent);
  gl.uniform2f(p_uRes, canvas.width, canvas.height);
  gl.uniform1f(p_uTime, t);
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D, writeTarget.tex);
  gl.uniform1i(p_uTex, 0);
  gl.drawArrays(gl.TRIANGLES, 0, 3);

  requestAnimationFrame(frame);
}

if (gl) requestAnimationFrame(frame);
</script>
</body>
</html>
MikeHiggins.me.uk LinkedIn GitHub Bluesky Contra Upwork
© 2026 Mike Higgins. All rights reserved.