From eae93d93dc01d50b71503a151e4af621d5bb3302 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Sun, 3 May 2026 09:41:31 +0100 Subject: [PATCH] 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). --- web/camera.js | 57 +++++++++++++++++++++++++++++++++++++++++ web/renderer.js | 54 +++++++++++++++++++------------------- webtest/camera.test.mjs | 28 ++++++++++++++++++++ 3 files changed, 111 insertions(+), 28 deletions(-) create mode 100644 web/camera.js create mode 100644 webtest/camera.test.mjs diff --git a/web/camera.js b/web/camera.js new file mode 100644 index 0000000..f420deb --- /dev/null +++ b/web/camera.js @@ -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); diff --git a/web/renderer.js b/web/renderer.js index 4778b8d..29c57fe 100644 --- a/web/renderer.js +++ b/web/renderer.js @@ -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, ]); -// Shared by the tile and grid programs so both use one camera. The 3D branch returns a real -// clip-space w (the view depth), which keeps textures perspective-correct on roofs while -// producing exactly the same screen positions as project() in JS. +// Shared by every program. 2D is an orthographic map; 3D goes through the same view-projection +// matrix that project() uses in JS, so WebGL, the overlay and picking agree exactly. const PROJECT = ` uniform vec2 uCenter; uniform vec2 uViewport; uniform float uZoom; uniform float uMode; -uniform vec2 uOrbit; -uniform float uDistance; +uniform mat4 uViewProj; +uniform float uHeightScale; vec4 projectWorld(vec3 world){ if(uMode < .5){ 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); } - vec3 p = vec3((world.xy - uCenter) / 420.0, world.z / 170.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); + return uViewProj * vec4(world.xy, world.z * uHeightScale, 1.0); }`; const VS = `#version 300 es @@ -117,6 +109,11 @@ function shader(gl, type, source) { 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 { constructor(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); 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;} - 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. @@ -184,15 +181,23 @@ export class LandscapeRenderer { gl.uniform2f(u.uViewport,this.width,this.height); gl.uniform1f(u.uZoom,this.camera.zoom); gl.uniform1f(u.uMode,this.mode==='3d'?1:0); - gl.uniform2f(u.uOrbit,this.camera.yaw,this.camera.pitch); - gl.uniform1f(u.uDistance,this.camera.distance); + gl.uniformMatrix4fv(u.uViewProj,false,this.viewProj); + 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) { const gl=this.gl; this.resize(); 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); - 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.gridUniforms||=Object.fromEntries(names.map(n=>[n,gl.getUniformLocation(this.gridProgram,n)])); if(this.mode==='3d'){ @@ -208,13 +213,6 @@ export class LandscapeRenderer { 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) { 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; @@ -225,11 +223,11 @@ export class LandscapeRenderer { 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]) { - const {cy,sy,cp,sp}=this.basis(),dx=(x-this.camera.x)/420,dy=(y-this.camera.y)/420,dz=z/170; - const rx=cy*dx-sy*dy,ry=sy*dx+cy*dy,py=cp*ry+sp*dz,pz=-sp*ry+cp*dz; - const depth=Math.max(.5,this.camera.distance-py),w=this.width,h=this.height; - return {x:w/2+(rx/(depth*w/h)*2.7)*w/2,y:h/2-((pz-.35)/depth*2.7)*h/2,depth}; + const [cx,cy,,cw]=transform(this.viewProj,x,y,z*this.heightScale); + if(cw<=1e-3)return {x:NaN,y:NaN,depth:Infinity}; + return {x:(cx/cw+1)/2*this.width,y:(1-cy/cw)/2*this.height,depth:cw}; } worldAt(clientX,clientY) { diff --git a/webtest/camera.test.mjs b/webtest/camera.test.mjs new file mode 100644 index 0000000..83c1d66 --- /dev/null +++ b/webtest/camera.test.mjs @@ -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}`); + } + } +});