Camera2D, and a test that pins its layout by arithmetic

GetScreenToWorld2D computes from every field of a Camera2D - offset, target,
rotation and zoom - so a wrong field order produces a wrong coordinate rather
than the same numbers back. That is the standard the texture lane arrived at
the hard way: a struct round trip is symmetric and passes for any layout.
This commit is contained in:
Joseph Ferano 2026-09-11 18:57:54 +07:00
commit f5d4cd5188
4 changed files with 149 additions and 2 deletions

View File

@ -24,6 +24,53 @@
(print-f64 (f64 (.width r))) (newline) (print-f64 (f64 (.width r))) (newline)
(print-f64 (f64 (.height r))) (newline)) (print-f64 (f64 (.height r))) (newline))
;; ── Camera2D ────────────────────────────────────────────────────────
;;
;; The two conversions are pure arithmetic and need no window, which makes
;; them the strongest headless material in the package: each one reads every
;; field of a Camera2D and every field of two Vector2s.
;;
;; They are asserted in both directions separately and never as a round trip.
;; world->screen->world is the store-and-return trap wearing a different hat:
;; the inverse cancels a permuted layout exactly, so it passes for any order.
;;
;; The camera below is chosen so that no field is silently unpinned — offset
;; and target differ, zoom is 2.0 and not the identity 1.0, and every
;; component is a distinct dyadic value that prints exactly.
(defconst cam (rl/Camera2D {:offset (rl/Vector2 {:x 100.0 :y 50.0})
:target (rl/Vector2 {:x 8.0 :y 4.0})
:rotation 0.0
:zoom 2.0}))
;; Absolute values, not a round trip: screen (140,90) is world (28,24) because
;; ((140-100)/2)+8 = 28 and ((90-50)/2)+4 = 24. Swap offset and target in the
;; defstruct and this reads (143,-16); swap rotation and zoom and the zoom
;; becomes 0, the transform is singular, and both come back NaN.
(defn show-v [v rl/Vector2]
(print-f64 (f64 (.x v))) (newline)
(print-f64 (f64 (.y v))) (newline))
;; What no geometric call can pin on its own is Vector2's own two fields:
;; exchange x and y everywhere and every component-wise formula is simply
;; mirrored, so the answer comes back mirrored too and compares equal. A
;; *rotated* camera is the exception — it mixes x into y — so the case below
;; is the one thing in this file that fixes which float is which.
;;
;; It cannot be compared as text: 90 degrees goes through sinf and cosf and
;; the answer is 27.9999981, not 28, and the table compares stdout byte for
;; byte at -O0 and -O2. So the comparison happens here, with a tolerance, and
;; what is printed is the verdict. A wrong-but-close value passing is not a
;; risk worth naming: a permuted layout is out by whole units. Swapping x and
;; y in Vector2 makes this print "rotated bad" — the same camera then reads
;; back as (-12,24).
(defn near? [a f32 b f32] bool
(let [d (- a b)]
(< (if (< d 0.0) (- 0.0 d) d) 0.0001)))
(defn show-near [name string v rl/Vector2 x f32 y f32]
(print-str name)
(print-line (if (and (near? (.x v) x) (near? (.y v) y)) " ok" " bad")))
(defn main [] i32 (defn main [] i32
(rl/set-trace-log-level :warning) (rl/set-trace-log-level :warning)
@ -71,4 +118,22 @@
;; visibly wrong sprite in the interactive run, and nowhere else. ;; visibly wrong sprite in the interactive run, and nowhere else.
(rl/set-shapes-texture (rl/Texture2D {:id 7 :width 0 :height 17 :mipmaps 2 :format 4}) rect) (rl/set-shapes-texture (rl/Texture2D {:id 7 :width 0 :height 17 :mipmaps 2 :format 4}) rect)
(show-texture (rl/get-shapes-texture))) (show-texture (rl/get-shapes-texture)))
;; Camera2D, each direction on its own. See the note above show-v for why
;; this is not a round trip.
(show-v (rl/get-screen-to-world-2d (rl/Vector2 {:x 140.0 :y 90.0}) cam))
(show-v (rl/get-world-to-screen-2d (rl/Vector2 {:x 28.0 :y 24.0}) cam))
;; And the rotated camera, which is what pins Vector2's own two fields.
(let [spun (rl/Camera2D {:offset (rl/Vector2 {:x 100.0 :y 50.0})
:target (rl/Vector2 {:x 8.0 :y 4.0})
:rotation 90.0
:zoom 2.0})]
(show-near "rotated screen-to-world"
(rl/get-screen-to-world-2d (rl/Vector2 {:x 140.0 :y 90.0}) spun)
28.0 -16.0)
(show-near "rotated world-to-screen"
(rl/get-world-to-screen-2d (rl/Vector2 {:x 28.0 :y 24.0}) spun)
60.0 90.0))
0) 0)

