vestige/apps/dashboard/src/lib/observatory/camera.ts

140 lines
3.3 KiB
TypeScript
Raw Normal View History

/**
* Cognitive Observatory minimal column-major mat4 camera math.
*
* Spec §6: no new dependencies small local helpers instead of three.js.
* All outputs are Float32Array(16) in WebGPU/WGSL column-major order.
*/
export type Mat4 = Float32Array;
/** Perspective projection (right-handed, depth 0..1 as WebGPU expects). */
export function perspective(fovYRad: number, aspect: number, near: number, far: number): Mat4 {
const f = 1 / Math.tan(fovYRad / 2);
const nf = 1 / (near - far);
// column-major
const m = new Float32Array(16);
m[0] = f / aspect;
m[5] = f;
m[10] = far * nf;
m[11] = -1;
m[14] = far * near * nf;
return m;
}
/** Right-handed lookAt view matrix. */
export function lookAt(
eye: [number, number, number],
target: [number, number, number],
up: [number, number, number]
): Mat4 {
const [ex, ey, ez] = eye;
let zx = ex - target[0];
let zy = ey - target[1];
let zz = ez - target[2];
let len = Math.hypot(zx, zy, zz) || 1;
zx /= len;
zy /= len;
zz /= len;
// x = up × z
let xx = up[1] * zz - up[2] * zy;
let xy = up[2] * zx - up[0] * zz;
let xz = up[0] * zy - up[1] * zx;
len = Math.hypot(xx, xy, xz) || 1;
xx /= len;
xy /= len;
xz /= len;
// y = z × x
const yx = zy * xz - zz * xy;
const yy = zz * xx - zx * xz;
const yz = zx * xy - zy * xx;
const m = new Float32Array(16);
m[0] = xx;
m[1] = yx;
m[2] = zx;
m[4] = xy;
m[5] = yy;
m[6] = zy;
m[8] = xz;
m[9] = yz;
m[10] = zz;
m[12] = -(xx * ex + xy * ey + xz * ez);
m[13] = -(yx * ex + yy * ey + yz * ez);
m[14] = -(zx * ex + zy * ey + zz * ez);
m[15] = 1;
return m;
}
/** out = a × b (column-major). */
export function multiply(a: Mat4, b: Mat4): Mat4 {
const out = new Float32Array(16);
for (let c = 0; c < 4; c++) {
for (let r = 0; r < 4; r++) {
out[c * 4 + r] =
a[r] * b[c * 4] +
a[4 + r] * b[c * 4 + 1] +
a[8 + r] * b[c * 4 + 2] +
a[12 + r] * b[c * 4 + 3];
}
}
return out;
}
export interface OrbitCamera {
viewProj: Mat4;
/** world-space camera right vector (billboarding) */
right: [number, number, number];
/** world-space camera up vector (billboarding) */
up: [number, number, number];
eye: [number, number, number];
}
/**
* Deterministic slow orbit camera: angle driven by the loop phase (frames),
* never wall clock the same frame always yields the same view. Returns the
* view-projection plus the camera basis for GPU billboards.
*/
export function orbitCamera(
phase: number,
aspect: number,
distance: number,
elevation = 0.35
): OrbitCamera {
const angle = phase * Math.PI * 2;
const eye: [number, number, number] = [
Math.sin(angle) * distance,
distance * elevation,
Math.cos(angle) * distance
];
const proj = perspective((50 * Math.PI) / 180, aspect, 0.1, 4000);
const view = lookAt(eye, [0, 0, 0], [0, 1, 0]);
// camera basis: forward = normalize(target - eye), right = f × up, up = r × f
let fx = -eye[0],
fy = -eye[1],
fz = -eye[2];
let len = Math.hypot(fx, fy, fz) || 1;
fx /= len;
fy /= len;
fz /= len;
let rx = fy * 0 - fz * 1;
let ry = fz * 0 - fx * 0;
let rz = fx * 1 - fy * 0;
len = Math.hypot(rx, ry, rz) || 1;
rx /= len;
ry /= len;
rz /= len;
const ux = ry * fz - rz * fy;
const uy = rz * fx - rx * fz;
const uz = rx * fy - ry * fx;
return {
viewProj: multiply(proj, view),
right: [rx, ry, rz],
up: [ux, uy, uz],
eye
};
}