feat(camera): #78 deterministic Camera.zoom (Q16.16 render-time zoom)
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 22s
ci / build-and-test (push) Successful in 2m15s
commit-lint / conventional-commits (push) Successful in 4s
docs / build-and-deploy (push) Successful in 28s

The #78 investigation rejected hardware f32/f64 for the coordinate types
(they would desync lockstep/replay/save) and identified camera zoom as the
one genuinely-missing render feature. Ship it: Camera.zoom(scale) scales the
whole view about the screen centre by a Q16.16 factor, threaded through the
same two framebuffer chokepoints (rt_put_px/rt_fill_rect) that carry the
camera offset, so it composes with Camera.set/follow/shake. Gated by an
internal rt_cam_zoomed flag so a game that never zooms renders byte-for-byte
identically (golden renders unchanged); Camera.zoom(1.0) turns it back off.
The world coordinate types stay integer px + Q16.16 velocity, so it's a pure
render-time transform and itself deterministic.

Example examples/library/camera_zoom.ludic (pixel-readback verified),
docs page, RFC updated (docs/RFC-POSITION-TYPES.md). Full suite 105/0,
fixpoint holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-02 06:55:37 +03:00
parent 5af5bdd060
commit 6a83c28e05
8 changed files with 14932 additions and 14780 deletions

View file

@ -1,8 +1,10 @@
# RFC — position & vector numeric types (#78)
Status: **decided — keep the deterministic integer core; add the three things
that were actually missing, all deterministic.** This document is the record of
the investigation asked for in issue #78, and the rationale for the decision.
Status: **decided + first feature shipped.** Keep the deterministic integer core;
add the things that were actually missing, all deterministic. `Camera.zoom` — the
one genuinely-missing render feature this investigation identified — is now
implemented (see below). This document is the record of the investigation asked
for in issue #78, and the rationale for the decision.
## The question
@ -64,15 +66,21 @@ wall*; none has, so we do not add speculative width now. Camera scroll itself is
just a draw offset (`Camera.set`/`follow`, already threaded through the two
framebuffer chokepoints) and needs no coordinate change at all.
### 3. Camera zoom
### 3. Camera zoom — **shipped**
Zoom is a **render-time** transform, not a world-coordinate change. Multiplying
world→screen by a Q16.16 scale in the blit path is deterministic (fixed×fixed with
a 64-bit intermediate, exactly as the existing sprite-scale and light math do).
Zoom does not require `Position` to be a float — the world stays integer, the
*camera* carries a Q16.16 zoom. **Decision: zoom belongs on the camera as a
Q16.16 scale (future `Camera.zoom`), reusing the existing fixed-point blit; the
coordinate types are untouched.**
Q16.16 scale, reusing the existing fixed-point blit; the coordinate types are
untouched — and this is now implemented as `Camera.zoom(scale)`.** It scales every
draw about the screen centre through the same two framebuffer chokepoints
(`rt_put_px` / `rt_fill_rect`) that already carry the camera offset, gated by a
`rt_cam_zoomed` flag so a game that never zooms renders byte-for-byte identically
(the golden renders are unchanged). `Camera.zoom(1.0)` turns it back off. See
`examples/library/camera_zoom.ludic` (pixel-readback verified) and
`docs/language/camera/camera-zoom.md`.
### 4. Precision
@ -112,7 +120,7 @@ Conflating them would cost more than it pays.
| `i64` positions | Deferred, additive later (`Position64`) only when a real game needs >±2e9 px. Not speculative now. |
| Sub-pixel motion | Already solved; `Body.rx`/`ry` Q16.16 accumulators, now documented as the pattern. |
| Large worlds | Range, not representation; `i32` px is ±2e9. Wide ints (`BigInt`) exist. |
| Camera zoom | Render-time Q16.16 scale on the camera (future `Camera.zoom`), not a coordinate change. |
| Camera zoom | **Shipped** as `Camera.zoom(scale)` — render-time Q16.16 scale about the screen centre, not a coordinate change; byte-identical when unused. |
| `Position` as `Vector`| **No.** `Position` is a reflected/saved/networked *component*; `IVec2` is the *value* type for math. Convert at the boundary. |
The deterministic integer + Q16.16 core stays. The genuinely-missing pieces are

View file

@ -0,0 +1,31 @@
---
id: camera-zoom
name: Camera.zoom
category: camera
kind: namespace-method
tokens: Camera.zoom
sig: Camera.zoom(scale)
tip: Scale the whole view about the screen centre by a Q16.16 factor (1.0 = none).
order: 4
ns: Camera
member: zoom
---
Sets a render-time zoom: every draw is scaled about the screen centre by <code>scale</code>, a Q16.16 <code>fixed</code> factor. <code>1.0</code> is no zoom (and turns the zoom path back off, restoring the byte-identical un-zoomed blit); <code>2.0</code> is 2x in; <code>0.5</code> is 2x out. It composes with <a href="camera-set"><code>Camera.set</code></a> / <a href="camera-follow"><code>Camera.follow</code></a> (which move the view) and <a href="camera-shake"><code>Camera.shake</code></a>.
Zoom is a *render-time transform*, not a change to the world coordinate types — those stay integer pixels + Q16.16 velocity so lockstep, replay and <code>world_save</code> hold (see the position-types RFC, #78). Because it rides on fixed-point, the zoom is itself deterministic: the same <code>scale</code> produces the same pixels on every machine and headless.
Parameters:
- `scale` — the zoom factor (`fixed`); `1.0` = no zoom, `>1` zooms in, `<1` zooms out
```ludic
program Demo {
property Cam { z: fixed = 1.0 }
model View { Cam }
handler DrawWorld phase Render {
Camera.zoom(2.0)
Screen.fill_rectangle(150, 110, 20, 20, 0xffcc00)
Screen.show()
}
}
```