View File

@ -192,13 +192,27 @@ let () =
would have passed with any of those permuted storing and returning is would have passed with any of those permuted storing and returning is
symmetric. What the last case cannot pin, because nothing raylib symmetric. What the last case cannot pin, because nothing raylib
computes without a GL context reads them, is width, height and mipmaps computes without a GL context reads them, is width, height and mipmaps
against each other. *) against each other.
The camera conversions are the strongest headless material here: both
are pure arithmetic over every field of a Camera2D and two Vector2s,
and both directions are asserted as absolute answers. A round trip
would not be the inverse cancels a permuted layout exactly, the same
way store-and-return does. The rotated pair is the only thing in the
package that pins Vector2's own two fields, because every
component-wise formula is merely mirrored by exchanging x and y and so
compares equal; a rotation mixes them. It reports ok/bad rather than a
number because sinf and cosf make the answer 27.9999981, and this
table compares stdout byte for byte. *)
let raylib_out = let raylib_out =
"17\n34\n51\n68\n\ "17\n34\n51\n68\n\
6\n1\n4\n3\n\ 6\n1\n4\n3\n\
7\n13\n17\n2\n4\n3.5\n7.25\n11.5\n13.75\n\ 7\n13\n17\n2\n4\n3.5\n7.25\n11.5\n13.75\n\
1\n1\n1\n1\n7\n\ 1\n1\n1\n1\n7\n\
7\n0\n17\n2\n4\n" 7\n0\n17\n2\n4\n\
28\n24\n140\n90\n\
rotated screen-to-world ok\n\
rotated world-to-screen ok\n"
in in
if Sys.command "ldconfig -p 2>/dev/null | grep -q libraylib" = 0 then begin if Sys.command "ldconfig -p 2>/dev/null | grep -q libraylib" = 0 then begin
outputs "raylib ffi, headless" "programs/raylib-ffi.flan" raylib_out; outputs "raylib ffi, headless" "programs/raylib-ffi.flan" raylib_out;

View File

