23

Twister Tunnel

move mouse to bend the tunnel, keys 1-4 swap texture

The classic demoscene tunnel effect, done the old-fashioned way. For every screen pixel relative to the tunnel center we precompute two numbers: the angular coordinate and the depth — the depth is the entire trick, since pixels near the center already have huge and so race through the texture as you 'fall in.' Each frame is then just one indirect texture lookup and one palette write per pixel: with scrolling along depth (forward motion) and a slow palette cycle on top. Mouse bends the tunnel center as , an S-curve that leans the top one way and the bottom the other. Keys swap the procedural texture: bricks, hex grid, checker, scanlines. Rendered at half resolution with all LUTs as typed arrays — the per-pixel hot path is two array reads, two adds, a mask, and a palette write, with zero calls inside the inner loop.

idle
235 lines · vanilla
view source
// Twister Tunnel — classic demoscene effect.
// For each screen pixel we precompute (angle, depth) into typed-array LUTs.
// Each frame: lookup texture[u, v + scroll] mod texSize → palette.
//
// Perf notes:
// - Render at half resolution into an OffscreenCanvas, then drawImage up.
// - LUTs are Uint16 (angle/depth scaled into 0..65535) so the inner loop
//   only does adds, ands and array indexing — no Math calls per pixel.
// - LUT is recomputed only when the bend (mouse x) shifts more than a
//   small epsilon, so steady-state cost per frame is essentially the
//   inner loop only.

const TEX_SIZE = 256;          // texture is square, power of two for cheap mod
const TEX_MASK = TEX_SIZE - 1;
const SCALE_DOWN = 2;          // render at 1/2 res, upscale on present

let W = 0, H = 0;              // logical canvas size
let RW = 0, RH = 0;            // render-buffer size (W/SCALE_DOWN, ...)
let off, octx, img, buf32;     // offscreen canvas + ImageData + Uint32 view

let angleLUT;                  // Uint16Array, size RW*RH, scaled angle in [0,TEX_SIZE)
let depthLUT;                  // Uint16Array, size RW*RH, scaled depth in [0,TEX_SIZE)
let lutBend = NaN;             // bend value the current LUT was built for

let textures = [];             // array of Uint8Array(TEX_SIZE*TEX_SIZE), brightness 0..255
let texIdx = 0;
let palette;                   // Uint32Array(256), cycles each frame
let palettePhase = 0;
let scroll = 0;                // depth scroll accumulator
let rot = 0;                   // angular scroll accumulator
let time = 0;
let hintFade = 1;              // overlay fades after a few seconds

// --- texture generators (all write a TEX_SIZE x TEX_SIZE Uint8 brightness buffer) ---

function makeBricks() {
  const a = new Uint8Array(TEX_SIZE * TEX_SIZE);
  const ROW_H = 32, COL_W = 64, MORTAR = 3;
  for (let y = 0; y < TEX_SIZE; y++) {
    const row = (y / ROW_H) | 0;
    const offset = (row & 1) ? (COL_W >> 1) : 0;
    const ry = y % ROW_H;
    for (let x = 0; x < TEX_SIZE; x++) {
      const rx = ((x + offset) % COL_W);
      let v;
      if (ry < MORTAR || rx < MORTAR) {
        v = 40;                         // mortar dark
      } else {
        // brick body with a soft gradient + speckle for texture
        const dx = (rx - COL_W / 2) / (COL_W / 2);
        const dy = (ry - ROW_H / 2) / (ROW_H / 2);
        const r2 = dx * dx + dy * dy;
        v = 200 - (r2 * 60) | 0;
        // hash-based speckle, deterministic
        const h = ((x * 1973 + y * 9277) ^ 0x5bd1e995) >>> 0;
        v += ((h & 31) - 16);
        if (v < 60) v = 60;
        if (v > 255) v = 255;
      }
      a[y * TEX_SIZE + x] = v;
    }
  }
  return a;
}

function makeHex() {
  // Honeycomb of pointy-top hexagons. We classify each pixel by the
  // closest cell-center on a hex lattice and shade by distance to that
  // center, drawing a dark border where distance crosses the cell edge.
  const a = new Uint8Array(TEX_SIZE * TEX_SIZE);
  const R = 22;                          // hex circumradius in texels
  const dx = Math.sqrt(3) * R;           // column spacing
  const dy = 1.5 * R;                    // row spacing
  for (let y = 0; y < TEX_SIZE; y++) {
    for (let x = 0; x < TEX_SIZE; x++) {
      // Try the nearest few centers (3x3 around the integer cell) and pick min dist.
      const col = x / dx;
      const row = y / dy;
      const ci = Math.round(col);
      const ri = Math.round(row);
      let best = 1e9;
      for (let i = -1; i <= 1; i++) {
        for (let j = -1; j <= 1; j++) {
          const rr = ri + j;
          const cc = ci + i;
          const cx = cc * dx + ((rr & 1) ? dx * 0.5 : 0);
          const cy = rr * dy;
          const ex = x - cx, ey = y - cy;
          const d2 = ex * ex + ey * ey;
          if (d2 < best) best = d2;
        }
      }
      const d = Math.sqrt(best);
      let v;
      if (d > R * 0.92) v = 30;          // border
      else v = 220 - ((d / R) * 100) | 0;
      a[y * TEX_SIZE + x] = v;
    }
  }
  return a;
}

