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

3
changes/camera-zoom.md Normal file
View file

@ -0,0 +1,3 @@
bump: minor
type: feat
Deterministic camera zoom (#78) — `Camera.zoom(scale)` scales the whole view about the screen centre by a Q16.16 factor (`1.0` = none, `2.0` = 2x in, `0.5` = out). It rides on the same two framebuffer chokepoints (`rt_put_px` / `rt_fill_rect`) that already carry the camera offset, so it composes with `Camera.set`/`follow`/`shake`, and it is a *render-time* transform — the world coordinate types stay integer pixels + Q16.16 velocity, so lockstep, replay and `world_save` are untouched, and the zoom itself is deterministic. 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. This is the concrete outcome of the #78 position-types investigation (`docs/RFC-POSITION-TYPES.md`), which rejected hardware floats for the deterministic coordinate core and identified zoom as the one genuinely-missing render feature. Example: `examples/library/camera_zoom.ludic` (pixel-readback verified).

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()
}
}
```

View file

@ -0,0 +1,37 @@
# camera_zoom.ludic — the deterministic render-time camera zoom (#78). Zoom is a
# Q16.16 scale applied about the screen centre in the blit path; the world stays
# integer pixels, so lockstep / replay / world_save are untouched. Verified by
# pixel readback (Screen.pixel reads the actual framebuffer, un-transformed).
#
# Deterministic; a full run prints: 1 0 0 1 1
program CameraZoom {
# a model so the program runs the ECS and links the core runtime (the framebuffer
# + Screen.* live in runtime/native/core.ludic, spliced for an ECS program).
property Cam { z: fixed = 1.0 }
model View { Cam }
function bi(b: bool) -> int { if b { return 1 }; return 0 }
entry {
# A. no zoom: a 4x4 white box drawn at (100,100) lands at (100,100).
Screen.clear(0)
Screen.fill_rectangle(100, 100, 4, 4, 0xffffff)
print(bi(Screen.pixel(101, 101) == 0xffffff)) # 1 — box is here
print(bi(Screen.pixel(43, 83) == 0xffffff)) # 0 — nothing here yet
# B. 2x zoom about the screen centre (160,120): (100,100) maps to
# ((100-160)*2+160, (100-120)*2+120) = (40, 80), size 4 -> 8, so the box
# now covers x 40..48, y 80..88 and has left (101,101).
Screen.clear(0)
Camera.zoom(2.0)
Screen.fill_rectangle(100, 100, 4, 4, 0xffffff)
print(bi(Screen.pixel(101, 101) == 0xffffff)) # 0 — moved away
print(bi(Screen.pixel(43, 83) == 0xffffff)) # 1 — scaled to here
# C. zoom back to 1.0 restores the byte-identical un-zoomed blit.
Screen.clear(0)
Camera.zoom(1.0)
Screen.fill_rectangle(100, 100, 4, 4, 0xffffff)
print(bi(Screen.pixel(101, 101) == 0xffffff)) # 1 — normal again
}
}

View file

@ -43,6 +43,14 @@ var rt_clip_y0: int = 0
var rt_clip_x1: int = 320
var rt_clip_y1: int = 240
var rt_blend: int = 0 # 0 = replace, 1 = additive
# #78 — deterministic camera zoom. A Q16.16 scale applied about the screen centre
# in the same two chokepoints as the camera offset. Rejected floats for the
# coordinate types (they would desync lockstep/replay/save); zoom is a *render-time*
# transform, so it rides on fixed-point exactly like sprite-scale and the light math.
# rt_cam_zoomed gates the fixed multiply out of the hot path so a game that never
# zooms renders byte-for-byte identically (the else-branch is the original code).
var rt_cam_zoom: fixed = 1.0 # 1.0 = no zoom; >1 zooms in, <1 zooms out
var rt_cam_zoomed: bool = false # true once a non-1.0 zoom is set
# 5x7 glyphs for ASCII 32..90, 7 rows per glyph, each row a 5-bit mask stored
# biased by '0' so the whole font is one printable string literal.
@ -99,8 +107,14 @@ function rt_blend_add(dst: int, src: int) -> int {
# the low-level plot: apply the camera (+ shake) offset, reject anything outside
# the clip rectangle or the framebuffer, then write or additively blend.
function rt_put_px(x: int, y: int, c: int) -> void {
let sx = x - rt_cam_x - rt_shake_x
let sy = y - rt_cam_y - rt_shake_y
var sx = x - rt_cam_x - rt_shake_x
var sy = y - rt_cam_y - rt_shake_y
if rt_cam_zoomed { # #78: scale about the screen centre
let hw = rt_fbw / 2
let hh = rt_fbh / 2
sx = floor(fixed(sx - hw) * rt_cam_zoom) + hw
sy = floor(fixed(sy - hh) * rt_cam_zoom) + hh
}
if sx < rt_clip_x0 { return }
if sy < rt_clip_y0 { return }
if sx >= rt_clip_x1 { return }
@ -115,12 +129,22 @@ function rt_put_px(x: int, y: int, c: int) -> void {
}
function rt_fill_rect(x: int, y: int, w: int, h: int, c: int) -> void {
let ox = x - rt_cam_x - rt_shake_x
let oy = y - rt_cam_y - rt_shake_y
var ox = x - rt_cam_x - rt_shake_x
var oy = y - rt_cam_y - rt_shake_y
var ow = w
var oh = h
if rt_cam_zoomed { # #78: scale position + size about the centre
let hw = rt_fbw / 2
let hh = rt_fbh / 2
ox = floor(fixed(ox - hw) * rt_cam_zoom) + hw
oy = floor(fixed(oy - hh) * rt_cam_zoom) + hh
ow = floor(fixed(w) * rt_cam_zoom); if ow < 1 { ow = 1 }
oh = floor(fixed(h) * rt_cam_zoom); if oh < 1 { oh = 1 }
}
let x0 = max(max(0, rt_clip_x0), ox)
let y0 = max(max(0, rt_clip_y0), oy)
let x1 = min(min(rt_fbw, rt_clip_x1), ox + w)
let y1 = min(min(rt_fbh, rt_clip_y1), oy + h)
let x1 = min(min(rt_fbw, rt_clip_x1), ox + ow)
let y1 = min(min(rt_fbh, rt_clip_y1), oy + oh)
var j = y0
while j < y1 {
let row = j * rt_fbw
@ -275,6 +299,15 @@ function rt_measure_text(text: string) -> int {
# (wx-x, wy-y)). Reset to (0,0) to draw a fixed HUD.
function rt_camera(x: int, y: int) -> void { rt_cam_x = x; rt_cam_y = y }
# #78 — set the render-time zoom (a Q16.16 scale applied about the screen centre):
# 1.0 = no zoom, 2.0 = 2x in, 0.5 = out. Deterministic (fixed-point), so it
# preserves lockstep / replay / world_save. Setting exactly 1.0 turns the zoom
# path back off, restoring the byte-identical no-zoom blit.
function rt_camera_zoom(scale: fixed) -> void {
rt_cam_zoom = scale
rt_cam_zoomed = scale != 1.0
}
# Ease the camera so (x,y) drifts toward the screen centre by `lerp` (a fixed in
# 0..1): 0 keeps it still, 65536 (1.0) snaps it centred. Deterministic.
function rt_camera_follow(x: int, y: int, lerp: fixed) -> void {

View file

@ -183,6 +183,7 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
if (meth == "set") { bare = "camera"; push(labels, "x"); push(labels, "y") }
if (meth == "follow") { bare = "camera_follow"; push(labels, "x"); push(labels, "y"); push(labels, "lerp") }
if (meth == "shake") { bare = "camera_shake"; push(labels, "amount") }
if (meth == "zoom") { bare = "camera_zoom"; push(labels, "scale") } # #78 deterministic Q16.16 zoom
}
if (ns == "Map") {
if (meth == "size") { bare = "map_size"; push(labels, "width"); push(labels, "height") }

File diff suppressed because it is too large Load diff

View file

@ -223,6 +223,7 @@ function cmd_test() -> int {
feat_case("library/grid", "", "1 2 3 4 5 6 7 8 9 10 11 12 13", "grid.ludic (Grid line/flood/line_of_sight + A* pathfinding over the tilemap)")
feat_case("library/anim", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34", "anim.ludic (Anim frame/once/pingpong/cell + Tween progress/loop/yoyo/ease/number/round/point/tint)")
feat_case("library/anim_sugar", "", "4 8 2 1 0 100 100 0 0 1 20 20 30 0 1", "anim_sugar.ludic (Anim.clip/play/on_frame/fired + Motion.to + fluent Tween.to/chain/delay/parallel handles; issue #48)")
feat_case("library/camera_zoom", "", "1 0 0 1 1", "camera_zoom.ludic (Camera.zoom deterministic Q16.16 render-time zoom about the screen centre, verified by pixel readback; issue #78)")
feat_case("library/query", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "query.ludic (Query count/first/nearest/within — ECS spatial queries over the reflection ABI)")
feat_case("library/reflect", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20", "reflect.ludic (Reflect prop/field enumeration + type + get/set/has/kind — runtime reflection over the world schema)")
feat_case("library/serialize", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16", "serialize.ludic (Value tree + Json encode/parse + Reflect.serialize/apply — bit-exact save/load; issue #44)")