feat(camera): #78 deterministic Camera.zoom (Q16.16 render-time zoom)
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:
parent
5af5bdd060
commit
6a83c28e05
8 changed files with 14932 additions and 14780 deletions
3
changes/camera-zoom.md
Normal file
3
changes/camera-zoom.md
Normal 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).
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
# RFC — position & vector numeric types (#78)
|
# RFC — position & vector numeric types (#78)
|
||||||
|
|
||||||
Status: **decided — keep the deterministic integer core; add the three things
|
Status: **decided + first feature shipped.** Keep the deterministic integer core;
|
||||||
that were actually missing, all deterministic.** This document is the record of
|
add the things that were actually missing, all deterministic. `Camera.zoom` — the
|
||||||
the investigation asked for in issue #78, and the rationale for the decision.
|
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
|
## 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
|
just a draw offset (`Camera.set`/`follow`, already threaded through the two
|
||||||
framebuffer chokepoints) and needs no coordinate change at all.
|
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
|
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
|
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).
|
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
|
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
|
*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
|
Q16.16 scale, reusing the existing fixed-point blit; the coordinate types are
|
||||||
coordinate types are untouched.**
|
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
|
### 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. |
|
| `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. |
|
| 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. |
|
| 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. |
|
| `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
|
The deterministic integer + Q16.16 core stays. The genuinely-missing pieces are
|
||||||
|
|
|
||||||
31
docs/language/camera/camera-zoom.md
Normal file
31
docs/language/camera/camera-zoom.md
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
37
examples/library/camera_zoom.ludic
Normal file
37
examples/library/camera_zoom.ludic
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -43,6 +43,14 @@ var rt_clip_y0: int = 0
|
||||||
var rt_clip_x1: int = 320
|
var rt_clip_x1: int = 320
|
||||||
var rt_clip_y1: int = 240
|
var rt_clip_y1: int = 240
|
||||||
var rt_blend: int = 0 # 0 = replace, 1 = additive
|
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
|
# 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.
|
# 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 low-level plot: apply the camera (+ shake) offset, reject anything outside
|
||||||
# the clip rectangle or the framebuffer, then write or additively blend.
|
# the clip rectangle or the framebuffer, then write or additively blend.
|
||||||
function rt_put_px(x: int, y: int, c: int) -> void {
|
function rt_put_px(x: int, y: int, c: int) -> void {
|
||||||
let sx = x - rt_cam_x - rt_shake_x
|
var sx = x - rt_cam_x - rt_shake_x
|
||||||
let sy = y - rt_cam_y - rt_shake_y
|
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 sx < rt_clip_x0 { return }
|
||||||
if sy < rt_clip_y0 { return }
|
if sy < rt_clip_y0 { return }
|
||||||
if sx >= rt_clip_x1 { 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 {
|
function rt_fill_rect(x: int, y: int, w: int, h: int, c: int) -> void {
|
||||||
let ox = x - rt_cam_x - rt_shake_x
|
var ox = x - rt_cam_x - rt_shake_x
|
||||||
let oy = y - rt_cam_y - rt_shake_y
|
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 x0 = max(max(0, rt_clip_x0), ox)
|
||||||
let y0 = max(max(0, rt_clip_y0), oy)
|
let y0 = max(max(0, rt_clip_y0), oy)
|
||||||
let x1 = min(min(rt_fbw, rt_clip_x1), ox + w)
|
let x1 = min(min(rt_fbw, rt_clip_x1), ox + ow)
|
||||||
let y1 = min(min(rt_fbh, rt_clip_y1), oy + h)
|
let y1 = min(min(rt_fbh, rt_clip_y1), oy + oh)
|
||||||
var j = y0
|
var j = y0
|
||||||
while j < y1 {
|
while j < y1 {
|
||||||
let row = j * rt_fbw
|
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.
|
# (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 }
|
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
|
# 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.
|
# 0..1): 0 keeps it still, 65536 (1.0) snaps it centred. Deterministic.
|
||||||
function rt_camera_follow(x: int, y: int, lerp: fixed) -> void {
|
function rt_camera_follow(x: int, y: int, lerp: fixed) -> void {
|
||||||
|
|
|
||||||
|
|
@ -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 == "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 == "follow") { bare = "camera_follow"; push(labels, "x"); push(labels, "y"); push(labels, "lerp") }
|
||||||
if (meth == "shake") { bare = "camera_shake"; push(labels, "amount") }
|
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 (ns == "Map") {
|
||||||
if (meth == "size") { bare = "map_size"; push(labels, "width"); push(labels, "height") }
|
if (meth == "size") { bare = "map_size"; push(labels, "width"); push(labels, "height") }
|
||||||
|
|
|
||||||
29572
selfhost/ludicc.seed.ll
29572
selfhost/ludicc.seed.ll
File diff suppressed because it is too large
Load diff
|
|
@ -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/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", "", "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/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/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/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)")
|
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)")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue