Screen Space to World Space
What You’ll Learn
Section titled “What You’ll Learn”How to run Section 5.1’s pipeline backwards: from a pixel the player clicked to a ray in the world, which is how clicking, picking and aiming all work. How to run it forwards for a single point, to put a health bar above someone’s head - and the one check that stops markers appearing for things behind you. And spherical coordinates, the two angles that make an orbit camera behave, which the capstone needs.
A Pixel Is Not a Position
Section titled “A Pixel Is Not a Position”Click on the screen and you have selected a pixel. A pixel is two numbers; a position in the world is three. The missing number is depth, and the screen has no opinion about it.
So a click does not name a point. It names a ray - every point in the world that would land on that pixel, which is a line running away from the camera. Finding out what was clicked means intersecting that ray with the scene.
Getting to the ray takes two steps.
Pixels to NDC
Section titled “Pixels to NDC”Section 5.1’s normalised device coordinates run to in both directions. Pixels run 0 to width and 0 to height. And they disagree about which way is up:
Screen Y counts downward from the top; NDC Y counts upward from the middle. That minus sign is the single most common bug in this whole area, and it is invisible when you test by clicking near the centre of the screen.
NDC to a ray
Section titled “NDC to a ray”Now use the frustum. Section 5.1 established that at distance the frustum is tall and that times the aspect ratio wide. A cursor at NDC is that fraction of the way across it:
Set , normalise, and that is the ray direction. No matrix inverse required - the geometry gives it directly.
That is worth checking rather than trusting, because it is a shortcut. unprojectAt derives the
point from the frustum and ndcOf puts it back through the projection matrix; the two share no
code. Across a grid of cursor positions at four different depths they agree to , so the
shortcut really is the matrix inverse.
Picking
Section titled “Picking”Below, the orange dot is a camera, the purple rectangle is its screen, and the purple dot is a cursor on it. The orange line is the ray that cursor becomes.
src/lib/gamedev/demos/picking.scene.ts /**
* A cursor on the near plane, the ray it becomes, and whatever that ray hits.
*/
import * as THREE from "three";
import { extentAt, frustumCorners, rayThroughNdc } from "../projection.ts";
import { ASPECT, FAR, FOV, NEAR, TARGETS, pick } from "./pick-shared.ts";
import { makeCanvas, addSlider, addReadout } from "./ui.ts";
import type { MountFn } from "./runner.ts";
const HIT = 0x39d3c3;
const MISS = 0x484f58;
const RAY = 0xf0883e;
const SCREEN = 0xd2a8ff;
const EDGES: ReadonlyArray<readonly [number, number]> = [
[0, 1],
[1, 2],
[2, 3],
[3, 0],
[4, 5],
[5, 6],
[6, 7],
[7, 4],
[0, 4],
[1, 5],
[2, 6],
[3, 7],
];
const mount: MountFn = (el) => {
const { renderer, width, height, background } = makeCanvas(el, 330);
const scene = new THREE.Scene();
scene.background = background;
const camera = new THREE.PerspectiveCamera(40, width / height, 0.1, 200);
const lineOf = (color: number, dashed = false) => {
const geom = new THREE.BufferGeometry();
const mesh = new THREE.LineSegments(
geom,
dashed
? new THREE.LineDashedMaterial({ color, dashSize: 0.3, gapSize: 0.22 })
: new THREE.LineBasicMaterial({ color }),
);
scene.add(mesh);
return (pts: THREE.Vector3[]) => {
geom.setFromPoints(pts);
if (dashed) mesh.computeLineDistances();
};
};
const frustumLines = lineOf(MISS, true);
const screenRect = lineOf(SCREEN);
const rayLine = lineOf(RAY);
// The camera doing the picking, drawn as a dot at its own origin.
scene.add(
new THREE.Mesh(
new THREE.SphereGeometry(0.2, 14, 10),
new THREE.MeshBasicMaterial({ color: RAY }),
),
);
const cursorDot = new THREE.Mesh(
new THREE.SphereGeometry(0.14, 12, 9),
new THREE.MeshBasicMaterial({ color: SCREEN }),
);
scene.add(cursorDot);
const hitDot = new THREE.Mesh(
new THREE.SphereGeometry(0.17, 14, 10),
new THREE.MeshBasicMaterial({ color: 0xffffff }),
);
scene.add(hitDot);
const blobs = TARGETS.map((t) => {
const m = new THREE.Mesh(
new THREE.SphereGeometry(t.radius, 18, 12),
new THREE.MeshBasicMaterial({ color: MISS, wireframe: true }),
);
m.position.set(t.centre.x, t.centre.y, t.centre.z);
scene.add(m);
return m;
});
const show = addReadout(el);
const cx = addSlider(
el,
"cursor across the screen",
-1,
1,
0.12,
draw,
"",
0.01,
);
const cy = addSlider(el, "cursor up the screen", -1, 1, 0.34, draw, "", 0.01);
const spin = addSlider(el, "walk around it", -180, 180, 40, draw);
function draw() {
const ndc = { x: cx(), y: cy() };
const corners = frustumCorners(FOV, ASPECT, NEAR, FAR);
const fpts: THREE.Vector3[] = [];
for (const [a, b] of EDGES) {
fpts.push(
new THREE.Vector3(corners[a].x, corners[a].y, corners[a].z),
new THREE.Vector3(corners[b].x, corners[b].y, corners[b].z),
);
}
frustumLines(fpts);
// The near plane, drawn as the screen the cursor lives on.
const { halfHeight: h, halfWidth: w } = extentAt(FOV, ASPECT, NEAR);
const rect = [
new THREE.Vector3(-w, -h, -NEAR),
new THREE.Vector3(w, -h, -NEAR),
new THREE.Vector3(w, h, -NEAR),
new THREE.Vector3(-w, h, -NEAR),
];
screenRect([
rect[0],
rect[1],
rect[1],
rect[2],
rect[2],
rect[3],
rect[3],
rect[0],
]);
cursorDot.position.set(ndc.x * w, ndc.y * h, -NEAR);
const ray = rayThroughNdc(FOV, ASPECT, ndc);
const found = pick(ndc);
const reach = found ? found.distance : FAR;
rayLine([
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(
ray.direction.x * reach,
ray.direction.y * reach,
ray.direction.z * reach,
),
]);
blobs.forEach((m, i) => {
(m.material as THREE.MeshBasicMaterial).color.setHex(
found?.index === i ? HIT : MISS,
);
});
hitDot.visible = found !== null;
if (found) {
hitDot.position.set(
ray.direction.x * found.distance,
ray.direction.y * found.distance,
ray.direction.z * found.distance,
);
}
const a = (spin() * Math.PI) / 180;
const r = 30;
camera.position.set(Math.sin(a) * r, 12, Math.cos(a) * r - 9);
camera.lookAt(0, 0, -11);
show(
found
? `hit target ${found.index + 1} at ${found.distance.toFixed(1)} m along the ray`
: `the ray misses everything`,
);
renderer.render(scene, camera);
}
draw();
return () => renderer.dispose();
};
export default mount; Move the cursor sliders and the ray sweeps the scene. When it meets a sphere, that sphere turns teal, a white dot marks the entry point, and the ray stops there.
Two details that matter in real code. The ray stops at the nearest hit, not the first one tested - which is why an intersection test returns a distance rather than a boolean. And the test runs in the camera’s own view space, where the camera sits at the origin looking down , so the ray needs no transforming at all.
The check pins the arithmetic: the centre ray meets a sphere of radius 1 at at exactly , its near face. A ray aimed into a corner misses it. A ray starting inside a sphere still reports a hit.
Part 6 does intersection properly - planes, boxes, capsules, and the cases raySphere glosses
over. This Section only needs enough to show the ray doing its job.
The Other Direction, and the Trap
Section titled “The Other Direction, and the Trap”Now forwards: given something’s position in the world, which pixel do you draw its health bar at?
That is just Section 5.1’s pipeline followed by the pixel mapping in reverse. And it contains a trap that everyone hits once.
src/lib/gamedev/demos/screenmath.ts /** The pixel mapping, the behind-the-camera trap, and the pole an orbit camera has to avoid. */
import {
cartesianToSpherical,
perspective,
projectToScreen,
screenToNdc,
sphericalToCartesian,
} from "../projection.ts";
import type { Demo } from "./runner.ts";
const W = 800;
const H = 450;
const PROJ = perspective(55, W / H, 0.1, 100);
const px = (p: { x: number; y: number }) =>
`(${p.x.toFixed(0)}, ${p.y.toFixed(0)})`;
const demo: Demo = (log) => {
log(
"top-left pixel in NDC",
JSON.stringify(screenToNdc(0, 0, W, H)),
"y is flipped",
);
log("bottom-right pixel in NDC", JSON.stringify(screenToNdc(W, H, W, H)));
// The trap, in one pair of rows: both land on the same pixel, and one is behind you.
const ahead = projectToScreen(PROJ, { x: 0, y: 0, z: -5 }, W, H);
const behind = projectToScreen(PROJ, { x: 0, y: 0, z: 5 }, W, H);
log("5 m in front of the camera", `${px(ahead)} inFront ${ahead.inFront}`);
log(
"5 m behind the camera",
`${px(behind)} inFront ${behind.inFront}`,
"the same pixel, so only the flag tells them apart",
);
// Spherical coordinates lose the azimuth at the poles, which is why cameras clamp short.
log(
"orbit camera at elevation 89",
JSON.stringify(cartesianToSpherical(sphericalToCartesian(10, 137, 89))),
);
log(
"orbit camera at elevation 90",
JSON.stringify(cartesianToSpherical(sphericalToCartesian(10, 137, 90))),
"the azimuth is gone - every value gives the same point",
);
};
export default demo; Look at the middle two rows. A point 5 metres in front of the camera and a point 5 metres behind it both project to pixel — dead centre of an screen. The same pixel. Nothing about the pixel coordinates distinguishes them.
The reason is Section 5.1’s divide. Clip is the point’s distance in front of the camera, so it goes negative behind - and dividing by a negative number mirrors the result through the origin. Points behind you land back on screen, mirrored, at plausible-looking coordinates.
src/lib/gamedev/demos/marker.scene.ts /**
* The screen as a rectangle of pixels, with markers placed where world objects project to.
*/
import * as THREE from "three";
import { SCREEN_H, SCREEN_W, markers } from "./marker-shared.ts";
import { makeCanvas, addSlider, addCheckbox, addReadout } from "./ui.ts";
import type { MountFn } from "./runner.ts";
const GOOD = 0x39d3c3;
const BAD = 0xff7b72;
const FRAME = 0x6e7681;
const mount: MountFn = (el) => {
const { renderer, width, height, background } = makeCanvas(el, 300);
// World units here are screen pixels, so the maths and the picture share a coordinate system.
const halfH = SCREEN_H * 0.72;
const halfW = (halfH * width) / height;
const scene = new THREE.Scene();
scene.background = background;
const camera = new THREE.OrthographicCamera(
-halfW,
halfW,
halfH,
-halfH,
0.1,
10,
);
camera.position.z = 5;
// Pixel space to scene space: origin at the top left, y counting down.
const toScene = (x: number, y: number) =>
new THREE.Vector3(x - SCREEN_W / 2, SCREEN_H / 2 - y, 0);
scene.add(
new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
toScene(0, 0),
toScene(SCREEN_W, 0),
toScene(SCREEN_W, SCREEN_H),
toScene(0, SCREEN_H),
toScene(0, 0),
]),
new THREE.LineBasicMaterial({ color: FRAME }),
),
);
/** A little health bar, so a marker looks like the thing it stands for. */
function makeMarker() {
const geom = new THREE.BufferGeometry();
const mesh = new THREE.LineSegments(
geom,
new THREE.LineBasicMaterial({ color: GOOD }),
);
scene.add(mesh);
return (x: number, y: number, color: number, visible: boolean) => {
mesh.visible = visible;
(mesh.material as THREE.LineBasicMaterial).color.setHex(color);
if (!visible) return;
const w = 26;
const h = 7;
const corners = [
toScene(x - w, y - h),
toScene(x + w, y - h),
toScene(x + w, y + h),
toScene(x - w, y + h),
];
geom.setFromPoints([
corners[0],
corners[1],
corners[1],
corners[2],
corners[2],
corners[3],
corners[3],
corners[0],
// A stalk down to the object's own position.
toScene(x, y + h),
toScene(x, y + h + 12),
]);
};
}
const marks = markers(0).map(() => makeMarker());
const show = addReadout(el);
const yaw = addSlider(el, "turn the camera", -180, 180, 25, draw);
/* Phrased so that ticking it does the *right* thing, and so it starts wrong. "Skip the
check" put three negatives in a row - a checkbox, the word skip, and a bug - and read as
though ticking it turned something on rather than off. */
const depthCheck = addCheckbox(
el,
"only draw a marker if the object is in front of the camera",
false,
draw,
);
function draw() {
const list = markers(yaw());
let drawn = 0;
let wrong = 0;
list.forEach((m, i) => {
const inBounds =
m.x >= 0 && m.x <= SCREEN_W && m.y >= 0 && m.y <= SCREEN_H;
const shown = depthCheck() ? m.onScreen : inBounds;
// A marker drawn for something behind the camera is the bug, so colour it as one.
const isWrong = shown && !m.inFront;
if (shown) drawn += 1;
if (isWrong) wrong += 1;
marks[i](m.x, m.y, isWrong ? BAD : GOOD, shown);
});
show(
`${drawn} marker${drawn === 1 ? "" : "s"} drawn \u00B7 ` +
(wrong > 0
? `${wrong} in red, for ${wrong === 1 ? "an object" : "objects"} behind the camera`
: depthCheck()
? "none behind the camera, because w > 0 is being checked"
: "none behind the camera at this angle - keep turning"),
);
renderer.render(scene, camera);
}
draw();
return () => renderer.dispose();
};
export default mount; It starts broken, so turn the camera and watch the red markers. Those are being drawn for objects behind the camera - and notice they drift the wrong way as you turn, which is exactly how this bug looks when you ship it.
Now tick the checkbox to add the test. The red markers disappear and only objects genuinely in view keep theirs.
The build check asserts the ugly part directly: that the in-front and behind points land on the same pixel, so the prose above cannot quietly become false. It also sweeps the camera through a full turn and confirms the careless path really does over-draw.
source Projection, both directions, and spherical coordinates
/**
* The last matrix in the chain, and the one that behaves differently from all the others.
*
* Everything in Part 2 kept the bottom row at `(0, 0, 0, 1)`, so `w` stayed 1 and a point stayed a
* point. Projection breaks that on purpose: it writes depth into `w`, and the divide by `w` that
* follows is what makes distant things small. That divide is also where depth precision goes, which
* is the practical half of this Section.
*/
import type { Mat4, Vec3, Vec4 } from "./matrices.ts";
import { applyMat4, rowsOf } from "./matrices.ts";
const DEG = Math.PI / 180;
/**
* A perspective projection, from a **vertical** field of view.
*
* Vertical is the convention worth defaulting to, because it keeps the same amount of the world
* visible when the window gets wider - only the horizontal extent grows. Do it the other way and
* an ultrawide monitor crops the top and bottom off your game.
*/
export function perspective(
fovYDegrees: number,
aspect: number,
near: number,
far: number,
): Mat4 {
const t = Math.tan(fovYDegrees * DEG * 0.5);
return {
i: { x: 1 / (aspect * t), y: 0, z: 0, w: 0 },
j: { x: 0, y: 1 / t, z: 0, w: 0 },
// The -1 in w is the whole trick: it copies view depth into clip w.
k: { x: 0, y: 0, z: -(far + near) / (far - near), w: -1 },
t: { x: 0, y: 0, z: (-2 * far * near) / (far - near), w: 0 },
};
}
/**
* An orthographic projection: no divide, so no convergence and no foreshortening.
*
* Its bottom row stays `(0, 0, 0, 1)`, which means it is an ordinary affine transform of the kind
* Part 2 already covered. That is the real difference between the two - not the look, the `w`.
*/
export function orthographic(
halfHeight: number,
aspect: number,
near: number,
far: number,
): Mat4 {
return {
i: { x: 1 / (aspect * halfHeight), y: 0, z: 0, w: 0 },
j: { x: 0, y: 1 / halfHeight, z: 0, w: 0 },
k: { x: 0, y: 0, z: -2 / (far - near), w: 0 },
t: { x: 0, y: 0, z: -(far + near) / (far - near), w: 1 },
};
}
/** Horizontal field of view implied by a vertical one at a given aspect ratio. */
export function fovXFromFovY(fovYDegrees: number, aspect: number): number {
return (2 * Math.atan(aspect * Math.tan(fovYDegrees * DEG * 0.5))) / DEG;
}
/** And back the other way, for a tool that insists on horizontal. */
export function fovYFromFovX(fovXDegrees: number, aspect: number): number {
return (2 * Math.atan(Math.tan(fovXDegrees * DEG * 0.5) / aspect)) / DEG;
}
/**
* Project a view-space point into normalised device coordinates, divide included.
*
* Anything with all three components inside `[-1, 1]` is on screen. Returns `null` when `w` has
* collapsed, which happens exactly at the camera's own position - the one place projection has no
* answer.
*/
export function ndcOf(proj: Mat4, p: Vec3): Vec3 | null {
const clip: Vec4 = applyMat4(proj, { x: p.x, y: p.y, z: p.z, w: 1 });
if (Math.abs(clip.w) < 1e-12) return null;
return { x: clip.x / clip.w, y: clip.y / clip.w, z: clip.z / clip.w };
}
/** Half the height and width of the frustum at a given distance in front of the camera. */
export function extentAt(
fovYDegrees: number,
aspect: number,
distance: number,
): { halfHeight: number; halfWidth: number } {
const halfHeight = distance * Math.tan(fovYDegrees * DEG * 0.5);
return { halfHeight, halfWidth: halfHeight * aspect };
}
/** The eight corners of the frustum in view space: near face first, then far. */
export function frustumCorners(
fovYDegrees: number,
aspect: number,
near: number,
far: number,
): Vec3[] {
const out: Vec3[] = [];
for (const distance of [near, far]) {
const { halfHeight: h, halfWidth: w } = extentAt(
fovYDegrees,
aspect,
distance,
);
out.push(
{ x: -w, y: -h, z: -distance },
{ x: w, y: -h, z: -distance },
{ x: w, y: h, z: -distance },
{ x: -w, y: h, z: -distance },
);
}
return out;
}
// ---- Six planes, straight out of the matrix ----------------------------------------------
/** A plane as `x*px + y*py + z*pz + d >= 0` for the inside. */
export type Plane = { x: number; y: number; z: number; d: number };
/**
* The frustum's six planes, read directly off the projection matrix rows.
*
* This is worth seeing rather than deriving trigonometrically. "Inside" means every NDC coordinate
* lies in `[-1, 1]`, and each of those six inequalities is a row of the matrix added to or
* subtracted from the last row. So the planes are not extra information - they were in the matrix
* the whole time.
*/
export function frustumPlanes(proj: Mat4): Plane[] {
const r = rowsOf(proj);
const combine = (a: number[], b: number[], sign: number): Plane => {
const raw = {
x: b[0] + sign * a[0],
y: b[1] + sign * a[1],
z: b[2] + sign * a[2],
d: b[3] + sign * a[3],
};
const len = Math.hypot(raw.x, raw.y, raw.z) || 1;
return { x: raw.x / len, y: raw.y / len, z: raw.z / len, d: raw.d / len };
};
return [
combine(r[0], r[3], 1), // left
combine(r[0], r[3], -1), // right
combine(r[1], r[3], 1), // bottom
combine(r[1], r[3], -1), // top
combine(r[2], r[3], 1), // near
combine(r[2], r[3], -1), // far
];
}
/** Signed distance from a plane. Negative means outside. */
export const distanceToPlane = (plane: Plane, p: Vec3): number =>
plane.x * p.x + plane.y * p.y + plane.z * p.z + plane.d;
/**
* Is a sphere at least partly inside all six planes?
*
* A sphere rather than a point, because that is what culling actually tests - the object's bounding
* volume. Fail any one plane by more than the radius and the object cannot be visible, which is why
* this is the cheap early-out that runs before anything is drawn.
*/
export function insideFrustum(planes: Plane[], p: Vec3, radius = 0): boolean {
for (const plane of planes) {
if (distanceToPlane(plane, p) < -radius) return false;
}
return true;
}
// ---- Where depth precision goes ----------------------------------------------------------
/** NDC depth for a point straight ahead at `distance` in front of the camera. */
export function ndcDepth(proj: Mat4, distance: number): number {
const ndc = ndcOf(proj, { x: 0, y: 0, z: -distance });
return ndc === null ? NaN : ndc.z;
}
/**
* How much world distance a single depth-buffer step covers, at a given distance out.
*
* Small is good: it is the thickness of the thinnest gap the depth buffer can still tell apart.
* When two surfaces are closer together than this they fight for the same value and flicker, which
* is z-fighting.
*
* Computed from the matrix by finite difference rather than from a remembered formula, so it cannot
* drift away from whatever `perspective` actually builds.
*/
export function depthResolution(
proj: Mat4,
distance: number,
bits = 24,
): number {
const quantum = 2 / Math.pow(2, bits);
const h = distance * 1e-6;
const slope =
(ndcDepth(proj, distance + h) - ndcDepth(proj, distance - h)) / (2 * h);
return Math.abs(quantum / slope);
}
// ---- Pixels, and going backwards ---------------------------------------------------------
/** Where a pixel sits in NDC. Note the **Y flip**: pixels count down, NDC counts up. */
export function screenToNdc(
px: number,
py: number,
width: number,
height: number,
): { x: number; y: number } {
return {
x: (px / width) * 2 - 1,
y: -((py / height) * 2 - 1),
};
}
/** And back to pixels, flipping Y again. */
export function ndcToScreen(
ndc: { x: number; y: number },
width: number,
height: number,
): { x: number; y: number } {
return {
x: (ndc.x * 0.5 + 0.5) * width,
y: (0.5 - ndc.y * 0.5) * height,
};
}
/**
* The view-space point that a cursor position corresponds to, at a chosen distance.
*
* Read straight off the frustum's geometry rather than by inverting the projection matrix.
* Section 5.1 already established that the frustum is `distance * tan(fov/2)` tall, so a
* cursor at NDC `(x, y)` is that fraction of the way across it. No matrix inverse needed, and
* `projectionCheck` confirms it round-trips through `ndcOf` exactly.
*
* Engines invert the matrix instead because they have to support projections this shortcut
* does not cover - off-centre frusta for VR, oblique projections for portals.
*/
export function unprojectAt(
fovYDegrees: number,
aspect: number,
ndc: { x: number; y: number },
distance: number,
): Vec3 {
const { halfHeight, halfWidth } = extentAt(fovYDegrees, aspect, distance);
return { x: ndc.x * halfWidth, y: ndc.y * halfHeight, z: -distance };
}
/** A ray from the camera through a cursor position: origin plus a unit direction. */
export function rayThroughNdc(
fovYDegrees: number,
aspect: number,
ndc: { x: number; y: number },
): { origin: Vec3; direction: Vec3 } {
const at = unprojectAt(fovYDegrees, aspect, ndc, 1);
const len = Math.hypot(at.x, at.y, at.z);
return {
origin: { x: 0, y: 0, z: 0 },
direction: { x: at.x / len, y: at.y / len, z: at.z / len },
};
}
/**
* Where a world point lands on screen, and whether it should be drawn at all.
*
* `inFront` is the part people forget. Clip `w` is the point's distance in front of the camera,
* so it goes **negative** behind the camera - and dividing by a negative number mirrors the
* result through the origin. Skip that test and markers for things behind you appear on screen,
* in the wrong place, upside down.
*/
export function projectToScreen(
proj: Mat4,
p: Vec3,
width: number,
height: number,
): { x: number; y: number; inFront: boolean; onScreen: boolean } {
const clip = applyMat4(proj, { x: p.x, y: p.y, z: p.z, w: 1 });
const inFront = clip.w > 1e-9;
const w = inFront ? clip.w : 1;
const ndc = { x: clip.x / w, y: clip.y / w, z: clip.z / w };
const screen = ndcToScreen(ndc, width, height);
return {
...screen,
inFront,
onScreen:
inFront &&
Math.abs(ndc.x) <= 1 &&
Math.abs(ndc.y) <= 1 &&
Math.abs(ndc.z) <= 1,
};
}
/**
* The nearest point where a ray enters a sphere, or `null` if it misses.
*
* Enough for picking, which is all this Section needs. Part 6 does intersection tests
* properly, including the cases this one glosses over.
*/
export function raySphere(
origin: Vec3,
direction: Vec3,
centre: Vec3,
radius: number,
): number | null {
const ox = origin.x - centre.x;
const oy = origin.y - centre.y;
const oz = origin.z - centre.z;
const b = ox * direction.x + oy * direction.y + oz * direction.z;
const c = ox * ox + oy * oy + oz * oz - radius * radius;
const discriminant = b * b - c;
if (discriminant < 0) return null;
const root = Math.sqrt(discriminant);
const near = -b - root;
const far = -b + root;
const t = near >= 0 ? near : far;
return t >= 0 ? t : null;
}
// ---- Spherical coordinates, for an orbit camera ------------------------------------------
/**
* A position on a sphere from two angles and a radius, which is what an orbit camera is.
*
* Azimuth sweeps around the Y axis, elevation tilts up and down. Storing a camera this way
* means dragging maps onto the two angles directly, and zoom is the radius - all three controls
* stay independent, which they do not if you store a position and try to rotate it.
*/
export function sphericalToCartesian(
radius: number,
azimuthDegrees: number,
elevationDegrees: number,
): Vec3 {
const az = azimuthDegrees * DEG;
const el = elevationDegrees * DEG;
const horizontal = radius * Math.cos(el);
return {
x: horizontal * Math.sin(az),
y: radius * Math.sin(el),
z: horizontal * Math.cos(az),
};
}
/**
* Back to angles. At the poles the azimuth is genuinely undefined - every value gives the same
* point - so it reports 0 rather than whatever the floating point noise suggests.
*/
export function cartesianToSpherical(p: Vec3): {
radius: number;
azimuth: number;
elevation: number;
} {
const radius = Math.hypot(p.x, p.y, p.z);
if (radius < 1e-12) return { radius: 0, azimuth: 0, elevation: 0 };
const horizontal = Math.hypot(p.x, p.z);
return {
radius,
azimuth: horizontal < 1e-12 ? 0 : Math.atan2(p.x, p.z) / DEG,
elevation: Math.asin(Math.min(1, Math.max(-1, p.y / radius))) / DEG,
};
} Spherical Coordinates for an Orbit Camera
Section titled “Spherical Coordinates for an Orbit Camera”An orbit camera goes round a target. The naive way to store it is a position plus a look-at, and then dragging means rotating that position, which drifts and tangles the controls together.
Store two angles and a radius instead:
| Control | Parameter |
|---|---|
| Drag horizontally | azimuth, the angle around |
| Drag vertically | elevation, the angle above the horizon |
| Scroll | radius |
Now each input drives exactly one number, nothing accumulates error, and clamping is trivial. The round trip is exact: the check sweeps azimuth and elevation and recovers both to .
Clamp the elevation short of . At the poles is zero, so and vanish and the azimuth stops meaning anything - every value gives the same point. The value list above shows it: at elevation 89 the azimuth comes back as 137 as set; at elevation 90 it comes back as 0, because there is nothing to recover.
That is the same degeneracy as Section 3.1’s gimbal lock, arriving from a different direction, and it has the same fix for the same reason.
Where This Shows Up
Section titled “Where This Shows Up”- Clicking on anything in a strategy game, an editor, or an inventory in 3D.
- Aiming, where a crosshair at the centre of the screen becomes a ray down and the shot is whatever it meets first.
- Health bars, name plates, objective markers and damage numbers, all of which are a world position projected to a pixel, and all of which need the depth check.
- Off-screen indicators, which use the projected position and the fact that it is off screen to point an arrow toward it.
- Drag and drop in 3D, which unprojects at a fixed depth each frame rather than taking a ray.
- Orbit cameras in editors, model viewers and strategy games - and the capstone’s third-person camera, which is this plus Section 4.1’s damping.