Skip to content

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 �?PerspectiveCamera owns a PerspectiveProjection; 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;
  }
}
  • fov2viewRatio stores the relationship between the original viewport aspect and the FOV aspect so resizes preserve appearance.
  • projectionMatrix() simply delegates to buildProj(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 & rotationQ are stored as gl-matrix objects and cloned on construction.
  • fitNearFar(aabb) computes distance between the camera position and the AABB center, then sets znear = max(d - radius, zfar / 1000) and zfar = (d + radius) * 1.5 for safety.
  • frustumPlanes() multiplies P * V, extracts rows, and forms normalized plane equations using the Gribb–Hartmann trick.

Math helpers

world2view(R_wc, C)

V = [ R_wc  | -R_wc * C ]
    [ 0 0 0 |     1     ]

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:

  1. Calls camera.updateMatrixWorld() / updateProjectionMatrix() to sync Three’s state.
  2. Copies matrixWorldInverse into viewMat and premultiplies by diag(-1, 1, -1, 1) so the view matrix matches the right-handed convention used by preprocess.
  3. Multiplies Three’s projection matrix by the same rotation so that P' * V' equals the original P_three * V_three.
  4. Optionally negates the second row (Y) to compensate for the single Y-flip performed later during preprocess packing.
  5. Captures camera position and per-axis focal lengths (viewport / (2 tan(fov/2))).
  6. Exposes the same viewMatrix(), projMatrix(), position(), and projection.focal() signature as the core Camera implementation, 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.
  • rotationQ is 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.