SOURCE / PINNED RELEASE
Made of little things.
Poly Dogfight
- Release
- b8511ee374e3…
- Author-recorded commit
- 5579434b9d8b…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-44f63e5422b
Archive hash verified: bcadd2430d8ea2bc…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { PerspectiveCamera, Vector3 } from 'three';
import { PLANET_RADIUS } from './sim/constants.ts';
import type { FlightState } from './sim/flight.ts';
import { surfaceHeight } from './sim/terrain.ts';
export type CameraMode = 'chase' | 'far' | 'cockpit';
export const CAMERA_MODES: CameraMode[] = ['chase', 'far', 'cockpit'];
const v = {
up: new Vector3(),
forward: new Vector3(),
bodyUp: new Vector3(),
desired: new Vector3(),
target: new Vector3(),
camUp: new Vector3(),
side: new Vector3(),
};
/** Pilot's eye in body axes, seated behind the windscreen and under the upper wing. */
const COCKPIT_EYE = { up: 0.9, forward: -0.45 };
/** Keep a camera position at least `clearance` metres above the ground or sea. */
function keepAboveGround(position: Vector3, clearance: number): void {
const r = position.length();
const floor = PLANET_RADIUS + surfaceHeight(position.x / r, position.y / r, position.z / r) + clearance;
if (r < floor) position.multiplyScalar(floor / r);
}
/**
* Vertical field of view for a viewport: 68° normally, opened up on tall/narrow
* split-screen views so the horizontal view never drops below ~80°.
*/
export function baseFov(aspect: number): number {
const minHorizontal = (80 * Math.PI) / 180;
const vertical = (2 * Math.atan(Math.tan(minHorizontal / 2) / Math.max(0.1, aspect)) * 180) / Math.PI;
return Math.min(95, Math.max(68, vertical));
}
/** Chase camera that blends the planet's local up with the plane's roll. */
export function createFlightCamera(aspect: number) {
const camera = new PerspectiveCamera(68, aspect, 0.9, 4000);
const smoothedUp = new Vector3(0, 1, 0);
let orbit = 0;
let initialized = false;
let shakeT = 0;
return {
camera,
snap() {
initialized = false;
},
update(state: FlightState, mode: CameraMode, dt: number, crashPoint: Vector3 | null) {
v.up.copy(state.position).normalize();
if (crashPoint) {
// Slow orbit around the wreck.
orbit += dt * 0.35;
v.up.copy(crashPoint).normalize();
v.side.set(1, 0, 0).cross(v.up);
if (v.side.lengthSq() < 1e-4) v.side.set(0, 0, 1).cross(v.up);
v.side.normalize();
const other = new Vector3().crossVectors(v.up, v.side);
v.desired
.copy(crashPoint)
.addScaledVector(v.side, Math.cos(orbit) * 32)
.addScaledVector(other, Math.sin(orbit) * 32)
.addScaledVector(v.up, 16);
keepAboveGround(v.desired, 10);
camera.position.lerp(v.desired, 1 - Math.exp(-dt * 2));
keepAboveGround(camera.position, 4);
camera.up.copy(v.up);
camera.lookAt(crashPoint);
return;
}
orbit = 0;
// Inside the cockpit the plane's own parts are centimetres away.
const near = mode === 'cockpit' ? 0.12 : 0.9;
if (camera.near !== near) {
camera.near = near;
camera.updateProjectionMatrix();
}
v.forward.set(0, 0, -1).applyQuaternion(state.orientation);
v.bodyUp.set(0, 1, 0).applyQuaternion(state.orientation);
if (mode === 'cockpit') {
// The pilot's eyes: just above the windscreen, looking past the cowling
// and propeller with the upper wing overhead.
camera.position
.copy(state.position)
.addScaledVector(v.bodyUp, COCKPIT_EYE.up)
.addScaledVector(v.forward, COCKPIT_EYE.forward);
camera.up.copy(v.bodyUp);
camera.lookAt(v.target.copy(camera.position).add(v.forward).addScaledVector(v.bodyUp, -0.02));
initialized = false;
} else {
const back = mode === 'far' ? 26 : 12.5;
const height = mode === 'far' ? 7 : 3.4;
v.camUp.copy(v.up).lerp(v.bodyUp, 0.4).normalize();
smoothedUp.lerp(v.camUp, 1 - Math.exp(-dt * 5)).normalize();
// On a planet this small the horizon dips steeply with altitude. Tilt the
// view down by part of that dip so the curving horizon stays in frame.
const dip = Math.acos(Math.min(1, PLANET_RADIUS / Math.max(PLANET_RADIUS, state.position.length())));
const tilt = Math.tan(Math.min(0.45, dip * 0.5));
v.desired
.copy(state.position)
.addScaledVector(v.forward, -back)
.addScaledVector(smoothedUp, height)
.addScaledVector(v.up, back * tilt);
if (!initialized) {
camera.position.copy(v.desired);
smoothedUp.copy(v.camUp);
initialized = true;
} else {
// Stiff enough to keep up at boost speed, loose enough to feel momentum.
camera.position.lerp(v.desired, 1 - Math.exp(-dt * 7));
}
keepAboveGround(camera.position, 2);
camera.up.copy(smoothedUp);
camera.lookAt(
v.target
.copy(state.position)
.addScaledVector(v.forward, 10)
.addScaledVector(smoothedUp, 1.2)
.addScaledVector(v.up, -10 * tilt),
);
}
// Stall buffet shake.
if (state.stallAmount > 0.05) {
shakeT += dt * 30;
const s = state.stallAmount * 0.012;
camera.rotateX(Math.sin(shakeT * 1.3) * s);
camera.rotateZ(Math.sin(shakeT * 0.9) * s);
}
const fov = baseFov(camera.aspect) + (mode === 'cockpit' ? 8 : 0) + (state.boosting ? 10 : 0) + Math.min(6, Math.max(0, state.airspeed - 62) * 0.15);
camera.fov += (fov - camera.fov) * (1 - Math.exp(-dt * 4));
camera.updateProjectionMatrix();
},
};
}