function makeChecker() {
  const a = new Uint8Array(TEX_SIZE * TEX_SIZE);
  const C = 16;
  for (let y = 0; y < TEX_SIZE; y++) {
    for (let x = 0; x < TEX_SIZE; x++) {
      const cx = (x / C) | 0;
      const cy = (y / C) | 0;
      const k = (cx + cy) & 1;
      // soft falloff toward cell edges so the pattern doesn't alias hard
      const fx = (x % C) / C - 0.5;
      const fy = (y % C) / C - 0.5;
      const edge = Math.max(Math.abs(fx), Math.abs(fy));
      const soft = 1 - Math.max(0, (edge - 0.42) * 12);
      const base = k ? 220 : 55;
      let v = (base * Math.max(0.3, soft)) | 0;
      if (v < 0) v = 0; if (v > 255) v = 255;
      a[y * TEX_SIZE + x] = v;
    }
  }
  return a;
}

function makeScanlines() {
  const a = new Uint8Array(TEX_SIZE * TEX_SIZE);
  for (let y = 0; y < TEX_SIZE; y++) {
    // multi-frequency horizontal bands → bright/dark stripes with a sub-beat
    const s1 = Math.sin((y / TEX_SIZE) * Math.PI * 2 * 6);
    const s2 = Math.sin((y / TEX_SIZE) * Math.PI * 2 * 19) * 0.35;
    const v = 140 + ((s1 + s2) * 95) | 0;
    const row = y * TEX_SIZE;
    for (let x = 0; x < TEX_SIZE; x++) {
      // a faint vertical wobble adds visual interest as it scrolls
      const w = Math.sin((x / TEX_SIZE) * Math.PI * 2 * 3 + y * 0.05) * 12;
      let vv = v + (w | 0);
      if (vv < 0) vv = 0; if (vv > 255) vv = 255;
      a[row + x] = vv;
    }
  }
  return a;
}

// --- palette: HSV-ish cycling color ramp keyed on brightness ---

function buildPalette(phase) {
  // 256-entry Uint32 LUT in ABGR (little-endian RGBA).
  if (!palette) palette = new Uint32Array(256);
  for (let i = 0; i < 256; i++) {
    const t = i / 255;
    // hue cycles with phase, value modulated by t for depth shading
    const hue = (phase + t * 0.6) % 1;
    const sat = 0.85;
    const val = 0.18 + 0.82 * t;
    // HSV → RGB
    const h6 = hue * 6;
    const c = val * sat;
    const x = c * (1 - Math.abs((h6 % 2) - 1));
    let r, g, b;
    if      (h6 < 1) { r = c; g = x; b = 0; }
    else if (h6 < 2) { r = x; g = c; b = 0; }
    else if (h6 < 3) { r = 0; g = c; b = x; }
    else if (h6 < 4) { r = 0; g = x; b = c; }
    else if (h6 < 5) { r = x; g = 0; b = c; }
    else             { r = c; g = 0; b = x; }
    const m = val - c;
    const R = ((r + m) * 255) | 0;
    const G = ((g + m) * 255) | 0;
    const B = ((b + m) * 255) | 0;
    palette[i] = (255 << 24) | (B << 16) | (G << 8) | R;
  }
}

// --- LUT: precompute (angle, depth) for each render-buffer pixel ---
// bend in [-1, 1] shifts the tunnel center horizontally as a function of y,
// giving the classic S-curve when combined with row-dependent offset.