@ -146,6 +146,56 @@
(get-shapes-texture-rectangle-raw (addr r)) (get-shapes-texture-rectangle-raw (addr r))
r)) r))
;; ── Camera2D ────────────────────────────────────────────────────────
;;
;; The 2D camera: everything drawn between begin-mode-2d and end-mode-2d is
;; transformed by it. `offset` is where the camera's target lands on screen —
;; half the window size is what centres it — `target` is the world point that
;; goes there, and rotation is in degrees.
;;
;; `zoom` of 0 makes the transform singular and both conversions below hand
;; back NaN rather than failing. raylib does not guard it and neither does
;; this; 1.0 is the identity and a fresh (Camera2D {}) is therefore NOT usable
;; as one — it has to be given a zoom.
(defstruct Camera2D [offset Vector2 target Vector2 rotation f32 zoom f32])
(declare begin-mode-2d-raw [camera (Ptr Camera2D)] "flan_rl_begin_mode_2d")
(defn begin-mode-2d [camera Camera2D]
(let [c camera]
(begin-mode-2d-raw (addr c))))
(declare end-mode-2d [] "flan_rl_end_mode_2d")
;; The two conversions are pure arithmetic over every field of the camera, so
;; unlike the rest of the camera they run with no window and no GL context.
;; That is what the acceptance table uses to pin Camera2D's layout, and — via
;; a rotated camera, which is the only call here that mixes x into y — it is
;; also the only thing that pins Vector2's two fields against each other.
(declare get-screen-to-world-2d-raw
[position (Ptr Vector2) camera (Ptr Camera2D) out (Ptr Vector2)]
"flan_rl_get_screen_to_world_2d")
(defn get-screen-to-world-2d [position Vector2 camera Camera2D] Vector2
(let [p position
c camera
out (Vector2 {})]
(get-screen-to-world-2d-raw (addr p) (addr c) (addr out))
out))
(declare get-world-to-screen-2d-raw
[position (Ptr Vector2) camera (Ptr Camera2D) out (Ptr Vector2)]
"flan_rl_get_world_to_screen_2d")
(defn get-world-to-screen-2d [position Vector2 camera Camera2D] Vector2
(let [p position
c camera
out (Vector2 {})]
(get-world-to-screen-2d-raw (addr p) (addr c) (addr out))
out))
;; ── Shapes ────────────────────────────────────────────────────────── ;; ── Shapes ──────────────────────────────────────────────────────────
;; ;;
;; Rectangle intersection, which raylib computes from all four fields in ;; Rectangle intersection, which raylib computes from all four fields in

18
vendor/raylib/shim.c vendored
View File

@ -22,6 +22,7 @@ typedef struct { float x, y; } Vector2;
typedef struct { unsigned char r, g, b, a; } Color; typedef struct { unsigned char r, g, b, a; } Color;
typedef struct { unsigned int id; int width, height, mipmaps, format; } Texture2D; typedef struct { unsigned int id; int width, height, mipmaps, format; } Texture2D;
typedef struct { float x, y, width, height; } Rectangle; typedef struct { float x, y, width, height; } Rectangle;
typedef struct { Vector2 offset, target; float rotation, zoom; } Camera2D;
extern void InitWindow(int width, int height, const char *title); extern void InitWindow(int width, int height, const char *title);
extern void CloseWindow(void); extern void CloseWindow(void);
@ -54,6 +55,10 @@ extern void DrawTextureEx(Texture2D texture, Vector2 position, float rotation,
float scale, Color tint); float scale, Color tint);
extern void DrawTextureRec(Texture2D texture, Rectangle source, extern void DrawTextureRec(Texture2D texture, Rectangle source,
Vector2 position, Color tint); Vector2 position, Color tint);
extern void BeginMode2D(Camera2D camera);
extern void EndMode2D(void);
extern Vector2 GetScreenToWorld2D(Vector2 position, Camera2D camera);
extern Vector2 GetWorldToScreen2D(Vector2 position, Camera2D camera);
/* A Flan string arrives as ptr+len and is not NUL-terminated, so a C API that /* A Flan string arrives as ptr+len and is not NUL-terminated, so a C API that
* wants a C string needs a copy. Two callers want one: the window title and a * wants a C string needs a copy. Two callers want one: the window title and a
@ -146,3 +151,16 @@ void flan_rl_draw_texture_rec(const Texture2D *texture, const Rectangle *source,
const Vector2 *position, const Color *tint) { const Vector2 *position, const Color *tint) {
DrawTextureRec(*texture, *source, *position, *tint); DrawTextureRec(*texture, *source, *position, *tint);
} }
void flan_rl_begin_mode_2d(const Camera2D *camera) { BeginMode2D(*camera); }
void flan_rl_end_mode_2d(void) { EndMode2D(); }
void flan_rl_get_screen_to_world_2d(const Vector2 *position,
const Camera2D *camera, Vector2 *out) {
*out = GetScreenToWorld2D(*position, *camera);
}
void flan_rl_get_world_to_screen_2d(const Vector2 *position,
const Camera2D *camera, Vector2 *out) {
*out = GetWorldToScreen2D(*position, *camera);
}