11. Vector CRT look · c. 1980 · Retro techniques
Wireframe terrain
About
A height field of summed sines, projected with a 1/z divide, stroked as a mesh that recedes into phosphor fog. The camera flies forward. Move the pointer to yaw. It evokes vector-monitor games such as Atari's Battlezone (1980) and the wireframe flight simulators of the early 1980s; those drew objects and horizons rather than a full mesh terrain, so this is a look, not a port.
Move the pointer left or right to steer.
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,
hills: 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 COLS = 24;
const ROWS = 16;
const CELL = 1.05;
const CAM_Y = 2.2;
const stars = [];
for (let i = 0; i < 48; i++) {
stars.push({ x: Math.random(), y: Math.random() * 0.46, a: 0.25 + Math.random() * 0.55 });
}
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
let reduced = motionQuery.matches;
let z0 = 12;
let look = 0;
let targetLook = 0;
let last = 0;
let raf = 0;
function height(x, z) {
const a = PARAMS.hills;
return (
Math.sin(x * 0.42 + z * 0.18) * 1.35 +
Math.sin(x * 0.19 - z * 0.33) * 0.85 +
Math.sin(z * 0.51 + x * 0.07) * 0.55
) * a;
}
canvas.addEventListener("pointermove", function (ev) {
const r = canvas.getBoundingClientRect();
targetLook = ((ev.clientX - r.left) / r.width - 0.5) * 1.15;
wake();
});
canvas.addEventListener("pointerleave", function () {
targetLook = 0;
wake();
});
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 project(x, y, z, w, h) {
if (z < 0.7) return null;
const nx = x / z;
const ny = y / z;
if (nx < -2.2 || nx > 2.2) return null;
const fov = Math.min(w, h) * 0.86;
return {
x: w * 0.5 + nx * fov,
y: h * 0.56 - ny * fov
};
}
function sample(i, j, w, h) {
const x = (i - (COLS - 1) * 0.5) * CELL + look * 1.8;
const z = 1.7 + j * CELL;
const y = height(x + look * 5, z0 + z) - CAM_Y;
return project(x, y, z, w, h);
}
function onScreen(p, w, h) {
return p && p.x > -w * 0.2 && p.x < w * 1.2 && p.y > -h * 0.2 && p.y < h * 1.2;
}
function strokeSeg(a, b, alpha, width, w, h) {
if (!onScreen(a, w, h) || !onScreen(b, w, h)) return;
const rgb = hexToRgb(PARAMS.color);
ctx.strokeStyle = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + alpha + ")";
ctx.lineWidth = width;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
function draw(w, h) {
ctx.fillStyle = "#0b0c0e";
ctx.fillRect(0, 0, w, h);
const rgb = hexToRgb(PARAMS.color);
for (let s = 0; s < stars.length; s++) {
const st = stars[s];
ctx.fillStyle = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + (st.a * 0.45) + ")";
ctx.fillRect(st.x * w, st.y * h * 0.52, 1.4, 1.4);
}
ctx.strokeStyle = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ",0.22)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, h * 0.56);
ctx.lineTo(w, h * 0.56);
ctx.stroke();
const grid = [];
for (let j = 0; j < ROWS; j++) {
const row = [];
for (let i = 0; i < COLS; i++) row.push(sample(i, j, w, h));
grid.push(row);
}
ctx.lineJoin = "round";
ctx.lineCap = "round";
for (let j = 0; j < ROWS; j++) {
const near = 1 - j / (ROWS - 1);
const alpha = 0.18 + near * 0.78;
const width = 0.7 + near * 1.35;
for (let i = 0; i < COLS - 1; i++) strokeSeg(grid[j][i], grid[j][i + 1], alpha, width, w, h);
if (j < ROWS - 1) {
for (let i = 0; i < COLS; i++) strokeSeg(grid[j][i], grid[j + 1][i], alpha * 0.85, width, w, h);
}
}
}
function frame(now) {
const dt = Math.min(0.05, last ? (now - last) * 0.001 : 0.016);
last = now;
if (!reduced) {
z0 += dt * 4.2 * PARAMS.speed;
look += (targetLook - look) * Math.min(1, dt * 6);
}
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();
})();