function buildLUT(bend) {
  const n = RW * RH;
  if (!angleLUT || angleLUT.length !== n) {
    angleLUT = new Uint16Array(n);
    depthLUT = new Uint16Array(n);
  }
  const cx = RW * 0.5;
  const cy = RH * 0.5;
  // depth scaling: pick k so that the deepest visible pixel maps near 0
  // and the closest pixel maps near TEX_SIZE-1. We use depth = k / r.
  const maxR = Math.sqrt(cx * cx + cy * cy);
  const k = maxR * 32;                   // arbitrary but tuned for nice spacing
  for (let y = 0; y < RH; y++) {
    // S-curve: bend modulates center-x as a function of relative y.
    // Top of screen leans one way, bottom leans the other.
    const ny = (y - cy) / cy;            // -1..1
    const shift = bend * cx * 0.55 * ny; // strongest at top/bottom
    const ox = cx + shift;
    const dy = y - cy;
    const dy2 = dy * dy;
    const row = y * RW;
    for (let x = 0; x < RW; x++) {
      const dx = x - ox;
      const r = Math.sqrt(dx * dx + dy2);
      // angle: atan2 → [0, 2π) → [0, TEX_SIZE)
      let a = Math.atan2(dy, dx);
      if (a < 0) a += Math.PI * 2;
      const ai = ((a / (Math.PI * 2)) * TEX_SIZE) | 0;
      // depth: 1/r scaled. Clamp near the center to avoid huge spikes.
      const d = r < 1 ? TEX_SIZE * 4 : k / r;
      const di = (d | 0) & 0xffff;       // wraparound handled at lookup time
      angleLUT[row + x] = ai & TEX_MASK;
      depthLUT[row + x] = di;
    }
  }
  lutBend = bend;
}

// --- frame ---

function ensureBuffers(width, height) {
  if (width === W && height === H && off) return;
  W = width; H = height;
  RW = Math.max(1, (W / SCALE_DOWN) | 0);
  RH = Math.max(1, (H / SCALE_DOWN) | 0);
  off = new OffscreenCanvas(RW, RH);
  octx = off.getContext("2d");
  img = octx.createImageData(RW, RH);
  buf32 = new Uint32Array(img.data.buffer);
  lutBend = NaN;                         // force LUT rebuild
}

function init({ width, height }) {
  ensureBuffers(width, height);
  textures = [makeBricks(), makeHex(), makeChecker(), makeScanlines()];
  texIdx = 0;
  buildPalette(0);
  buildLUT(0);
}

function tick({ dt, ctx, width, height, input }) {
  ensureBuffers(width, height);

  // texture swap via 1..4
  if (input.justPressed("1")) texIdx = 0;
  if (input.justPressed("2")) texIdx = 1;
  if (input.justPressed("3")) texIdx = 2;
  if (input.justPressed("4")) texIdx = 3;

  time += dt;
  hintFade = Math.max(0, 1 - Math.max(0, time - 3.5) * 0.6);

  // bend in [-1, 1] from mouse x; if mouse hasn't been touched yet, breathe
  // a small auto-bend so the effect doesn't look static on load.
  let bend;
  if (input.mouseX > 0 || input.mouseY > 0) {
    bend = (input.mouseX / W) * 2 - 1;
    if (bend < -1) bend = -1; else if (bend > 1) bend = 1;
  } else {
    bend = Math.sin(time * 0.6) * 0.6;
  }
  // recompute LUT only when bend changed enough (LUT rebuild is the
  // expensive thing — per-frame inner loop is cheap).
  if (Math.abs(bend - lutBend) > 0.01) buildLUT(bend);

  // animate scroll, rotation, palette
  scroll = (scroll + dt * 90) % (TEX_SIZE * 1024);   // arbitrary long period
  rot = (rot + dt * 18) % TEX_SIZE;
  palettePhase = (palettePhase + dt * 0.08) % 1;
  buildPalette(palettePhase);

  const scrollI = scroll | 0;
  const rotI = rot | 0;
  const tex = textures[texIdx];
  const pal = palette;
  const ang = angleLUT;
  const dep = depthLUT;
  const n = RW * RH;

  // Inner loop. Two LUT reads, two adds, mask, texture read, palette write.
  // No function calls, no Math calls per pixel.
  for (let i = 0; i < n; i++) {
    const u = (ang[i] + rotI) & TEX_MASK;
    const v = (dep[i] + scrollI) & TEX_MASK;
    buf32[i] = pal[tex[(v << 8) | u]];   // TEX_SIZE=256 → v*256 = v<<8
  }

  octx.putImageData(img, 0, 0);
  ctx.imageSmoothingEnabled = false;
  ctx.drawImage(off, 0, 0, W, H);

  // overlay
  if (hintFade > 0.01) {
    const a = hintFade;
    ctx.fillStyle = `rgba(0,0,0,${0.55 * a})`;
    ctx.fillRect(8, 8, 268, 76);
    ctx.fillStyle = `rgba(255,224,200,${a})`;
    ctx.font = "12px monospace";
    ctx.fillText("Twister Tunnel", 16, 26);
    const names = ["bricks", "hex grid", "checker", "scanlines"];
    ctx.fillStyle = `rgba(180,210,255,${a})`;
    ctx.fillText(`texture: ${names[texIdx]}  (keys 1-4)`, 16, 46);
    ctx.fillStyle = `rgba(170,170,170,${a})`;
    ctx.fillText("move mouse to bend the tunnel", 16, 64);
    ctx.fillText(`fps target: 60 · render: ${RW}x${RH}`, 16, 78);
  }
}

Comments (0)

Log in to comment.