feat(rendering): add Screen.camera/clip/blend_mode/oval + Camera.* + Screen.pixel (#23)
Completes the transform/state-based rendering #23 tracked as blocked on new renderer state. All of it threads through the two framebuffer chokepoints every draw primitive already funnels through (rt_put_px / rt_fill_rect), so one place gives the whole draw API a camera, a clip rect, and a blend mode. Defaults are neutral — camera (0,0), clip = full screen, blend = replace — so every existing golden render is byte-identical (the 60+ render tests still pass unchanged). New renderer state (runtime/native/core.ludic): - Screen.camera(x, y) / Camera.set(x, y) world-space draw offset; a world point draws at (wx-x, wy-y). Moves everything — reset to (0,0) for a HUD. - Camera.follow(x, y, lerp) ease the offset toward centring a target (fixed lerp 0..1) - Camera.shake(amount) +/- amount jitter from the seeded RNG (replay shakes identically); 0 clears - Screen.clip(x,y,w,h) / clip_reset() screen-space clip rectangle - Screen.blend_mode(m) 0 = replace, 1 = additive (clamped) New primitives: - Screen.oval(x, y, rx, ry, color) axis-aligned ellipse outline (midpoint) - Screen.measure_text(text) -> int advance width in the 5x7 font - Screen.pixel(x, y) -> int read a framebuffer pixel (0x00RRGGBB) Everything stays integer and deterministic (the camera, shake, and blend all reproduce exactly under identical inputs), so headless renders remain diffable. Camera.follow interpolates in the fixed domain (fixed*fixed then floor) to avoid the int*fixed coercion trap. Screen.pixel makes the whole surface testable by reading rendered pixels back: examples/library/render.ludic asserts 18 cases — pixel round-trip, camera and Camera.set/follow offsets, clip in/out + reset, additive blend with 255 clamp, oval extremes vs hollow centre, and text measurement — all verified against the actual framebuffer, not just that the call compiled. Wired into x test (now 65 passed). Docs: 7 new Screen pages + a Camera section with 3 pages, inventory/coverage green. Seed reseeded; the C-free bootstrap fixpoint holds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
12f2dbe958
commit
31cfbc2465
16 changed files with 8383 additions and 7183 deletions
79
examples/library/render.ludic
Normal file
79
examples/library/render.ludic
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# render.ludic — the rendering & camera extras: Screen.pixel/oval/measure_text,
|
||||
# Screen.camera/clip/blend_mode, and Camera.set/follow/shake. Each assertion that
|
||||
# holds prints its number, so a full run prints:
|
||||
# 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
||||
# It draws into the headless framebuffer and reads pixels back with Screen.pixel,
|
||||
# so it verifies the actual rendered result (not just that a call compiled). All
|
||||
# deterministic. Colours are 0x00RRGGBB.
|
||||
program Render {
|
||||
property Tag { v: int = 0 }
|
||||
model Marker { Tag }
|
||||
|
||||
handler Boot phase Start {
|
||||
let WHITE = 16777215 # 0xFFFFFF
|
||||
let RED = 16711680 # 0xFF0000
|
||||
|
||||
# --- Screen.pixel round-trips a plotted pixel ---
|
||||
Screen.clear(0)
|
||||
Screen.put_pixel(10, 20, WHITE)
|
||||
if Screen.pixel(10, 20) == WHITE { print(1) }
|
||||
if Screen.pixel(11, 20) == 0 { print(2) }
|
||||
|
||||
# --- Screen.camera offsets the draw path (world -> screen) ---
|
||||
Screen.camera(5, 5)
|
||||
Screen.put_pixel(30, 30, RED) # lands at screen (25, 25)
|
||||
if Screen.pixel(25, 25) == RED { print(3) }
|
||||
if Screen.pixel(30, 30) == 0 { print(4) }
|
||||
Screen.camera(0, 0)
|
||||
|
||||
# --- Camera.set is the same offset control ---
|
||||
Camera.set(8, 8)
|
||||
Screen.put_pixel(40, 40, 65280) # green, lands at (32, 32)
|
||||
if Screen.pixel(32, 32) == 65280 { print(5) }
|
||||
Camera.set(0, 0)
|
||||
|
||||
# --- Camera.follow eases the offset toward centring a target ---
|
||||
Camera.follow(260, 120, fixed(1)) # tx = 260 - 160 = 100 -> cam_x = 100
|
||||
Screen.put_pixel(100, 50, 255) # blue, lands at screen (0, 50)
|
||||
if Screen.pixel(0, 50) == 255 { print(6) }
|
||||
Camera.set(0, 0)
|
||||
|
||||
# --- Camera.shake(0) clears any shake (draw lands unshifted) ---
|
||||
Camera.shake(0)
|
||||
Screen.put_pixel(70, 70, WHITE)
|
||||
if Screen.pixel(70, 70) == WHITE { print(7) }
|
||||
|
||||
# --- Screen.clip restricts drawing to a rectangle ---
|
||||
Screen.clip(0, 0, 4, 4)
|
||||
Screen.put_pixel(2, 2, WHITE) # inside the clip
|
||||
Screen.put_pixel(12, 12, WHITE) # outside the clip
|
||||
if Screen.pixel(2, 2) == WHITE { print(8) }
|
||||
if Screen.pixel(12, 12) == 0 { print(9) }
|
||||
Screen.clip_reset()
|
||||
Screen.put_pixel(12, 12, WHITE) # now allowed
|
||||
if Screen.pixel(12, 12) == WHITE { print(10) }
|
||||
|
||||
# --- Screen.blend_mode(1) adds colours channel-wise, clamped at 255 ---
|
||||
Screen.blend_mode(1)
|
||||
Screen.put_pixel(90, 90, Color.rgb(100, 0, 0))
|
||||
Screen.put_pixel(90, 90, Color.rgb(80, 0, 0))
|
||||
if Screen.pixel(90, 90) == Color.rgb(180, 0, 0) { print(11) }
|
||||
Screen.put_pixel(90, 90, Color.rgb(200, 0, 0)) # 180 + 200 -> clamp 255
|
||||
if Screen.pixel(90, 90) == Color.rgb(255, 0, 0) { print(12) }
|
||||
Screen.blend_mode(0)
|
||||
|
||||
# --- Screen.oval outlines an ellipse (rx != ry) ---
|
||||
Screen.clear(0)
|
||||
Screen.oval(50, 50, 10, 6, WHITE)
|
||||
if Screen.pixel(60, 50) == WHITE { print(13) } # rightmost
|
||||
if Screen.pixel(40, 50) == WHITE { print(14) } # leftmost
|
||||
if Screen.pixel(50, 56) == WHITE { print(15) } # bottom
|
||||
if Screen.pixel(50, 50) == 0 { print(16) } # outline, not filled
|
||||
|
||||
# --- Screen.measure_text: advance width in the 5x7 font (6 px/glyph) ---
|
||||
if Screen.measure_text("abc") == 18 { print(17) }
|
||||
if Screen.measure_text("") == 0 { print(18) }
|
||||
}
|
||||
|
||||
handler Run phase Update { quit() }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue