Skip to content

Commit c17be31

Browse files
committed
Orthographic camera
1 parent 5be4a9b commit c17be31

5 files changed

Lines changed: 163 additions & 70 deletions

File tree

webgpu/camera.py

Lines changed: 82 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,67 @@
33
from .uniforms import BaseBinding, Binding, UniformBase, ct
44
from .utils import read_shader_file, Lock
55

6+
# Shared frustum constants. The camera sits at distance FOCAL from the world
7+
# origin (the view matrix translates z by -FOCAL); content is scaled to ~unit
8+
# size by the transform, so NEAR/FAR comfortably bracket it.
9+
NEAR = 0.1
10+
FAR = 10
11+
FOV = 45
12+
FOCAL = 3
13+
14+
15+
def _projection_matrix(aspect, orthographic=False, zoom=1.0):
16+
"""Build the 4x4 projection matrix for the current aspect ratio.
17+
18+
Perspective and orthographic share the same vertical field of view: the
19+
orthographic half-height is the perspective half-height measured at the
20+
focal plane (distance FOCAL from the camera), so both projections render the
21+
centered content at the same on-screen size.
22+
"""
23+
if orthographic:
24+
top = FOCAL * np.tan(np.radians(FOV) / 2) * zoom
25+
height = 2 * top
26+
width = aspect * height
27+
28+
x = 2 / width
29+
y = 2 / height
30+
c = -1 / (FAR - NEAR)
31+
d = -NEAR / (FAR - NEAR)
32+
33+
return np.array(
34+
[
35+
[x, 0, 0, 0],
36+
[0, y, 0, 0],
37+
[0, 0, c, d],
38+
[0, 0, 0, 1],
39+
]
40+
)
41+
42+
top = NEAR * (np.tan(np.radians(FOV) / 2)) * zoom
43+
height = 2 * top
44+
width = aspect * height
45+
left = -0.5 * width
46+
right = left + width
47+
bottom = top - height
48+
49+
x = 2 * NEAR / (right - left)
50+
y = 2 * NEAR / (top - bottom)
51+
52+
a = (right + left) / (right - left)
53+
b = (top + bottom) / (top - bottom)
54+
55+
c = -FAR / (FAR - NEAR)
56+
d = (-FAR * NEAR) / (FAR - NEAR)
57+
58+
return np.array(
59+
[
60+
[x, 0, a, 0],
61+
[0, y, b, 0],
62+
[0, 0, c, d],
63+
[0, 0, -1, 0],
64+
]
65+
)
66+
667

768
class CameraUniforms(UniformBase):
869
"""Uniforms class, derived from ctypes.Structure to ensure correct memory layout"""
@@ -21,49 +82,24 @@ class CameraUniforms(UniformBase):
2182
("dpr", ct.c_float),
2283
]
2384

24-
def update(self, transform, canvas, write_buffer=True):
85+
def update(self, transform, canvas, write_buffer=True, orthographic=False):
2586
"""Recompute projection/model-view matrices from transform and canvas dimensions.
2687
2788
Returns (model_view_proj, model_view) matrices, or (None, None) if canvas is unavailable.
2889
2990
With ``write_buffer`` False the matrices are computed but the GPU buffer
3091
is not uploaded (JS-engine mode, where the browser owns the camera).
92+
93+
With ``orthographic`` True a parallel projection is used instead of the
94+
perspective default.
3195
"""
3296
if canvas is None or canvas.height == 0:
3397
return None, None
3498

35-
near = 0.1
36-
far = 10
37-
fov = 45
3899
aspect = canvas.width / canvas.height
100+
proj_mat = _projection_matrix(aspect, orthographic)
39101

40-
zoom = 1.0
41-
top = near * (np.tan(np.radians(fov) / 2)) * zoom
42-
height = 2 * top
43-
width = aspect * height
44-
left = -0.5 * width
45-
right = left + width
46-
bottom = top - height
47-
48-
x = 2 * near / (right - left)
49-
y = 2 * near / (top - bottom)
50-
51-
a = (right + left) / (right - left)
52-
b = (top + bottom) / (top - bottom)
53-
54-
c = -far / (far - near)
55-
d = (-far * near) / (far - near)
56-
57-
proj_mat = np.array(
58-
[
59-
[x, 0, a, 0],
60-
[0, y, b, 0],
61-
[0, 0, c, d],
62-
[0, 0, -1, 0],
63-
]
64-
)
65-
66-
view_mat = np.array([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, -3], [0, 0, 0, 1]])
102+
view_mat = np.array([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, -FOCAL], [0, 0, 0, 1]])
67103
model_view = view_mat @ transform.mat
68104
model_view_proj = proj_mat @ model_view
69105
normal_mat = np.linalg.inv(model_view)
@@ -218,6 +254,7 @@ class Camera:
218254

219255
def __init__(self):
220256
self.transform = Transform()
257+
self.orthographic = False
221258
self._observers = []
222259
self._observers_lock = Lock()
223260
self._is_moving = False
@@ -226,8 +263,9 @@ def __init__(self):
226263
self._dblclick_handlers = {}
227264

228265
def __setstate__(self, state):
229-
"""Restore pickled camera state (only the transform)."""
266+
"""Restore pickled camera state (transform and projection mode)."""
230267
self.transform = state["transform"]
268+
self.orthographic = state.get("orthographic", False)
231269
self._observers = []
232270
self._observers_lock = Lock()
233271
self._is_moving = False
@@ -237,7 +275,18 @@ def __setstate__(self, state):
237275

238276
def __getstate__(self):
239277
"""Return a minimal picklable representation of the camera."""
240-
return {"transform": self.transform}
278+
return {"transform": self.transform, "orthographic": self.orthographic}
279+
280+
def set_orthographic(self, value: bool):
281+
"""Switch between orthographic and perspective projection.
282+
283+
Notifies observers (which pushes the change to the JS engine in
284+
JS-engine mode) only when the mode actually changes.
285+
"""
286+
value = bool(value)
287+
if value != self.orthographic:
288+
self.orthographic = value
289+
self._notify_observers()
241290

242291
def register_observer(self, callback):
243292
"""Register a callback to be called when the camera transform changes.

webgpu/engine/camera.js

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ class Transform {
193193
class Camera {
194194
constructor() {
195195
this.transform = new Transform();
196+
this.orthographic = false;
196197
this._observers = [];
197198
}
198199

@@ -234,29 +235,42 @@ class Camera {
234235
updateUniforms(width, height) {
235236
if (height === 0) return null;
236237

237-
// --- Projection (matches Python exactly) ---
238-
const near = 0.1, far = 10, fov = 45;
238+
// --- Projection (matches Python camera.py exactly) ---
239+
const near = 0.1, far = 10, fov = 45, focal = 3;
239240
const aspect = width / height;
240241
const zoom = 1.0;
241-
const top = near * Math.tan(fov * DEG2RAD / 2) * zoom;
242-
const h = 2 * top;
243-
const w = aspect * h;
244-
const left = -0.5 * w;
245-
const right = left + w;
246-
const bottom = top - h;
247-
248-
const px = 2 * near / (right - left);
249-
const py = 2 * near / (top - bottom);
250-
const a = (right + left) / (right - left);
251-
const b = (top + bottom) / (top - bottom);
252-
const c = -far / (far - near);
253-
const d = (-far * near) / (far - near);
254242

255243
const proj = new Float64Array(16);
256-
proj[0] = px; proj[2] = a;
257-
proj[5] = py; proj[6] = b;
258-
proj[10] = c; proj[11] = d;
259-
proj[14] = -1;
244+
if (this.orthographic) {
245+
// Parallel projection: half-height matches perspective at the focal plane.
246+
const top = focal * Math.tan(fov * DEG2RAD / 2) * zoom;
247+
const h = 2 * top;
248+
const w = aspect * h;
249+
proj[0] = 2 / w;
250+
proj[5] = 2 / h;
251+
proj[10] = -1 / (far - near);
252+
proj[11] = -near / (far - near);
253+
proj[15] = 1;
254+
} else {
255+
const top = near * Math.tan(fov * DEG2RAD / 2) * zoom;
256+
const h = 2 * top;
257+
const w = aspect * h;
258+
const left = -0.5 * w;
259+
const right = left + w;
260+
const bottom = top - h;
261+
262+
const px = 2 * near / (right - left);
263+
const py = 2 * near / (top - bottom);
264+
const a = (right + left) / (right - left);
265+
const b = (top + bottom) / (top - bottom);
266+
const c = -far / (far - near);
267+
const d = (-far * near) / (far - near);
268+
269+
proj[0] = px; proj[2] = a;
270+
proj[5] = py; proj[6] = b;
271+
proj[10] = c; proj[11] = d;
272+
proj[14] = -1;
273+
}
260274

261275
// --- View matrix: identity with z-translation = -3 ---
262276
const view = mat4Identity();

webgpu/engine/engine.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,15 @@ class RenderEngine {
665665
this._notifyCameraChanged(true);
666666
}
667667

668+
/** Live-mode hook: host switches between orthographic and perspective. */
669+
setProjection(orthographic) {
670+
if (!this.camera) return;
671+
this.camera.orthographic = !!orthographic;
672+
this._updateCameraBuffer();
673+
if (this._cameraBufferId) this.computeDAG.markDirty(this._cameraBufferId);
674+
this.render();
675+
}
676+
668677
/** Live-mode hook: host sets the background clear color [r,g,b,a] (0..1). */
669678
setClearColor(rgba) {
670679
this._clearColorOverride = rgba || null;
@@ -1157,6 +1166,16 @@ class RenderEngine {
11571166

11581167
pass.end();
11591168
device.queue.submit([encoder.finish()]);
1169+
1170+
// Return the exact camera transform the depth was rendered with, so the
1171+
// host reconstructs pick positions with a matching model_view_projection.
1172+
// (Browser-side zoom/pan reach the host's camera mirror only via the
1173+
// debounced on_camera_changed, so without this the host's matrix can lag
1174+
// the rendered depth and pick coordinates come out mis-scaled.)
1175+
return {
1176+
matrix: Array.from(this.camera.transform._mat),
1177+
center: Array.from(this.camera.transform._center),
1178+
};
11601179
}
11611180

11621181
// =========================================================================

webgpu/renderer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ def update_buffers(self):
119119
mvp, _ = self._camera_uniforms.update(
120120
self.camera.transform, self.canvas,
121121
write_buffer=not self.skip_camera_buffer_write,
122+
orthographic=self.camera.orthographic,
122123
)
123124
if mvp is not None:
124125
self.model_view_proj = mvp

webgpu/scene.py

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -521,10 +521,13 @@ def _render_select_via_engine(self, canvas):
521521
buffers. The host still reads the texture back (caller). Best-effort:
522522
on failure the select buffer is simply left cleared."""
523523
try:
524-
self._js_engine.renderSelectInto(
524+
cam = self._js_engine.renderSelectInto(
525525
platform.toJS(canvas.select_texture),
526526
platform.toJS(canvas.select_depth_texture),
527527
)
528+
# Sync the Python camera mirror to the transform the engine used for picking
529+
if self._set_camera_transform_from_payload(cam):
530+
self.options.update_buffers()
528531
except Exception as e:
529532
print(f"warning: js_engine.renderSelectInto() failed: {e}")
530533

@@ -920,6 +923,7 @@ def _on_camera_changed(self):
920923
platform.toJS(t._mat.flatten().tolist()),
921924
platform.toJS(list(center)),
922925
)
926+
self._js_engine.setProjection(self.options.camera.orthographic)
923927
except Exception as e:
924928
print(f"warning: js_engine.setCameraTransform() failed: {e}")
925929
return
@@ -929,27 +933,33 @@ def _on_camera_changed(self):
929933
self.options.update_buffers()
930934
self.render()
931935

