Gododin Gododin Documentation

Example project

Bunny Mark (Native)

examples/bunny_mark/README.md

View original Markdown · Download · All examples

Bunny Mark lets you switch between GDScript and Odin while the same seeded bunnies bounce across the screen. It compares per-sprite and whole-buffer submission paths in both languages. Godot owns drawing, scene composition, the texture and UI in every mode. Use it to see where you place the native boundary: one call per bunny or one call for the whole population.

Controls and scope

  • Mode changes the transport path and restarts the seeded population.
  • Count restarts the current mode at the requested capacity.
  • Speed / Gravity retune all modes consistently without moving them back to their seed positions. The UI labels custom history accurately.
  • Stats update every 0.25 seconds; eval controls are disabled.

Build and run

Use a configured copy with its attached SDK; the separate project setup guide covers the connection. From the example directory:

sh
./test.sh          # pure tests + exact SDK doctor/generate/check/debug build
./test.sh eval     # pure measurement, replay and allocation gates; no receipt writes
./test.sh import   # accepted runtime import/parser check; rendering unverified
./test.sh run      # windowed field/node/GDScript campaign, serialized
./test.sh play     # interactive accepted artifact

Set GODODIN_PROFILE=release for both the build and subsequent run to measure optimized native code. The runner separates debug/release acceptance caches and checks the publication profile. Windowed evals disable VSync and the engine FPS cap, wait for a scene click, and reject samples collected without window focus. Ordinary play keeps the project's presentation settings.

test.sh uses the engine in the attached SDK lock and keeps --rendering-driver opengl3 for windowed runs. The project also defaults to Compatibility. This bypasses Godot GH-123394, the dev5 windowed Metal texture-free corruption. Do not drop the flag based on an assumed future fix. Prepared runtimes come from the accepted publication with isolated writable caches and a fresh HOME; check exact engine/build identities in the retained run log. Run one Godot process at a time and close it fully.

Read the code

The scene driver selects the mode and owns presentation. Compare the native per-node class with the native field class and its pure population kernel. The matching GDScript paths are the per-sprite loop and the bulk loop. generated/ is compiler-owned output; never edit it by hand.

The three variants

  • GDScript loop (project/gd_bunnies.gd) uses one Sprite2D per bunny and one GDScript driver loop over cached sprite references and packed velocities.
  • Native per-node (declarations/bunny.odin) uses one authored Sprite2D per bunny. Each step crosses into Odin, reads the engine position, integrates it and writes it back through the typed Node2D surface. This deliberately demonstrates the cost of putting the native boundary inside the hot loop.
  • Native staged (declarations/bunny_field.odin) advances the pure SoA population, packs live state into a retained Packed_Float32_Staging owner, publishes it, and calls multimesh_set_buffer once. Foreign dispatch count is independent of the population size; physics, packing, engine copies and drawing still scale with the number of bunnies.

All arms use the same integer seed stream, initial positions/velocities/scales, 14-pixel textured footprint and f32 semi-implicit Euler rule. A wall collision clamps the overshoot and reverses that velocity component. Speed scales both axes' displacement; gravity changes vertical velocity. This is a discrete bunnymark rule, not an exact collision solver. Bounds are 1280×720 and fixed dt is the exact f32 representation of 1/60. Interactive modes all use the scene's 60 Hz physics driver; eval modes step once per rendered frame for a fixed amount of comparable work regardless of display pacing. There are no hidden native _process callbacks outside the measured driver bracket.

The pure subsystem imports only Odin core packages. One owned block contains five SoA rows (x/y/vx/vy/scale); create allocates that block and its owner, while step and packing allocate nothing. Destroy consumes and clears the owning pointer. Count or mode changes start the same seed at frame zero. A profile change continues from current state and makes canonical-history replay inapplicable until the population is reset.

The authored preset (EX-BUNNY)

The scene owns an authored BunnyMarkPreset Resource (bunny_mark_preset.tres): initial count, speed scale, gravity, population seed, and the scene-owned texture and draw scale. The interactive controls initialize from it at ready; Apply preset re-reads the assigned Resource into a full three-arm reinitialization (an Inspector edit never reaches the simulation on its own), and Reset restores the last-applied values. The seed and presentation thread through all three arms, so every arm launches from the same seeded identities and draws the same scene-owned texture at the same scale. Canonical evaluation runs never read the Resource — their mode, count, and frames stay explicit engine arguments over the canonical seed and texture. The headless preset harness mode (verify_preset.gd) proves initialization, Apply/Reset semantics, and a saved-preset reopen across all three arms.

The GDScript bulk arm (DX-7)

The comparison keeps four arms: GDScript per-sprite, native per-node, native staged, and — the maintained optimized GDScript bulk baseline — a GDScript MultiMesh arm (gd_bunnies_bulk.gd) whose state lives in one packed 5-float-per-bunny array and whose frame submits through one MultiMesh buffer assignment. It shares the identity stream, fixed step, arithmetic order, texture, scale, and drawable with the other arms, so what it measures against the native staged arm is exactly the language and the submission call — the engine's own buffer copy is counted, not hidden. Its packed state feeds the same verify_buffer oracle the other arms use, and a local check (verify_bulk.gd) pins bulk state to the per-sprite arm byte-for-byte after ten steps. Evaluation mode gd-bulk measures it under the same warm/timed discipline; canonical receipts are historical and every arm is re-measured before ranking.

Verification and measurement

This example does not qualify native editor reload or stored-property restoration. Its controls are ordinary live method calls. It does not claim a language speedup, a constant total frame cost, one total draw call, cross-platform bitwise determinism, or GPU throughput. Exact equality is checked on the pinned compiler/engine/architecture for the named workload.

