Baktiyar Ablaikhan

Pixel Threshold Poster

Source
arlan.me/vault ↗
Author
Arlan
Licence
MIT

Loading…

The word arcade printed into a flat colour poster with its edges quantised into hard pixel blocks; the colourway cycles slowly and the blocks sharpen where the pointer passes.

Code

"use client";

import { createNoise3D } from "simplex-noise";
import { approach, clamp, hex, makeCanvas, mixRGB, mulberry32, rgbStr, smooth, useStage, type RGB, type Scene } from "./stage";

/*
 * "arcade" printed into a flat two-colour poster. A soft field of the word is
 * quantised into hard pixel blocks by a drifting threshold; the colourway
 * cycles slowly and the blocks resolve finer wherever the pointer passes.
 */

const FW = 400;
const FH = 250;
const WAYS: [string, string, string][] = [
  ["#ff5a1f", "#fff2d4", "#1d1a6b"],
  ["#1f3cff", "#ffe14d", "#ff4f9a"],
  ["#111111", "#3cff8f", "#ff3cac"],
  ["#ffd84d", "#e0242c", "#1a1a1a"],
  ["#f2efe6", "#151515", "#2b46b8"],
];
const WAY_S = 5.5;
const FADE_S = 1.4;

function buildField() {
  const { ctx } = makeCanvas(FW, FH);
  ctx.fillStyle = "#000";
  ctx.fillRect(0, 0, FW, FH);
  ctx.font = `900 92px "Arial Black", "Helvetica Neue", Arial, sans-serif`;
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.shadowColor = "#fff";
  ctx.shadowBlur = 9;
  ctx.fillStyle = "#fff";
  ctx.fillText("arcade", FW / 2, FH / 2 + 4);
  ctx.shadowBlur = 0;
  ctx.fillText("arcade", FW / 2, FH / 2 + 4);
  const d = ctx.getImageData(0, 0, FW, FH).data;
  const f = new Float32Array(FW * FH);
  for (let i = 0; i < f.length; i++) f[i] = d[i * 4] / 255;
  // soften once more with a box pass so thresholds produce rounded steps
  const g = new Float32Array(f.length);
  const R = 3;
  for (let y = 0; y < FH; y++) {
    for (let x = 0; x < FW; x++) {
      let s = 0;
      let n = 0;
      for (let k = -R; k <= R; k++) {
        const xx = Math.min(FW - 1, Math.max(0, x + k));
        s += f[y * FW + xx];
        n++;
      }
      g[y * FW + x] = s / n;
    }
  }
  for (let y = 0; y < FH; y++) {
    for (let x = 0; x < FW; x++) {
      let s = 0;
      let n = 0;
      for (let k = -R; k <= R; k++) {
        const yy = Math.min(FH - 1, Math.max(0, y + k));
        s += g[yy * FW + x];
        n++;
      }
      f[y * FW + x] = s / n;
    }
  }
  return f;
}

function createScene(): Scene {
  let field: Float32Array | null = null;
  const noise = createNoise3D(mulberry32(3));
  const pts = { x: 0, y: 0, a: 0 };
  const sample = (fx: number, fy: number) => {
    if (!field || fx < 0 || fy < 0 || fx >= FW - 1 || fy >= FH - 1) return 0;
    const x0 = fx | 0;
    const y0 = fy | 0;
    const tx = fx - x0;
    const ty = fy - y0;
    const i = y0 * FW + x0;
    return (
      field[i] * (1 - tx) * (1 - ty) + field[i + 1] * tx * (1 - ty) + field[i + FW] * (1 - tx) * ty + field[i + FW + 1] * tx * ty
    );
  };

  return {
    frame({ ctx, w, h, t, dt, p }) {
      if (!field) field = buildField();
      // colourway cross-fade
      const k = t / WAY_S;
      const i0 = Math.floor(k) % WAYS.length;
      const i1 = (i0 + 1) % WAYS.length;
      const f = smooth(((k % 1) * WAY_S - (WAY_S - FADE_S)) / FADE_S);
      const col = (j: number): RGB => mixRGB(hex(WAYS[i0][j]), hex(WAYS[i1][j]), f);
      const bg = col(0);
      ctx.fillStyle = rgbStr(bg);
      ctx.fillRect(0, 0, w, h);

      pts.x = approach(pts.x, p.x, 10, dt);
      pts.y = approach(pts.y, p.y, 10, dt);
      pts.a = approach(pts.a, p.inside ? 1 : 0, 4, dt);

      const S = Math.min(w / FW, h / FH) * 0.98;
      const ox = (w - FW * S) / 2;
      const oy = (h - FH * S) / 2;
      const cell = Math.max(5, w / 64);
      const fine = cell / 4;
      const reach = Math.min(w, h) * 0.32;
      const z = t * 0.25;
      const thr = (x: number, y: number) => 0.5 + 0.13 * noise(x * 0.012, y * 0.012, z);

      const off = Math.max(2, cell * 0.45);
      const paint = (color: string, dx: number, dy: number) => {
        ctx.fillStyle = color;
        ctx.beginPath();
        for (let y = 0; y < h; y += cell) {
          for (let x = 0; x < w; x += cell) {
            const cx = x + cell / 2;
            const cy = y + cell / 2;
            const near = pts.a * clamp(1 - Math.hypot(cx - pts.x, cy - pts.y) / reach);
            if (near > 0.25) {
              // sharper blocks near the pointer
              const sub = near > 0.6 ? fine : cell / 2;
              for (let sy = y; sy < y + cell - 0.01; sy += sub) {
                for (let sx = x; sx < x + cell - 0.01; sx += sub) {
                  const v = sample((sx + sub / 2 - ox) / S, (sy + sub / 2 - oy) / S);
                  if (v > thr(sx, sy)) ctx.rect(sx + dx, sy + dy, sub + 0.4, sub + 0.4);
                }
              }
            } else {
              const v = sample((cx - ox) / S, (cy - oy) / S);
              if (v > thr(cx, cy)) ctx.rect(x + dx, y + dy, cell + 0.4, cell + 0.4);
            }
          }
        }
        ctx.fill();
      };
      paint(rgbStr(col(2)), off, off);
      paint(rgbStr(col(1)), 0, 0);

      // poster furniture
      ctx.fillStyle = rgbStr(col(1));
      ctx.font = `700 ${Math.max(9, h * 0.04)}px ui-monospace, Menlo, Consolas, monospace`;
      ctx.textBaseline = "top";
      ctx.textAlign = "left";
      ctx.fillText("INSERT COIN", h * 0.06, h * 0.06);
      ctx.textAlign = "right";
      ctx.textBaseline = "bottom";
      ctx.fillText(`P1 ${String(Math.floor(t * 37) % 100000).padStart(5, "0")}`, w - h * 0.06, h - h * 0.06);
    },
  };
}

export default function ArcadeThreshold() {
  const ref = useStage(createScene, 1);
  return <div ref={ref} className="relative h-full w-full overflow-hidden bg-[#ff5a1f]" />;
}
Prompt

Build "Pixel Threshold Poster", a text animation. What it does: The word arcade printed into a flat colour poster with its edges quantised into hard pixel blocks; the colourway cycles slowly and the blocks sharpen where the pointer passes. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS, using simplex-noise. Technique: an HTML canvas, a requestAnimationFrame loop, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://www.arlan.me/vault (Arlan, MIT).