02. Hyperspace · 1993
Retro 3d starfield
About
700 points in a unit cube, projected with a 1/z divide. Depth is decremented each frame; when a star passes the camera it is recycled at the far plane. Motion blur is a cheap trail fade on the 2D canvas. Move the pointer to shift the vanishing point.
Move the pointer over the preview to shift the vanishing point.
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. prefers-reduced-motion freezes the first still.
Source
(function () {
const PARAMS = {
color: "#baffe6",
speed: 1,
trail: 0.2
};
function hexToRgb(hex) {
const n = parseInt(String(hex).replace("#", ""), 16);
if (isNaN(n)) return [186, 255, 230];
return [n >> 16 & 255, n >> 8 & 255, n & 255];
}
function rgbCss(hex, a) {
const r = hexToRgb(hex);
return "rgba(" + r[0] + ", " + r[1] + ", " + r[2] + ", " + a + ")";
}
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 COUNT = 720;
const stars = new Array(COUNT);
for (let i = 0; i < COUNT; i++) {
stars[i] = {
x: Math.random() * 2 - 1,
y: Math.random() * 2 - 1,
z: Math.random(),
};
}
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const look = { x: 0, y: 0 };
const target = { x: 0, y: 0 };
let last = 0;
let raf = 0;
function onPointer(e) {
const rect = canvas.getBoundingClientRect();
target.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
target.y = ((e.clientY - rect.top) / rect.height) * 2 - 1;
}
canvas.addEventListener("pointermove", onPointer, { passive: true });
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) {
resize();
const dt = Math.min(0.1, last ? (now - last) * 0.001 : 0.016);
last = now;
const t = now * 0.001;
look.x += (target.x - look.x) * (1 - Math.exp(-dt * 6));
look.y += (target.y - look.y) * (1 - Math.exp(-dt * 6));
const w = canvas.width;
const h = canvas.height;
const warp = reduced ? 0.12 : 0.18 + 0.82 * (0.5 + 0.5 * Math.sin(t * 0.32));
const speed = (reduced ? 0 : 0.22 + warp * 1.55) * PARAMS.speed;
if (reduced) {
ctx.fillStyle = "#0b0c0e";
ctx.fillRect(0, 0, w, h);
} else {
ctx.fillStyle = "rgba(11, 12, 14, " + PARAMS.trail + ")";
ctx.fillRect(0, 0, w, h);
}
const cx = w * 0.5 + look.x * w * 0.1;
const cy = h * 0.5 + look.y * h * 0.1;
const fov = Math.min(w, h) * 0.52;
for (let i = 0; i < COUNT; i++) {
const s = stars[i];
const pz = s.z;
s.z -= speed * dt;
if (s.z <= 0.02) {
s.x = Math.random() * 2 - 1;
s.y = Math.random() * 2 - 1;
s.z = 1;
}
const sx = cx + (s.x / s.z) * fov;
const sy = cy + (s.y / s.z) * fov;
const px = cx + (s.x / pz) * fov;
const py = cy + (s.y / pz) * fov;
const bright = (1 - s.z);
const a = 0.25 + bright * (0.45 + warp * 0.5);
ctx.strokeStyle = rgbCss(PARAMS.color, a);
ctx.lineWidth = Math.max(1, bright * (1.1 + warp * 2.4));
ctx.beginPath();
ctx.moveTo(px, py);
ctx.lineTo(sx, sy);
ctx.stroke();
}
if (!reduced) raf = requestAnimationFrame(frame);
}
if (reduced) frame(performance.now());
else raf = requestAnimationFrame(frame);
})();