The pure oracle independently replays one bunny from its seed in O(frames), without calling the production step, bounce or layout writer. This costs more than the old circular O(1) check and executes outside timing. Fixed seed vectors, wall collisions, current-state packing, deliberate corruption, capacity-prefix identity, bounded inputs, allocation failure and repeated cleanup are tested.

./test.sh eval runs the pure workload at 1,000 and 10,000 bunnies, 60 warm frames and 1,200 measured frames. It repeats both populations, requires exact state and payload digest equality, checks every final bunny against independent replay and checks all 1,000 shared capacity-prefix identities. Tracking allocators cover both context.allocator and context.temp_allocator and require zero allocations/frees during warm producer work and zero remaining bytes after cleanup. Invalid arguments and failed checks exit nonzero. It never rewrites the historical receipt. Timings are informational; equality, corruption rejection and ownership checks are hard gates.

The windowed campaign uses all three modes at 1,000 bunnies and 600 total frames: 300 warm and 300 measured (frames=600, measured_frames=300). submit_avg_usec measures only the driver step loop, including native boundary calls and submission. frame_avg_usec and frame_p95_usec measure wall-clock intervals between consecutive measured process entries, including scene/rendering/pacing work. They are not GPU execution timings. A single window is not a controlled cross-language ranking; MultiMesh versus Sprite2D also changes scene and renderer work, so this is not an isolated measurement of foreign-call overhead.

After measurement, each mode verifies every actual bunny against the same canonical replay. GD supplies its live positions/velocities/scales in a packed input; native nodes read real engine positions and compare their velocity; staged mode checks SoA state, all staged float bits including padding, and every engine buffer readback. The driver checks actual textured drawable instances and count. Staged eval additionally resizes twice, submits/verifies each fresh population, then reconstructs and verifies the measured state before capture. The gate saves a fresh user://bunny-<mode>.png, checks cleanup, and prints:

text
GODODIN_BUNNY_MARK_PASS workload=bunny-mark-v2 mode=<gd|node|field> ... verified=<count>
GODODIN_BUNNY_MARK_TEARDOWN_PASS staging_live=false count=0

A successful workload sentinel alone is insufficient: the runner also requires successful engine exit, no engine errors, and a Host receipt with zero instances and owned foreign values. Headless mode cannot establish staged GPU readback or visual correctness; it does not produce a rendered campaign PASS.

Engine layout and ownership

The pinned dev5 source archive's servers/rendering/renderer_rd/storage_rd/mesh_storage.cpp:1607–1610 establishes an eight-float TRANSFORM_2D stride without colors/custom data. Lines 1963–1970 write xx,yx,0,ox,xy,yy,0,oy; lines 2081–2086 reconstruct the six-float Transform2D. Runtime bulk-buffer readback checks the submitted eight-float contract; the six-float getter mapping remains a pure layout test. These are interleaved matrix rows; “column-major” alone is misleading. The scene constructs a 14px ArrayMesh with top-left UV(0,0), matching Sprite2D; its decoded vertices, UVs and triangle indices are asserted before the campaign. Dev5 PlaneMesh FACE_Z (used by QuadMesh) has the opposite V direction in canvas coordinates (scene/resources/3d/primitive_meshes.cpp:1469–1478).

The scene's MultiMesh has a fixed custom_aabb around the 1280×720 simulation volume with an 11-pixel margin. Every center remains clamped inside that volume, including after interactive speed/gravity changes; the 14px quad's seed-derived scale stays below 1.5, so its half-width stays below 10.5 pixels. Changing the bounds, mesh size or scale requires updating this bound. Dev5 GLES3 drivers/gles3/storage/mesh_storage.cpp:2118 skips the per-instance bounds scan when this explicit bound exists. Godot still culls the field as one object.

Native verification uses the bulk buffer getter and releases its result before returning. Dev5 Compatibility individual transform getters create a CPU cache that later retains submissions (drivers/gles3/storage/mesh_storage.cpp:1965,2092,2133); using them would invalidate a later retained write. Two extra verify/submit cycles exercise continued streaming before the capacity checks and final reconstruction.

This demo disables physics interpolation on the staged MultiMeshInstance, pins the Safe rendering thread model and checks RenderingServer.is_on_render_thread() at startup. Direct same-thread consumption permits reusing the retained store. A separate render thread can queue the array (rendering_server_default.h:457 and server_wrap_mt_common.h:267–275) and is outside this staging workload.

The scene owns the MultiMeshInstance2D, MultiMesh, canvas ArrayMesh and texture. Native setup temporarily owns the reference returned by get_multimesh and releases it on every subsequent return. Staging and population have explicit native owners, including setup failures. Resizing creates replacement owners before server allocation; after successful server resize, replacement state is adopted before old-owner cleanup. A cleanup failure is fatal and does not claim an engine rollback. Every scene exit calls native shutdown, including interactive quit and failed evals. CPU-only deinit is a fallback, not a staging owner release.

Historical comparison

The original implementation did not meet that comparison: the native per-node arm had no sprites, and the staged arm used different spawn and motion rules. The original review records those defects. The corrected workload is named bunny-mark-v2. It retains the original seed, counts and fixed timestep but replaces the invalid motion contract. The old receipt (repository: examples/bunny_mark/eval/bunny-mark-evidence.json) and screenshot.png are retained historical artifacts; neither establishes the corrected workload's performance or appearance. Follow-up evidence belongs in eval/followups/ after execution.