0
Hyperspace Starfield
move mouse to steer, hold click to jump
idle
169 lines · vanilla
view source
// Hyperspace Starfield — a radial warp field.
//
// Each star lives in (x, y, z) where (x, y) is offset from the vanishing point
// in some abstract "plate space" and z is depth (positive, towards camera).
// We project to screen by sx = x * focal / z + cx, sy = y * focal / z + cy.
// Every frame z shrinks by speed*dt; when a star passes the camera or its
// projected point leaves the screen, we respawn it at a far z with random x,y.
//
// Click-hold engages "jump to lightspeed": speed ramps 6x and each streak is
// drawn three times (center white, +offset red, -offset blue) for a cheap
// chromatic-aberration fringe.
//
// Performance notes:
// - We bucket streaks into 6 brightness buckets and emit one path per bucket
// per frame, so the cost is O(buckets) stroke calls (3x during CA) instead
// of O(N).
// - Star data lives in three Float32 SoA arrays for cache locality.
// - The hot loop hoists invariants (cx, cy, dz) and avoids any per-star
// allocation; bucket arrays are plain JS arrays that we just length-reset
// each frame rather than reallocating.
const STAR_COUNT = 3000;
const FOCAL = 320; // perspective focal length in pixels
const Z_FAR = 1200; // spawn depth
const Z_NEAR = 1.0; // minimum depth (anything closer respawns)
const BASE_SPEED = 240; // px-per-second worth of depth movement
const JUMP_MULT = 6.0; // click-hold speed multiplier
const SPEED_EASE = 4.5; // s^-1 — how fast actual speed chases the target
const VP_EASE = 5.0; // s^-1 — vanishing point follows mouse
const BUCKETS = 6; // brightness buckets for path batching
// Per-segment tag values used only when chromatic aberration is active.
// Plain integers; the entire bucket is in tagged mode when CA is on, so
// there's no risk of confusing a tag byte with a pixel coordinate.
const TAG_WHITE = 0;
const TAG_RED = 1;
const TAG_BLUE = 2;
let W, H;
let cx, cy; // vanishing point (eased toward mouse)
let speed; // current depth speed (eased toward target)
// Star arrays — Float32 SoA for cache friendliness.
let sx; // plate x (offset relative to vanishing point, in "plate units")
let sy; // plate y
let sz; // depth (>0)
// Per-bucket Path2D-style cache: we use ctx.beginPath() once per bucket and
// reuse module-level scratch arrays for projected endpoints.
let bucketLines; // Array<Array<number>> flat [x0,y0,x1,y1, ...]
function rand(min, max) { return min + Math.random() * (max - min); }
function spawnFar(i) {
// Random plate (x,y); biased toward visible region after projection.
// Pick plate coords so that, at z = Z_FAR, the projected offset spans the
// diagonal — guarantees stars sweep across the whole screen as they
// approach. Plate units ~ pixels at z = FOCAL.
const R = Math.max(W, H) * 1.2;
// Use a uniform disk in plate-space so density looks even after radial flow.
const ang = Math.random() * Math.PI * 2;
const rad = Math.sqrt(Math.random()) * R;
sx[i] = Math.cos(ang) * rad;
sy[i] = Math.sin(ang) * rad;
sz[i] = rand(Z_NEAR + Z_FAR * 0.4, Z_FAR);
}
function spawnFresh(i) {
// Like spawnFar but with a full z range, used at init so we don't see
// a single coordinated wavefront on the first frame.
spawnFar(i);
sz[i] = rand(Z_NEAR + 5, Z_FAR);
}
function init({ width, height, ctx }) {
W = width; H = height;
cx = W * 0.5; cy = H * 0.5;
speed = BASE_SPEED;
sx = new Float32Array(STAR_COUNT);
sy = new Float32Array(STAR_COUNT);
sz = new Float32Array(STAR_COUNT);
for (let i = 0; i < STAR_COUNT; i++) spawnFresh(i);
bucketLines = new Array(BUCKETS);
for (let b = 0; b < BUCKETS; b++) bucketLines[b] = [];
// Initial wipe (opaque) so motion-blur trails start from black, not garbage.
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, W, H);
}
function tick({ ctx, dt, width, height, input }) {
if (width !== W || height !== H) {
W = width; H = height;
cx = Math.min(Math.max(cx, 0), W);
cy = Math.min(Math.max(cy, 0), H);
}
// Clamp dt so a backgrounded tab doesn't fling all stars past the camera
// in one frame when we resume.
if (dt > 0.05) dt = 0.05;
// Target vanishing point follows mouse, but only once the user has moved it.
// input.mouseX/Y default to 0 before any move; treat (0,0) as "no input"
// to avoid yanking the VP to the corner on first frame.
const haveMouse = input && (input.mouseX !== 0 || input.mouseY !== 0);
const tx = haveMouse ? input.mouseX : W * 0.5;
const ty = haveMouse ? input.mouseY : H * 0.5;
// Exponential easing toward target — frame-rate independent.
const k = 1 - Math.exp(-VP_EASE * dt);
cx += (tx - cx) * k;
cy += (ty - cy) * k;
// Target speed: BASE_SPEED normally, BASE_SPEED * JUMP_MULT while held.
const jumping = !!(input && input.mouseDown);
const targetSpeed = jumping ? BASE_SPEED * JUMP_MULT : BASE_SPEED;
const sk = 1 - Math.exp(-SPEED_EASE * dt);
speed += (targetSpeed - speed) * sk;
// Motion-blur trail: low-alpha black fill instead of clear.
// Heavier fill during hyperspace would erase the long streaks too fast, so
// ease alpha down when speed is high.
const speedNorm = (speed - BASE_SPEED) / (BASE_SPEED * (JUMP_MULT - 1) + 1e-9);
const trailAlpha = 0.22 - 0.14 * Math.max(0, Math.min(1, speedNorm));
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = `rgba(0,0,0,${trailAlpha.toFixed(3)})`;
ctx.fillRect(0, 0, W, H);
// Reset per-bucket line lists.
for (let b = 0; b < BUCKETS; b++) bucketLines[b].length = 0;
// Pre-compute screen bounds with a small margin for cheap culling.
const margin = 8;
const xMin = -margin, xMax = W + margin;
const yMin = -margin, yMax = H + margin;
// dz this frame (depth advance). Streak length is proportional to local
// screen speed = derivative of projection ~ FOCAL * dz / z^2 in plate units.
const dz = speed * dt;
// Cap streak in screen px so a brief mega-jump doesn't draw line segments
// longer than the canvas (and helps with bucket batching).
const maxStreak = jumping ? Math.max(W, H) * 0.5 : 36;
// Chromatic aberration offset (only during jump). Per-star direction comes
// from the streak vector; small fraction of streak length.
const caStrength = jumping ? 0.18 * Math.min(1, speedNorm) : 0;
// Reusable refs in the hot loop.
const _sx = sx, _sy = sy, _sz = sz;
const _cx = cx, _cy = cy;
for (let i = 0; i < STAR_COUNT; i++) {
const zPrev = _sz[i];
let zNew = zPrev - dz;
if (zNew <= Z_NEAR) {
// Respawn at far plane; skip drawing this frame to avoid a long
// wraparound streak across the screen.
spawnFar(i);
continue;
}
_sz[i] = zNew;
const px = _sx[i];
const py = _sy[i];
const invPrev = FOCAL / zPrev;
const invNew = FOCAL / zNew;
const x1 = px * invPrev + _cx;
const y1 = py * invPrev + _cy;
const x2 = px * invNew + _cx;
const y2 = py * invNew + _cy;
// Quick reject: both endpoints outside the same edge.
if ((x1 < xMin && x2 < xMin) || (x1 > xMax && x2 > xMax) ||
(y1 < yMin && y2 < yMin) || (y1 > yMax && y2 > yMax)) {
// If it's left the frame, respawn — otherwise stars far off-axis at
// close z would never wrap and we'd lose them.
spawnFar(i);
continue;
}
// Cap streak length in screen space.
let dxs = x2 - x1, dys = y2 - y1;
const slen2 = dxs * dxs + dys * dys;
if (slen2 > maxStreak * maxStreak) {
const inv = maxStreak / Math.sqrt(slen2);
dxs *= inv; dys *= inv;
}
const x1c = x2 - dxs;
const y1c = y2 - dys;
// Brightness bucket: closer star = brighter. Map zNew in [Z_NEAR..Z_FAR]
// to bucket index 0 (farthest, dimmest) .. BUCKETS-1 (closest, brightest).
const zFrac = 1 - Math.min(1, (zNew - Z_NEAR) / (Z_FAR - Z_NEAR));
let bIdx = (zFrac * BUCKETS) | 0;
if (bIdx >= BUCKETS) bIdx = BUCKETS - 1;
if (bIdx < 0) bIdx = 0;
if (caStrength > 0) {
// Three offset copies: white center, red shifted along +streak,
// blue along -streak. We pack all three into the same bucket as
// tagged 5-int records: [tag, x1, y1, x2, y2], where tag is a
// float sentinel impossible to collide with screen-pixel coords.
// Using 4-int records for the non-CA path would require a separate
// shape; instead, when CA is active *every* record in the bucket
// is 5-int and the first int is the tag (0=white, 1=red, 2=blue).
const off = caStrength;
const ox = dxs * off * 0.15;
const oy = dys * off * 0.15;
const lines = bucketLines[bIdx];
// Mark this bucket as CA-tagged for the render pass.
lines.push(TAG_WHITE, x1c, y1c, x2, y2);
lines.push(TAG_RED, x1c + ox, y1c + oy, x2 + ox, y2 + oy);
lines.push(TAG_BLUE, x1c - ox, y1c - oy, x2 - ox, y2 - oy);
} else {
bucketLines[bIdx].push(x1c, y1c, x2, y2);
}
}
// Render: one stroke pass per bucket. Lighter compositing so overlapping
// streaks bloom toward white.
ctx.globalCompositeOperation = 'lighter';
ctx.lineCap = 'butt';
for (let b = 0; b < BUCKETS; b++) {
const arr = bucketLines[b];
const n = arr.length;
if (n === 0) continue;
// Brightness scales with bucket index. Slight blue cast at extremes
// gives the warp tunnel a colder fringe.
const t = b / (BUCKETS - 1);
const lum = 80 + t * 175; // 80..255
const blueTint = 220 + t * 35; // sky-leaning
const alphaBase = 0.35 + t * 0.55; // 0.35..0.9
const w = 0.7 + t * 1.4; // 0.7..2.1 px line
ctx.lineWidth = w;
if (caStrength > 0) {
// Tagged-mode bucket: stride 5, first int is tag. Three sub-passes.
const drawTag = (tag, style) => {
ctx.strokeStyle = style;
ctx.beginPath();
let any = false;
for (let i = 0; i < n; i += 5) {
if (arr[i] !== tag) continue;
const x1 = arr[i + 1], y1 = arr[i + 2];
const x2 = arr[i + 3], y2 = arr[i + 4];
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
any = true;
}
if (any) ctx.stroke();
};
drawTag(TAG_WHITE, `rgba(${lum|0},${lum|0},${blueTint|0},${alphaBase.toFixed(3)})`);
drawTag(TAG_RED, `rgba(255,${(lum*0.35)|0},${(lum*0.35)|0},${(alphaBase*0.55).toFixed(3)})`);
drawTag(TAG_BLUE, `rgba(${(lum*0.3)|0},${(lum*0.55)|0},255,${(alphaBase*0.55).toFixed(3)})`);
} else {
// Plain mode: stride 4. One stroke per bucket.
ctx.strokeStyle = `rgba(${lum|0},${lum|0},${blueTint|0},${alphaBase.toFixed(3)})`;
ctx.beginPath();
for (let i = 0; i < n; i += 4) {
ctx.moveTo(arr[i], arr[i + 1]);
ctx.lineTo(arr[i + 2], arr[i + 3]);
}
ctx.stroke();
}
}
// Bright pip at the vanishing point itself — gives the eye something to
// anchor on while steering.
ctx.globalCompositeOperation = 'lighter';
const pipR = jumping ? 3.5 : 2;
ctx.fillStyle = `rgba(255,255,255,${jumping ? 0.9 : 0.5})`;
ctx.beginPath();
ctx.arc(cx, cy, pipR, 0, Math.PI * 2);
ctx.fill();
ctx.globalCompositeOperation = 'source-over';
}
Comments (0)
Log in to comment.