18. Dot tunnel · demoscene, early 1990s · Retro techniques
Dot/square tunnel
About
Rings recede along Z. Each ring is a square outlined with dots, corners drawn as squares. A 1/z divide is the tunnel. Twist is a spin that grows with depth. Dot tunnels were a stock intro effect on the Amiga and PC around 1990 to 1993; this is the generic form, not a port of one. Move the pointer to drag the vanishing point.
Move the pointer to shift the vanishing point.
Reference
Interpretation: this evokes a period look and does not reproduce one specific original.
Browser APIs
- Canvas 2D
- 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: "#6ee7b7",
speed: 1,
twist: 1
};
function hexToRgb(hex) {
const n = parseInt(String(hex).replace("#", ""), 16);
if (isNaN(n)) return [110, 231, 183];
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 RINGS = 36;
const SIDES = 4;
const PER = 7;
const rings = [];
for (let i = 0; i < RINGS; i++) rings.push((i / RINGS) * 18 + 0.55);
let t = 0, lookX = 0, lookY = 0, targetX = 0, targetY = 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();
targetX = (ev.clientX - r.left) / r.width - 0.5;
targetY = (ev.clientY - r.top) / r.height - 0.5;
wake();
});
canvas.addEventListener("pointerleave", function () { targetX = 0; targetY = 0; wake(); });
function onSquare(k, n, spin) {
const u = (k / n) * 4;
const side = u | 0;
const f = u - side;
let x = 0, y = 0;
if (side === 0) { x = -1 + 2 * f; y = -1; }
else if (side === 1) { x = 1; y = -1 + 2 * f; }
else if (side === 2) { x = 1 - 2 * f; y = 1; }
else { x = -1; y = 1 - 2 * f; }
const c = Math.cos(spin), s = Math.sin(spin);
return [x * c - y * s, x * s + y * c];
}
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 draw(w, h) {
ctx.fillStyle = "#0b0c0e";
ctx.fillRect(0, 0, w, h);
const rgb = hexToRgb(PARAMS.color);
const cx = w * 0.5 + lookX * w * 0.42;
const cy = h * 0.5 + lookY * h * 0.42;
const fov = Math.min(w, h) * 0.55;
const n = SIDES * PER;
for (let i = 0; i < RINGS; i++) {
const z = rings[i];
const spin = t * 0.35 + z * 0.28 * PARAMS.twist;
const fog = Math.max(0.08, 1 - z / 19);
const s = (1.15 / z) * fov;
const size = Math.max(1.2, 5.5 / z);
ctx.fillStyle = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + fog + ")";
ctx.strokeStyle = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + (fog * 0.55) + ")";
ctx.lineWidth = Math.max(1, 2.2 / z);
ctx.beginPath();
for (let k = 0; k <= n; k++) {
const p = onSquare(k % n, n, spin);
const x = cx + p[0] * s;
const y = cy + p[1] * s;
if (k === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
for (let k = 0; k < n; k++) {
const p = onSquare(k, n, spin);
const x = cx + p[0] * s;
const y = cy + p[1] * s;
const corner = k % PER === 0;
if (corner) ctx.fillRect(x - size, y - size, size * 2, size * 2);
else {
ctx.beginPath();
ctx.arc(x, y, size * 0.45, 0, Math.PI * 2);
ctx.fill();
}
}
}
}
function frame(now) {
const dt = Math.min(0.05, last ? (now - last) * 0.001 : 0.016);
last = now;
lookX += (targetX - lookX) * Math.min(1, dt * 6);
lookY += (targetY - lookY) * Math.min(1, dt * 6);
if (!reduced) {
t += dt * PARAMS.speed;
const dz = dt * 6.5 * PARAMS.speed;
for (let i = 0; i < RINGS; i++) {
rings[i] -= dz;
if (rings[i] < 0.5) rings[i] += 18;
}
}
resize();
draw(canvas.width, canvas.height);
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();
})();