Camera Module Architecture
The camera module keeps all view/projection math in one place so preprocessing, sorting, and rendering can treat the camera as a simple data source. It consists of a thin Camera interface, a concrete PerspectiveCamera + PerspectiveProjection pair, and an optional CameraAdapter for Three.js scenes.
Module layout
src/camera/
├── perspective.ts
�? ├── Camera interface
�? ├── PerspectiveProjection
�? ├── PerspectiveCamera
�? ├── world2view()
�? └── buildProj()
├── CameraAdapter.ts (Three.js bridge)
└── index.ts (re-exports + math helpers)
Design principles
- Interface first �?renderers and preprocessors depend only on
Camera, so future orthographic or stereo cameras can slot in without touching GPU code. - Composition �?
PerspectiveCameraowns aPerspectiveProjection; projection math stays isolated from position/orientation logic. - Immutable-ish �?Setters clone inputs (
vec3,quat, matrices) to avoid accidental mutations; getters return clones for the same reason. - GPU friendly �?Matrices are column-major, right-handed, and already oriented for WebGPU’s
[0, 1]depth range.
PerspectiveProjection internals
class PerspectiveProjection {
constructor(viewport, [fovx, fovy], znear, zfar) {
this.fovx = fovx;
this.fovy = fovy;
this.znear = znear;
this.zfar = zfar;
this.fov2viewRatio = (viewport.w / viewport.h) / (fovx / fovy);
}
resize(viewport) {
const viewportRatio = viewport.w / viewport.h;
const targetFovRatio = viewportRatio / this.fov2viewRatio;
const newFovy = 2 * Math.atan(Math.tan(this.fovy / 2) * targetFovRatio / (this.fovx / this.fovy));
this.fovy = newFovy;
this.fovx = this.fovy * targetFovRatio;
}
}
fov2viewRatiostores the relationship between the original viewport aspect and the FOV aspect so resizes preserve appearance.projectionMatrix()simply delegates tobuildProj(znear, zfar, fovx, fovy).focal(viewport)converts FOV to per-axis focal lengths (pixels) used by preprocess.lerp(other, t)interpolates every scalar (fovx/y, near/far) plus the stored ratio for smooth camera animations.
PerspectiveCamera internals
class PerspectiveCamera implements Camera {
positionV: vec3;
rotationQ: quat; // world -> camera
projection: PerspectiveProjection;
viewMatrix(): mat4 {
const R_wc = mat3.fromQuat(mat3.create(), this.rotationQ);
return world2view(R_wc, this.positionV);
}
}
positionV&rotationQare stored as gl-matrix objects and cloned on construction.fitNearFar(aabb)computes distance between the camera position and the AABB center, then setsznear = max(d - radius, zfar / 1000)andzfar = (d + radius) * 1.5for safety.frustumPlanes()multipliesP * V, extracts rows, and forms normalized plane equations using the Gribb–Hartmann trick.
Math helpers
world2view(R_wc, C)
rotationQ is treated as world→camera, so R_wc is inserted directly. Translation is computed as -R_wc * C, ensuring the camera ends up at the origin looking down −Z.
buildProj(znear, zfar, fovx, fovy)
Produces a column-major perspective matrix compatible with WebGPU (depth range [0, 1]). The function derives top/bottom/right/left from tangent of the half FOVs, then follows the textbook perspective formula with out[11] = 1 and out[15] = 0 (Row/Column order matches gl-matrix expectations).
CameraAdapter (Three.js bridge)
CameraAdapter mirrors the behaviour of DirectCameraAdapter used in the Three.js integration:
- Calls
camera.updateMatrixWorld()/updateProjectionMatrix()to sync Three’s state. - Copies
matrixWorldInverseintoviewMatand premultiplies bydiag(-1, 1, -1, 1)so the view matrix matches the right-handed convention used by preprocess. - Multiplies Three’s projection matrix by the same rotation so that
P' * V'equals the originalP_three * V_three. - Optionally negates the second row (Y) to compensate for the single Y-flip performed later during preprocess packing.
- Captures camera position and per-axis focal lengths (
viewport / (2 tan(fov/2))). - Exposes the same
viewMatrix(),projMatrix(),position(), andprojection.focal()signature as the coreCameraimplementation, so preprocess simply consumes the adapter like any other camera.
This adapter is used extensively by three-integration/GaussianSplattingThreeWebGPU.ts, allowing Three.js cameras to drive preprocess + renderer without rewriting math.
Uniforms & GPU layout
Both preprocess and renderer expect the following structure (std140 alignment):
struct CameraUniforms {
view_matrix : mat4x4<f32>;
proj_matrix : mat4x4<f32>;
view_proj_matrix : mat4x4<f32>;
camera_position : vec3<f32>; // padded to 16 bytes
camera_direction : vec3<f32>; // padded to 16 bytes
}
PerspectiveCamera produces the first three matrices directly; the app or renderer handles packaging/duplication when uploading to UniformBuffer.
Precision & stability
- Near/far fitting enforces a minimum ratio (�?1:1000) before adding the safety multiplier, preventing z-fighting in preprocess/projection.
rotationQis normalised whenever the camera is constructed or mutated, avoiding drift when quaternion math accumulates.- Plane normals from
frustumPlanes()are normalized so distance tests are straightforward. - Resizing and FOV changes go through consistent helpers so UI actions cannot push the projection into invalid ranges.
Integration summary
| Consumer | What it reads |
|---|---|
Preprocess (dispatchModel) |
camera.viewMatrix(), camera.projMatrix(), camera.position(), camera.projection.focal(viewport) |
Renderer (GaussianRenderer) |
Same as above; fills camera uniform buffers before draw. |
| Controls/orbit/FPS systems | Mutate positionV, rotationQ, or call projection.resize. |
| Three.js demos | CameraAdapter translates Three cameras into the core interface. |
By keeping the math self-contained and exposing a tiny interface, Visionary can swap cameras, plug into external engines, and keep GPU uploads predictable without duplicating projection logic throughout the codebase.