22. Trumbull · 1968 · Retro techniques
Slit-scan stargate
Demos are animating. Following your system setting.
About
Douglas Trumbull's slit-scan: a one-dimensional strip of artwork is sampled while the camera dollies, so each pixel is a different moment. Here the slit is polar. Color streaks recede into a vanishing point. Move the pointer to shove the corridor.
Move the pointer to shift the vanishing point.
Reference
Douglas Trumbull's slit-scan Star Gate sequence in 2001: A Space Odyssey, 1968
Browser APIs
- Canvas 2D
- ImageData
- Pointer Events
- requestAnimationFrame
- matchMedia
If WebGL is missing, the demo draws a message on the canvas instead of a blank frame. By default a demo holds a still frame when your system asks for reduced motion; the Motion buttons override that, and a standalone copy animates with #motion=play on its URL.
Source
(function () {
const PARAMS = {
color: "#ff6a2a",
speed: 1,
streaks: 1
};
function hexToRgb(hex) {
const n = parseInt(String(hex).replace("#", ""), 16);
if (isNaN(n)) return [255, 106, 42];
return [n >> 16 & 255, n >> 8 & 255, n & 255];
}
const canvas = document.getElementById("c");
const fallback = document.getElementById("fallback");
const ctx = canvas.getContext("2d");
if (!ctx) {
fallback.hidden = false;
fallback.textContent = "Canvas 2D is not available in this browser.";
return;
}
const BW = 280, BH = 160;
const buf = document.createElement("canvas");
buf.width = BW; buf.height = BH;
const bctx = buf.getContext("2d");
const img = bctx.createImageData(BW, BH);
const px = img.data;
let t = 0, lookX = 0, lookY = 0, last = 0;
let raf = 0;
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
let reduced = motionQuery.matches;
canvas.addEventListener("pointermove", function (ev) {
const r = canvas.getBoundingClientRect();
lookX = (ev.clientX - r.left) / r.width - 0.5;
lookY = (ev.clientY - r.top) / r.height - 0.5;
wake();
});
function slit(u, rgb) {
const s = (u % 1 + 1) % 1;
const band = Math.sin(s * Math.PI * 10) * 0.5 + 0.5;
const cool = s < 0.45;
if (cool) {
return [
20 + band * 40 + rgb[0] * 0.05,
30 + band * 90,
80 + band * 160
];
}
return [
rgb[0] * (0.35 + band * 0.65),
rgb[1] * (0.15 + band * 0.5) + band * 80,
rgb[2] * 0.12 + (1 - band) * 40
];
}
function draw() {
const rgb = hexToRgb(PARAMS.color);
const cx = BW * (0.5 + lookX * 0.35);
const cy = BH * (0.5 + lookY * 0.35);
const k = 7.5 * PARAMS.streaks;
for (let y = 0; y < BH; y++) {
for (let x = 0; x < BW; x++) {
const dx = x - cx, dy = y - cy;
const r = Math.hypot(dx, dy) / BH;
const a = Math.atan2(dy, dx);
const u = a / (Math.PI * 2) * 6 + t * 0.15;
const scan = t * 1.4 * PARAMS.speed - r * k;
const col = slit(u * 0.2 + scan, rgb);
const glow = Math.max(0, 1 - r * 1.15);
const i = (y * BW + x) * 4;
px[i] = Math.min(255, col[0] * (0.25 + glow) + glow * 40);
px[i + 1] = Math.min(255, col[1] * (0.25 + glow));
px[i + 2] = Math.min(255, col[2] * (0.2 + glow * 0.8));
px[i + 3] = 255;
}
}
bctx.putImageData(img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(buf, 0, 0, canvas.width, canvas.height);
}
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
}
function frame(now) {
const dt = Math.min(0.05, last ? (now - last) * 0.001 : 0.016);
last = now;
if (!reduced) t += dt;
resize();
draw();
if (!reduced) raf = requestAnimationFrame(frame);
}
// Motion. The system's reduced-motion preference decides by default. A
// standalone copy animates anyway with "#motion=play" in its URL (or holds a
// frame with "#motion=still"); an embedding page can post {motion: "play"}.
let motionMode = "auto";
function applyMotion() {
const next = motionMode === "still" || (motionMode === "auto" && motionQuery.matches);
if (next === reduced) return;
reduced = next;
last = 0;
if (reduced) { cancelAnimationFrame(raf); raf = 0; frame(performance.now()); }
else if (!raf) raf = requestAnimationFrame(frame);
}
function setMotion(mode) {
if (mode === "play" || mode === "still" || mode === "auto") { motionMode = mode; applyMotion(); }
}
function readMotionHash() {
const m = /motion=(play|still|auto)/.exec(location.hash);
let mode = m ? m[1] : "auto";
// Embedded in the gallery, the page sets window.__demoMotion before any frame loads.
try { if (!m && window.parent !== window && typeof window.parent.__demoMotion === "string") mode = window.parent.__demoMotion; } catch (e) { /* cross-origin parent */ }
setMotion(mode);
}
function wake() { if (reduced) frame(performance.now()); }
if (motionQuery.addEventListener) motionQuery.addEventListener("change", applyMotion);
else if (motionQuery.addListener) motionQuery.addListener(applyMotion);
window.addEventListener("hashchange", readMotionHash);
window.addEventListener("message", function (e) { if (e.data && typeof e.data.motion === "string") setMotion(e.data.motion); });
window.addEventListener("resize", wake);
if (reduced) frame(performance.now());
else raf = requestAnimationFrame(frame);
readMotionHash();
})();