Space / index.html
One's picture
Upload index.html
c27b761 verified
Raw History Blame Contribute Delete
91.3 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Star Drift — Space Flight Explorer</title>
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #050510;
cursor: none; /* native cursor hidden; a fading one is drawn on the canvas */
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
-webkit-user-select: none;
user-select: none;
}
#game {
display: block;
width: 100%;
height: 100%;
cursor: none;
touch-action: none;
}
</style>
</head>
<body>
<canvas id="game"></canvas>
<script>
(function () {
'use strict';
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
let W = 0, H = 0, DPR = 1, CX = 0, CY = 0, FOCAL = 0;
// Static layers are baked to bitmaps in resize(): they only change when the
// window size changes, so there is no reason to re-evaluate a gradient over
// every pixel of every frame.
let bgLayer = null, vignetteLayer = null;
function bakeLayer(w, h, paint) {
const cv = document.createElement('canvas');
cv.width = Math.floor(w * DPR);
cv.height = Math.floor(h * DPR);
const c2 = cv.getContext('2d');
c2.setTransform(DPR, 0, 0, DPR, 0, 0);
paint(c2);
return cv;
}
function clamp(v, a, b) {
return Math.max(a, Math.min(b, v));
}
// ------------------------------------------------------------------
// Perf config — the tunables introduced by the performance pass.
// ------------------------------------------------------------------
const PERF = {
blobMaxR: 80, // galactic-band blobs are capped at this many screen px
sortAsteroids: false // a rock is convex, so backface-culled faces cannot
// overlap in projection and painter order is dead work.
// Measured over random tumbles: 0% overlap on an
// undamaged rock. Crater displacement breaks convexity:
// 1.75% of drawn face area overlaps, and 0.9% would be
// painted in the wrong order (a crater floor over its
// rim). Set true to restore the exact guarantee.
};
// Quantised alpha strings. The hot loops used to build one rgba() string per
// particle per frame; now they index a table built once.
const ALPHA_STR = new Array(64);
for (let i = 0; i < 64; i++) ALPHA_STR[i] = (i / 63).toFixed(3);
function alphaStr(a) {
return ALPHA_STR[Math.min(63, (a * 63) | 0)];
}
// rgb() strings cached per distinct colour. Asteroid faces redraw the same
// handful of shades thousands of times per frame. The cache is bounded: a face
// colour depends on its fixed base tint times a lighting value that varies
// continuously, so over a long session it could drift. Cap it and rebuild.
const RGB_CACHE = new Map();
const RGB_CACHE_MAX = 4000;
function rgbStr(r, g, b) {
if (RGB_CACHE.size > RGB_CACHE_MAX) RGB_CACHE.clear();
const key = (r << 16) | (g << 8) | b;
let s = RGB_CACHE.get(key);
if (s === undefined) {
s = 'rgb(' + r + ',' + g + ',' + b + ')';
RGB_CACHE.set(key, s);
}
return s;
}
// pow(x, 0.8) for asteroid lighting, quantised to 1/256 (a few gray levels,
// not visible against the per-vertex mottling).
const LIT_LUT = new Array(256);
for (let i = 0; i < 256; i++) LIT_LUT[i] = Math.pow(i / 256, 0.8);
// Reused projection targets so the render loop allocates nothing. P_ONE /
// P_TWO / P_CENTER are for a few simultaneous points; P_VERTS, P_RING and
// P_FIL hold batches that must survive across a loop.
function makePool(n) {
const a = new Array(n);
for (let i = 0; i < n; i++) a[i] = { x: 0, y: 0, z: 0, fade: 0 };
return a;
}
const P_ONE = makePool(1)[0], P_TWO = makePool(1)[0], P_THREE = makePool(1)[0], P_CENTER = makePool(1)[0];
const P_VERTS = makePool(128); // asteroid rocks are 42 verts, the ship is 19
const P_RING = makePool(128); // 57 ring points
const P_FIL = makePool(128); // catmullRom output is at most (10-1)*7+1 = 64
const P_OK = new Uint8Array(128); // 1 = this pooled point is valid
const RING_FRONT = new Uint8Array(128); // 0 = invalid, 1 = behind, 2 = in front
const SHIP_W = [0, 0, 0]; // scratch for shipWorldPoint
const AST_LOCAL = new Float64Array(128 * 3); // rotated local verts (backface test)
// Run-length grouping for filament strokes: consecutive segments that quantise
// to the same alpha are drawn as one path with one stroke. Alpha is monotonic
// in distance, so a run sits at a similar depth and can share one width. This
// is the only change in the file that is not exact: alpha is quantised to
// 1/63 (max error 0.016 on these alphas) and widths are averaged per run.
function flushRun(colorStr, bucket, cnt, wsum) {
if (!cnt) return;
ctx.strokeStyle = 'rgba(' + colorStr + ',' + ALPHA_STR[bucket] + ')';
ctx.lineWidth = wsum / cnt;
ctx.stroke();
}
// Reused face records (ship + asteroids).
const FACE_REC = new Array(128);
for (let i = 0; i < FACE_REC.length; i++) FACE_REC[i] = { a: 0, b: 0, c: 0, z: 0, k: '', col: '' };
const faceList = [];
// Shared far-first painter list (nebulae / galaxies / planets / asteroids).
const ENTRY = new Array(256);
for (let i = 0; i < ENTRY.length; i++) {
ENTRY[i] = { it: null, x: 0, y: 0, z: 0, r: 0, dist: 0, fade: 0, dx: 0, dy: 0, dz: 0 };
}
const list = [];
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 2);
W = window.innerWidth;
H = window.innerHeight;
canvas.width = Math.floor(W * DPR);
canvas.height = Math.floor(H * DPR);
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
CX = W / 2;
CY = H / 2;
FOCAL = Math.max(1, H) * 0.9;
bgLayer = bakeLayer(W, H, (c2) => {
const g = c2.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, '#050510');
g.addColorStop(0.5, '#070718');
g.addColorStop(1, '#050510');
c2.fillStyle = g;
c2.fillRect(0, 0, W, H);
});
// cinematic vignette (not UI — just darkened corners)
vignetteLayer = bakeLayer(W, H, (c2) => {
const g = c2.createRadialGradient(CX, CY, Math.min(W, H) * 0.34, CX, CY, Math.hypot(W, H) * 0.72);
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(0.62, 'rgba(2,3,10,0.16)');
g.addColorStop(1, 'rgba(2,3,12,0.48)');
c2.fillStyle = g;
c2.fillRect(0, 0, W, H);
});
}
window.addEventListener('resize', resize);
resize();
// ------------------------------------------------------------------
// 3D math helpers (plain objects, no libraries)
// ------------------------------------------------------------------
function sub(a, b) { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; }
function dot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function cross(a, b) {
return [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0]
];
}
function norm(a) {
const l = Math.hypot(a[0], a[1], a[2]) || 1;
return [a[0] / l, a[1] / l, a[2] / l];
}
function len(a) { return Math.hypot(a[0], a[1], a[2]); }
const TAU = Math.PI * 2;
// Rodrigues rotation of v about a unit axis — the primitive the steering
// uses to swing the nose along one continuous arc.
function rotateAbout(v, axis, ang) {
const c = Math.cos(ang), s = Math.sin(ang), t = 1 - c;
const k = axis[0] * v[0] + axis[1] * v[1] + axis[2] * v[2];
const cx = axis[1] * v[2] - axis[2] * v[1];
const cy = axis[2] * v[0] - axis[0] * v[2];
const cz = axis[0] * v[1] - axis[1] * v[0];
return [
v[0] * c + cx * s + axis[0] * k * t,
v[1] * c + cy * s + axis[1] * k * t,
v[2] * c + cz * s + axis[2] * k * t
];
}
function smoothstep(t) { return t * t * (3 - 2 * t); }
function dot3(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
// Ship state: position, velocity, and the nose as a real 3D unit vector.
// heading / pitch / bank are read back out of that vector for the camera
// and the mesh, so everything stays consistent.
const ship = {
pos: [0, 0, 0],
vel: [0, 0, 0],
fwd: [0, 0, -1], // unit nose direction; heading 0 = facing -Z
omega: [0, 0, 0], // live angular velocity (rad/s) of the nose
heading: 0,
pitch: 0,
bank: 0,
throttle: 0
};
// Turning: the nose is rotated about the single axis perpendicular to the
// plane spanned by (current nose, click target) — one continuous great-circle
// arc instead of independent yaw and pitch ramps. The turn rate is
// proportional to the remaining error (so it eases to a stop, never slams),
// capped (so it stays slow and readable) and slew-limited (so it ramps up
// instead of snapping). Result: soft start, gentle peak, soft arrival.
const TURN_MAX = 0.6; // rad/s ceiling on the turn (~34 deg/s)
const TURN_GAIN = 0.8; // proportional ease-out toward the target
const TURN_SLEW = 0.85; // rad/s^2 — how fast the turn can build or relax
const MAX_PITCH = 1.30; // ~74 deg nose authority (keeps the level camera sane)
const PITCH_SIN = Math.sin(MAX_PITCH);
const ACCEL = 88; // thrust strength
const DRAG = 0.95; // exponential drag coefficient
const MAX_SPEED = 118; // hard speed cap
const THROTTLE_RATE = 1.6; // how fast the engines ease in / out
const ENGINE_LOCAL = [[-0.3, 0.12, 1.32], [0.3, 0.12, 1.32]];
// The turn axis is remembered between frames so the nose cannot jitter as
// it converges (see update()).
const turnAxis = [0, 0, 1];
// ------------------------------------------------------------------
// Click-to-fly: click anywhere and the ship turns to face that screen
// direction, then keeps flying that way forever until the next click.
// The click is un-projected through the current camera basis, so the
// ship always aims exactly at the spot you click (WYSIWYG aiming).
// ------------------------------------------------------------------
let hasTarget = false;
const targetFwd = [0, 0, -1]; // unit world direction the nose steers toward
let lastCam = null; // camera basis from the last rendered frame
function setTargetFromClick(mx, my) {
if (!lastCam) return;
// screen point -> camera-space ray direction (x right, y up, z forward)
let dx = mx - CX, dy = CY - my, dz = FOCAL;
const l = Math.hypot(dx, dy, dz) || 1;
dx /= l; dy /= l; dz /= l;
// camera space -> world space via the camera basis vectors
const wx = dx * lastCam.xAxis[0] + dy * lastCam.yAxis[0] + dz * lastCam.zAxis[0];
const wy = dx * lastCam.xAxis[1] + dy * lastCam.yAxis[1] + dz * lastCam.zAxis[1];
const wz = dx * lastCam.xAxis[2] + dy * lastCam.yAxis[2] + dz * lastCam.zAxis[2];
const h = Math.atan2(wx, -wz);
const p = clamp(Math.asin(clamp(wy, -1, 1)), -MAX_PITCH, MAX_PITCH);
const cp = Math.cos(p);
targetFwd[0] = Math.sin(h) * cp;
targetFwd[1] = Math.sin(p);
targetFwd[2] = -Math.cos(h) * cp;
hasTarget = true;
}
// ------------------------------------------------------------------
// Cursor: fades out 1 second after the mouse stops moving, and fades
// back in as soon as it moves again.
// ------------------------------------------------------------------
let mouseX = CX, mouseY = CY;
let lastMove = -1e9; // long ago -> cursor starts hidden
let cursorAlpha = 0;
canvas.addEventListener('pointerdown', (e) => {
setTargetFromClick(e.clientX, e.clientY);
lastMove = performance.now();
});
canvas.addEventListener('pointermove', (e) => {
mouseX = e.clientX;
mouseY = e.clientY;
lastMove = performance.now();
});
// Fixed: use canvas mouseleave, not document mouseleave.
canvas.addEventListener('mouseleave', () => {
lastMove = -1e9;
});
window.addEventListener('blur', () => {
lastMove = -1e9;
});
// ------------------------------------------------------------------
// World objects
// ------------------------------------------------------------------
const STAR_COUNT = 15000;
const stars = [];
const starColors = [
[255, 255, 255], [150, 180, 255], [255, 225, 140], [255, 140, 130]
];
const starStyle = starColors.map(c => 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')');
const FIELD = 2600; // respawn half-extent (infinite space wrap)
function makeStar() {
return {
x: (Math.random() * 2 - 1) * FIELD,
y: (Math.random() * 2 - 1) * FIELD,
z: (Math.random() * 2 - 1) * FIELD,
ci: (Math.random() * 4) | 0,
s: Math.random() < 0.18 ? 2 : 1
};
}
for (let i = 0; i < STAR_COUNT; i++) stars.push(makeStar());
// Seeded value noise + fBm (procedural detail for nebulae and asteroids)
function makeNoise(seed) {
const rnd = (h) => {
const s = Math.sin(h * 127.1 + seed * 311.7) * 43758.5453;
return s - Math.floor(s);
};
const grad = (x, y) => rnd(Math.floor(x) * 157 + Math.floor(y) * 113);
const smooth = (t) => t * t * (3 - 2 * t);
function noise(x, y) {
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const u = smooth(xf), v = smooth(yf);
const a = grad(xi, yi), b = grad(xi + 1, yi);
const c = grad(xi, yi + 1), d = grad(xi + 1, yi + 1);
return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}
function fbm(x, y, oct = 4) {
let v = 0, amp = 0.5, f = 1, tot = 0;
for (let o = 0; o < oct; o++) {
v += noise(x * f, y * f) * amp;
tot += amp;
amp *= 0.5;
f *= 2;
}
return v / tot;
}
return { noise, fbm };
}
// Nebulae — wrapped in a box 2x the ship field so they can exist far away
// and fade in from invisible instead of popping into view.
const FIELD_NEB = FIELD * 2;
const NEBULA_COUNT = 16; // 8x the volume of the old box -> same density
const nebulae = [];
const nebPalette = [
[255, 120, 200], [110, 170, 255], [120, 230, 170],
[255, 170, 90], [180, 120, 255]
];
// Pre-render each nebula into a wispy gas-cloud texture: domain-warped fBm
// filaments, dark dust lanes, bright emission cores and embedded stars —
// so nebulae read as real clouds, not flat gradient circles.
function makeNebulaTexture(c, seed) {
const S = 128;
const cv = document.createElement('canvas');
cv.width = cv.height = S;
const c2 = cv.getContext('2d');
const nz = makeNoise(seed);
const img = c2.createImageData(S, S);
const data = img.data;
const R = S * 0.5;
for (let y = 0; y < S; y++) {
for (let x = 0; x < S; x++) {
const dx = x - R, dy = y - R;
const d = Math.hypot(dx, dy) / R;
if (d >= 1) continue;
const ang = Math.atan2(dy, dx);
// domain-warped fBm -> swirling gas filaments
const q1 = nz.fbm(dx * 0.05 + 5.2, dy * 0.05 + 1.3, 4);
const q2 = nz.fbm(dx * 0.05 + 9.1, dy * 0.05 + 7.7, 4);
const n = nz.fbm(dx * 0.06 + q1 * 8 + ang * 0.9, dy * 0.06 + q2 * 8, 5);
const fil = Math.pow(n, 2.3);
// dark dust lanes
const dust = nz.fbm(dx * 0.08 + 21.4, dy * 0.08 + 33.9, 3);
const lane = dust < 0.42 ? (0.42 - dust) * 2.2 : 0;
const soft = 1 - d;
const a = Math.max(0, fil * 1.9 - lane) * soft * soft;
const i = (y * S + x) * 4;
const hot = Math.pow(n, 6) * 120; // bright cores along filaments
data[i] = Math.min(255, c[0] + hot);
data[i + 1] = Math.min(255, c[1] + hot * 0.9);
data[i + 2] = Math.min(255, c[2] + hot * 0.8);
data[i + 3] = Math.min(255, a * 255);
}
}
c2.putImageData(img, 0, 0);
// a few faint background stars seen through the gas
for (let k = 0; k < 46; k++) {
const sx = nz.noise(k * 3.1, 7.7) * S;
const sy = nz.noise(2.2, k * 5.7) * S;
const sd = Math.hypot(sx - R, sy - R) / R;
if (sd > 0.85) continue;
c2.fillStyle = 'rgba(255,255,255,' + (0.2 + nz.noise(k, k) * 0.55).toFixed(2) + ')';
c2.fillRect(sx, sy, 1, 1);
}
return cv;
}
const nebulaTex = nebPalette.map((c, i) => makeNebulaTexture(c, 1234 + i * 77));
function makeNebula() {
const ci = (Math.random() * nebPalette.length) | 0;
return {
x: (Math.random() * 2 - 1) * FIELD_NEB,
y: (Math.random() * 2 - 1) * FIELD_NEB * 0.6,
z: (Math.random() * 2 - 1) * FIELD_NEB,
r: 700 + Math.random() * 1000,
ci,
rot: Math.random() * Math.PI * 2
};
}
for (let i = 0; i < NEBULA_COUNT; i++) nebulae.push(makeNebula());
// Asteroids — irregular gray polyhedra
// Asteroids wrap in a box 3x the ship field — a huge runway so they can
// fade in from invisible over many seconds instead of popping into view.
const FIELD_AST = FIELD * 3;
const ASTEROID_COUNT = 150; // 150 rocks in a 27x-larger box keeps the same encounter density as before
const asteroids = [];
function makeAsteroid() {
// Icosahedron base, one subdivision -> 42 verts / 80 triangles
const T = (1 + Math.sqrt(5)) / 2;
let verts = [
[-1, T, 0], [1, T, 0], [-1, -T, 0], [1, -T, 0],
[0, -1, T], [0, 1, T], [0, -1, -T], [0, 1, -T],
[T, 0, -1], [T, 0, 1], [-T, 0, -1], [-T, 0, 1]
];
const il = Math.hypot(1, T);
verts = verts.map(p => [p[0] / il, p[1] / il, p[2] / il]);
const F = [
[0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11],
[1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8],
[3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9],
[4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1]
];
let faces = F.map(f => f.slice());
const midCache = {};
function midpoint(a, b) {
const key = a < b ? a + '_' + b : b + '_' + a;
const hit = midCache[key];
if (hit !== undefined) return hit;
const pa = verts[a], pb = verts[b];
let m = [(pa[0] + pb[0]) / 2, (pa[1] + pb[1]) / 2, (pa[2] + pb[2]) / 2];
const l = Math.hypot(m[0], m[1], m[2]) || 1;
m = [m[0] / l, m[1] / l, m[2] / l];
verts.push(m);
midCache[key] = verts.length - 1;
return midCache[key];
}
const nextF = [];
for (const f of faces) {
const ab = midpoint(f[0], f[1]);
const bc = midpoint(f[1], f[2]);
const ca = midpoint(f[2], f[0]);
nextF.push([f[0], ab, ca], [ab, f[1], bc], [ca, bc, f[2]], [ab, bc, ca]);
}
faces = nextF;
// random tumble of the whole shape
const rz = Math.random() * Math.PI * 2, ry = Math.random() * Math.PI * 2;
const rx = (Math.random() - 0.5) * 1.1;
verts = verts.map(p => {
let x = p[0], y = p[1], z = p[2];
let t = x * Math.cos(rz) - y * Math.sin(rz);
y = x * Math.sin(rz) + y * Math.cos(rz); x = t;
t = y * Math.cos(rx) - z * Math.sin(rx);
z = y * Math.sin(rx) + z * Math.cos(rx); y = t;
t = x * Math.cos(ry) + z * Math.sin(ry);
z = -x * Math.sin(ry) + z * Math.cos(ry); x = t;
return [x, y, z];
});
// lumpy regolith displacement
const nz = makeNoise(3000 + ((Math.random() * 9999) | 0));
verts = verts.map(p => {
const k = 1 + 0.16 * (nz.fbm(p[0] * 2.3 + 5, p[1] * 2.3 + 9) - 0.5)
+ 0.10 * (nz.fbm(p[1] * 5.1 + 3, p[2] * 5.1 + 7) - 0.5);
return [p[0] * k, p[1] * k, p[2] * k];
});
// mottled per-vertex tint
const seed = Math.random() * 97;
const vcol = verts.map(p => {
const g = 0.50 + (nz.fbm(p[0] * 3.7 + seed, p[2] * 3.7 + seed) - 0.5) * 0.42;
return [g, g, g * 1.05];
});
// craters: sink a random vertex, raise its neighbours into a rim
const nv = verts.length;
const craters = 8 + ((Math.random() * 7) | 0);
for (let k = 0; k < craters; k++) {
const vi = (Math.random() * nv) | 0;
const dir = verts[vi];
const l = Math.hypot(dir[0], dir[1], dir[2]) || 1;
const depth = 0.12 + Math.random() * 0.14;
verts[vi] = [dir[0] / l * (1 - depth), dir[1] / l * (1 - depth), dir[2] / l * (1 - depth)];
vcol[vi][0] -= 0.10; vcol[vi][1] -= 0.10; vcol[vi][2] -= 0.08;
const seen = {};
for (const f of faces) {
if (f[0] !== vi && f[1] !== vi && f[2] !== vi) continue;
for (const r of [f[0], f[1], f[2]]) {
if (r === vi || seen[r]) continue;
seen[r] = 1;
const rr = verts[r];
const rl = Math.hypot(rr[0], rr[1], rr[2]) || 1;
const lift = 1 + depth * 0.8;
verts[r] = [rr[0] / rl * lift, rr[1] / rl * lift, rr[2] / rl * lift];
vcol[r][0] += 0.14; vcol[r][1] += 0.14; vcol[r][2] += 0.11;
}
}
}
// per-face base colour (crater floors dark, rims bright)
const faceCol = faces.map(f => {
let r = 0, g = 0, b = 0;
for (const vi of f) { r += vcol[vi][0]; g += vcol[vi][1]; b += vcol[vi][2]; }
r /= 3; g /= 3; b /= 3;
const cc = v => Math.max(0, Math.min(1, v)) * 255;
return [cc(r), cc(g), cc(b)];
});
// Per-face unit normals, computed once here. drawAsteroids used to rebuild
// the cross product and a normalising sqrt for all 80 faces of every rock
// every frame. Rotating a stored unit normal by the tumble matrix is 9
// multiplies, and because that matrix is a pure rotation the normal stays
// unit length — so no renormalising is needed at draw time.
const faceNormal = faces.map(f => {
const v0 = verts[f[0]], v1 = verts[f[1]], v2 = verts[f[2]];
const nx = (v1[1] - v0[1]) * (v2[2] - v0[2]) - (v1[2] - v0[2]) * (v2[1] - v0[1]);
const ny = (v1[2] - v0[2]) * (v2[0] - v0[0]) - (v1[0] - v0[0]) * (v2[2] - v0[2]);
const nz = (v1[0] - v0[0]) * (v2[1] - v0[1]) - (v1[1] - v0[1]) * (v2[0] - v0[0]);
const l = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
return [nx / l, ny / l, nz / l];
});
const scale = 14 + Math.random() * 22;
return {
x: (Math.random() * 2 - 1) * FIELD_AST,
y: (Math.random() * 2 - 1) * FIELD_AST * 0.7,
z: (Math.random() * 2 - 1) * FIELD_AST,
verts, faces, faceNormal, scale, faceCol,
spin: [Math.random() * 0.8 - 0.4, Math.random() * 0.8 - 0.4, Math.random() * 0.8 - 0.4],
rot: [Math.random() * 6, Math.random() * 6, Math.random() * 6],
shade: 90 + Math.random() * 50
};
}
for (let i = 0; i < ASTEROID_COUNT; i++) asteroids.push(makeAsteroid());
// ------------------------------------------------------------------
// Deep-space scenery: a galactic band across the whole sky, filamentary
// strands of stars and gas, spiral galaxies, sunlit planets, meteor
// streaks, near-field dust and engine exhaust. All procedural, all
// wrapping or direction-based, so the flight never runs out of sky.
// ------------------------------------------------------------------
const SUN_DIR = norm([0.42, 0.58, -0.7]);
// Soft glow sprites — far cheaper than a gradient per particle.
function makeSoftDot(rgb, size) {
const cv = document.createElement('canvas');
cv.width = cv.height = size;
const c2 = cv.getContext('2d');
const g = c2.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
g.addColorStop(0, 'rgba(' + rgb + ',1)');
g.addColorStop(0.22, 'rgba(' + rgb + ',0.5)');
g.addColorStop(0.55, 'rgba(' + rgb + ',0.13)');
g.addColorStop(1, 'rgba(' + rgb + ',0)');
c2.fillStyle = g;
c2.fillRect(0, 0, size, size);
return cv;
}
const softCool = makeSoftDot('190,230,255', 64);
const softWarm = makeSoftDot('255,226,190', 64);
// Cockpit halo as a bitmap too — the ship is drawn every frame, and building
// a radial gradient per frame is the expensive part, not the fill.
const HALO_R = 52;
const cockpitGlow = (function () {
const cv = document.createElement('canvas');
cv.width = cv.height = HALO_R;
const c2 = cv.getContext('2d');
const g = c2.createRadialGradient(HALO_R / 2, HALO_R / 2, 0, HALO_R / 2, HALO_R / 2, HALO_R / 2);
g.addColorStop(0, 'rgba(120,225,255,0.55)');
g.addColorStop(1, 'rgba(120,225,255,0)');
c2.fillStyle = g;
c2.fillRect(0, 0, HALO_R, HALO_R);
return cv;
})();
// ---- galactic band ------------------------------------------------
// Direction-only: no wrap and no parallax, so it behaves like real
// infinity and can never pop into view.
const SKY_R = 13000;
const GAL_N = norm([0.45, 0.72, 0.53]); // galactic plane normal
const GAL_U = norm(cross(GAL_N, [0, 0, 1]));
const GAL_V = norm(cross(GAL_N, GAL_U));
const skyNoise = makeNoise(4242);
function bandDir(a, off) {
const ca = Math.cos(a), sa = Math.sin(a);
return norm([
GAL_U[0] * ca + GAL_V[0] * sa + GAL_N[0] * off,
GAL_U[1] * ca + GAL_V[1] * sa + GAL_N[1] * off,
GAL_U[2] * ca + GAL_V[2] * sa + GAL_N[2] * off
]);
}
const bandBlobs = [];
const bandStars = [];
{
const N = 420;
for (let i = 0; i < N; i++) {
const a = i / N * TAU, ca = Math.cos(a), sa = Math.sin(a);
const b = skyNoise.fbm(ca * 2.4 + 11, sa * 2.4 + 5, 4);
const dust = skyNoise.fbm(ca * 5.5 + 47, sa * 5.5 + 19, 3);
const off = (skyNoise.noise(ca * 4.3, sa * 4.3) - 0.5) * 0.26;
// fbm brightness minus dark dust lanes where the noise dips
const bright = Math.max(0, b * 1.5 - (dust < 0.44 ? (0.44 - dust) * 2.8 : 0));
if (bright < 0.04) continue;
bandBlobs.push({
dir: bandDir(a, off),
r: 1500 + b * 3400,
a: Math.min(0.17, bright * 0.13),
tex: b > 0.58 ? softWarm : softCool
});
}
for (let i = 0; i < 2600; i++) {
const a = Math.random() * TAU;
const g = (Math.random() + Math.random() + Math.random() - 1.5) * 0.3; // hug the plane
bandStars.push({
dir: bandDir(a, g),
s: Math.random() < 0.1 ? 2 : 1,
b: (Math.random() * 3) | 0,
c: (Math.random() * 3) | 0
});
}
}
// ---- galactic filaments (cosmic web strands) ----------------------
const FIELD_FIL = FIELD * 4;
const FIL_COUNT = 26;
const filPalette = [
[120, 170, 255], [160, 200, 255], [140, 235, 220], [255, 200, 160], [200, 160, 255]
];
function catmullRom(points, per) {
const out = [], n = points.length;
for (let i = 0; i < n - 1; i++) {
const p0 = points[Math.max(0, i - 1)], p1 = points[i];
const p2 = points[i + 1], p3 = points[Math.min(n - 1, i + 2)];
for (let s = 0; s < per; s++) {
const t = s / per, t2 = t * t, t3 = t2 * t;
out.push([
0.5 * (2 * p1[0] + (-p0[0] + p2[0]) * t + (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 + (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3),
0.5 * (2 * p1[1] + (-p0[1] + p2[1]) * t + (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 + (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3),
0.5 * (2 * p1[2] + (-p0[2] + p2[2]) * t + (2 * p0[2] - 5 * p1[2] + 4 * p2[2] - p3[2]) * t2 + (-p0[2] + 3 * p1[2] - 3 * p2[2] + p3[2]) * t3)
]);
}
}
out.push(points[n - 1].slice());
return out;
}
function makeFilament() {
const seed = (Math.random() * 9999) | 0;
const nz = makeNoise(seed);
const n = 5 + ((Math.random() * 5) | 0);
let dir = norm([Math.random() * 2 - 1, (Math.random() * 2 - 1) * 0.55, Math.random() * 2 - 1]);
let x = (Math.random() * 2 - 1) * FIELD_FIL * 0.8;
let y = (Math.random() * 2 - 1) * FIELD_FIL * 0.5;
let z = (Math.random() * 2 - 1) * FIELD_FIL * 0.8;
const step = 500 + Math.random() * 600;
const nodes = [];
for (let i = 0; i < n; i++) {
nodes.push([x, y, z]);
const w1 = nz.noise(i * 1.7, seed * 0.01) - 0.5;
const w2 = nz.noise(seed * 0.02, i * 2.3) - 0.5;
dir = norm([dir[0] + w1 * 0.9, dir[1] + w2 * 0.5, dir[2] + w1 * 0.4]);
x += dir[0] * step; y += dir[1] * step; z += dir[2] * step;
}
// faint star clusters gathered at the knots, in local offsets
const knots = [];
for (let i = 0; i < n; i++) {
const k = [];
const m = 9 + ((Math.random() * 10) | 0);
for (let j = 0; j < m; j++) {
k.push([(Math.random() * 2 - 1) * 150, (Math.random() * 2 - 1) * 150, (Math.random() * 2 - 1) * 150]);
}
knots.push(k);
}
const pts = catmullRom(nodes, 7);
// Bounding sphere, so drawFilaments can reject a whole strand with one
// distance test instead of projecting its 35 points. Headroom covers the
// stroke width and the 150-unit knot star offsets. Translation by f.off
// moves the sphere with the strand.
let bx = 0, by = 0, bz = 0;
for (const p of pts) { bx += p[0]; by += p[1]; bz += p[2]; }
bx /= pts.length; by /= pts.length; bz /= pts.length;
let br = 0;
for (const p of pts) {
const l = Math.sqrt((p[0] - bx) * (p[0] - bx) + (p[1] - by) * (p[1] - by) + (p[2] - bz) * (p[2] - bz));
if (l > br) br = l;
}
return {
nodes, knots, pts,
center: [bx, by, bz], radius: br + 200,
ci: (Math.random() * filPalette.length) | 0,
colorStr: '',
w: 22 + Math.random() * 46,
glow: 0.45 + Math.random() * 0.55,
off: [0, 0, 0]
};
}
const filaments = [];
for (let i = 0; i < FIL_COUNT; i++) {
const f = makeFilament();
f.colorStr = filPalette[f.ci][0] + ',' + filPalette[f.ci][1] + ',' + filPalette[f.ci][2]; // once, not per frame
filaments.push(f);
}
// Filaments live far away: they fade out before they reach the camera and
// before a wrap can land them, so they never pop and never straddle you.
function filFade(d) {
if (d < 700 || d > FIELD_FIL) return 0;
return smoothstep(Math.min(1, (d - 700) / 1300)) * smoothstep(Math.min(1, (FIELD_FIL - d) / 2600));
}
// ---- spiral galaxies ----------------------------------------------
const FIELD_GAL = FIELD * 2.4;
const GALAXY_COUNT = 12;
const galPalette = [[150, 190, 255], [255, 200, 170], [170, 240, 225], [215, 175, 255]];
function makeGalaxyTexture(seed, col) {
const S = 160, R = S / 2;
const cv = document.createElement('canvas');
cv.width = cv.height = S;
const c2 = cv.getContext('2d');
const nz = makeNoise(seed);
const cs = col[0] + ',' + col[1] + ',' + col[2];
c2.globalCompositeOperation = 'lighter';
const g0 = c2.createRadialGradient(R, R, 0, R, R, R);
g0.addColorStop(0, 'rgba(255,248,235,0.7)');
g0.addColorStop(0.16, 'rgba(' + cs + ',0.32)');
g0.addColorStop(0.52, 'rgba(' + cs + ',0.1)');
g0.addColorStop(1, 'rgba(' + cs + ',0)');
c2.fillStyle = g0;
c2.fillRect(0, 0, S, S);
const arms = 2 + (seed % 2);
const twist = 2.6 + nz.noise(seed, 3) * 1.9;
for (let arm = 0; arm < arms; arm++) {
const base = arm * TAU / arms + nz.noise(arm * 2.1, seed) * 1.2;
const steps = 110;
for (let i = 1; i < steps; i++) {
const t = i / steps;
const rr = 8 + t * R * 0.92;
const th = base + t * twist + (nz.noise(i * 0.19, arm * 3.7) - 0.5) * 0.7;
const jit = (nz.noise(i * 0.63, arm * 1.9) - 0.5) * 14 * t;
const x = R + Math.cos(th) * (rr + jit);
const y = R + Math.sin(th) * (rr + jit) * 0.58; // tilted disc
const rad = 4 + t * 9;
const a = (1 - t * 0.6) * 0.17;
const gg = c2.createRadialGradient(x, y, 0, x, y, rad);
gg.addColorStop(0, 'rgba(' + cs + ',' + a.toFixed(3) + ')');
gg.addColorStop(1, 'rgba(' + cs + ',0)');
c2.fillStyle = gg;
c2.beginPath(); c2.arc(x, y, rad, 0, TAU); c2.fill();
if (nz.noise(i * 1.31, arm * 7.7) > 0.86) { // young blue star knots
c2.fillStyle = 'rgba(200,230,255,' + (0.3 + nz.noise(i, arm) * 0.4).toFixed(2) + ')';
c2.beginPath(); c2.arc(x, y, 1.3, 0, TAU); c2.fill();
}
}
}
c2.globalCompositeOperation = 'destination-out';
for (let k = 0; k < 26; k++) { // dark dust lanes cutting the disc
const t = nz.noise(k * 2.7, seed);
const th = nz.noise(k * 1.1, seed * 0.3) * TAU;
const rr = 12 + t * R * 0.85;
const x = R + Math.cos(th) * rr, y = R + Math.sin(th) * rr * 0.58;
const rad = 9 + t * 12;
const gg = c2.createRadialGradient(x, y, 0, x, y, rad);
gg.addColorStop(0, 'rgba(0,0,0,0.75)');
gg.addColorStop(1, 'rgba(0,0,0,0)');
c2.fillStyle = gg;
c2.beginPath(); c2.arc(x, y, rad, 0, TAU); c2.fill();
}
c2.globalCompositeOperation = 'source-over';
return cv;
}
const galaxyTex = galPalette.map((c, i) => makeGalaxyTexture(511 + i * 53, c));
function makeGalaxy() {
return {
x: (Math.random() * 2 - 1) * FIELD_GAL,
y: (Math.random() * 2 - 1) * FIELD_GAL * 0.55,
z: (Math.random() * 2 - 1) * FIELD_GAL,
r: 380 + Math.random() * 720,
ci: (Math.random() * galaxyTex.length) | 0,
rot: Math.random() * TAU,
squash: 0.4 + Math.random() * 0.6
};
}
const galaxies = [];
for (let i = 0; i < GALAXY_COUNT; i++) galaxies.push(makeGalaxy());
// ---- sunlit planets -----------------------------------------------
const FIELD_PLANET = FIELD * 2.2;
const PLANET_COUNT = 8;
const planetPal = [
[[36, 64, 118], [58, 104, 146], [214, 228, 238]], // ocean
[[92, 66, 48], [132, 104, 72], [226, 216, 200]], // rocky
[[48, 82, 70], [108, 140, 96], [228, 236, 224]], // verdant
[[124, 86, 58], [176, 126, 84], [242, 222, 192]], // desert
[[40, 52, 82], [92, 112, 152], [222, 232, 246]] // ice
];
function makePlanetTexture(seed) {
const S = 128;
const cv = document.createElement('canvas');
cv.width = cv.height = S;
const c2 = cv.getContext('2d');
const img = c2.createImageData(S, S), data = img.data;
const nz = makeNoise(seed);
const pal = planetPal[seed % planetPal.length];
for (let py = 0; py < S; py++) {
for (let px = 0; px < S; px++) {
const dx = (px - S / 2 + 0.5) / (S / 2), dy = (py - S / 2 + 0.5) / (S / 2);
const rr = dx * dx + dy * dy;
const i = (py * S + px) * 4;
if (rr >= 1) { data[i + 3] = 0; continue; }
data[i + 3] = 255;
const z = Math.sqrt(1 - rr);
const lon = Math.atan2(dy, dx) + seed * 0.37;
const lat = Math.asin(clamp(z, -1, 1)) - Math.PI / 2;
const cl = Math.cos(lat);
// seamless-in-longitude continent field
const c = nz.fbm(Math.cos(lon) * 2.1 + 7, Math.sin(lon) * 2.1 + 3 + lat * 2.6, 4);
const land = c > 0.52;
let base = land ? pal[1] : pal[0];
if (land) {
const h = Math.min(1, (c - 0.52) * 3.2);
base = [base[0] + (pal[2][0] - base[0]) * h * 0.45,
base[1] + (pal[2][1] - base[1]) * h * 0.45,
base[2] + (pal[2][2] - base[2]) * h * 0.45];
}
// ice caps
const ice = Math.abs(lat) > 1.18 + nz.noise(lon * 3, 9) * 0.3;
if (ice) {
const k = Math.min(1, (Math.abs(lat) - 1.1) * 3);
base = [base[0] + (240 - base[0]) * k, base[1] + (246 - base[1]) * k, base[2] + (252 - base[2]) * k];
}
// cloud banding
const cloud = nz.fbm(Math.cos(lon) * 3.3 + 21, Math.sin(lon) * 3.3 + 17 + lat * 3.4, 3);
const ca = clamp((cloud - 0.52) * 2.4, 0, 1) * 0.72;
data[i] = base[0] + (250 - base[0]) * ca;
data[i + 1] = base[1] + (252 - base[1]) * ca;
data[i + 2] = base[2] + (255 - base[2]) * ca;
// limb darkening so the sprite reads as a sphere even before shading
const limb = 0.72 + 0.28 * Math.pow(z, 0.45);
data[i] *= limb; data[i + 1] *= limb; data[i + 2] *= limb;
}
}
c2.putImageData(img, 0, 0);
return cv;
}
const planetTex = [];
for (let i = 0; i < 5; i++) planetTex.push(makePlanetTexture(101 + i * 37));
function makePlanet() {
const nrm = norm([Math.random() * 2 - 1, Math.random() * 1.2 + 0.4, Math.random() * 2 - 1]);
let A = cross(nrm, [0, 1, 0]);
if (len(A) < 1e-4) A = [1, 0, 0]; else A = norm(A);
const B = norm(cross(nrm, A));
const ringed = Math.random() < 0.4;
return {
x: (Math.random() * 2 - 1) * FIELD_PLANET * 0.9,
y: (Math.random() * 2 - 1) * FIELD_PLANET * 0.5,
z: (Math.random() * 2 - 1) * FIELD_PLANET * 0.9,
r: 240 + Math.random() * 620,
tex: (Math.random() * planetTex.length) | 0,
spin: Math.random() * TAU,
spinRate: (Math.random() - 0.5) * 0.06,
atmo: [[120, 190, 255], [255, 170, 120], [150, 240, 210], [205, 205, 255]][(Math.random() * 4) | 0],
atmoStr: '',
ring: ringed,
ringA: A, ringB: B,
ringR: 1.7 + Math.random() * 0.9,
ringW: 26 + Math.random() * 40
};
}
const planets = [];
for (let i = 0; i < PLANET_COUNT; i++) {
const pl = makePlanet();
pl.atmoStr = pl.atmo[0] + ',' + pl.atmo[1] + ',' + pl.atmo[2]; // built once, not per frame
planets.push(pl);
}
// ---- meteors / shooting stars -------------------------------------
const meteors = [];
let nextMeteor = 0, exhaustTick = 0;
const exhaust = [];
function spawnMeteors(cam, now) {
if (now < nextMeteor) return;
nextMeteor = now + 500 + Math.random() * 2200;
const burst = Math.random() < 0.22 ? 2 + ((Math.random() * 3) | 0) : 1;
for (let i = 0; i < burst && meteors.length < 16; i++) {
// spawn in the forward hemisphere, off-axis
const d = norm([(Math.random() * 2 - 1) * 1.1, (Math.random() * 2 - 1) * 0.8, 0.45 + Math.random() * 0.55]);
const dist = 500 + Math.random() * 900;
// travel across the view so it reads as a streak
let lat = cross(d, [0, 1, 0]);
if (len(lat) < 1e-4) lat = [1, 0, 0]; else lat = norm(lat);
lat = rotateAbout(lat, d, Math.random() * TAU);
const speed = 700 + Math.random() * 1500;
const fireball = Math.random() < 0.14;
const vx = lat[0] * speed + d[0] * (Math.random() * 220 - 80);
const vy = lat[1] * speed + d[1] * (Math.random() * 220 - 80);
const vz = lat[2] * speed + d[2] * (Math.random() * 220 - 80);
meteors.push({
x: cam.camPos[0] + d[0] * dist + (Math.random() - 0.5) * 300,
y: cam.camPos[1] + d[1] * dist + (Math.random() - 0.5) * 300,
z: cam.camPos[2] + d[2] * dist + (Math.random() - 0.5) * 300,
vx: vx, vy: vy, vz: vz,
life: 0, max: 0.9 + Math.random() * 1.2,
trail: (fireball ? 0.22 : 0.12) + Math.random() * 0.06,
w: fireball ? 3.2 + Math.random() * 2 : 1.2 + Math.random() * 1.4,
warm: fireball || Math.random() < 0.3,
fireball: fireball
});
}
}
// ---- near-field dust motes (speed cue) ----------------------------
const DUST_COUNT = 460, DUST_R = 620;
const dust = [];
for (let i = 0; i < DUST_COUNT; i++) {
dust.push({
x: (Math.random() * 2 - 1) * DUST_R,
y: (Math.random() * 2 - 1) * DUST_R,
z: (Math.random() * 2 - 1) * DUST_R
});
}
// ------------------------------------------------------------------
// Camera: chase cam behind the ship with a FIXED WORLD-UP view.
// The camera's up vector is always (0,1,0), so the horizon stays level
// and the view never rolls or flips no matter how the ship yaws or
// pitches. Position and look-target are exponentially smoothed (no
// overshoot, no drift) so turns feel fluid.
//
// The view direction follows the FLIGHT PATH, not the nose. The nose can
// swing at ~34 deg/s, but the ship's course only changes as its velocity
// is rebuilt along the new nose. So during a turn the sky holds still and
// the ship rotates inside the frame, instead of the whole world spinning
// the other way under the turn. Once you are flying straight, velocity and
// nose agree and the camera behaves exactly as it always did.
//
// CAM_VIEW_BLEND 0 = old behaviour (view snaps to the nose).
// higher = steadier sky, ship swings more in frame.
// CAM_VIEW_FOLLOW how fast the view settles. Lower = steeper lag.
// CAM_MAX_ANGLE how far the view may fall behind the nose, so the
// ship can never swing out of frame.
// ------------------------------------------------------------------
const CAM_BACK = 110; // distance behind the ship
const CAM_UP = 55; // height above the ship
const CAM_FOLLOW = 7; // smoothing rate (1/s) — softer chase
const CAM_LOOKAHEAD = 26; // camera aims along the view, ship leads it
const CAM_VIEW_BLEND = 0.8; // nose vs. flight path, 0 = pure nose
const CAM_VIEW_FOLLOW = 0.6; // view settling rate (1/s)
const CAM_MAX_ANGLE = 0.8; // rad (~46 deg) soft limit on nose-vs-view lag
const CAM_UNWIND = 0.9; // rad/s — how gently the lag is pulled back
let camState = null;
function cameraBasis(dt) {
const fwd = ship.fwd;
// What the view is allowed to turn toward. Blended with the nose so a
// ship that is barely moving (or decelerating out of the old direction)
// still has a well-defined view.
let want = fwd;
if (CAM_VIEW_BLEND > 0) {
const sp = len(ship.vel);
if (sp > 1) {
const w = CAM_VIEW_BLEND;
const bx = fwd[0] * (1 - w) + (ship.vel[0] / sp) * w;
const by = fwd[1] * (1 - w) + (ship.vel[1] / sp) * w;
const bz = fwd[2] * (1 - w) + (ship.vel[2] / sp) * w;
const bl = Math.hypot(bx, by, bz);
if (bl > 1e-6) want = [bx / bl, by / bl, bz / bl];
}
}
const first = !camState;
if (first) camState = { pos: [0, 0, 0], tgt: [0, 0, 0], view: want.slice() };
const kv = 1 - Math.exp(-CAM_VIEW_FOLLOW * dt);
for (let i = 0; i < 3; i++) camState.view[i] += (want[i] - camState.view[i]) * kv;
// Soft limit on how far the view may fall behind the nose. A hard cap
// would snap the view in spikes, which is the very thing being reduced.
const ca = clamp(dot3(camState.view, fwd), -1, 1);
const lag = Math.acos(ca);
if (lag > CAM_MAX_ANGLE) {
const ax = cross(camState.view, fwd);
const al = len(ax);
if (al > 1e-6) {
const n = [ax[0] / al, ax[1] / al, ax[2] / al];
// positive rotation about cross(view, nose) swings the view back
// toward the nose, but only by what this frame can afford
const r = rotateAbout(camState.view, n, Math.min(lag - CAM_MAX_ANGLE, CAM_UNWIND * dt));
camState.view[0] = r[0]; camState.view[1] = r[1]; camState.view[2] = r[2];
}
}
const view = camState.view;
const desiredPos = [
ship.pos[0] - view[0] * CAM_BACK,
ship.pos[1] - view[1] * CAM_BACK + CAM_UP,
ship.pos[2] - view[2] * CAM_BACK
];
const desiredTgt = [
ship.pos[0] + view[0] * CAM_LOOKAHEAD,
ship.pos[1] + view[1] * CAM_LOOKAHEAD + 8,
ship.pos[2] + view[2] * CAM_LOOKAHEAD
];
const k = first ? 1 : 1 - Math.exp(-CAM_FOLLOW * dt);
for (let i = 0; i < 3; i++) {
camState.pos[i] += (desiredPos[i] - camState.pos[i]) * k;
camState.tgt[i] += (desiredTgt[i] - camState.tgt[i]) * k;
}
const forward = norm(sub(camState.tgt, camState.pos));
let xAxis = cross(forward, [0, 1, 0]); // screen right
if (len(xAxis) < 1e-6) xAxis = [1, 0, 0]; else xAxis = norm(xAxis);
const yAxis = cross(xAxis, forward); // screen up (always world-level)
lastCam = { camPos: camState.pos, xAxis, yAxis, zAxis: forward };
return lastCam;
}
// Transform a world point into camera space and project it into `out`, a
// pooled object, so the render loop allocates nothing. Returns null if behind.
function project(px, py, pz, cam, out) {
const dx = px - cam.camPos[0];
const dy = py - cam.camPos[1];
const dz = pz - cam.camPos[2];
const cz = dx * cam.zAxis[0] + dy * cam.zAxis[1] + dz * cam.zAxis[2];
if (cz <= 0.5) return null; // depth first: most rejects stop here
out.x = CX + ((dx * cam.xAxis[0] + dy * cam.xAxis[1] + dz * cam.xAxis[2]) / cz) * FOCAL;
out.y = CY - ((dx * cam.yAxis[0] + dy * cam.yAxis[1] + dz * cam.yAxis[2]) / cz) * FOCAL;
out.z = cz;
return out;
}
// ------------------------------------------------------------------
// Ship geometry (local space, nose toward -Z, scale factor 15)
// ------------------------------------------------------------------
const SHIP_SCALE = 15;
const shipVerts = [
[0, 0, -1.4], // 0 nose tip
[-0.5, 0.15, -0.4], // 1 cockpit ring left
[0.5, 0.15, -0.4], // 2 cockpit ring right
[0, 0.42, -0.4], // 3 cockpit ring top
[-0.62, 0, 0.6], // 4 mid fuselage left
[0.62, 0, 0.6], // 5 mid fuselage right
[0, 0.5, 0.6], // 6 mid fuselage top
[-0.5, 0, 1.3], // 7 rear left
[0.5, 0, 1.3], // 8 rear right
[0, 0.4, 1.3], // 9 rear top
[-2.1, 0.05, 0.9], // 10 left wing tip (swept back)
[2.1, 0.05, 0.9], // 11 right wing tip (swept back)
[-0.62, 0, 1.25], // 12 left wing root rear
[0.62, 0, 1.25], // 13 right wing root rear
[0, 1.15, 1.55], // 14 tail fin tip
[-0.3, 0.35, 1.25], // 15 tail fin base left
[0.3, 0.35, 1.25], // 16 tail fin base right
[-1.0, 0.55, 1.6], // 17 left side fin tip
[1.0, 0.55, 1.6] // 18 right side fin tip
];
// [a,b,c] index triples, plus a color key: h=hull, w=wing, g=glass, e=engine
const shipFaces = [
// nose cone
[0, 1, 2, 'h'],
[0, 2, 3, 'h'],
[0, 3, 1, 'h'],
// glowing cockpit window
[1, 3, 2, 'g'],
// upper fuselage
[1, 3, 6, 'h'],
[1, 6, 4, 'h'],
[2, 5, 6, 'h'],
[2, 6, 3, 'h'],
// lower fuselage
[4, 6, 5, 'h'],
[4, 5, 8, 'h'],
[4, 8, 7, 'h'],
// rear engine block
[7, 8, 9, 'e'],
[7, 9, 5, 'h'],
[8, 5, 9, 'h'],
// left swept wing
[4, 10, 12, 'w'],
[4, 12, 7, 'w'],
[6, 4, 10, 'w'],
// right swept wing
[5, 11, 13, 'w'],
[5, 13, 8, 'w'],
[6, 5, 11, 'w'],
// tail fin
[15, 16, 14, 'h'],
[9, 14, 15, 'h'],
[9, 16, 14, 'h'],
// side tail fins
[7, 17, 12, 'w'],
[8, 13, 18, 'w']
];
const shipColors = {
h: [74, 80, 96], // dark gray hull
w: [104, 112, 132], // lighter wing
g: [130, 226, 255], // glowing blue cockpit
e: [40, 44, 58] // engine block
};
// ------------------------------------------------------------------
// Update
// ------------------------------------------------------------------
let distance = 0;
let last = performance.now();
function update(dt) {
// --- steering: one smooth arc, eased in and eased out -------------
let errTrue = 0;
if (hasTarget) {
errTrue = Math.acos(clamp(dot3(ship.fwd, targetFwd), -1, 1));
// Route: take the direct great circle, unless that arc would pitch the
// nose past its authority — then level out toward the target's bearing
// first and change pitch afterwards. That is what a real ship does, and
// it is what stops a 180-degree reversal from stalling against the
// pitch limit.
let tgt = targetFwd;
const a0 = cross(ship.fwd, targetFwd);
const a0l = len(a0);
if (a0l > 1e-4) {
const nx = a0[0] / a0l, ny = a0[1] / a0l, nz = a0[2] / a0l;
const err0 = Math.acos(clamp(dot3(ship.fwd, targetFwd), -1, 1));
// Along the arc, nose.y(t) = nose.y*cos t + cy*sin t where
// cy = (axis x nose).y. Its peak inside the swept range is what
// matters — not the peak of the whole great circle.
const cy = nz * ship.fwd[0] - nx * ship.fwd[2];
const fy = ship.fwd[1];
let hi = Math.max(fy, fy * Math.cos(err0) + cy * Math.sin(err0));
let lo = Math.min(fy, fy * Math.cos(err0) + cy * Math.sin(err0));
const tp = Math.atan2(cy, fy);
if (tp > 0 && tp < err0) hi = Math.max(hi, Math.hypot(fy, cy));
const tn = tp - Math.PI;
if (tn > 0 && tn < err0) lo = Math.min(lo, -Math.hypot(fy, cy));
if ((hi > PITCH_SIN || lo < -PITCH_SIN) && Math.hypot(targetFwd[0], targetFwd[2]) > 1e-3) {
const hh = Math.hypot(targetFwd[0], targetFwd[2]);
tgt = [targetFwd[0] / hh, 0, targetFwd[2] / hh];
}
}
const err = Math.acos(clamp(dot3(ship.fwd, tgt), -1, 1));
if (err > 1e-4) {
if (err > Math.PI - 0.02) {
// pointing exactly backwards: the cross product is undefined, so
// pick the natural rotation — yaw level about world up
const ax = cross(ship.fwd, [0, 1, 0]);
if (len(ax) > 1e-4) { turnAxis[0] = ax[0] / len(ax); turnAxis[1] = ax[1] / len(ax); turnAxis[2] = ax[2] / len(ax); }
else { turnAxis[0] = 1; turnAxis[1] = 0; turnAxis[2] = 0; }
} else if (err > 0.05) {
// recompute while the arc is well defined; below that the cross
// product of two near-parallel vectors is noise, and re-deriving it
// per frame makes the nose jitter. Remembering the axis instead lets
// the turn settle dead straight — the stored axis is perpendicular to
// both directions, so rotating by the remaining error lands exactly
// on the target.
const ax = cross(ship.fwd, tgt);
const al = len(ax) || 1;
turnAxis[0] = ax[0] / al; turnAxis[1] = ax[1] / al; turnAxis[2] = ax[2] / al;
}
const axis = turnAxis;
const cur = len(ship.omega);
const want = Math.min(TURN_MAX, err * TURN_GAIN);
const rate = Math.max(0, cur + clamp(want - cur, -TURN_SLEW * dt, TURN_SLEW * dt));
const step = Math.min(rate * dt, err); // never swings past the target
ship.fwd = rotateAbout(ship.fwd, axis, step);
ship.omega[0] = axis[0] * rate;
ship.omega[1] = axis[1] * rate;
ship.omega[2] = axis[2] * rate;
} else {
ship.omega[0] = ship.omega[1] = ship.omega[2] = 0;
}
// safety net: keep the nose off vertical so the level camera stays conditioned
if (Math.abs(ship.fwd[1]) > PITCH_SIN) {
const y = clamp(ship.fwd[1], -PITCH_SIN, PITCH_SIN);
const hh = Math.hypot(ship.fwd[0], ship.fwd[2]) || 1e-6;
const k = Math.sqrt(Math.max(0, 1 - y * y)) / hh;
ship.fwd[0] *= k; ship.fwd[1] = y; ship.fwd[2] *= k;
}
}
// heading / pitch are just the nose vector read back out
ship.pitch = Math.asin(clamp(ship.fwd[1], -1, 1));
ship.heading = Math.atan2(ship.fwd[0], -ship.fwd[2]);
// Bank into the turn (visual roll only — the camera itself stays level).
// Heading grows when the ship rotates about -Y, so the yaw rate is -omega[1].
const targetBank = clamp(-ship.omega[1] * 0.95, -0.62, 0.62);
ship.bank += (targetBank - ship.bank) * Math.min(1, dt * 2.6);
// Throttle eases up once the nose is on course and stays low while the
// ship is still swinging through the turn, so the burn matches the aim.
const wantThrottle = hasTarget ? 0.35 + 0.65 * (1 - Math.min(1, errTrue / 1.15)) : 0;
ship.throttle += (wantThrottle - ship.throttle) * Math.min(1, dt * THROTTLE_RATE);
// Thrust along the nose direction
const fwd = ship.fwd;
if (ship.throttle > 0.001) {
const a = ACCEL * ship.throttle * dt;
ship.vel[0] += fwd[0] * a;
ship.vel[1] += fwd[1] * a;
ship.vel[2] += fwd[2] * a;
}
// integrate velocity with drag
const dragF = Math.exp(-DRAG * dt);
ship.vel[0] *= dragF;
ship.vel[1] *= dragF;
ship.vel[2] *= dragF;
// clamp speed
const sp = len(ship.vel);
if (sp > MAX_SPEED) {
const k = MAX_SPEED / sp;
ship.vel[0] *= k; ship.vel[1] *= k; ship.vel[2] *= k;
}
// integrate position
ship.pos[0] += ship.vel[0] * dt;
ship.pos[1] += ship.vel[1] * dt;
ship.pos[2] += ship.vel[2] * dt;
distance += sp * dt;
// Spin asteroids, planets
for (const a of asteroids) {
a.rot[0] += a.spin[0] * dt;
a.rot[1] += a.spin[1] * dt;
a.rot[2] += a.spin[2] * dt;
}
for (const pl of planets) pl.spin += pl.spinRate * dt;
// Engine exhaust: soft particles shed from the nozzles, drifting back
if (ship.throttle > 0.12) {
for (let i = 0; i < 2; i++) {
const e = ENGINE_LOCAL[exhaustTick++ & 1];
const wp = shipWorldPoint(e, SHIP_W);
const back = 150 + Math.random() * 190;
exhaust.push({
x: wp[0], y: wp[1], z: wp[2],
vx: ship.vel[0] * 0.4 - fwd[0] * back + (Math.random() - 0.5) * 45,
vy: ship.vel[1] * 0.4 - fwd[1] * back + (Math.random() - 0.5) * 45,
vz: ship.vel[2] * 0.4 - fwd[2] * back + (Math.random() - 0.5) * 45,
life: 0, max: 0.5 + Math.random() * 0.5, r: 0.6 + Math.random() * 1.1
});
}
if (exhaust.length > 240) exhaust.splice(0, exhaust.length - 240);
}
for (let i = exhaust.length - 1; i >= 0; i--) {
const p = exhaust[i];
p.life += dt;
if (p.life >= p.max) { exhaust.splice(i, 1); continue; }
p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt;
p.vx *= 0.985; p.vy *= 0.985; p.vz *= 0.985;
}
// Meteors
for (let i = meteors.length - 1; i >= 0; i--) {
const m = meteors[i];
m.life += dt;
if (m.life >= m.max) { meteors.splice(i, 1); continue; }
m.x += m.vx * dt; m.y += m.vy * dt; m.z += m.vz * dt;
}
}
// ------------------------------------------------------------------
// Rendering
// ------------------------------------------------------------------
function drawBackground() {
if (bgLayer) {
ctx.drawImage(bgLayer, 0, 0, W, H);
} else {
ctx.fillStyle = '#050510';
ctx.fillRect(0, 0, W, H);
}
}
function drawStars(cam) {
// Batch stars into 4 color paths for fast rendering
const paths = [new Path2D(), new Path2D(), new Path2D(), new Path2D()];
const sx = ship.pos[0], sy = ship.pos[1], sz = ship.pos[2];
const xlo = sx - FIELD, xhi = sx + FIELD, ylo = sy - FIELD, yhi = sy + FIELD, zlo = sz - FIELD, zhi = sz + FIELD;
const cp = cam.camPos, xa = cam.xAxis, ya = cam.yAxis, za = cam.zAxis;
for (let i = 0; i < stars.length; i++) {
const st = stars[i];
// infinite space: wrap around the ship so stars respawn ahead
if (st.x < xlo) st.x += 2 * FIELD; else if (st.x > xhi) st.x -= 2 * FIELD;
if (st.y < ylo) st.y += 2 * FIELD; else if (st.y > yhi) st.y -= 2 * FIELD;
if (st.z < zlo) st.z += 2 * FIELD; else if (st.z > zhi) st.z -= 2 * FIELD;
const dx = st.x - cp[0], dy = st.y - cp[1], dz = st.z - cp[2];
const cz = dx * za[0] + dy * za[1] + dz * za[2];
if (cz <= 0.5) continue;
const px = CX + ((dx * xa[0] + dy * xa[1] + dz * xa[2]) / cz) * FOCAL;
if (px < -4 || px > W + 4) continue;
const py = CY - ((dx * ya[0] + dy * ya[1] + dz * ya[2]) / cz) * FOCAL;
if (py < -4 || py > H + 4) continue;
paths[st.ci].rect(px, py, st.s, st.s);
}
for (let i = 0; i < 4; i++) {
ctx.fillStyle = starStyle[i];
ctx.fill(paths[i]);
}
}
function drawNebulae(cam) {
// Nebulae wrap in a box of +/-FIELD_NEB (2x the ship field) around the
// ship: the furthest one can ever be is the box corner (NEB_MAX, ~9006u).
// A wrap teleports a nebula to somewhere between FIELD_NEB (one axis) and
// NEB_MAX (a corner) away, so alpha is a pure function of distance:
// exactly 0 everywhere a wrap can land, ramping to full at a FIELD in.
const NEB_MAX = FIELD_NEB * Math.sqrt(3); // view/cull distance, 2x the old one
// painter's: far first
list.length = 0;
for (const n of nebulae) {
// infinite space: wrap around the ship
if (n.x < ship.pos[0] - FIELD_NEB) n.x += 2 * FIELD_NEB; else if (n.x > ship.pos[0] + FIELD_NEB) n.x -= 2 * FIELD_NEB;
if (n.y < ship.pos[1] - FIELD_NEB) n.y += 2 * FIELD_NEB; else if (n.y > ship.pos[1] + FIELD_NEB) n.y -= 2 * FIELD_NEB;
if (n.z < ship.pos[2] - FIELD_NEB) n.z += 2 * FIELD_NEB; else if (n.z > ship.pos[2] + FIELD_NEB) n.z -= 2 * FIELD_NEB;
const dz = n.z - cam.camPos[2];
const dx = n.x - cam.camPos[0];
const dy = n.y - cam.camPos[1];
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (dist > NEB_MAX) continue;
const p = project(n.x, n.y, n.z, cam, P_ONE);
if (!p) continue;
const e = ENTRY[list.length];
e.it = n; e.x = p.x; e.y = p.y; e.z = p.z; e.dist = dist; e.fade = 0;
list.push(e);
}
list.sort((a, b) => b.dist - a.dist);
ctx.globalCompositeOperation = 'lighter';
for (const e of list) {
const n = e.it;
const r = (n.r / e.z) * FOCAL;
if (r < 1) continue;
// fully transparent from FIELD_NEB (5200u) out to the horizon, full by
// a FIELD (2600u) in — smoothstep so the ramp itself has no kinks
const t = Math.max(0, Math.min(1, (FIELD_NEB - e.dist) / (FIELD_NEB - FIELD)));
const fade = t * t * (3 - 2 * t);
const alpha = 0.5 * fade;
ctx.save();
ctx.globalAlpha = alpha;
ctx.translate(e.x, e.y);
ctx.rotate(n.rot);
ctx.drawImage(nebulaTex[n.ci], -r, -r, r * 2, r * 2);
ctx.restore();
}
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
}
// Rotate a local point into world space, then translate & scale.
// Yaw+pitch are one rotation matrix whose columns are the ship's right,
// up and back axes in world space. The local nose (-Z) therefore maps to
// exactly (sin h * cos p, sin p, -cos h * cos p) — the same direction
// update() thrusts along — at every heading and pitch.
function shipWorldPoint(v, out) {
const s = SHIP_SCALE;
const x = v[0] * s, y = v[1] * s, z = v[2] * s;
// bank (visual roll) in the body frame about local forward (local Z)
const cb = Math.cos(ship.bank), sb = Math.sin(ship.bank);
const x1 = x * cb + y * sb;
const y1 = -x * sb + y * cb;
const z1 = z;
const ch = Math.cos(ship.heading), sh = Math.sin(ship.heading);
const cp = Math.cos(ship.pitch), sp2 = Math.sin(ship.pitch);
out[0] = ship.pos[0] + ch * x1 - sh * sp2 * y1 - sh * cp * z1;
out[1] = ship.pos[1] + cp * y1 - sp2 * z1;
out[2] = ship.pos[2] + sh * x1 + ch * sp2 * y1 + ch * cp * z1;
return out;
}
function worldShipPoint(v, cam, out) {
const w = shipWorldPoint(v, SHIP_W);
return project(w[0], w[1], w[2], cam, out);
}
function drawShip(cam, now) {
// Precompute projected verts into the pool (19 verts, far under the pool size)
for (let i = 0; i < shipVerts.length; i++) P_OK[i] = worldShipPoint(shipVerts[i], cam, P_VERTS[i]) ? 1 : 0;
// Build face list with depth + normal (skip if any vert behind)
faceList.length = 0;
for (const f of shipFaces) {
if (!P_OK[f[0]] || !P_OK[f[1]] || !P_OK[f[2]]) continue;
const a = P_VERTS[f[0]], b = P_VERTS[f[1]], c = P_VERTS[f[2]];
const rec = FACE_REC[faceList.length];
rec.a = a; rec.b = b; rec.c = c;
rec.z = (a.z + b.z + c.z) / 3;
rec.k = f[3];
faceList.push(rec);
}
faceList.sort((p, q) => q.z - p.z);
for (const fc of faceList) {
const key = fc.k;
const base = shipColors[key];
// face normal in camera-ish space via projected verts (approx)
const bx = fc.b.x - fc.a.x, by = fc.b.y - fc.a.y;
const fx = fc.c.x - fc.a.x, fy = fc.c.y - fc.a.y;
const nx = bx * fy - fx * by;
let shade = 0.55 + 0.45 * Math.abs(nx) / (Math.max(1, Math.sqrt(bx * bx + by * by)));
shade = Math.min(1.2, shade);
const col = key === 'g'
? base
: [Math.min(255, base[0] * shade), Math.min(255, base[1] * shade), Math.min(255, base[2] * shade)];
ctx.beginPath();
ctx.moveTo(fc.a.x, fc.a.y);
ctx.lineTo(fc.b.x, fc.b.y);
ctx.lineTo(fc.c.x, fc.c.y);
ctx.closePath();
ctx.fillStyle = rgbStr(col[0] | 0, col[1] | 0, col[2] | 0);
ctx.fill();
if (key !== 'g') {
ctx.strokeStyle = 'rgba(0,0,0,0.25)';
ctx.lineWidth = 0.5;
ctx.stroke();
}
}
// Cockpit glow halo — baked once as a bitmap, so no gradient is built per frame
const cp = worldShipPoint([0, 0.35, -0.4], cam, P_ONE);
if (cp) {
ctx.globalCompositeOperation = 'lighter';
ctx.drawImage(cockpitGlow, cp.x - HALO_R / 2, cp.y - HALO_R / 2, HALO_R, HALO_R);
ctx.globalCompositeOperation = 'source-over';
}
// Thruster flames at the REAR (two engines)
drawThrusters(cam, now);
}
function drawThrusters(cam, now) {
const thr = ship.throttle;
if (thr < 0.04) return;
const t = now * 0.02;
const flick = 0.72 + 0.28 * Math.sin(t) + 0.14 * Math.sin(t * 2.7);
const baseLen = (0.55 + 1.25 * thr) * flick;
const glow = 0.25 + 0.75 * thr;
ctx.globalCompositeOperation = 'lighter';
for (const e of ENGINE_LOCAL) {
const p0 = worldShipPoint(e, cam, P_ONE);
if (!p0) continue;
// flame extends further back (+Z local)
const p1 = worldShipPoint([e[0], e[1], 1.32 + baseLen * 0.6], cam, P_TWO);
const p2 = worldShipPoint([e[0], e[1], 1.32 + baseLen], cam, P_THREE);
if (!p1 || !p2) continue;
const grad = ctx.createLinearGradient(p0.x, p0.y, p2.x, p2.y);
grad.addColorStop(0, 'rgba(180,235,255,' + alphaStr(0.9 * glow) + ')');
grad.addColorStop(0.4, 'rgba(70,160,255,' + alphaStr(0.7 * glow) + ')');
grad.addColorStop(1, 'rgba(40,90,255,0)');
ctx.strokeStyle = grad;
ctx.lineWidth = 5 * flick * (0.55 + 0.5 * thr);
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.quadraticCurveTo(p1.x, p1.y, p2.x, p2.y);
ctx.stroke();
// core glow
ctx.fillStyle = 'rgba(200,240,255,' + alphaStr(0.5 * flick * glow) + ')';
ctx.beginPath();
ctx.arc(p0.x, p0.y, 3.2, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
}
const AST_LIGHT = norm([0.42, 0.58, -0.7]); // fixed sun direction (world space)
function drawAsteroids(cam) {
const cp = cam.camPos;
list.length = 0;
for (const a of asteroids) {
// infinite space: wrap around the ship
if (a.x < ship.pos[0] - FIELD_AST) a.x += 2 * FIELD_AST; else if (a.x > ship.pos[0] + FIELD_AST) a.x -= 2 * FIELD_AST;
if (a.y < ship.pos[1] - FIELD_AST) a.y += 2 * FIELD_AST; else if (a.y > ship.pos[1] + FIELD_AST) a.y -= 2 * FIELD_AST;
if (a.z < ship.pos[2] - FIELD_AST) a.z += 2 * FIELD_AST; else if (a.z > ship.pos[2] + FIELD_AST) a.z -= 2 * FIELD_AST;
const dx = a.x - cp[0], dy = a.y - cp[1], dz = a.z - cp[2];
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (dist > FIELD_AST * Math.sqrt(3)) continue; // view distance, 3x the old 3640u
// Pop-proof fade in (same idea as the nebulae, with a hard guard).
// Two continuous factors, both pure functions of distance, so a wrap
// (which can only land an asteroid >= ~7560u from the camera) always
// lands it at EXACTLY 0 alpha. The runway is now ~5300u long (about
// 40s of travel at max speed) and concave, so a rock is a faint speck
// far away, becomes clear over many seconds, and eases up to solid:
// 7000u -> ~8% 5500u -> ~46% 4500u -> ~70% 2800u -> ~98%
// guard — hard zero within 300u of the far wrap edge (respawn zone)
// main — smoothstep 7540u -> 2200u, gamma-lifted
const guard = Math.max(0, Math.min(1, (7540 - dist) / 300));
if (guard <= 0) continue; // guaranteed-invisible respawn zone
const t = Math.max(0, Math.min(1, (7540 - dist) / 5340));
const alpha = Math.pow(t * t * (3 - 2 * t), 0.7) * guard;
if (alpha < 0.02) continue; // below this it's invisible anyway — skip work
const p = project(a.x, a.y, a.z, cam, P_ONE);
if (!p) continue;
const e = ENTRY[list.length];
e.it = a; e.x = p.x; e.y = p.y; e.z = p.z; e.dist = dist; e.fade = alpha;
list.push(e);
}
list.sort((x, y) => y.dist - x.dist);
for (const e of list) {
if (e.z < 2) continue;
ctx.globalAlpha = e.fade;
const a = e.it;
const s = a.scale;
const c0 = Math.cos(a.rot[0]), s0 = Math.sin(a.rot[0]);
const c1 = Math.cos(a.rot[1]), s1 = Math.sin(a.rot[1]);
const c2 = Math.cos(a.rot[2]), s2 = Math.sin(a.rot[2]);
// tumble matrix (same orientation as the original three-step rotation),
// so a stored normal can be rotated with 9 multiplies instead of a cross
// product plus a normalising sqrt per face.
const m0 = c1 * c2, m1 = s0 * s1 * c2 - c0 * s2, m2 = c0 * s1 * c2 + s0 * s2;
const m3 = c1 * s2, m4 = s0 * s1 * s2 + c0 * c2, m5 = c0 * s1 * s2 - s0 * c2;
const m6 = -s1, m7 = s0 * c1, m8 = c0 * c1;
for (let i = 0; i < a.verts.length; i++) {
const v = a.verts[i];
const x = v[0] * s, y = v[1] * s, z = v[2] * s;
const lx = m0 * x + m1 * y + m2 * z;
const ly = m3 * x + m4 * y + m5 * z;
const lz = m6 * x + m7 * y + m8 * z;
const o = i * 3;
AST_LOCAL[o] = lx; AST_LOCAL[o + 1] = ly; AST_LOCAL[o + 2] = lz;
P_OK[i] = project(a.x + lx, a.y + ly, a.z + lz, cam, P_VERTS[i]) ? 1 : 0;
}
faceList.length = 0;
for (let fi = 0; fi < a.faces.length; fi++) {
const f = a.faces[fi];
if (!P_OK[f[0]] || !P_OK[f[1]] || !P_OK[f[2]]) continue;
const A = P_VERTS[f[0]], B = P_VERTS[f[1]], C = P_VERTS[f[2]];
const o0 = f[0] * 3, o1 = f[1] * 3, o2 = f[2] * 3;
// stored unit normal, rotated by the tumble matrix
const n = a.faceNormal[fi];
const nx = m0 * n[0] + m1 * n[1] + m2 * n[2];
const ny = m3 * n[0] + m4 * n[1] + m5 * n[2];
const nz = m6 * n[0] + m7 * n[1] + m8 * n[2];
// backface cull: normal must point toward the camera
const fx = (AST_LOCAL[o0] + AST_LOCAL[o1] + AST_LOCAL[o2]) / 3;
const fy = (AST_LOCAL[o0 + 1] + AST_LOCAL[o1 + 1] + AST_LOCAL[o2 + 1]) / 3;
const fz = (AST_LOCAL[o0 + 2] + AST_LOCAL[o1 + 2] + AST_LOCAL[o2 + 2]) / 3;
if (nx * (cp[0] - a.x - fx) + ny * (cp[1] - a.y - fy) + nz * (cp[2] - a.z - fz) <= 0) continue;
// normal is already unit length, so the dot is cos(theta) directly
const lit = LIT_LUT[Math.max(0, Math.min(255, ((nx * AST_LIGHT[0] + ny * AST_LIGHT[1] + nz * AST_LIGHT[2]) * 256) | 0))];
const fc = a.faceCol[fi];
const br = (a.shade / 100) * (0.28 + 0.95 * lit);
const r = Math.min(255, fc[0] * br + 10);
const g = Math.min(255, fc[1] * br + 10);
const b = Math.min(255, fc[2] * br + 16);
const rec = FACE_REC[faceList.length];
rec.a = A; rec.b = B; rec.c = C;
rec.z = (A.z + B.z + C.z) / 3;
rec.col = rgbStr(r | 0, g | 0, b | 0);
faceList.push(rec);
}
// A convex rock's backface-culled faces cannot overlap in projection, so
// painter order is dead work here. Crater displacement breaks convexity
// slightly: measured over random tumbles, 1.75% of drawn face area
// overlaps and 0.9% would be painted in the wrong order (crater floor over
// rim). Set PERF.sortAsteroids to true if you see that.
if (PERF.sortAsteroids) faceList.sort((m, n) => n.z - m.z);
for (const fc of faceList) {
ctx.fillStyle = fc.col;
ctx.beginPath();
ctx.moveTo(fc.a.x, fc.a.y);
ctx.lineTo(fc.b.x, fc.b.y);
ctx.lineTo(fc.c.x, fc.c.y);
ctx.closePath();
ctx.fill();
}
}
ctx.globalAlpha = 1;
}
const BAND_ALPHAS = [0.16, 0.3, 0.5];
const BAND_COLORS = ['rgb(150,180,235)', 'rgb(235,225,215)', 'rgb(175,215,245)'];
function drawSky(cam) {
const cp = cam.camPos, xa = cam.xAxis, ya = cam.yAxis, za = cam.zAxis;
ctx.globalCompositeOperation = 'lighter';
// soft band glow with dust gaps
for (const b of bandBlobs) {
const cz = b.dir[0] * za[0] + b.dir[1] * za[1] + b.dir[2] * za[2];
if (cz < 0.05) continue;
const cx = b.dir[0] * xa[0] + b.dir[1] * xa[1] + b.dir[2] * xa[2];
const cy = b.dir[0] * ya[0] + b.dir[1] * ya[1] + b.dir[2] * ya[2];
const px = CX + (cx / cz) * FOCAL;
const py = CY - (cy / cz) * FOCAL;
// b.r is a world-space radius; the band lives on a unit-direction shell
// of radius SKY_R, so convert it to an angular size first. Without the
// /SKY_R these blobs are ~1.4M px wide and additive-blend the whole
// screen to white.
const r = Math.min(PERF.blobMaxR, (b.r / SKY_R / cz) * FOCAL);
if (r < 3 || px < -r || px > W + r || py < -r || py > H + r) continue;
ctx.globalAlpha = b.a * Math.min(1, cz * 1.5);
ctx.drawImage(b.tex, px - r, py - r, r * 2, r * 2);
}
ctx.globalAlpha = 1;
// faint deep-field dusting hugging the band
const paths = [new Path2D(), new Path2D(), new Path2D(), new Path2D(), new Path2D(),
new Path2D(), new Path2D(), new Path2D(), new Path2D()];
for (const st of bandStars) {
const cz = st.dir[0] * za[0] + st.dir[1] * za[1] + st.dir[2] * za[2];
if (cz <= 0.05) continue;
const cx = st.dir[0] * xa[0] + st.dir[1] * xa[1] + st.dir[2] * xa[2];
const cy = st.dir[0] * ya[0] + st.dir[1] * ya[1] + st.dir[2] * ya[2];
const px = CX + (cx / cz) * FOCAL;
if (px < -2 || px > W + 2) continue;
const py = CY - (cy / cz) * FOCAL;
if (py < -2 || py > H + 2) continue;
paths[st.c * 3 + st.b].rect(px, py, st.s, st.s);
}
for (let i = 0; i < 9; i++) {
ctx.globalAlpha = BAND_ALPHAS[i % 3];
ctx.fillStyle = BAND_COLORS[(i / 3) | 0];
ctx.fill(paths[i]);
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
function drawFilaments(cam) {
const cp = cam.camPos, xa = cam.xAxis, ya = cam.yAxis, za = cam.zAxis;
ctx.globalCompositeOperation = 'lighter';
const cluster = new Path2D();
for (const f of filaments) {
// wrap the whole strand around the ship (node data stays untouched)
const c0 = f.nodes[0];
for (let i = 0; i < 3; i++) {
const p = c0[i] + f.off[i];
if (p < ship.pos[i] - FIELD_FIL) f.off[i] += 2 * FIELD_FIL;
else if (p > ship.pos[i] + FIELD_FIL) f.off[i] -= 2 * FIELD_FIL;
}
// Bounding-sphere cull. filFade is exactly zero past FIELD_FIL, so if the
// nearest point of the strand is beyond that the whole strand is invisible
// for sure — one distance test instead of projecting 35 points. The screen
// test uses the same padding as before, so it can never reject a strand
// that had something to draw.
const gx = f.center[0] + f.off[0], gy = f.center[1] + f.off[1], gz = f.center[2] + f.off[2];
let dx = gx - cp[0], dy = gy - cp[1], dz = gz - cp[2];
const gd = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (gd - f.radius > FIELD_FIL) continue;
const gcz = dx * za[0] + dy * za[1] + dz * za[2];
// Only reject if no point of the sphere could pass the per-point depth
// test (cz >= 40). A strand whose centre is close to the camera can still
// have a visible far end.
if (gcz + f.radius < 40) continue;
const gpx = CX + ((dx * xa[0] + dy * xa[1] + dz * xa[2]) / gcz) * FOCAL;
const gpy = CY - ((dx * ya[0] + dy * ya[1] + dz * ya[2]) / gcz) * FOCAL;
// Conservative projected radius: a point at the front of the sphere
// projects further off-centre than one at the centre, so bound it with the
// nearest possible depth. This can only reject strands that had nothing to
// draw, never part of one.
const near = gcz - f.radius;
if (near > 0) {
const sr = f.radius * FOCAL / near;
if (gpx < -300 - sr || gpx > W + 300 + sr || gpy < -300 - sr || gpy > H + 300 + sr) continue;
}
const n = f.pts.length;
for (let i = 0; i < n; i++) P_OK[i] = 0;
// project the smoothed polyline once
for (let i = 0; i < n; i++) {
const nd = f.pts[i];
const x = nd[0] + f.off[0], y = nd[1] + f.off[1], z = nd[2] + f.off[2];
const px2 = x - cp[0], py2 = y - cp[1], pz2 = z - cp[2];
const cz = px2 * za[0] + py2 * za[1] + pz2 * za[2];
const fade = filFade(Math.sqrt(px2 * px2 + py2 * py2 + pz2 * pz2));
if (cz < 40 || fade <= 0.01) continue;
const sx = CX + ((px2 * xa[0] + py2 * xa[1] + pz2 * xa[2]) / cz) * FOCAL;
const sy = CY - ((px2 * ya[0] + py2 * ya[1] + pz2 * ya[2]) / cz) * FOCAL;
if (sx < -300 || sx > W + 300 || sy < -300 || sy > H + 300) continue;
const p = P_FIL[i];
p.x = sx; p.y = sy; p.z = cz; p.fade = fade;
P_OK[i] = 1;
}
// One stroke per run of segments sharing a quantised alpha, instead of one
// stroke per segment. Measured over random views: ~300 strokes/frame
// become ~39. A gap in the polyline ends the run and starts a new path.
for (let pass = 0; pass < 2; pass++) {
const mult = pass === 0 ? 0.1 : 0.3;
const minW = pass === 0 ? 1.5 : 0.6;
const maxW = pass === 0 ? 14 : 6;
const scale = pass === 0 ? 2.2 : 0.45;
let bucket = -1, cnt = 0, wsum = 0;
for (let i = 0; i + 1 < n; i++) {
if (!P_OK[i] || !P_OK[i + 1]) { flushRun(f.colorStr, bucket, cnt, wsum); cnt = 0; wsum = 0; bucket = -1; continue; }
const A = P_FIL[i], B = P_FIL[i + 1];
const a = (A.fade + B.fade) * 0.5 * f.glow * mult;
const wpx = f.w * FOCAL / ((A.z + B.z) * 0.5);
const w = Math.min(maxW, Math.max(minW, wpx * scale));
const b = Math.min(63, (a * 63) | 0);
if (b !== bucket) {
flushRun(f.colorStr, bucket, cnt, wsum);
bucket = b; cnt = 0; wsum = 0;
ctx.beginPath();
}
ctx.moveTo(A.x, A.y);
ctx.lineTo(B.x, B.y);
cnt++; wsum += w;
}
flushRun(f.colorStr, bucket, cnt, wsum);
}
// galaxy clusters gathered at the knots
for (let i = 0; i < f.nodes.length; i++) {
const nd = f.nodes[i];
const x = nd[0] + f.off[0], y = nd[1] + f.off[1], z = nd[2] + f.off[2];
const dxk = x - cp[0], dyk = y - cp[1], dzk = z - cp[2];
const cz = dxk * za[0] + dyk * za[1] + dzk * za[2];
const fade = filFade(Math.sqrt(dxk * dxk + dyk * dyk + dzk * dzk));
if (cz < 40 || fade <= 0.05) continue;
const px = CX + ((dxk * xa[0] + dyk * xa[1] + dzk * xa[2]) / cz) * FOCAL;
const py = CY - ((dxk * ya[0] + dyk * ya[1] + dzk * ya[2]) / cz) * FOCAL;
if (px < -80 || px > W + 80 || py < -80 || py > H + 80) continue;
const hs = Math.min(120, 120 * FOCAL / cz);
ctx.globalAlpha = fade * 0.3 * f.glow;
ctx.drawImage(softCool, px - hs / 2, py - hs / 2, hs, hs);
ctx.globalAlpha = 1;
const knots = f.knots[i];
for (let k = 0; k < knots.length; k++) {
const kx = x + knots[k][0] - cp[0], ky = y + knots[k][1] - cp[1], kz = z + knots[k][2] - cp[2];
const kcz = kx * za[0] + ky * za[1] + kz * za[2];
if (kcz < 40) continue;
const qx = CX + ((kx * xa[0] + ky * xa[1] + kz * xa[2]) / kcz) * FOCAL;
const qy = CY - ((kx * ya[0] + ky * ya[1] + kz * ya[2]) / kcz) * FOCAL;
if (qx < -2 || qx > W + 2 || qy < -2 || qy > H + 2) continue;
cluster.rect(qx, qy, 1, 1);
}
}
}
ctx.globalAlpha = 0.45;
ctx.fillStyle = 'rgb(215,230,255)';
ctx.fill(cluster);
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
function drawGalaxies(cam) {
const G_MAX = FIELD_GAL * Math.sqrt(3);
list.length = 0;
for (const g of galaxies) {
if (g.x < ship.pos[0] - FIELD_GAL) g.x += 2 * FIELD_GAL;
else if (g.x > ship.pos[0] + FIELD_GAL) g.x -= 2 * FIELD_GAL;
if (g.y < ship.pos[1] - FIELD_GAL) g.y += 2 * FIELD_GAL;
else if (g.y > ship.pos[1] + FIELD_GAL) g.y -= 2 * FIELD_GAL;
if (g.z < ship.pos[2] - FIELD_GAL) g.z += 2 * FIELD_GAL;
else if (g.z > ship.pos[2] + FIELD_GAL) g.z -= 2 * FIELD_GAL;
const dx = g.x - cam.camPos[0], dy = g.y - cam.camPos[1], dz = g.z - cam.camPos[2];
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (dist > G_MAX) continue;
const t = Math.max(0, Math.min(1, (FIELD_GAL - dist) / (FIELD_GAL - FIELD)));
const fade = smoothstep(t);
if (fade < 0.01) continue;
const p = project(g.x, g.y, g.z, cam, P_ONE);
if (!p) continue;
const e = ENTRY[list.length];
e.it = g; e.x = p.x; e.y = p.y; e.z = p.z; e.dist = dist; e.fade = fade;
list.push(e);
}
list.sort((a, b) => b.dist - a.dist);
ctx.globalCompositeOperation = 'lighter';
for (const e of list) {
const g = e.it;
const r = (g.r / e.z) * FOCAL;
if (r < 1.5) continue;
ctx.save();
ctx.globalAlpha = 0.62 * e.fade;
ctx.translate(e.x, e.y);
ctx.rotate(g.rot);
ctx.scale(1, g.squash);
ctx.drawImage(galaxyTex[g.ci], -r, -r, r * 2, r * 2);
ctx.restore();
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
function drawPlanets(cam) {
const P_MAX = FIELD_PLANET * Math.sqrt(3);
list.length = 0;
for (const pl of planets) {
for (let i = 0; i < 3; i++) {
if (pl.x < ship.pos[0] - FIELD_PLANET) pl.x += 2 * FIELD_PLANET;
else if (pl.x > ship.pos[0] + FIELD_PLANET) pl.x -= 2 * FIELD_PLANET;
if (pl.y < ship.pos[1] - FIELD_PLANET) pl.y += 2 * FIELD_PLANET;
else if (pl.y > ship.pos[1] + FIELD_PLANET) pl.y -= 2 * FIELD_PLANET;
if (pl.z < ship.pos[2] - FIELD_PLANET) pl.z += 2 * FIELD_PLANET;
else if (pl.z > ship.pos[2] + FIELD_PLANET) pl.z -= 2 * FIELD_PLANET;
}
const dx = pl.x - cam.camPos[0], dy = pl.y - cam.camPos[1], dz = pl.z - cam.camPos[2];
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (dist > P_MAX) continue;
const t = Math.max(0, Math.min(1, (FIELD_PLANET - dist) / (FIELD_PLANET - FIELD)));
const fade = smoothstep(t);
if (fade < 0.01) continue;
const p = project(pl.x, pl.y, pl.z, cam, P_ONE);
if (!p) continue;
const r = (pl.r / p.z) * FOCAL;
if (r < 1.5) continue;
const e = ENTRY[list.length];
e.it = pl; e.x = p.x; e.y = p.y; e.z = p.z; e.r = r; e.dist = dist; e.fade = fade;
e.dx = dx; e.dy = dy; e.dz = dz;
list.push(e);
}
list.sort((a, b) => b.dist - a.dist);
// Sun direction in camera space: once per frame, not once per planet.
const lx = SUN_DIR[0] * cam.xAxis[0] + SUN_DIR[1] * cam.xAxis[1] + SUN_DIR[2] * cam.xAxis[2];
const ly = SUN_DIR[0] * cam.yAxis[0] + SUN_DIR[1] * cam.yAxis[1] + SUN_DIR[2] * cam.yAxis[2];
for (const e of list) {
const pl = e.it, px = e.x, py = e.y, r = e.r, fade = e.fade;
const dl = Math.sqrt(e.dx * e.dx + e.dy * e.dy + e.dz * e.dz) || 1;
const f = (SUN_DIR[0] * e.dx + SUN_DIR[1] * e.dy + SUN_DIR[2] * e.dz) / dl; // +1 = night side to us
const phase = (1 - f) * 0.5; // 1 = full, 0 = new
let ux = lx, uy = -ly;
const ul = Math.sqrt(ux * ux + uy * uy);
if (ul < 1e-4) { ux = 1; uy = 0; } else { ux /= ul; uy /= ul; }
const ringN = pl.ring ? ringProjection(pl, cam) : 0;
if (ringN) strokeRing(pl, ringN, 1, fade);
// surface
ctx.save();
ctx.beginPath();
ctx.arc(px, py, r, 0, TAU);
ctx.clip();
ctx.globalAlpha = fade;
ctx.save();
ctx.translate(px, py);
ctx.rotate(pl.spin);
ctx.drawImage(planetTex[pl.tex], -r, -r, r * 2, r * 2);
ctx.restore();
// day/night terminator multiplied over the surface only
const soft = 0.24;
const grad = ctx.createLinearGradient(px - ux * r, py - uy * r, px + ux * r, py + uy * r);
const dark = 'rgb(11,13,22)', light = 'rgb(255,255,255)';
grad.addColorStop(0, dark);
grad.addColorStop(Math.min(1, phase), dark);
grad.addColorStop(Math.max(0, Math.min(1, phase + soft)), light);
grad.addColorStop(1, light);
ctx.globalCompositeOperation = 'multiply';
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(px, py, r, 0, TAU);
ctx.fill();
ctx.restore();
// atmosphere rim
const ac = pl.atmoStr;
ctx.globalCompositeOperation = 'lighter';
const ag = ctx.createRadialGradient(px, py, r * 0.82, px, py, r * 1.3);
ag.addColorStop(0, 'rgba(' + ac + ',0)');
ag.addColorStop(0.45, 'rgba(' + ac + ',' + alphaStr(0.2 * fade) + ')');
ag.addColorStop(1, 'rgba(' + ac + ',0)');
ctx.fillStyle = ag;
ctx.beginPath();
ctx.arc(px, py, r * 1.3, 0, TAU);
ctx.fill();
ctx.globalCompositeOperation = 'source-over';
if (ringN) strokeRing(pl, ringN, 2, fade);
}
ctx.globalAlpha = 1;
}
// Ring points are classified by depth against the planet centre, so the
// far half is drawn behind the body and the near half over it. Points go
// into the P_RING pool and the front/behind flags into RING_FRONT; the
// function returns the count (0 = nothing to draw).
function ringProjection(pl, cam) {
const centre = project(pl.x, pl.y, pl.z, cam, P_CENTER);
if (!centre) return 0;
const N = 56;
for (let i = 0; i <= N; i++) {
const a = i / N * TAU;
const ca = Math.cos(a), sa = Math.sin(a);
const R = pl.r * pl.ringR;
const q = project(pl.x + (pl.ringA[0] * ca + pl.ringB[0] * sa) * R,
pl.y + (pl.ringA[1] * ca + pl.ringB[1] * sa) * R,
pl.z + (pl.ringA[2] * ca + pl.ringB[2] * sa) * R, cam, P_RING[i]);
RING_FRONT[i] = q ? (q.z < centre.z ? 2 : 1) : 0;
}
return N + 1;
}
function strokeRing(pl, n, front, fade) {
ctx.lineCap = 'round';
const style = 'rgba(' + pl.atmoStr + ',' + alphaStr(0.26 * fade) + ')'; // built once, not per segment
for (let i = 0; i + 1 < n; i++) {
if (RING_FRONT[i] !== front || RING_FRONT[i + 1] !== front) continue;
const A = P_RING[i], B = P_RING[i + 1];
const z = (A.z + B.z) * 0.5;
ctx.strokeStyle = style;
ctx.lineWidth = Math.max(0.7, pl.ringW * FOCAL / z); // width still varies with depth
ctx.beginPath();
ctx.moveTo(A.x, A.y);
ctx.lineTo(B.x, B.y);
ctx.stroke();
}
}
function drawMeteors(cam) {
if (!meteors.length) return;
ctx.globalCompositeOperation = 'lighter';
ctx.lineCap = 'round';
for (const m of meteors) {
const head = project(m.x, m.y, m.z, cam, P_ONE);
if (!head) continue;
const sp = Math.sqrt(m.vx * m.vx + m.vy * m.vy + m.vz * m.vz) || 1;
const tl = sp * m.trail;
const tail = project(m.x - m.vx / sp * tl, m.y - m.vy / sp * tl, m.z - m.vz / sp * tl, cam, P_TWO);
if (!tail) continue;
const t = m.life / m.max;
let env = 1;
if (t < 0.18) env = t / 0.18;
else if (t > 0.6) env = Math.max(0, (1 - t) / 0.4);
if (env <= 0.02 || !tail) continue;
const cs = m.warm ? '255,226,190' : '205,235,255';
const g = ctx.createLinearGradient(head.x, head.y, tail.x, tail.y);
g.addColorStop(0, 'rgba(' + cs + ',' + alphaStr(0.95 * env) + ')');
g.addColorStop(0.35, 'rgba(' + cs + ',' + alphaStr(0.4 * env) + ')');
g.addColorStop(1, 'rgba(' + cs + ',0)');
ctx.strokeStyle = g;
ctx.lineWidth = m.w;
ctx.beginPath();
ctx.moveTo(head.x, head.y);
ctx.lineTo(tail.x, tail.y);
ctx.stroke();
const hs = (m.fireball ? 52 : 24) * env;
ctx.globalAlpha = 0.8 * env;
ctx.drawImage(m.warm ? softWarm : softCool, head.x - hs / 2, head.y - hs / 2, hs, hs);
ctx.globalAlpha = 1;
}
ctx.globalCompositeOperation = 'source-over';
}
function drawExhaust(cam) {
if (!exhaust.length) return;
ctx.globalCompositeOperation = 'lighter';
for (const p of exhaust) {
const q = project(p.x, p.y, p.z, cam, P_ONE);
if (!q) continue;
const t = p.life / p.max;
const a = (1 - t) * (1 - t) * 0.55;
let hs = p.r * (1 + t * 2.4) * 2 * FOCAL / q.z;
if (hs < 1) continue;
if (hs > 34) hs = 34;
ctx.globalAlpha = a;
ctx.drawImage(softCool, q.x - hs / 2, q.y - hs / 2, hs, hs);
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
const DUST_ALPHAS = [0.1, 0.22, 0.36];
function drawDust(cam) {
const cp = cam.camPos;
const paths = [new Path2D(), new Path2D(), new Path2D()];
const TRAIL = 0.05; // motion-blur trail time — the speed cue
const vx = ship.vel[0] * TRAIL, vy = ship.vel[1] * TRAIL, vz = ship.vel[2] * TRAIL;
for (const d of dust) {
if (d.x < cp[0] - DUST_R) d.x += 2 * DUST_R;
else if (d.x > cp[0] + DUST_R) d.x -= 2 * DUST_R;
if (d.y < cp[1] - DUST_R) d.y += 2 * DUST_R;
else if (d.y > cp[1] + DUST_R) d.y -= 2 * DUST_R;
if (d.z < cp[2] - DUST_R) d.z += 2 * DUST_R;
else if (d.z > cp[2] + DUST_R) d.z -= 2 * DUST_R;
const dx = d.x - cp[0], dy = d.y - cp[1], dz = d.z - cp[2];
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (dist > DUST_R || dist < 12) continue;
const cz = dx * cam.zAxis[0] + dy * cam.zAxis[1] + dz * cam.zAxis[2];
if (cz < 20) continue;
const cx = dx * cam.xAxis[0] + dy * cam.xAxis[1] + dz * cam.xAxis[2];
const cy = dx * cam.yAxis[0] + dy * cam.yAxis[1] + dz * cam.yAxis[2];
const px = CX + (cx / cz) * FOCAL;
if (px < -20 || px > W + 20) continue;
const py = CY - (cy / cz) * FOCAL;
if (py < -20 || py > H + 20) continue;
// the mote appeared at here a moment ago: that's the streak
const cz2 = cz + (vx * cam.zAxis[0] + vy * cam.zAxis[1] + vz * cam.zAxis[2]);
if (cz2 < 20) continue;
const cx2 = cx + vx * cam.xAxis[0] + vy * cam.xAxis[1] + vz * cam.xAxis[2];
const cy2 = cy + vx * cam.yAxis[0] + vy * cam.yAxis[1] + vz * cam.yAxis[2];
const qx = CX + (cx2 / cz2) * FOCAL;
const qy = CY - (cy2 / cz2) * FOCAL;
const bucket = Math.min(2, ((1 - dist / DUST_R) * 3) | 0);
const path = paths[bucket];
path.moveTo(px, py);
path.lineTo(qx, qy);
}
ctx.globalCompositeOperation = 'lighter';
ctx.strokeStyle = 'rgb(200,225,255)';
ctx.lineWidth = 1;
for (let i = 0; i < 3; i++) {
ctx.globalAlpha = DUST_ALPHAS[i];
ctx.stroke(paths[i]);
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
function drawCursor() {
if (cursorAlpha <= 0.01) return;
ctx.save();
ctx.globalAlpha = cursorAlpha;
ctx.strokeStyle = '#cfeaff';
ctx.fillStyle = '#cfeaff';
ctx.lineWidth = 1.5;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(mouseX - 13, mouseY); ctx.lineTo(mouseX - 5, mouseY);
ctx.moveTo(mouseX + 5, mouseY); ctx.lineTo(mouseX + 13, mouseY);
ctx.moveTo(mouseX, mouseY - 13); ctx.lineTo(mouseX, mouseY - 5);
ctx.moveTo(mouseX, mouseY + 5); ctx.lineTo(mouseX, mouseY + 13);
ctx.stroke();
ctx.beginPath();
ctx.arc(mouseX, mouseY, 1.6, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
// ------------------------------------------------------------------
// Main loop
// ------------------------------------------------------------------
function frame(now) {
let dt = (now - last) / 1000;
last = now;
// Fixed: guard against zero or negative dt to avoid NaN in yawRate.
if (dt <= 0) {
requestAnimationFrame(frame);
return;
}
if (dt > 0.05) dt = 0.05; // clamp big gaps
update(dt);
const cam = cameraBasis(dt);
spawnMeteors(cam, now);
drawBackground();
drawSky(cam);
drawNebulae(cam);
drawGalaxies(cam);
drawFilaments(cam);
drawStars(cam);
drawPlanets(cam);
drawAsteroids(cam);
drawExhaust(cam);
drawMeteors(cam);
drawDust(cam);
drawShip(cam, now);
if (vignetteLayer) {
ctx.drawImage(vignetteLayer, 0, 0, W, H);
}
// Cursor fade: visible while the mouse moved within the last second,
// eases out after that, eases back in on movement.
{
const idle = now - lastMove;
const target = idle < 1000 ? 1 : 0;
const speed = target === 1 ? 12 : 4;
cursorAlpha += (target - cursorAlpha) * Math.min(1, speed * dt);
if (target === 0 && cursorAlpha < 0.01) cursorAlpha = 0;
else if (target === 1 && cursorAlpha > 0.99) cursorAlpha = 1;
}
drawCursor();
requestAnimationFrame(frame);
}
// Fixed: ensure a valid camera basis exists before the first click.
cameraBasis(0.016);
requestAnimationFrame(frame);
})();
</script>
</body>
</html>