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 (.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
(rl/set-trace-log-level :warning)
@ -71,4 +118,22 @@
;; 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)
(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)

View File

@ -192,13 +192,27 @@ let () =
would have passed with any of those permuted storing and returning is
symmetric. What the last case cannot pin, because nothing raylib
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 =
"17\n34\n51\n68\n\
6\n1\n4\n3\n\
7\n13\n17\n2\n4\n3.5\n7.25\n11.5\n13.75\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
if Sys.command "ldconfig -p 2>/dev/null | grep -q libraylib" = 0 then begin
outputs "raylib ffi, headless" "programs/raylib-ffi.flan" raylib_out;

View File

@ -146,6 +146,56 @@
(get-shapes-texture-rectangle-raw (addr 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 ──────────────────────────────────────────────────────────
;;
;; 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 int id; int width, height, mipmaps, format; } Texture2D;
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 CloseWindow(void);
@ -54,6 +55,10 @@ extern void DrawTextureEx(Texture2D texture, Vector2 position, float rotation,
float scale, Color tint);
extern void DrawTextureRec(Texture2D texture, Rectangle source,
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
* 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) {
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);
}