import { Vec3, Mat4 } from "gl-matrix";
export type CameraState = {
position: Float32Array; // [x, y, z]
rotation: Float32Array; // [theta, phi]
view: Float32Array;
inverseView: Float32Array;
projection: Float32Array;
currentViewProj: Float32Array;
previousViewProj: Float32Array;
dirty: boolean;
};
const moveVec = Vec3.create();
const lastPosition = Vec3.create();
const lastRotation = new Float32Array(2);
const rotateDelta = new Float32Array(2);
const keyState = new Set<string>();
const sprintMultiplier = 2.5;
let isPointerLocked = false;
let oldTime = 0;
export function createCamera(canvas: HTMLCanvasElement): CameraState {
const position = Vec3.fromValues(0, -75, 20);
const view = Mat4.create();
const projection = Mat4.create();
const inverseView = Mat4.create();
const lookAtTarget = Vec3.fromValues(0, 0, 20);
const up = Vec3.fromValues(0, 0, 1);
Mat4.lookAt(view, position, lookAtTarget, up);
Mat4.invert(inverseView, view);
Mat4.perspectiveZO(projection, Math.PI / 4, canvas.width / canvas.height, 0.1, 100);
const forward = Vec3.create();
Vec3.subtract(forward, lookAtTarget, position);
Vec3.normalize(forward, forward);
const phi = Math.asin(forward[2]);
const theta = Math.atan2(forward[0], forward[1]);
Vec3.copy(lastPosition, position);
lastRotation[0] = theta;
lastRotation[1] = phi;
return {
position,
rotation: new Float32Array([theta, phi]),
view,
inverseView,
projection,
currentViewProj: Mat4.create(),
previousViewProj: Mat4.create(),
dirty: true,
};
}
export function updateCamera(cam: CameraState): void {
const now = performance.now() * 0.001;
const dt = now - oldTime;
oldTime = now;
const theta = cam.rotation[0] += rotateDelta[0] * dt * 60;
const phi = cam.rotation[1] -= rotateDelta[1] * dt * 60;
cam.rotation[1] = Math.min(88, Math.max(-88, cam.rotation[1]));
rotateDelta[0] = rotateDelta[1] = 0;
updateMovementInput();
const scaledMove = Vec3.create();
Vec3.scale(scaledMove, moveVec, dt);
const moved = Vec3.length(scaledMove) > 1e-5;
const rotated = theta !== lastRotation[0] || phi !== lastRotation[1];
const changedPos = !Vec3.exactEquals(cam.position, lastPosition);
cam.dirty = moved || rotated || changedPos;
if (!cam.dirty) return;
const forwards = Vec3.fromValues(
Math.sin(toRad(theta)) * Math.cos(toRad(phi)),
Math.cos(toRad(theta)) * Math.cos(toRad(phi)),
Math.sin(toRad(phi))
);
const right = Vec3.create();
Vec3.cross(right, forwards, [0, 0, 1]);
Vec3.normalize(right, right);
const up = Vec3.create();
Vec3.cross(up, right, forwards);
Vec3.normalize(up, up);
Vec3.scaleAndAdd(cam.position, cam.position, forwards, scaledMove[0]);
Vec3.scaleAndAdd(cam.position, cam.position, right, scaledMove[1]);
Vec3.scaleAndAdd(cam.position, cam.position, up, scaledMove[2]);
const target = Vec3.create();
Vec3.add(target, cam.position, forwards);
Mat4.lookAt(cam.view, cam.position, target, up);
Mat4.invert(cam.inverseView, cam.view);
Vec3.copy(lastPosition, cam.position);
lastRotation[0] = theta;
lastRotation[1] = phi;
}
export function updateMovementInput(): void {
moveVec[0] = moveVec[1] = moveVec[2] = 0;
const speed = keyState.has("shift") ? 10 * sprintMultiplier : 10;
if (keyState.has("w")) moveVec[0] += speed;
if (keyState.has("s")) moveVec[0] -= speed;
if (keyState.has("d")) moveVec[1] += speed;
if (keyState.has("a")) moveVec[1] -= speed;
if (keyState.has("q")) moveVec[2] += speed;
if (keyState.has("e")) moveVec[2] -= speed;
}
function toRad(deg: number): number {
return (deg * Math.PI) / 180;
}
export function setupCameraInput(canvas: HTMLCanvasElement): void {
canvas.addEventListener("click", () => canvas.requestPointerLock());
document.addEventListener("pointerlockchange", () => {
isPointerLocked = document.pointerLockElement != null;
});
window.addEventListener("keydown", e => keyState.add(e.key.toLowerCase()));
window.addEventListener("keyup", e => keyState.delete(e.key.toLowerCase()));
window.addEventListener("mousemove", (e) => {
if (!isPointerLocked) return;
const sensitivity = 0.5;
rotateDelta[0] = e.movementX * sensitivity;
rotateDelta[1] = e.movementY * sensitivity;
});
}