Render 3D through a real perspective camera

Adds camera.js with perspective, lookAt and matrix helpers. The orbit
view is now a lookAt pose feeding one view-projection matrix shared by
the shaders and project(), replacing the hand-rolled projection with
its clamped depth. A node test checks the new camera reproduces the old
projection to within 0.05 px, so the landscape looks the same; this is
the base the city's walk and fly cameras need (near plane, correct w).
This commit is contained in:
Andy Dixon
2026-05-03 09:41:31 +01:00
parent 94577118a4
commit eae93d93dc
3 changed files with 111 additions and 28 deletions
+57
View File
@@ -0,0 +1,57 @@
// Minimal column-major 4x4 matrix helpers and the cameras built on them. World axes: x right,
// y "down" the 2D map, z up. Every 3D view renders and projects through one view-projection
// matrix, so WebGL, the overlay and picking agree exactly.
// The world is left-handed (y runs down the map, z up), so x is negated to keep the map unmirrored.
export function perspective(fovY, aspect, near, far) {
const f = 1 / Math.tan(fovY / 2), nf = 1 / (near - far);
return new Float32Array([-f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) * nf, -1, 0, 0, 2 * far * near * nf, 0]);
}
export function lookAt([ex, ey, ez], [tx, ty, tz], [ux, uy, uz] = [0, 0, 1]) {
let zx = ex - tx, zy = ey - ty, zz = ez - tz;
let len = Math.hypot(zx, zy, zz) || 1; zx /= len; zy /= len; zz /= len;
let xx = uy * zz - uz * zy, xy = uz * zx - ux * zz, xz = ux * zy - uy * zx;
len = Math.hypot(xx, xy, xz);
if (len < 1e-6) { xx = 1; xy = 0; xz = 0; len = 1; } // looking straight along up
xx /= len; xy /= len; xz /= len;
const yx = zy * xz - zz * xy, yy = zz * xx - zx * xz, yz = zx * xy - zy * xx;
return new Float32Array([
xx, yx, zx, 0, xy, yy, zy, 0, xz, yz, zz, 0,
-(xx * ex + xy * ey + xz * ez), -(yx * ex + yy * ey + yz * ez), -(zx * ex + zy * ey + zz * ez), 1,
]);
}
export function multiply(a, b) {
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;
}
// Clip coordinates of a world point.
export function transform(m, x, y, z) {
return [
m[0] * x + m[4] * y + m[8] * z + m[12],
m[1] * x + m[5] * y + m[9] * z + m[13],
m[2] * x + m[6] * y + m[10] * z + m[14],
m[3] * x + m[7] * y + m[11] * z + m[15],
];
}
// Unit view direction for a yaw (around z, 0 = looking toward -y) and pitch (up positive).
export function direction(yaw, pitch) {
return [-Math.sin(yaw) * Math.cos(pitch), -Math.cos(yaw) * Math.cos(pitch), Math.sin(pitch)];
}
// Orbit camera matching the original landscape projection: focal length 2.7, world scaled by
// 1/scale, with the view lifted by `lift` along the camera's up axis. `pitch` is the elevation.
export function orbitPose({ x, y, yaw, pitch, distance }, scale = 420, lift = .35) {
const cy = Math.cos(yaw), sy = Math.sin(yaw), cp = Math.cos(pitch), sp = Math.sin(pitch), d = distance * scale;
const up = [-sy * sp, -cy * sp, cp], shift = lift * scale;
const target = [x + up[0] * shift, y + up[1] * shift, up[2] * shift];
return { eye: [target[0] + sy * cp * d, target[1] + cy * cp * d, target[2] + sp * d], target, up };
}
export const ORBIT_FOV = 2 * Math.atan(1 / 2.7);
+26 -28
View File
@@ -13,29 +13,21 @@ const VERTICES = new Float32Array([
0,1,0, 0,0,0, 1,0,0, 0,1,0, 1,0,0, 1,1,0, 0,1,0, 0,0,0, 1,0,0, 0,1,0, 1,0,0, 1,1,0,
]); ]);
// Shared by the tile and grid programs so both use one camera. The 3D branch returns a real // Shared by every program. 2D is an orthographic map; 3D goes through the same view-projection
// clip-space w (the view depth), which keeps textures perspective-correct on roofs while // matrix that project() uses in JS, so WebGL, the overlay and picking agree exactly.
// producing exactly the same screen positions as project() in JS.
const PROJECT = ` const PROJECT = `
uniform vec2 uCenter; uniform vec2 uCenter;
uniform vec2 uViewport; uniform vec2 uViewport;
uniform float uZoom; uniform float uZoom;
uniform float uMode; uniform float uMode;
uniform vec2 uOrbit; uniform mat4 uViewProj;
uniform float uDistance; uniform float uHeightScale;
vec4 projectWorld(vec3 world){ vec4 projectWorld(vec3 world){
if(uMode < .5){ if(uMode < .5){
vec2 p = (world.xy - uCenter) * uZoom; vec2 p = (world.xy - uCenter) * uZoom;
return vec4(p.x * 2.0 / uViewport.x, -p.y * 2.0 / uViewport.y, min(world.z, 1.0) * .001, 1.0); return vec4(p.x * 2.0 / uViewport.x, -p.y * 2.0 / uViewport.y, min(world.z, 1.0) * .001, 1.0);
} }
vec3 p = vec3((world.xy - uCenter) / 420.0, world.z / 170.0); return uViewProj * vec4(world.xy, world.z * uHeightScale, 1.0);
float cy=cos(uOrbit.x), sy=sin(uOrbit.x), cp=cos(uOrbit.y), sp=sin(uOrbit.y);
p = vec3(cy*p.x-sy*p.y, sy*p.x+cy*p.y, p.z);
// Positive pitch places the camera above the ground plane.
p = vec3(p.x, cp*p.y+sp*p.z, -sp*p.y+cp*p.z);
float depth = max(.5, uDistance - p.y);
float aspect = uViewport.x / uViewport.y;
return vec4(p.x/aspect*2.7, (p.z-.35)*2.7, (depth-1.0)/10.0*depth, depth);
}`; }`;
const VS = `#version 300 es const VS = `#version 300 es
@@ -117,6 +109,11 @@ function shader(gl, type, source) {
return value; return value;
} }
import { perspective, lookAt, multiply, transform, orbitPose, ORBIT_FOV } from './camera.js';
// Landscape heights are exaggerated relative to the 1000x680 map so small repositories still read as 3D.
const LANDSCAPE_HEIGHT_SCALE = 420 / 170;
export class LandscapeRenderer { export class LandscapeRenderer {
constructor(canvas) { constructor(canvas) {
this.canvas = canvas; this.canvas = canvas;
@@ -162,7 +159,7 @@ export class LandscapeRenderer {
this.width=Math.max(1,this.canvas.clientWidth); this.height=Math.max(1,this.canvas.clientHeight); this.width=Math.max(1,this.canvas.clientWidth); this.height=Math.max(1,this.canvas.clientHeight);
const dpr=Math.min(devicePixelRatio||1,2), w=Math.max(1,Math.floor(this.width*dpr)), h=Math.max(1,Math.floor(this.height*dpr)); const dpr=Math.min(devicePixelRatio||1,2), w=Math.max(1,Math.floor(this.width*dpr)), h=Math.max(1,Math.floor(this.height*dpr));
if(this.canvas.width!==w||this.canvas.height!==h){this.canvas.width=w;this.canvas.height=h;} if(this.canvas.width!==w||this.canvas.height!==h){this.canvas.width=w;this.canvas.height=h;}
this.dpr=dpr; this.gl.viewport(0,0,w,h); this.dpr=dpr; this.gl.viewport(0,0,w,h); this.updateView();
} }
// Uploads the code overview canvas as the roof texture; call again after it changes. // Uploads the code overview canvas as the roof texture; call again after it changes.
@@ -184,15 +181,23 @@ export class LandscapeRenderer {
gl.uniform2f(u.uViewport,this.width,this.height); gl.uniform2f(u.uViewport,this.width,this.height);
gl.uniform1f(u.uZoom,this.camera.zoom); gl.uniform1f(u.uZoom,this.camera.zoom);
gl.uniform1f(u.uMode,this.mode==='3d'?1:0); gl.uniform1f(u.uMode,this.mode==='3d'?1:0);
gl.uniform2f(u.uOrbit,this.camera.yaw,this.camera.pitch); gl.uniformMatrix4fv(u.uViewProj,false,this.viewProj);
gl.uniform1f(u.uDistance,this.camera.distance); gl.uniform1f(u.uHeightScale,this.heightScale);
}
// Recomputes the 3D view-projection from the camera; called once per frame and before picking.
updateView() {
this.heightScale=LANDSCAPE_HEIGHT_SCALE;
const pose=orbitPose(this.camera);
this.eye=pose.eye;
this.viewProj=multiply(perspective(ORBIT_FOV,this.width/this.height,4,60000),lookAt(pose.eye,pose.target,pose.up));
} }
render(alpha=1) { render(alpha=1) {
const gl=this.gl; this.resize(); const gl=this.gl; this.resize();
gl.clearColor(0,0,0,0); gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT); gl.clearColor(0,0,0,0); gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LEQUAL); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA); gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LEQUAL); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA);
const names=['uCenter','uViewport','uZoom','uMode','uOrbit','uDistance','uAlpha','uCode','uCodeOn']; const names=['uCenter','uViewport','uZoom','uMode','uViewProj','uHeightScale','uAlpha','uCode','uCodeOn'];
this.uniforms||=Object.fromEntries(names.map(n=>[n,gl.getUniformLocation(this.program,n)])); this.uniforms||=Object.fromEntries(names.map(n=>[n,gl.getUniformLocation(this.program,n)]));
this.gridUniforms||=Object.fromEntries(names.map(n=>[n,gl.getUniformLocation(this.gridProgram,n)])); this.gridUniforms||=Object.fromEntries(names.map(n=>[n,gl.getUniformLocation(this.gridProgram,n)]));
if(this.mode==='3d'){ if(this.mode==='3d'){
@@ -208,13 +213,6 @@ export class LandscapeRenderer {
gl.drawArraysInstanced(gl.TRIANGLES,0,36,this.count); gl.drawArraysInstanced(gl.TRIANGLES,0,36,this.count);
} }
// Per-frame trigonometry for project(); camera values only change between frames.
basis() {
const c=this.camera,b=this._basis||(this._basis={});
if(b.yaw!==c.yaw||b.pitch!==c.pitch){b.yaw=c.yaw;b.pitch=c.pitch;b.cy=Math.cos(c.yaw);b.sy=Math.sin(c.yaw);b.cp=Math.cos(c.pitch);b.sp=Math.sin(c.pitch);}
return b;
}
screenRect(item) { screenRect(item) {
if(this.mode==='2d') return { x:(item.x-this.camera.x)*this.camera.zoom+this.width/2, y:(item.y-this.camera.y)*this.camera.zoom+this.height/2, w:item.w*this.camera.zoom, h:item.h*this.camera.zoom }; if(this.mode==='2d') return { x:(item.x-this.camera.x)*this.camera.zoom+this.width/2, y:(item.y-this.camera.y)*this.camera.zoom+this.height/2, w:item.w*this.camera.zoom, h:item.h*this.camera.zoom };
let x0=Infinity,y0=Infinity,x1=-Infinity,y1=-Infinity; let x0=Infinity,y0=Infinity,x1=-Infinity,y1=-Infinity;
@@ -225,11 +223,11 @@ export class LandscapeRenderer {
return {x:x0,y:y0,w:x1-x0,h:y1-y0}; return {x:x0,y:y0,w:x1-x0,h:y1-y0};
} }
// Screen position of a world point; depth is the view distance, and points behind the camera are NaN.
project([x,y,z=0]) { project([x,y,z=0]) {
const {cy,sy,cp,sp}=this.basis(),dx=(x-this.camera.x)/420,dy=(y-this.camera.y)/420,dz=z/170; const [cx,cy,,cw]=transform(this.viewProj,x,y,z*this.heightScale);
const rx=cy*dx-sy*dy,ry=sy*dx+cy*dy,py=cp*ry+sp*dz,pz=-sp*ry+cp*dz; if(cw<=1e-3)return {x:NaN,y:NaN,depth:Infinity};
const depth=Math.max(.5,this.camera.distance-py),w=this.width,h=this.height; return {x:(cx/cw+1)/2*this.width,y:(1-cy/cw)/2*this.height,depth:cw};
return {x:w/2+(rx/(depth*w/h)*2.7)*w/2,y:h/2-((pz-.35)/depth*2.7)*h/2,depth};
} }
worldAt(clientX,clientY) { worldAt(clientX,clientY) {
+28
View File
@@ -0,0 +1,28 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { perspective, lookAt, multiply, transform, orbitPose, ORBIT_FOV } from '../web/camera.js';
// The landscape projection before the real camera, kept as the reference.
function legacyProject(camera, [x, y, z], width, height) {
let px = (x - camera.x) / 420, py = (y - camera.y) / 420, pz = z / 170;
const cy = Math.cos(camera.yaw), sy = Math.sin(camera.yaw), cp = Math.cos(camera.pitch), sp = Math.sin(camera.pitch);
[px, py] = [cy * px - sy * py, sy * px + cy * py]; [py, pz] = [cp * py + sp * pz, -sp * py + cp * pz];
const depth = camera.distance - py, aspect = width / height;
return { x: width / 2 + (px / (depth * aspect) * 2.7) * width / 2, y: height / 2 - ((pz - .35) / depth * 2.7) * height / 2, depth };
}
test('orbit camera reproduces the legacy landscape projection', () => {
const width = 1280, height = 800;
for (const camera of [{ x: 500, y: 340, yaw: -.12, pitch: .76, distance: 3.5 }, { x: 120, y: 600, yaw: 2.4, pitch: .2, distance: 1.1 }, { x: 800, y: 50, yaw: -1.3, pitch: 1.4, distance: 7 }]) {
const pose = orbitPose(camera);
const viewProj = multiply(perspective(ORBIT_FOV, width / height, 1, 100000), lookAt(pose.eye, pose.target, pose.up));
for (const point of [[500, 340, 0], [0, 0, 40], [1000, 680, 12], [430, 200, 80]]) {
const legacy = legacyProject(camera, point, width, height);
if (legacy.depth < .6) continue;
const [cx, cy, , cw] = transform(viewProj, point[0], point[1], point[2] * 420 / 170);
const x = (cx / cw + 1) / 2 * width, y = (1 - cy / cw) / 2 * height;
assert.ok(Math.abs(x - legacy.x) < .05 && Math.abs(y - legacy.y) < .05, `${JSON.stringify(camera)} ${point}: got ${x},${y} want ${legacy.x},${legacy.y}`);
assert.ok(Math.abs(cw / 420 - legacy.depth) < 1e-3, `depth ${cw / 420} vs ${legacy.depth}`);
}
}
});