0

Hyperspace Starfield

move mouse to steer, hold click to jump

Three thousand stars live in a volume and project to the screen by simple pinhole perspective . Every frame shrinks by , so each star sweeps outward from the vanishing point along the radial line through itself; when a star passes the camera it respawns at the far plane with fresh . The vanishing point eases toward the mouse, so steering tilts the entire field. Each streak is drawn from the previous projected position to the current one, with length proportional to the local screen-space derivative — close stars look fastest. Holding the mouse engages 'jump to lightspeed': depth velocity ramps , streaks elongate, and each star is drawn three times (white, red-shifted forward, blue-shifted back) for a cheap chromatic-aberration fringe. Brightness is bucketed by depth and rendered in six passes so the whole field costs stroke calls per frame instead of .

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.