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).