23
Twister Tunnel
move mouse to bend the tunnel, keys 1-4 swap texture
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.