932-
def _apply_camera_from_js(self, payload):
933-
"""Mirror a JS-engine camera move back into the Python camera (for
934-
bookmarks/screenshots/picking). Does not re-render or push back to JS."""
936+
def _set_camera_transform_from_payload(self, payload):
937+
"""Apply an engine camera payload ({matrix, center}) to the Python
938+
camera mirror. Returns True on success."""
935939
import numpy as np
936940

937941
try:
938-
try:
939-
import pyodide.ffi
942+
import pyodide.ffi
940943

941-
if isinstance(payload, pyodide.ffi.JsProxy):
942-
payload = payload.to_py()
943-
except ImportError:
944-
pass
945-
mat = list(payload["matrix"])
946-
center = list(payload["center"])
947-
t = self.options.camera.transform
948-
t._mat = np.array(mat, dtype=float).reshape(4, 4)
949-
t._center = np.array(center, dtype=float)
950-
if self._render_mutex is not None:
951-
with self._render_mutex:
952-
self._select_buffer_valid = False
944+
if isinstance(payload, pyodide.ffi.JsProxy):
945+
payload = payload.to_py()
946+
except ImportError:
947+
pass
948+
if not payload:
949+
return False
950+
t = self.options.camera.transform
951+
t._mat = np.array(list(payload["matrix"]), dtype=float).reshape(4, 4)
952+
t._center = np.array(list(payload["center"]), dtype=float)
953+
return True
954+
955+
def _apply_camera_from_js(self, payload):
956+
"""Mirror a JS-engine camera move back into the Python camera (for
957+
bookmarks/screenshots/picking). Does not re-render or push back to JS."""
958+
try:
959+
if self._set_camera_transform_from_payload(payload):
960+
if self._render_mutex is not None:
961+
with self._render_mutex:
962+
self._select_buffer_valid = False
953963
except Exception as e:
954964
print(f"warning: apply camera from js failed: {e}")
955965

0 commit comments

Comments
 (0)