RustingEngine is currently a functional Vulkan renderer prototype with a native Rust ECS runtime, an egui editor, and a GPU-accelerated hybrid physics bridge. The goal of this roadmap is to turn it into a complete, general-purpose Windows/Linux game engine in the same class as Godot — covering 3D and 2D rendering, animation, audio, UI, navigation, networking, a full editor, and export — whose defining strength is physics. RustingEngine should be the engine people pick because of its physics.
The roadmap covers two products:
- The engine (Milestones 0-23): Godot-class feature breadth, with physics as the flagship subsystem. Milestones 0-7 build the foundation and a vertical slice; Milestone 8 makes simulation deterministic; Milestones 9-23 reach feature parity with Godot while taking physics well beyond it.
- Sundering (Milestones 24-29): a deterministic, networked, destructible 5v5 competitive game built on the engine. It is the engine’s hardest physics customer and its proof that the physics claims are real.
This document is the implementation source of truth. Tasks should be completed in dependency order, kept behind compiling intermediate states, and verified against the acceptance gates at the end of each milestone.
Long-lived ownership boundaries and dependency rules are recorded in architecture.md. Roadmap work must preserve those boundaries or document a migration before changing them.
Working agreement for implementation sessions
This roadmap lists outcomes, not tasks. Turning an outcome into a task is part of the work, not a reason to stop.
How to pick an item
Pick the lowest-numbered unchecked item whose dependencies are satisfied. Milestones 2, 3, 4, and 5 are parallel tracks and may be interleaved. After Milestone 9, Milestones 10-22 are parallel tracks and may be interleaved, subject to the dependencies stated at the top of each milestone; physics milestones (10-12) take priority when two items are otherwise equally ready. If an item is too large for one session, split it, implement the first part, and record the split in this file as sub-items.
What counts as verification
Not every item is provable the same way. Match the proof to the item instead of treating “I cannot run the game” as a universal blocker.
| Kind of item | Required proof |
|---|---|
| Types, APIs, error handling, identity mapping | Unit test |
| ECS, assets, serialization, scene round-trip | Unit test, no GPU |
| Extraction, dirty ranges, ordering, capacity policy | Unit test on data structures, no GPU |
| Buffer layout, push constants, vertex formats | Generated/reflected layout assertion, no GPU |
| Shader source changes | Shader compiles in the build, plus a layout assertion |
| Compute dispatch behaviour, readback, physics results | Headless Vulkan test, software device acceptable |
| Rendered output correctness | Headless offscreen render plus golden-image comparison |
| CPU physics behaviour (stacking, joints, CCD, queries) | Deterministic headless unit/scenario test, no GPU |
| Editor panel behaviour | Unit test on editor state/commands; golden image for compositing |
| Audio mixing and DSP | Offline render to buffer plus sample comparison, no device |
| Frame pacing, throughput, memory growth | Hardware GPU run, cannot be verified in software |
| Determinism across vendors | Two different Vulkan implementations |
Only the last two rows genuinely require hardware. Everything above them is verifiable on a headless machine once Milestone 0.5 exists. Until Milestone 0.5 exists, GPU-behaviour items are blocked by missing tooling, not by the roadmap.
When blocked
Do not stop with nothing delivered. In order:
- Implement the part that is verifiable now, behind a compiling intermediate state.
- Write the unverified part as a test marked
#[ignore]with the reason, so the proof is ready when hardware is. - Add the blocker as a new unchecked item in this file, naming what is missing.
- Move to the next item.
Constraints must live in the repository
Any file or subsystem an implementation session must not touch belongs in AGENTS.md at the repository root, with the reason. A constraint that exists only in one session’s context cannot be respected by the next session and must not be invented.
Performance numbers
Performance targets are acceptance gates for a milestone, never individual tasks. Never treat a frame-rate number as an item to implement.
Product target
The engine’s 1.0 release (Milestone 23) should provide everything a team expects from Godot, plus physics no general-purpose engine offers:
- A real application runtime with ECS entities, components, resources, schedules, events, observers, reflection, input, and fixed updates.
- Reusable scene composition: prefabs/instanced scenes with nested overrides, groups, signals, and data resources.
- A Vulkan renderer with explicit frames in flight, forward PBR, shadows, transparency, HDR, global illumination, reflection probes, screen-space effects, volumetric fog, post-processing, decals, GPU particles, culling, LOD, and profiling.
- A first-class 2D pipeline: sprites, tilemaps, 2D lights, 2D particles, and 2D physics.
- Custom shaders through a stable engine shader language and a visual shader graph.
- Skeletal and property animation, animation state machines, blend spaces, IK, retargeting, and tweens.
- An audio engine with buses, effects, streaming, and 3D spatialization.
- A runtime UI toolkit with layout containers, themes, rich text, localization, and accessibility.
- Navigation meshes, agents, and avoidance for 2D and 3D.
- High-level multiplayer: RPCs, replicated spawning/synchronization, and deterministic rollback.
- Typed, deduplicated assets with glTF import, per-asset import settings, texture compression, scene serialization, and hot reload.
- An in-engine egui editor at Godot parity: viewport, hierarchy, inspector, asset browser, console, debugger, profiler, gizmos, animation/shader/tilemap editors, project settings, export, and an editor plugin API.
- Fast iteration: Rust game-code hot reload and an optional sandboxed scripting layer.
- Export to Windows, Linux, and macOS, then Android.
- Documentation, tutorials, templates, and demo projects for every major feature.
- Best-in-class physics — see the next section.
Primary platforms are Windows and Linux desktop. Native Rust systems are the gameplay API, and games remain normal Cargo projects that can use external libraries. A versioned custom physics-compute ABI is part of the hybrid milestone. Deferred rendering, web export, iOS, and consoles remain out of scope until after 1.0.
Physics is the flagship
Godot ships a capable general-purpose physics engine (Godot Physics or Jolt). RustingEngine must be measurably better, not merely equivalent. “Best physics” is defined by the following pillars, each owned by a milestone and each proven by a benchmark or test rather than asserted:
| Pillar | What it means | Owner |
|---|---|---|
| Scale | Hundreds of thousands to millions of simultaneously simulated bodies through GPU solvers, with per-body CPU/GPU allocation | M5, M11 |
| Gameplay connection | GPU simulation is never a black box: typed conditions, asynchronous events, commands, and selective readback reach normal Rust gameplay code | M5 |
| Determinism | Bit-identical simulation across runs, builds, thread counts, and (where proven) GPU vendors; replay and rollback built in | M8 |
| Breadth | Rigid, articulated, character, vehicle, ragdoll, soft-body, cloth, rope, fluid, granular, and fracture simulation in one world with two-way coupling | M10, M11 |
| Correctness | Stable stacking, continuous collision, robust contacts, and conservation behaviour verified by a regression scenario suite | M5, M10 |
| Tooling | A physics debugger with recording, timeline scrubbing, per-body inspection, and authoring tools in the editor | M12 |
| Integration | Animation, particles, audio, navigation, and networking consume physics through the same bridge instead of parallel ad-hoc systems | M13-M19 |
| Evidence | A published, repeatable benchmark suite comparing RustingEngine against Jolt, PhysX, Rapier, and Godot Physics on identical scenes | M12 |
Every other subsystem is judged against Godot parity. Physics is judged against the best standalone physics engines.
Godot parity map
Each Godot feature area has an owning milestone. A feature area is at parity when its milestone’s exit gate passes.
| Godot area | RustingEngine equivalent | Milestone |
|---|---|---|
Node tree, scenes, PackedScene instancing, inheritance | ECS hierarchy, prefabs with nested overrides | M1, M9 |
| Signals, groups | Typed events, observers, entity groups | M1, M9 |
Resources (.tres) | Typed data assets with reflection | M2, M9 |
| GDScript / C# / GDExtension | Native Rust, Rust hot reload, optional WASM scripting | M9 |
| Godot Physics / Jolt 3D | Hybrid CPU/GPU physics | M5, M10-M12 |
| Soft bodies, cloth | XPBD deformables | M11 |
| Forward+/Mobile/Compatibility renderers | Forward PBR with quality profiles and capability fallback | M3, M4 |
| SDFGI/VoxelGI/LightmapGI, probes, SSAO/SSR/SSIL, fog, glow | Advanced rendering | M13 |
| Shading language, visual shaders | Engine shader language and shader graph | M13 |
| GPUParticles, decals, MultiMesh, CSG, GridMap | Advanced rendering and editor tools | M13, M20 |
| AnimationPlayer, AnimationTree, IK, tweens | Animation system | M14 |
| Audio buses, effects, 3D audio | Audio engine | M15 |
| Control nodes, themes, RichTextLabel, translations | Runtime UI and localization | M16 |
| 2D renderer, TileMap, 2D physics, 2D lights | 2D engine | M17 |
| NavigationServer, agents, avoidance | Navigation | M18 |
| High-level multiplayer, ENet, WebSocket, HTTP | Networking | M19 |
| Editor docks, debugger, profilers, editor plugins, asset library | Editor parity | M20 |
| Export templates, platforms | Platforms and export | M21 |
| Documentation, demo projects | Documentation and ecosystem | M22 |
Second product target: Sundering
Sundering is a 5v5 competitive game whose terrain is made of millions of simulated chunks that come apart permanently. Its design document lives at ~/Code/games/Sundering/README.md. It is built after the engine’s physics pillars exist and consumes them harder than any other project will.
Sundering demands four things a general-purpose engine does not provide by default:
- Determinism. Competitive play requires the simulation to produce identical results for every participant. This constrains the physics solver, the GPU reduction strategy, and the ordering of every simulation operation. The engine provides it in Milestone 8.
- Authoritative networking. A server owns the simulation; clients must stay consistent with it under real latency and loss. The engine provides general networking in Milestone 19; Sundering adds competitive authority in Milestone 24.
- Destructible terrain as simulation state. Terrain is not authored geometry. It is a first-class simulated entity that feeds collision, navigation, and vision. It builds on the engine’s fracture support from Milestone 11.
- Navigation over geometry that changes every tick. Nav meshes assume a static world. Sundering has no static world. It builds on the engine’s navigation from Milestone 18.
Determinism cannot be retrofitted. Milestone 8 defines the full requirements and their acceptance gate, but three of its ordering rules must be respected while Milestone 5 is implemented, or that work will need rewriting. They are restated at the top of Milestone 5.
Milestones 24-29 depend on Milestones 8, 10, 11, 18, and 19. Milestone 8 must precede any networking milestone, because the determinism result decides which networking model is available.
Architectural direction
The project should become a Cargo workspace with one-way dependencies:
rusting-math deterministic math, fixed-point, seeded RNG streams
↑
rusting-core ECS components, schedules, time, input, hierarchy, events, reflection
↑
rusting-assets typed handles, cache, importers, serialization, prefabs, hot reload
↑
rusting-physics 2D/3D CPU physics, GPU compute simulation, deformables, fluids, synchronization, queries
↑
rusting-terrain heightmap and volumetric terrain, fracture, structural load, Scar persistence
↑
rusting-nav navigation meshes/volumes, flow fields, agents, avoidance
rusting-anim skeletal and property animation, state machines, IK, physics-driven animation
↑
rusting-render Vulkan context, extraction, frame graph, 2D/3D passes, materials, shaders, particles, profiling
rusting-net transport, wire protocol, RPC, replication, rollback, interest mechanism
rusting-audio device management, mixing, buses, effects, spatialization
rusting-ui runtime UI layout, themes, text shaping, localization, accessibility
↑
rusting-gameplay teams, match state, abilities as forces, vision, bots
↑
rusting-editor egui panels, viewport, inspector, gizmos, play controls, editor plugins
rusting-script optional sandboxed WASM scripting host
↑
rusting-engine plugins, application facade, compatibility API, export
↑
game projects vertical slice, demo projects, Sunderingrusting-nav and rusting-anim are siblings. rusting-render, rusting-net, rusting-audio, and rusting-ui are siblings at one level and must not depend on each other; UI and particles reach the screen through render extraction, not by calling the renderer. rusting-editor and rusting-script are siblings. Full layer definitions are in architecture.md.
Dependency cycles between runtime, renderer, physics, assets, and editor are not allowed. ECS entities are the canonical identity and authored scene state. A GPU-owned body’s newest runtime transform may live on the GPU; ECS keeps its stable ID, settings, last synchronized state, and pending events. Render batches, physics arrays, indirect buffers, and other GPU representations are derived data.
The existing Engine::new, add_cube, add_sphere, add_gltf, and run API remains temporarily available as a deprecated compatibility facade implemented over the new runtime.
Current focus: agent-built games and the editor
Owner direction (2026-09): the editor, example games, and a workflow where an
agent builds a whole game end to end with the rusting CLI and project files
alone, with no MCP server or glue scripts. Deep physics items are paused.
Method: build a small game with the CLI only, log every gap, fix the gaps,
keep the game as a sample, then repeat with another genre. Editor work comes
from the docs/editor-overhaul.md backlog, and the CLI and the editor should
share one undoable command layer.
- Game 1, Hammer Run (
samples/hammer_run). Evidence:rusting checkand its three scenarios pass from the repository; captures show the course, the HUD and a collected gem;hammer_hitsfails with the respawn code removed. - Gap:
scene patchrejected partial built-in sections. Missing fields now take their defaults. Evidence:scene_patch::tests::created_sections_take_defaults_for_missing_fields. - Gap: patches needed UUIDs, so an agent had to query first.
idandparenttake unique names. Evidence:scene_patch::tests::operations_address_entities_by_unique_name. - Gap: new projects had no guide for an agent.
rusting newwritesAGENTS.md. Evidence:project::testscreate-project test. - Gap: scenarios could only check exact ticks or “holds through”.
withinpasses on the first tick a check holds. Evidence:scenario::tests::within_passes_on_the_first_tick_the_check_holds. - Gap: game code could not see contacts or the ECS world.
GameScene::touchingandGameScene::world. Evidence: Hammer Run’shammer_hitsscenario. - Gap: CLI commands needed an explicit project root; they default to
.. Evidence:project_root_defaults_to_the_current_folderinsrc/bin/rusting.rs. - Gap:
scene patchcould not change scene-level fields.set_scenedoes. Evidence:scene_patch::tests::set_scene_changes_scene_fields_but_not_entities. - Gap: a scenario could not set up state.
setsteps write a transform or component value before a tick. Evidence:scenario::tests::set_steps_move_entities_before_the_tick_runs, and a Hammer Run scenario that sets theGoal Countcounter and the player’s position. - Gap:
GameScenehad no counter helper.GameScene::counter(name). Evidence:project_runner::tests::game_code_reads_and_sets_counters_by_name. Other components stay behindworld(). - Gap (deferred with deep physics): a kinematic player is never pushed
by dynamic bodies, so hazards need game code. Pushing it out of overlaps
was tried: the solver treats the kinematic player as infinite mass, so a
motorised sweeper stalls against it and only nudges it a few centimetres.
Real knockback needs the player as a finite-mass body in the solver. The
agent guide documents the
touchingpattern instead. - Gap:
run --ticksreported nothing about the end state. It saves the scene tobuild/final.rsceneforscene query; gameeprintln!output is in the--jsonresult. Evidence:rusting run samples/hammer_run --ticks 120thenrusting scene query samples/hammer_run/build/final.rscene. - Game 2, Target Range (
samples/target_range), a first-person shooting gallery. Evidence:rusting test samples/target_range samples/target_range/testspasses all three scenarios; a capture shows the targets, crosshair and HUD. - Gap: games had no custom input.
rusting.input_actionbinds a named action to keys and mouse buttons from the scene;GameScene::pressedandheldread it. Evidence:project_runner::tests::scene_input_actions_bind_and_report_presses. - Gap: game code could not ray cast or remove objects.
GameScene::raycast,aim(along the active camera),despawn,set_visibleandtrigger. Evidence:project_runner::tests::aim_hits_what_the_camera_faces_and_despawn_removes_it. - Gap: a scenario could not check that an object is gone. Expectations
take
"exists": false. Evidence:scenario::tests::exists_checks_entities_and_pathsand Target Range’sfirst_shot. - Gap:
rusting testran one file per call. With a folder, or no argument (tests/), it runs every scenario and lists each result. Evidence:rusting testinsamples/target_range. - Gap:
rusting newwrote no.gitignore, sotarget/andbuild/showed up in git. Evidence:project::testscreate-project test. - Game 3, Sky Hop (
samples/sky_hop), a 2D platformer. Evidence:rusting testinsamples/sky_hoppasses all four scenarios, includingclear_level, which finishes the level with timed inputs only; a capture shows the tile map, coin and HUD. - Gap: character controllers did not ride moving platforms and stuck to
ceilings while rising. Both now follow the floor body’s motion and stop
rising at a ceiling. Evidence:
runtime::tests::controllers_ride_moving_platforms_and_stop_at_ceilingsand Sky Hop’sride_lift. - Gap: the editor showed no tile map tiles or background color until
Play, so Sky Hop’s level was blank while editing. Edit mode now runs
build_tile_mapsandapply_scene_background; a clicked tile selects its map, and tiles stay out of the Hierarchy and the saved scene. Evidence:editor::tests::edit_mode_builds_tile_maps_without_listing_or_saving_tiles. - Gap: tile maps could only be edited as text rows. The Inspector’s
Tile Painter paints and erases cells from the Scene View, one Undo step
per stroke. Evidence:
editor::picking::tests::scene_view_points_paint_the_tile_map_cell_under_themandruntime::tests::tile_map_cells_are_found_by_position_and_painted_with_padding. - Tile Painter tools, as in Godot: Rectangle fills the cells between
the pressed and released cells, Fill flood fills the joined cells that
match the clicked one, and the cell under the pointer (or the dragged
rectangle) is outlined in the Scene View. Each is one Undo step.
Evidence:
runtime::tests::tile_map_rectangles_and_flood_fills_paint_regions,editor::overlay::tests::tile_rect_lines_outline_the_cells_between_two_cornersandeditor::picking::tests::scene_view_points_paint_the_tile_map_cell_under_them. The Scene View input path has no automated test; try it by hand. - Tile Painter Line tool paints Bresenham’s line from the pressed cell
to the released one, with each cell outlined while dragging, and Fill
no longer grows the grid when clicked outside the used area. Evidence:
runtime::tests::tile_map_rectangles_and_flood_fills_paint_regions(line cells,fill_line, fill outside the grid). The Scene View input path has no automated test; try it by hand. - Tile Painter palette: the brush is a row of swatches, one per tile in
its color with its character on top, then Erase and Off, as in Godot’s
TileSet panel. Evidence:
editor::inspector::tests::the_tile_palette_shows_each_tile_color_and_picks_a_brush. - Tile Painter tool shortcuts, Godot’s keys: D Paint, R Rectangle, L
Line, B Fill. They apply only while a brush is active on a selected tile
map in Edit mode, where they win over the Scene View keys (R rotates
otherwise), and they are rebindable in their own Keyboard Shortcuts
section. Evidence:
editor::shortcuts::tests::tile_tool_keys_apply_only_while_a_brush_paints_a_tile_map. - Game 4, Ember Arena (
samples/ember_arena), a third-person arena survival game lit by fog, bloom, ambient occlusion and point lights. Evidence:rusting testinsamples/ember_arenapasses all three scenarios (ember_burns,pulse_quenches,game_over); captures intests/shots/show the fogged arena, gate glow and ember lights. - Gap: game code could not spawn copies of a scene object, so enemies
had to be pre-placed.
GameScene::spawn_copycopies a template object and its children (children are named"<copy>/<child>"so names stay unique), andGameScene::in_classlists the objects in a class. Evidence:project_runner::tests::spawn_copy_clones_the_template_tree_and_in_class_lists_it. - Gap: a patch that created a registered component with some fields
missing failed with “missing field”. Registered components now take
defaults like built-in sections. Evidence:
scene_patch::tests::created_sections_take_defaults_for_missing_fields. - Gap: lights on hidden objects still lit the scene, so a hidden
template’s glow showed. Light extraction skips objects hidden by
themselves or a parent, as meshes already did. Evidence:
runtime::render_world::tests::lights_under_hidden_objects_are_not_extracted. - Game 5, Tower Topple (
samples/tower_topple), a first-person game: thrown balls knock two towers and a pyramid off their stands, with ten balls per round. Evidence:rusting testinsamples/tower_topplepasses all three scenarios (topple_pyramid,win_and_restart,out_of_balls);tests/shots/shows the throw and the win text. - Gap: game code could not launch a projectile: there was no camera
ray to aim along, a template copy could not switch from kinematic to
dynamic, and a velocity set on a sleeping body was lost.
GameScene::camera_ray,GameScene::set_body_kind, andset_linear_velocitywaking the body cover it. Evidence:project_runner::tests::a_kinematic_template_copy_launches_along_the_camera_ray. - Gap: a round could not be restarted without resetting every object
by hand.
GameScene::restartreloads the starting scene and rerunsoncesetup. Evidence:project_runner::tests::restart_puts_the_starting_scene_back_and_reruns_setup. - Gap: hidden HUD elements were still drawn, so a win message could
not be hidden by game code. Evidence:
runtime::tests::hidden_hud_elements_are_not_drawn. - Gap: a thrown ball passed through the pyramid. The continuous
collision sweep cast only the ball’s center, which slipped through the
seam between two blocks, and it stopped the ball dead on a dynamic
target instead of pushing it. The sweep now casts against colliders
grown by the ball’s radius and leaves the momentum to the contact solve.
Evidence:
runtime::tests::a_fast_ball_between_two_dynamic_boxes_pushes_both_back(fails on the old sweep) andfast_cpu_bodies_do_not_tunnel_through_thin_walls. - Gap: HUD text wrapped when it grew (“14 / 14” broke onto two lines).
HUD labels now break only at
\n. Evidence:tests/shots/win.pnginsamples/tower_topple. - Game 6, Crate Keeper (
samples/crate_keeper), a top-down crate-pushing puzzle on a tile map grid. Evidence:rusting testinsamples/crate_keeperpasses all three scenarios (solve,blocked,restart);tests/shots/solved.pngshows every crate on a gold square and the win text. - Gap: game code could not read the tile map, so grid games had to
duplicate the level layout in code.
GameScene::tilereads andGameScene::set_tilewrites the cell under a world position. Evidence:project_runner::tests::game_code_reads_and_writes_tile_map_cells_by_world_position. - Gap: scenario
toleranceapplied only to single numbers, so a position check failed on0.10000000149against0.1. It now applies to every number in an array or object. Evidence:scenario::tests::tolerance_applies_to_every_number_in_a_vector_or_object. - Game 7, Brick Bounce (
samples/brick_bounce), a 2D brick breaker with a dynamic ball bouncing off fixed walls and bricks and a kinematic paddle. Evidence:rusting testinsamples/brick_bouncepasses all four scenarios (first_hit,lose_ball,paddle_limits,win_and_restart);tests/shots/playing.pngshows a broken brick and the returning ball. The ball’s z stays 0 through bounces. - Gap: game code could set a body’s velocity but not read it, so
constant-speed balls needed
world()queries.GameScene::linear_velocityreads it. Evidence:project_runner::tests::a_kinematic_template_copy_launches_along_the_camera_ray. - Game 8, Core Defense (
samples/core_defense), a top-down turret shooter aimed with the mouse cursor. Evidence:rusting testinsamples/core_defensepasses all four scenarios (aimed_shot,missed_shot,core_falls,defended);tests/shots/firing.pngshows a bolt flying at the first drone. - Gap: game code had no ray through the mouse cursor, and scenarios
could not move the cursor, so mouse aiming could not be written or tested.
GameScene::pointer_rayreturns it, and thepointerscenario step places the cursor at a fraction of the view. Evidence:project_runner::tests::pointer_ray_goes_through_the_cursor,scenario::tests::pointer_steps_place_the_cursor_in_the_capture_sized_view. - Gap:
GameObjectcould set rotation and scale but not read them back.GameObject::rotationandGameObject::scaleread them. Evidence: Core Defense bolts fly along their stored yaw. - Game 9, Putt Course (
samples/putt_course), a mini golf hole with a dynamic ball rolling on physics, a charged putt aimed with the mouse, a kinematic sweeper and a sensor cup. Evidence:rusting testinsamples/putt_coursepasses all four scenarios (first_putt,overcharge,sink,too_fast);tests/shots/aiming.pngshows the aim arrow at half power andtests/shots/sunk.pngthe win text after one stroke. - Gap: six of eight games defined their own
value,addandcompletecounter helpers, and a helper that borrows the scene cannot be nested in another scene call.GameScene::counter_value,add_to_counterandcounter_completedo it in one call. Evidence:project_runner::tests::game_code_reads_and_sets_counters_by_name. - Gap: game code had no short way to draw seeded random numbers; it
had to read
RandomSeedandFrameTimethroughworld(), and earlier samples used fixed lists instead.GameScene::random(stream)draws from the seed, the fixed tick and the stream. Evidence:project_runner::tests::game_randomness_repeats_for_a_seed_and_varies_by_tick_and_stream. - Game 10, Lantern Grid (
samples/lantern_grid), a lights-out puzzle played with mouse clicks on a top-down orthographic board. Evidence:rusting test samples/lantern_gridpasses both scenarios (center,solve);tests/shots/scrambled.pngshows the 11 lit lanterns of the fixed scramble andtests/shots/solved.pngthe full board with the win text after three moves. - Gap: game code could not recolor one object. Scene materials are
shared by value, so editing one through
world()recolored every object with the same color.GameScene::color,set_colorandset_emissivegive the object its own material. Evidence:project_runner::tests::game_code_recolors_one_object_without_touching_shared_materials. - Gap:
rusting test samples/gameread the project folder as a scenario and failed onproject.json, and a scenario path was resolved from the current folder rather than the project. A lone project argument now runs itstests/folder, and scenario paths are looked up in the project first. Evidence:rusting::tests::project_root_defaults_to_the_current_folder;rusting test samples/lantern_gridfrom the repository root. - Game 11, Snake Trail (
samples/snake_trail), a snake game on the 2D template: grid steps on fixed ticks, walls read from arusting.tile_map, and a tail grown withspawn_copy. Evidence:rusting test samples/snake_trailpasses all three scenarios (eat,crash,win);tests/shots/grown.pngshows the four-cell snake after the first apple andtests/shots/won.pngthe win text. - Gap: game code had no way to set a counter to a value; Putt Course,
Lantern Grid and Snake Trail each wrote the same borrow-and-assign
helper.
GameScene::set_counterdoes it. Evidence:project_runner::tests::game_code_reads_and_sets_counters_by_name. - Game 12, Night Vault (
samples/night_vault), a third-person stealth game at night: guards patrol on fixed-tick paths with spot lights, see the player through a view cone and a line-of-sight raycast, and raise an alert meter; crates and hedges block their view. Evidence:rusting test samples/night_vaultpasses all three scenarios (spotted,hidden,heist);tests/shots/spotted.pngshows the lose text with a guard’s lamp on the floor andtests/shots/escaped.pngthe win text at the vault door. - Gap: the third-person camera went through walls behind the player
and showed only the sky. A camera-sized sphere cast from the orbit
center now stops the camera in front of the first solid collider,
ignoring the player’s own body. Evidence:
runtime::tests::third_person_camera_stops_in_front_of_a_wall_behind_the_body; the Night Vault captures with the player backed against a hedge. - Gap: game code could only restart the scene it started with, so a
game had no levels, menus or separate end screens.
GameScene::load_scene(path)replaces the scene with another project scene file, whichrestartthen returns to. Evidence:project_runner::tests::load_scene_switches_to_another_project_scene(a missing file leaves the scene as it was;onceruns again; restart returns to the loaded level).rusting exportstill ships only the main scene. -
skills/rusting-game/SKILL.md: a skill file for LLM agents that build games on the engine, with the CLI loop, scene and patch format, theGameSceneAPI, determinism rules for game code, the scenario format and its pitfalls, and a done checklist, taken from the twelve sample games. - Gap:
rusting exportshould copy every scene a game loads, not only the main one. Export now copies the project’sscenes/folder; checked inexport_package_contains_executable_scene_assets_and_readme. - Gap: a misspelled field in a partial scene component is dropped and
takes its default without a diagnostic (Night Vault’s moon used
intensityinstead ofilluminanceand lit the night at full sun).rusting scene patchandrusting checkshould report unknown fields. Done forscene patch: unit testsa_misspelled_field_in_a_section_is_an_erroranda_full_material_and_light_pass_the_field_check; full check green.rusting checkreads scenes strictly through serde already. - Gap (Same Shift): game projects built the engine unoptimized, so a
busy CPU physics scene took 20 to 35 ms per tick and the fixed-step
catch-up spiraled to near 0 FPS. The
rusting newtemplate and every sampleCargo.tomlnow build dependencies withopt-level = 3. When a frame hitsmax_fixed_stepsand its steps take longer than the time they simulate, the next frames run one step each (slow motion instead of a freeze) until steps are fast again, with one warning that names the likely cause. The clamp happens before the replay recorder, so replays step the same way.rusting run --ticks N --jsonreportstimings.headless_ms_per_tick. Evidence:runtime::tests::fixed_steps_slower_than_real_time_fall_back_to_slow_motion. - Gap (Same Shift):
restartandload_scenedid not repeat physics, because the CPU solver ordered bodies byEntity, and a reload hands out entities in a different order. Bodies now sort bySpawnOrder, the object’s rank by scene ID within its document; runtime spawns take the next number. Joints and articulations index bodies by map and sort by body order. Evidence:project_runner::tests::restart_repeats_a_physics_pile_exactly(a 12-box pile saved in reverse ID order matches afterrestartwith tolerance 0; fails with the old entity sort). - Gap (Same Shift): game code had no snapshot, body reset, starting
state, look direction or class hash. Added
GameScene::snapshot,restore(keeps velocities, sleep and solver caches, renamed to the new entities),reset_body,set_angular_velocity,angular_velocity,initial,set_lookandstate_hash(class);set_body_kindtoKinematicorFixednow zeroes velocities. The prelude exportsEntity,World,Name,RigidBody,PlayerControllerandPhysicsWorld. Evidence:project_runner::tests::body_calls_stop_spin_and_reset_bodies_and_set_look_turns_the_playerandrestart_repeats_a_physics_pile_exactly(restored mid-fall run matches the run that went on; fails without the solver-state rename). - Gap (Same Shift): sleeping bodies floated when their support was
removed or moved. Sleepers near a body that gameplay removed, moved or
changed wake on the next step. Evidence:
runtime::tests::sleeping_cpu_bodies_fall_when_their_support_is_removed_or_moved. - Gap (Same Shift): scenarios had no way to record a value. A
logstep recordsentity.path(every tick withuntil) and never fails;rusting test --jsonlists the lines. Evidence:scenario::tests::log_steps_record_a_value_every_tick_and_never_fail. - Gap (Same Shift): GPU bodies reach game code 1 to 3 frames late and cannot be reset or teleported after setup. Document their feature set and let game code move one to a pose with zero velocity.
- Gap (Same Shift): a camera that renders into a texture or a screen rectangle, for monitors and split screen.
- Gap:
RenderSettings::render_scaleis declared and reflected but no renderer code reads it, so setting it does nothing. The tone map is a subpass that reads the HDR target as an input attachment, so a scaled scene needs the tone map to become its own pass that samples the smaller target (a resolution scale and an upscale filter). - Gap:
rusting capturedoes not run game code. - Gap: a scenario stops at its first failed check.
"keep_going": truein the scenario file reports every failure; testkeep_going_reports_every_failed_check. - Gap: a windowed
rusting runshows no on-screen FPS or frame time.RUSTING_PERF=1prints fps, update/render split, CPU phases, GPU pass times and draw counts to stderr once a second and shows fps and frame time in the window title. An in-game overlay is still open. - Perf (Same Shift, RTX 3060, 1080p, clean GPU): 355 to about 1000 fps.
Swapchain image count 2 to 4 (Wayland throttled Immediate with 2), a
reflectionsrender setting that skips the scene copy, mip chain and depth pyramid, and batching by mesh and texture group (97 to 18 draws, 0.56 to 0.22 ms recording). Evidence:RUSTING_PERF=1lines in CHANGELOG, testmaterials_that_differ_only_in_color_share_a_batch,the_reflections_setting_reaches_the_render_world; full check green with and without--features gpu-tests. Note: a second game instance on the GPU halves every number; close it before measuring. - Gap (Same Shift): a scenario
expectwith tolerance 0 failed on every printed spelling of an f32 value, because serde_json parses decimal text only to within an f64 ulp. A value that is an exact f32 now also matches any number that rounds to the same f32. Evidence:scenario::tests::a_printed_f32_matches_itself_with_tolerance_zero. - Gap (Same Shift): cube side faces showed images upside down. Cube
UVs follow glTF: v = 0 is the image’s first row, at the top of each side
face and the -Z edge of the top and bottom faces (spheres already did).
Evidence:
assets::tests::cube_side_faces_show_the_first_image_row_at_the_top. - Gap (Same Shift): textures could not repeat. Materials have
uv_scaleanduv_offset, saved in scenes (default[1, 1]and[0, 0]), reflected for the Inspector and applied in the vertex shader. Evidence:rendering::scene_renderer::tests::render_instance_layout_matches_shader_structchecks the newuv_transformfield against the shader struct. - Gap (Same Shift):
rusting schemacalled scene texture slots a “project path”; they are plain strings relative to the scene file, not{"$asset": ...}. Themesh_rendererentry now says so and listsuv_scaleanduv_offset. - Gap (Same Shift): the player controller climbed boxes on the round
bottom of its capsule and could not be kept from pushing dynamic bodies.
PlayerControllerhasmax_slope(default 45°),max_step_height(default 0.3 m) andpush_bodies(default true), andPhysicsWorld::move_character_on_footwalks with the first two. Withpush_bodiesoff the solver drops the player’s contacts with dynamic bodies, which still block its movement. Evidence:runtime::tests::players_step_onto_low_ledges_but_not_high_ones_and_can_leave_crates_alone.
Goal: establish a trustworthy baseline before adding architecture or features.
Completed
- Make
MaterialBuilder::default()agree withMaterial::default(). - Copy all material properties when creating cube and sphere instances.
- Cache sphere meshes by subdivision level.
- Replace mutable batch/index instance handles with stable IDs.
- Align all physics compute-shader instance fields with the Rust
InstanceDatalayout. - Align physics push constants and add explicit padding.
- Repair the
NoCollisionshader’s mass, gravity, friction, restitution, and angular-velocity interpretation. - Make broad-phase sphere and box radius conventions agree with collision shaders.
- Add compile-time CPU structure size/offset assertions.
- Add tests that enforce common layouts across every compute shader.
- Make
cargo fmt --check, all-target tests, strict clippy, and all-target checking pass. - Upgrade Vulkano 0.33 to 0.35 and migrate pipeline, descriptor, image, allocation, and command APIs.
- Remove
vulkano-winand migrate event dispatch to winit 0.30’sApplicationHandlerlifecycle. - Negotiate surface format, color space, image count, composite alpha, and present mode.
- Enable the Khronos validation layer automatically in debug builds when it is installed.
- Enable synchronization validation and opt-in GPU-assisted validation with
RUSTING_GPU_VALIDATION=1.
Remaining
- Move compatibility-facade window and surface creation into
ApplicationHandler::resumed, removing the last deprecated winit call. Evidence:Enginenow builds an ECSAppandrun()hands it toproject_runner::run_windowed, whoseWindowRunnercreates the window inresumed().rendering::init_vulkan(the eager window and the last#[allow(deprecated)] create_window) is deleted; its validation-layer setup moved torendering::vulkano_config, andRUSTING_VULKAN_DEVICEnow applies to every window. Verified:RUSTING_VULKAN_DEVICE=llvmpipe ./target/debug/user_mainprintsUsing device llvmpipe ...; an unknown selector panics withmatches no available Vulkan device; available devices: [NVIDIA GeForce RTX 3060, llvmpipe ...]; the 8-seconduser_mainandeditorruns print no validation warnings or errors. Limit: the main queue no longer gets a debug name on windowed runs becausevulkano_utilcreates the queues. - Replace public initialization and asset-loading panics with typed errors and
ResultAPIs.-
geometry::gltf_loader::load_gltf_scenereturnsResult<_, GltfLoadError>andEngine::add_gltfreturnsResult<(), AssetError>instead of panicking on a missing/corrupt glTF file or a primitive without positions. -
Engine::load_texturereturnsResult<usize, AssetError>instead of panicking on a missing/corrupt image file. - Remaining production
.unwrap()/.expect()/panic!call sites outside tests audited (~220 acrossscene/mod.rs,engine/mod.rs,rendering/*,project_runner.rs). Findings: no genuine unconverted caller-recoverable site remains (checked for file/filesystem loading specifically — the only productionfs::/Fileusage is a compiled-in shader module load and this session’s own golden-image test helper). The rest fall into three buckets that are correctly left as panics, not oversights: (1) internal invariants after initialization — ECS resource/component lookups, GPU pipeline/queue state assumed valid once the device exists; (2) documented API panics that already ship a non-panicking alternative, e.g.GameScene::objectpanics butGameScene::try_objectreturnsOption; (3) the legacyEngine/scene::RenderScenecompatibility facade (engine/mod.rs,scene/mod.rs), already deferred to the Milestone 1 facade replacement per the compatibility-facade item above — converting its error style now would be churn thrown away at that rewrite. No new unchecked item added since nothing actionable was found outside what’s already tracked.
-
- Add Vulkan debug names and scoped command-buffer labels.
init_vulkannamed the main queue viaDevice::set_debug_utils_object_namewhenext_debug_utilswas enabled (removed withinit_vulkanin Milestone 0’s resumed-window item);SceneRenderer::renderwraps its command buffer in abegin_debug_utils_label/end_debug_utils_labelscope (“SceneRenderer::render”), both gated oninstance().enabled_extensions().ext_debug_utilsso release/no-validation builds pay nothing. Verified live on real hardware (NVIDIA RTX 3060):rendering::debug_utils_tests::object_names_and_command_buffer_labels_are_accepted_by_the_drivercreates an instance withext_debug_utilsforced on, names a queue, and records/submits a command buffer with a begin/end label pair, asserting the driver accepts both calls with no validation error.cargo test --lib --features gpu-tests: 146 passed. - Validate rendering, resizing, minimizing, restoring, and shutdown on Linux and Windows. Partially verified live in this environment (Linux, Wayland session, real NVIDIA RTX 3060):
./target/debug/game testGame/build/main.rscene.binopens a window and renders continuously for 5s undertimeoutwith no panic/validation output, then exits cleanly (SIGTERM). Resizing, minimizing, and restoring need a window-manager automation tool (xdotool/wmctrl, neither installed, not added since installing new system packages is outside this session’s scope) to script without a human; Windows has no machine available in this environment at all. See new item below for the remaining manual QA pass. - Manually verify window resize, minimize, and restore on Linux (with
xdotool/wmctrlinstalled, or by hand) and repeat the full rendering/resize/minimize/restore/shutdown pass on a Windows machine — blocked in this environment per item above. - Replace fixed grid limits with explicit capacity tracking and overflow reporting. Never silently omit bodies. Evidence: the rebuilt
EnginemapsGridCollisionandFullPhysicstoPhysicsSolver::Full, so every runner now uses the ECS contact grid. That grid grows with the body count, reports overflow inRenderCapacityDiagnostics, and keeps every body through its fallback list (Milestone 5 overflow items;gpu_bodies_collide_with_each_other_through_grid_and_fallback). The oldgrid_build.compwithMAX_PER_CELL = 128is still compiled, but only the legacyRenderScenecode and its tests use it; that code is deleted by the Milestone 1 cleanup item. - Add generated/reflected layout checks for storage buffers, uniforms, vertex data, and push constants.
render_instance_layout_matches_shader_struct,light_gpu_layouts_match_shader_structs, andhybrid_physics_gpu_layouts_match_shader_structs(rendering/scene_renderer.rs) now compare CPU upload structs’size_of/offset_ofagainst thevulkano_shaders-generated types for the same GLSL struct/block (vertex_shader::RenderInstance/Camera,fragment_shader::Light,physics_shader::PhysicsState/ConditionInstruction/RuleState/PhysicsEvent/PhysicsPush) — reflected straight from the compiled SPIR-V — instead of hardcoded magic-number offsets that could silently drift from the shader source.GpuEventHeaderhas no named GLSL struct (bare buffer block) so it keeps its one hand-computed size assertion. Verified:cargo test --lib --features gpu-tests: 146 passed.
Exit gate
- Debug validation produces no errors during startup, rendering, resize, minimize/restore, and shutdown.
- Linux and Windows smoke tests render at least 1,000 frames.
- No known CPU/GPU layout mismatch remains.
- Formatting, strict clippy, tests, shader compilation, and all-target checking pass in CI.
Milestone 0.5: Verifiable development environment
Goal: make GPU-touching work provable on a machine with no display and no second GPU. This milestone precedes all remaining GPU work. Without it, most of Milestones 3, 4, 5, 8, 11, 13, 25, and 26 cannot be verified by anyone, including CI.
Software Vulkan device
- Document Mesa lavapipe as the supported software Vulkan implementation. On Arch the package is
vulkan-swrast; on Debian/Ubuntu it ismesa-vulkan-drivers. Seedocs/dev-environment.md. - Document the invocation. Correction: on this machine (Arch, current
vulkan-swrast) the ICD file is/usr/share/vulkan/icd.d/lvp_icd.json, notlvp_icd.x86_64.json— the exact name is distro/version-dependent, seedocs/dev-environment.md. - Verify
vulkaninfo --summaryreports a second device withvendorID = 0x10005when the variable is set. Confirmed on this machine. - Record required Vulkan features and extensions, and which of them lavapipe does not provide.
init_vulkanrequests only thekhr_swapchaindevice extension and no explicit device features; lavapipe supportskhr_swapchainvia the standard surface path, so it provides everything the engine currently requests. Seedocs/dev-environment.md.
Device selection
- Add
RUSTING_VULKAN_DEVICEto select a physical device by index or by name substring.rendering::select_device_index(pure, unit-tested) plus wiring ininit_vulkan_headlessandvulkano_config(every window). - Log the selected device name, vendor ID, driver version, and API version at startup. Verified live:
Selected Vulkan device: llvmpipe (LLVM 22.1.8, 256 bits) (vendor 0x10005, driver 109060098, api 1.4.354)withRUSTING_VULKAN_DEVICE=llvmpipeand lavapipe’s ICD. - Fail with a typed error naming every available device when the selection matches nothing. Never fall back silently.
DeviceSelectionErrorlists every candidate device name;vulkano_configandinit_vulkan_headlesspanic with itsDisplaymessage, becauseVulkanoContextitself panics on a failed device pick.
Headless mode
- Create instance, physical device, and logical device without a surface, a swapchain, or a winit window.
rendering::init_vulkan_headless/HeadlessVulkanBase, sharing device-selection logic with the windowed path viaselect_physical_device. Verified live (no window created):Selected headless Vulkan device: NVIDIA GeForce RTX 3060 (vendor 0x10de, driver 2580660800, api 1.4.351). - Add offscreen render targets with the same formats used by the windowed path.
swapchain::create_offscreen_target/OffscreenTarget(colorB8G8R8A8_SRGB+TRANSFER_SRCfor later readback, depthD16_UNORM), sharingcreate_render_pass_for_formatwith the windowedcreate_render_pass. Verified live againstinit_vulkan_headlesson real hardware, no window created. - Add a fenced image readback returning CPU pixel data.
readback::read_back_image(copy-to-buffer command,then_signal_fence_and_flush,wait(None), host-visible buffer read). Verified live againstinit_vulkan_headless/create_offscreen_targeton real hardware. - Add a fenced storage-buffer readback returning CPU data after a compute dispatch.
readback::read_back_buffer<T>(copy-buffer command, same fence pattern asread_back_image). Verified live:reads_back_storage_buffer_results_after_a_compute_dispatchbuilds aComputePipelinefromsrc/shaders/compute/test_double.comp, dispatches against a 64-element storage buffer on real hardware (NVIDIA GeForce RTX 3060), and asserts every value was doubled. - Add
--headlessto the game binary, running a fixed number of ticks and exiting with a status code.project_runner::run_project_headlessbuilds the sameApp/plugin stack asrun_project(AssetPlugin,HybridPhysicsPlugin,RenderExtractPlugin, game plugin) with noVulkanoContext, window, or renderer, and runs 60 fixed-step ticks;src/bin/game.rswires--headlessto it. Verified live:./target/debug/game --headless testGame/build/main.rscene.binopens no window and exits 0.
Test harness
- Add a
gpu-testsfeature. GPU tests compile always and run only under that feature.gpu-tests = []inCargo.toml; every GPU test carries#[cfg_attr(not(feature = "gpu-tests"), ignore = "...")]. Verified live:cargo test --libshows4 ignored;cargo test --lib --features gpu-testsruns all 142 with 0 ignored, both clean undercargo clippy --workspace --all-targets [--features gpu-tests] -- -D warnings. - Add a shared test fixture that creates a headless device once per test binary.
rendering::test_support::headless_device()(OnceLock<HeadlessVulkanBase>), used byswapchain::testsand bothreadback::tests. Verified live:cargo test --lib --features gpu-tests -- --nocaptureprints “Selected headless Vulkan device” only twice for 4 GPU tests (once for the fixture’s one-time init, once for the unrelatedheadless_device_teststest that exercisesinit_vulkan_headlessdirectly). - Skip with an explicit message, not a failure, when no Vulkan device is present. Every GPU test still opens with
if VulkanLibrary::new().is_err() { eprintln!("skipping: no Vulkan driver present"); return; }in addition to thegpu-testsignore gate, so a--features gpu-testsrun on a driverless machine reports pass, not failure. - Add golden-image comparison with a per-pixel tolerance, writing actual/expected/difference images to an artifact directory on mismatch.
rendering::test_support::assert_matches_golden_image(actual, golden_path, artifact_dir, tolerance)(per-channelabs_diff, dimension check first, writesactual.png/expected.png/diff.pngvia theimagecrate on any mismatch, no GPU required). Verified live:identical_image_matches_its_own_golden_with_zero_tolerance,a_small_difference_within_tolerance_passes, anda_mismatch_beyond_tolerance_panics_and_writes_artifact_images(which asserts the three artifact files exist after a caught panic) all pass undercargo test --lib. - Add a compute-dispatch fixture: upload known input, dispatch, read back, assert.
rendering::test_support::dispatch_and_read_back(generic over the storage-buffer element type; builds the pipeline/layout/descriptor set, dispatches, reads back viareadback::read_back_buffer).readback::tests::reads_back_storage_buffer_results_after_a_compute_dispatchnow calls it instead of repeating the boilerplate inline. Verified live against real hardware: same doubling assertion still passes. - Mark every test whose result depends on real hardware timing
#[ignore], with the reason in the attribute. Audited: no test in the crate asserts on wall-clock elapsed time, FPS, or frame pacing —Durationvalues inruntime::testsandproject_runnertests are deterministic simulated inputs (fixedDuration::from_millis(...)passed toApp::update), not measured real time, so nothing currently qualifies. Note for future work: apply this to any new test that measures real elapsed time, frame pacing, or throughput.
Repository constraints
- Add
AGENTS.mdrecording files that implementation sessions must not modify, and why. - Record the verification tier table from the working agreement above, or link to it.
Exit gate
- A headless compute test and a headless offscreen render test both pass under lavapipe on a machine with no display.
- The same two tests pass on the NVIDIA device.
- CI runs the
gpu-testsfeature under lavapipe on every pull request. RUSTING_VULKAN_DEVICEselects between lavapipe and hardware on a machine where both are present.
Milestone 1: Workspace and runtime foundation
Goal: create the engine runtime that owns canonical scene state and system execution.
Workspace
- Convert the package into a Cargo workspace using the architectural dependency direction above. Verified: root
Cargo.tomlalready had[workspace] members = ["testGame"]; addedcrates/rusting-coreas the first real architectural-layer member (bottom of the dependency direction).cargo build --workspaceandcargo clippy --workspace --all-targets -- -D warningsclean. - Move reusable public data types into
rusting-corewithout Vulkan dependencies. First increment: movedTransformandCollisionTypeintocrates/rusting-core. Second increment: moved the Vulkan-independent canonical scene, hierarchy, camera, naming/classification, light, visibility, render-settings, physics-settings/status, and rigid-body/collider data fromsrc/runtime/components.rsintocrates/rusting-core/src/components.rs;rusting_engine::runtime::*remains compatible through re-exports. Third increment: moved the genericEventQueue<T>and its frame-buffer semantics intocrates/rusting-core/src/events.rs; the engine retains only theWorldadapter and its public runtime path remains an exact re-export of the core type. Fourth increment: moved the Vulkan-independentFrameTimeresource and its duration accessors intocrates/rusting-core/src/time.rs; the engine retains time control and advancement policy while its public runtime path remains an exact re-export of the core type. Fifth increment: moved hierarchy mutation, transform propagation, malformed-hierarchy diagnostics, and the typedHierarchyErrorintocrates/rusting-core/src/hierarchy.rs; the engine adapter preserves the existingAppErrorvariants and public diagnostics/propagation paths. Sixth increment: moved the publicScheduleStageandFrameReportscheduler data types intocrates/rusting-core/src/schedule.rs; schedule storage and execution remain engine-local while the existing runtime paths are exact re-exports. Seventh increment: movedTimeControl, its private accumulator/queued-step state, the deterministic advancement algorithm, and typedTimeAdvanceErrorintocrates/rusting-core/src/time.rs; the engine retains only theWorld/AppErrorcompatibility adapter and its public time resource paths remain exact re-exports. Eighth increment: movedRuntimeInput,ActionMap,InputBinding, and theKeyCode/MouseButtonre-exports intocrates/rusting-core/src/input.rs(adds awinitdependency to core for the key/button types only; no Vulkan);src/runtime/input.rs/actions.rskeep only theWorldinstall adapters and exact re-exports. Built-inCollisionEventremains engine-local with the CPU physics implementation.MeshRendererstays engine-local because it uses engine asset handles.src/core/material.rsandsrc/core/physics.rsalso remain engine-local because they depend on rendering registries. Audit complete: remaining public runtime types are domain-owned rather than core data:MeshRendererand scene documents depend on assets, physics events and GPU IDs belong to the planned physics crate, render snapshots and picking belong to the planned render crate, andApp/Plugin/EngineBuilderremain engine application logic.cargo tree -p rusting-coreshows no Vulkan, egui, image, or glTF dependency.- Ninth increment: moved
CpuFrameTimingstorusting-core::scheduleas a Vulkan-independent ECS resource and preservedrusting_engine::runtime::CpuFrameTimingsas an exact re-export. Evidence: core resource test passes;cargo fmt --all --check, strict workspace clippy (default,--no-default-features, and--features gpu-tests),cargo test --workspace, andcargo test --workspace --features gpu-tests -- --test-threads=1pass. The parallel GPU suite crashed with SIGSEGV in the test process; the serial run passed all 312 engine library tests. - Tenth increment: moved the Vulkan-independent
AppErrorenum and its error formatting intorusting-core::app, preservingrusting_engine::runtime::AppErroras an exact re-export. Evidence:app::tests::errors_preserve_entity_and_plugin_contextpasses; formatting, strict workspace clippy in all three configurations, default workspace tests, and serialgpu-testsall pass. - Eleventh increment: moved the asset-independent
ClickEventdata type intorusting-core::inputwhile keeping gameplay picking and event routing in the engine;rusting_engine::runtime::ClickEventremains an exact re-export. Evidence:runtime::tests::clicking_a_rendered_cube_fires_a_click_eventpasses and exercises event delivery through the old path.
- Ninth increment: moved
- Keep compatibility re-exports in
rusting-engineduring migration.src/core/mod.rsre-exportsrusting_core::{transform, collisions}at their originalcrate::core::transform/crate::core::collisionspaths, so every existing caller (src/tests.rs,src/scene/mod.rs,src/engine/mod.rs,src/core/physics.rs) compiles unchanged. Confirmedsrc/editor/mod.rs/src/editor/view.rsnever importcrate::core::at all, so this migration step could not have touched the protected editor/gizmo files even indirectly; verified viagit statusthat both files are untouched. - Add feature flags for editor, validation, experimental GPU physics, and optional importers.
editorandwindowalready gated their modules.validationnow force-enables the Khronos validation layer (when installed) in non-debug builds too (now inrendering::validation_instance_info, used byvulkano_config:cfg!(debug_assertions) || cfg!(feature = "validation")). Newgltffeature (default, implied byeditorbecausesrc/editor/view.rscallsAssetServer::import_gltf) makes thegltfcrate optional and gatesgeometry::gltf_loader,Engine::add_gltf,AssetServer::import_gltf, and thegltf_testexample.experimental-gpu-physicsexists but gates nothing yet: all current GPU physics is the shipped hybrid path, so the flag is reserved for Milestone 5 GPU solvers. Verified: strict clippy over--workspace --all-targetsclean for default,--no-default-features,--no-default-features --features window,--no-default-features --features gltf,validation, and--features gpu-tests;cargo test --workspacepasses with default and--no-default-features.
ECS and application lifecycle
- Add standalone
bevy_ecswith its parallel scheduler enabled. - Introduce a Vulkan-independent
Appand fallibleEngineBuilder. - Add
add_plugin,add_system,add_systems,insert_resource,spawn,despawn,run, and controlled shutdown. - Define ordered schedules:
Startup,FixedUpdate,Update,PostUpdate, andRenderExtract. - Support command-buffered entity spawning and despawning inside systems through Bevy
Commands. - Implement deterministic fixed timestep with configurable maximum catch-up limits.
- Add pause, single-step, time scale, elapsed time, frame delta, and fixed delta resources.
- Add typed events with frame-bounded lifetime.
Canonical components and resources
-
TransformandGlobalTransformECS components. - Parent/child hierarchy, cycle rejection, diagnostics, and cycle-safe propagation.
-
Cameraand perspective/orthographic projection settings. - Deterministic active-camera selection by active flag, priority, and stable entity identity.
-
MeshRendererand visibility components using typed asset handles. - Directional, point, and ambient light components.
-
RigidBody, primitiveCollider, sensor, and collision-layer components. -
GpuEffectBodymarker for bodies currently assigned to GPU simulation. Removed in Milestone 5: it only mirroredPhysicsBody::uses_gpu, whichSimulationClass::Gpunow states directly. -
FrameTime,TimeControl,RenderSettings,PhysicsSettings, andQualityProfileresources. - Input action mapping with keyboard, mouse, and gamepad-ready abstractions.
Runtime ownership
- Split the monolithic loop into
WindowRunner,Renderer,PhysicsWorld,AssetServer, and optionalEditorState.- Windowed and headless project runners now use one
load_project_runtimefunction to install assets, hybrid physics, render extraction, the game plugin, and the cooked scene in the same order. GPU availability remains a windowed runner setting. Evidence: default workspace tests and serialgpu-testspass; the full window/renderer ownership split remains open. - Extracted
WindowRunnerfromProjectApplicationinsrc/project_runner.rs. It owns the Vulkan context, windows, scene renderer, VSync state, resize, frame pacing, swapchain acquisition, rendering, and presentation;ProjectApplicationkeeps the ECS runtime and coordinates input, GPU result delivery, and updates. Evidence:cargo check --workspace,cargo run --bin game -- --headless testGame/build/main.rscene.bin, formatting, strict workspace clippy (default, no default features, GPU tests), default workspace tests, and serial GPU tests pass. The legacyEnginefacade and editor window loops still need the same separation. - Extracted
EditorWindowRunnerfromEditorApplicationinsrc/bin/editor.rs. It owns the Vulkan context, window/swapchain, scene renderer, offscreen scene target, egui painter state, VSync state, and frame pacer, and initializes them onresumed.EditorApplicationkeeps scene ECS, input, and update coordination. Evidence:cargo check --workspace --bin editor, formatting, strict workspace clippy in all three configurations, default workspace tests, and serial GPU tests pass. Editor draw/present delegation and a live GUI smoke run remain open indocs/editor-overhaul.md. - Rebuilt the legacy
Enginefacade overApp+ ECS + the sharedWindowRunner.add_cube/add_sphere/add_gltfspawnMeshRenderer,PhysicsBody,RigidBody, andColliderentities; legacy compute shaders map toSimulationClass/PhysicsSolver;set_lightbecomes aDirectionalLightplusAmbientLight; the fly camera is anUpdatesystem that uses the newRuntimeInput::mouse_motionand cursor capture. Breaking changes:PerspectiveCamera::newdrops the aspect ratio andupdateno longer returns a view matrix;gltf_cacheholds imported nodes; the C-key culling toggle is gone;run()needs thewindowfeature. Evidence:engine::tests(component mapping, scene overrides, camera and light directions, and the GPU render test below);user_main(10k PBR cubes) ran 8 s with no errors. Not smoke-run:native_textureandgltf_testneed the untrackedtestModels/directory, which is missing here. - Delete the legacy
scene::RenderScene,ShaderRegistry,ComputeShaderRegistrygrid path, andgeometry::gltf_loadernow that no runner uses them. Removed the retired scene, registry, pipeline, render, and glTF loader modules and disconnected the legacy shader module. Kept the publicComputeShaderTypeand physics profile mapping inrendering::compute_profilefor theEnginefacade, with arendering::compute_registrycompatibility re-export that contains no pipeline registry. Replacedsrc/tests.rswith its still relevant material tests; the old registry GPU test and layout checks exercised only the retired path, whileSceneRendererhas active GPU rendering, physics, and shader layout coverage. Evidence:cargo fmt --all --check, strict workspace clippy (default,--no-default-features, and--features gpu-tests),cargo test --workspace, and serialcargo test --workspace --features gpu-tests -- --test-threads=1all pass;rgfinds no Rust references to the removed scene, shader-registry, or glTF loader modules. - Moved the editor scene draw, egui paint, and present sequence into
EditorWindowRunner::draw;EditorApplication::window_eventnow only updates the ECS, builds the GUI, extracts, and delegates drawing. Evidence: formatting, strict workspace clippy in all three configurations, default workspace tests, and serial GPU tests pass;timeout 8 cargo run --bin editoron Wayland opened the editor and ran with no error output until killed. Interactive resize and window-close checks remain open indocs/editor-overhaul.md.
- Windowed and headless project runners now use one
- Keep the window, ECS world, and rendering submission main-thread owned.
Evidence: thread audit (2026-09-23) found only
std::thread::yield_now()inApp::runand editor cargo-build/stdout worker threads insrc/editor/mod.rs; no thread touches the window,World, or Vulkan submission. - Remove unnecessary
Arc<Mutex<_>>usage from camera and scene state. Evidence: legacyEnginenow ownscamera: PerspectiveCameraandscene: RenderScenedirectly. Same change fixed a pre-existingAccessConflict(DeviceRead)panic inprepare_frame_uboby keeping one fence per frame slot and waiting only before reusing that slot. Clippy/tests clean;user_mainstress_pbr ran 8 s at ~1000-1300 FPS without panic. - Use worker tasks only for safe asset decoding and preparation.
Evidence:
Assets::load_asyncruns only the CPU loader on a worker; results are published on the main thread bypoll_loads(see Milestone 2). - Execute animations as ECS systems instead of passive data.
The unused legacy
SceneObject::animationfield was removed withRenderScene. Real ECS animation remains part of Milestone 14; it needs clip sampling, pose application, and a fixed/update schedule integration.
Exit gate
- A headless test app can run startup, fixed, variable, hierarchy, and event systems deterministically.
- Entities remain stable through spawn, despawn, sorting, and render extraction.
- Pause and single-step work without affecting editor interaction.
- The old facade can spawn and render cubes and spheres through ECS components. Met: GPU test
engine::tests::engine_cubes_and_spheres_render_through_ecsbuilds anEnginewith a red cube and a green sphere, runs oneAppupdate, renders the extractedRenderWorldoffscreen, and finds both colors (llvmpipe and RTX 3060 via--features gpu-tests).
Milestone 2: Asset system and scene persistence
Goal: make assets stable, deduplicated, reloadable, and serializable.
Typed assets
- Add generational
Handle<MeshAsset>,Handle<TextureAsset>,Handle<MaterialAsset>, andHandle<SceneAsset>. - Build typed asset storage with generation checks, explicit reference tracking, and frame-deferred destruction.
- Canonicalize asset paths and deduplicate repeated loads.
- Separate CPU mesh/material assets from renderer-owned prepared GPU mesh buffers.
- Add synchronous loading state inspection and structured asset errors.
- Add worker-backed asynchronous loading states.
Evidence:
LoadState::Loading,Assets::load_async(path dedup, panic capture, failed-path retry, cancel viaremove) andAssets::poll_loads, polled eachUpdatebyAssetPlugin. Unit testasync_loads_publish_success_failure_panic_and_discard_cancelled. - Add default/fallback mesh, material, and texture assets.
Import pipeline
- Deduplicate glTF meshes, images, and materials by synthesized asset path, including sampler identity.
- Import base-color textures with sRGB formats.
- Import normal, metallic-roughness, occlusion, and emissive textures with correct linear/sRGB treatment.
- Import sampler filtering and wrapping modes.
Evidence:
TextureAsset::sampler(TextureSamplerwith mag/min/mipmapTextureFilterand U/VTextureWrap) is filled from glTF samplers; the sampler index is part of the synthesized texture path. Unit testgltf_import_carries_sampler_filtering_and_wrapping. API note: adds a public field toTextureAsset. The renderer still uses one shared linear/repeat sampler; per-texture samplers land when the ECS renderer uploadsTextureAssets (GPU tier). - Generate or import tangents.
Evidence: glTF
TANGENTis imported when present; otherwiseassets::generate_tangentsderives tangents and handedness from triangle UV gradients, with a perpendicular fallback for degenerate UVs. Unit testgenerated_tangents_follow_uvs_handedness_and_degenerate_fallback. - Support alpha opaque, mask, and blend modes. First increment (data):
AlphaMode::{Opaque, Mask { cutoff }, Blend}onMaterialAsset, imported from glTFalphaMode/alphaCutoff, saved asSceneAlphaModein scene format 5. Cooked v1-v4 scenes migrate toOpaque(v4 is dispatched on its leadingformat_versionbecause a v4 material can also parse as v5). Tests:gltf_import_carries_sampler_state_and_alpha_mode,material_alpha_mode_survives_scene_round_trip,version_four_cooked_scene_migrates_materials_to_opaque_alpha; cooked v3testGamescene still runs. Second increment (renderer): the ECSSceneRendererdiscards masked fragments below the cutoff, writes alpha 1 for opaque/mask, and draws blended instances last through a second pipeline (alpha blend, depth test without depth write), sorted back to front along the camera view each frame. GPU testalpha_modes_render_opaque_mask_and_sorted_blend(RTX 3060, headless readback) fails when sorting is disabled; unit testblended_objects_render_last_unbatched_and_back_to_front. Known ceiling: blended GPU-physics bodies sort by their last CPU transform. - Import node hierarchy and cameras/lights where available.
AssetServer::import_gltf_scenereturns oneImportedGltfNodeper glTF node (name, parent index, localTransformfrom the decomposed TRS, mesh primitives,Camera,KHR_lights_punctuallight);spawn_gltf_nodesspawns them withParentlinks, extra primitives as child entities. Evidence: unit testgltf_scene_import_spawns_hierarchy_cameras_and_lights(parent/child links, quaternion to Euler, orthographic camera, spot light, spawned components). Light intensities keep glTF physical units. The editor’s flatimport_gltfpath is unchanged (AGENTS.md); wiring the editor to the node import is the owner’s call. - Sample imported UVs and material textures in the ECS renderer.
Found in the
issues.mdaudit.SceneVertexnow carries UVs, and the fragment shader multiplies the material color by the base-color texture (set 1, one descriptor set per texture, white when a material has none or its texture is still loading). Uploads go through a staging buffer copied in the frame’s own command buffer, with no CPU wait. sRGB textures use an_SRGBformat; each glTF sampler (filter, wrap) gets a cachedSampler. A changed texture revision re-uploads, and removed textures leave the cache like meshes. Evidence: GPU testsbase_color_texture_tints_the_surface_and_reloads_next_frame(fails with sampling disabled in the shader) andmaterial_without_texture_samples_white. Known ceiling: no mip chain yet. The other maps landed with the Milestone 4 PBR pass. - Add skins and animation after the static scene path is stable. Blocked: needs the skeleton/clip/pose runtime from Milestone 14; import lands together with that runtime rather than as data nothing consumes.
- Preserve glTF materials unless the caller explicitly supplies an override.
spawn_gltf_nodes(app, nodes, material_override)keeps each primitive’s imported material when the override isNone. Fixed a real loss: glTF textures used synthesized.rtexturekeys that never existed on disk, so a saved scene with a glTF material failed to load in a fresh process. The importer now writes.rtexture(bincode, carrying color space and sampler) next to the source like.rmesh, andload_texturedecodes it. Evidence: unit testgltf_materials_survive_save_and_fresh_load_unless_overridden(factors, alpha mode, linear normal map, sampler survive save plus fresh load; override replaces material); fails with the.rtexturewrite removed.
Scene serialization
- Define versioned, human-readable
.rscenefiles with Serde plus a compact cooked binary form. - Assign stable UUIDs to serialized scene objects.
- Store asset paths/UUIDs rather than runtime handles or ECS entity IDs.
- Add an allowlisted component serialization registry.
- Serialize hierarchy, transforms, renderers, lights, physics, and editor metadata.
Hierarchy, transforms, renderers, cameras, visibility, classes,
directional/point/spot lights, and physics were already typed scene fields.
The missing
AmbientLightcomponent is now built into the defaultSceneComponentRegistryasrusting.ambient_light, so the scene binary layout is unchanged and the editor can add it like other registered components. Evidence:all_authored_light_types_round_trip_togethernow round-trips an ambient light through cooked bytes and fails with the registration removed. Editor metadata is split out below. - Serialize per-scene editor metadata (editor camera pose, selection).
save_editor_scenewrites the editor camera position, rotation, orbit distance, and selection (bySceneId, active object first) to a<scene>.editor.jsonsidecar, like Godot’s per-scene edit state, so the game’s scene file stays clean.load_editor_scenerestores them after a Replace load; a missing or corrupt sidecar only clears the selection. Every editor save and open path, and the startup restore, uses the pair. Evidence: unit testeditor_scene_restores_camera_pose_and_selection_after_reload. - Reject unsupported scene versions with a clear structured error.
- Add explicit migration for legacy unversioned project and text-scene files.
- Add scene save, load, additive load, and unload operations.
Hot reload
- Watch source assets and scenes for changes.
Assets::changed()compares each path-backed slot’s file modification time with the one recorded at load (stdlib polling, no new dependency); missing files are ignored so save-by-rename never drops a value. It works for every asset type, includingscenes.AssetPluginscans everyHOT_RELOAD_SCAN_INTERVAL(500 ms). Known ceilings: onestatper watched file per scan; editing a source.gltfdoes not re-import it (only rewritten.rmesh/.rtexturereload). - Decode changed assets in worker tasks.
Assets::reload_asyncdecodes on a worker while the old value stays visible;poll_loadspublishes the new value and bumps the revision, and a failed decode keeps the last good value and lands intake_reload_failures.AssetServer::reload_changedwires meshes and textures. Evidence: unit testchanged_files_reload_on_workers_and_failures_keep_last_value(unchanged file skipped, old value visible mid-decode, new value published with a higher revision, corrupt rewrite keeps the old value and reports one failure, deleted file ignored). - Swap prepared resources at safe frame boundaries.
Reloads publish in
poll_loadsduring the ECSUpdatestage, so assets never change mid-render;SceneRenderer::prepare_visible_meshessees the new revision at the start of the next frame and uploads fresh buffers instead of writing into in-use ones. Evidence: GPU testhot_reloaded_mesh_swaps_next_frame_and_old_buffers_outlive_in_flight_frame(frame 2 shows the reloaded mesh while frame 1 is still unwaited; a control run without the reload reads white, so the black result proves the swap). - Keep old GPU resources alive until every referencing frame completes.
Old buffers are released by ownership: each submitted command buffer holds
Arcs to the buffers it binds, and the frame future keeps the command buffer until its fence completes. Evidence: the same GPU test holds aWeakto the replaced vertex buffer; it stays alive while frame 1 is in flight and is freed once frame 1 is dropped after completion. Textures follow the same pattern: each frame’s command buffer holds the descriptor set, and with it the image, it binds. - Surface reload success and failure in the editor console.
poll_loadsrecords the path of each published hot reload;AssetServer::take_reloaded()drains them, and the editor logs “Hot reloaded” at Info and each failure at Error. Evidence: unit test changed_files_reload_on_workers_and_failures_keep_last_value(one path after a good reload, none after a failed one).
Exit gate
- Loading the same asset path returns the same live asset identity.
- Saving and reloading a representative scene preserves all allowlisted state.
- Asset reload during rendering produces no invalid descriptor/resource use.
- Golden tests verify glTF channels, alpha modes, normals, and tangents.
Milestone 3: Render extraction and frame management
Goal: eliminate frame-loop stalls and host/GPU races while making rendering derived from ECS state.
Render extraction
- Define a render world separate from the gameplay world.
- Extract cameras, visible renderers, global transforms, lights, and material handles during
RenderExtract. - Diff extracted state so unchanged render data produces no dirty uploads.
- Build deterministic render keys and stable ordering from typed handles and entity identity.
- Track contiguous dirty upload ranges and full-range reorder/removal updates.
- Grow GPU buffers based on demand and device memory budgets.
Render-instance uploads (rebuilt whenever anything moves) used a dedicated
allocation per change; they now come from a vulkano
SubbufferAllocatorwhose arenas start at 256 KiB, double when an upload outgrows them, and are reused once no in-flight frame references them. One upload is capped at half the largest device-local heap (transient_upload_budget); going over returns aSceneRenderErrorinstead of allocating. Mesh buffers stay exact-size per revision. Evidence: unit testtransient_budget_is_half_the_largest_device_local_heap; GPU testinstance_uploads_reuse_arenas_grow_on_demand_and_respect_budget(two small uploads share one arena buffer, 5,000 instances grow it and still render, a budget one byte short fails with an explicit error). Known ceiling: the budget uses static heap size, not live usage (VK_EXT_memory_budgetis not exposed by vulkano 0.35). - Add explicit errors or fallback paths for allocation/capacity failures.
Audit of
SceneRenderer: every Vulkan buffer/descriptor/command allocation already maps toSceneRenderError; instance uploads over budget are an explicit error; missing meshes fall back to the cube and missing materials render magenta. The two silent paths now report: lights pastMAX_LIGHTS(64) keep the first 64 and count the rest inRenderCapacityDiagnostics::dropped_lights; GPU physics event-buffer overflow accumulates intophysics_events_dropped(still logged). Both are read throughSceneRenderer::capacity_diagnostics(). Evidence: GPU testlights_over_capacity_render_first_max_lights_and_report_the_rest(67 point lights upload 64 and report 3 dropped; back to 1 light reports 0). Physics overflow counting has no dedicated test; it sits on the same readback path as the existing GPU physics event tests.
Frames in flight
- Create two or three explicit
FrameContextobjects.SceneRendererowns[FrameContext; FRAMES_IN_FLIGHT](2), used round-robin byrender. Each context holds the fence of its last submission, which also keeps that submission’s resources alive. Every frame now ends in a fence signal before the caller’s present, not only physics frames. Physics readbacks share that fence. Evidence: GPU testframe_contexts_bound_frames_in_flight_and_wait_only_on_reuse. - Give each context its own fence, command allocator/pool state, uniform allocations, indirect buffers, visible lists, and transient descriptors.
Each
SceneRendererFrameContextowns its fence and a host-visibleSubbufferAllocator(64 KiB first arena). Debug-overlay vertices and GPU physics readback buffers now come from that arena instead of a dedicated allocation per frame. The physics descriptor set that references them is per-frame and pooled. The rest of the list maps as follows: command buffers come from vulkano’sStandardCommandBufferAllocator, which pools per thread and recycles a buffer when its frame drops it.SceneRendererhas no uniform buffers (the camera is a push constant) and no indirect buffers. Visible lists are CPU-only. Evidence: GPU teststransient_uploads_come_from_per_context_arenas_across_reuse(the debug line renders in each of 4 frames, two full laps, and the two contexts never share an arena) andphysics_events_read_back_from_transient_arenas_every_tick(oneWhileTrueevent per tick for 4 ticks, tick numbers match, no overflow). Before this test, no test covered the readback path throughSceneRenderer. The legacyEnginestill shares one indirect buffer per batch across frames; see the next two items. - Wait only when reusing a frame context whose fence has not completed.
At the start of
render, contexts whose fence already signaled are released without waiting. The one context being reused is waited on only if its fence has not signaled; that wait bounds CPU run-ahead toFRAMES_IN_FLIGHTframes. Evidence: the same GPU test hands back two frames unflushed. The thirdrendersubmits and waits for frame 1 only; frame 2’s fence stays unsignaled. With the wait removed, the test fails (“reusing context 0 first submitted and waited for its frame”). Frame pacing on real hardware is not verified here (hardware tier). - Chain acquire, transfer/compute, render, and present without CPU
wait()calls in the normal frame path. In the product path (project_runner+SceneRenderer), one future chain runs from acquire to present: the acquire future isrender’sbefore, physics compute and drawing share one command buffer, then come the frame fence andpresent(.., false), which does not wait. Physics events are collected withis_signaledplus a zero-timeout cleanup, so that never blocks either. The only CPU block left is the bounded context-reuse wait from the item above. Evidence: the frame-context test shows a context that is not being reused is never waited on. Out of scope: the legacyEnginefacade still waits on its separate compute and cull submissions. It follows the Milestone 0 precedent: that facade is deferred to its Milestone 1 replacement, not patched. - Retain every submitted future/fence until completion.
Frame fences stay in their
FrameContextuntil they signal or their context is reused after a wait. The present future is retained byvulkano_util(previous_frame_end), and physics readbacks bypending_physics. Evidence: the frame-context test asserts no unfinished fence is released. The hot-reload test shows old mesh buffers live until the frame that used them completed and the next frame released its fence. - Add deferred GPU-resource destruction queues per frame context.
No separate queue was added; ownership already provides the deferral.
A vulkano command buffer holds an
Arcto every buffer, image, descriptor set and framebuffer it uses. EachFrameContextfence holds that command buffer until the GPU finishes. So anything the renderer replaces (mesh and instance buffers, lights, the depth target and framebuffers on resize) is freed only after its last frame completes. It is freed on the nextrenderafter that, with no wait. Evidence: GPU testshot_reloaded_mesh_swaps_next_frame_and_old_buffers_outlive_in_flight_frame(mesh buffers) andresize_defers_old_depth_destruction_until_its_frame_completes(the depth target is replaced while frame 1 is pending, stays alive until frame 1 completes, then is released). - Clear culling counters and indirect commands using queued GPU operations.
The legacy
Engineresetinstance_countwith a host write every frame while the previous frame could still draw from that buffer. This madeuser_mainpanic in 2 of 10 runs withAccessConflict(DeviceRead). The reset is nowrecord_reset_instance_count(src/scene/mod.rs), afill_bufferof that one field recorded before the cull dispatch. The cull and physics submissions are now chained after the previous frame instead of starting fromsync::now. Evidence: GPU testinstance_count_reset_is_a_queued_gpu_fill_of_that_field_only, anduser_mainran to the timeout in 25 of 25 runs, about 3000 FPS as before. Known ceiling (ponytail:comment): on frames that run physics or culling, the CPU now waits for the previous frame, so CPU and GPU do not overlap there. Per-slot buffers would restore the overlap. - Remove host writes to buffers that may still be in GPU use.
Audit of every
.write()in the render paths: the legacy per-slot UBO is written only after that slot’s fence wait.SceneRendererwrites only fresh suballocations: instance and transient arenas are reused only once no frame holds them, and vulkano’s host-access lock rejects any conflicting write with an error, never a race. The one racing write, the indirect reset in the legacyEngine, was removed in the item above.
Capabilities and fallback policy
-
Define a low-end Vulkan baseline suitable for Intel UHD 620-class hardware.
LOW_END_BASELINE(src/rendering/scene_renderer.rs) requires:- Vulkan 1.1
maxComputeWorkGroupInvocationsandmaxComputeWorkGroupSize[0]of at least 256, which the physics shader’slocal_size_xneedsmaxPushConstantsSizeof at least 128 (the largest block used is 96 bytes)maxStorageBufferRangeof at least 128 MiBD32_SFLOATusable as a depth attachment Every limit except compute invocations is the Vulkan-required minimum. No optional features or extensions are required.SceneRenderer::newreadsDeviceLimitsfrom the physical device and refuses a device below the baseline with an error that names the device and every shortfall. The instance upload budget is now also clamped tomaxStorageBufferRange, because half of a large heap could exceed the range one storage binding may cover. Evidence: unit testbaseline_shortfalls_name_every_missing_property, and GPU testtest_device_meets_the_low_end_baselineon the headless test device. Not verified on a real UHD 620 (hardware tier). Its limits are expected, not measured, to meet the baseline.
-
Detect optional capabilities and expose them through renderer capabilities.
SceneRenderer::capabilities()returnsRendererCapabilities(src/rendering/scene_renderer.rs), detected once inSceneRenderer::new:- device name, integrated-GPU flag, and largest device-local heap size
- multi-draw indirect, indirect-count drawing (core feature or
VK_KHR_draw_indirect_count), bindless textures (all four descriptor indexing bits), andVK_EXT_memory_budget, each reported assupportedby the GPU andenabledon the device - timestamp queries on graphics and compute queues
A pass may use a feature only when
Capability::usable()holds. No optional feature is enabled on the device yet, because no pass uses one. Evidence:- Unit test
optional_features_need_every_bindless_bit_and_accept_either_indirect_count_source. - GPU test
renderer_reports_detected_capabilities_and_enables_none_it_does_not_use. On the RTX 3060 it reports all four as supported and none as enabled.
-
Provide fallbacks for bindless descriptors, indirect-count drawing, and other advanced features. Audit of every draw call and shader:
SceneRendereruses only directdraw/draw_indexedand fixed descriptor sets, so its current path is already the baseline fallback. No shader declares a GLSL extension or a descriptor array. The GPU suite runs on a device with no optional feature enabled, which the capabilities test asserts. A pass that adopts an advanced feature later must branch onCapability::usable()and keep this path tested. The audit found one path that silently depended on an optional feature: the legacyEngineculling path wrote each batch’s offset into the indirect command’sfirstInstance, which needsdrawIndirectFirstInstance. That feature is never enabled, so the result was undefined. NowfirstInstanceis 0 andbase.vertadds the batch offset from the push constant it already receives (data[v_visible_list_offset + gl_InstanceIndex]). A dead, unused indirect staging buffer next to it was deleted. Evidence: the shader compiles and the smoke runs pass. The culled image itself is not checked by a test; the exit gate’s culling-equivalence check covers that. -
Implement
QualityProfile::{Auto, Eco, Balanced, High}.RenderSettings::qualityis now extracted intoRenderWorld::qualityevery frame.resolve_quality(src/rendering/scene_renderer.rs) turnsAutointo a concrete profile fromRendererCapabilities:Ecoon integrated GPUsBalancedbelow 4 GiB of device-local memoryHighotherwise Concrete profiles pass through unchanged. Today the resolved profile sets the per-frame light budget: 16, 32 or 64 lights (light_budget). Lights past the budget are dropped and counted inRenderCapacityDiagnostics::dropped_lights. A profile change rebuilds the light list even when the lights did not change. Later budgets (shadows, culling mode, substeps) plug into the same resolved profile. Evidence:- Unit test
auto_quality_resolves_from_device_class_and_concrete_profiles_pass_through. - GPU test
lights_over_capacity_render_first_max_lights_and_report_the_restnow switches High to Eco with unchanged lights and checks the 16-light upload. It fails (3 dropped instead of 51) without the rebuild on profile change. TheAutoheuristic is static, marked with aponytail:comment. Whether it picks the right profile on a real UHD 620 is hardware tier. The editor’s Project area shows the resolved profile, marked “(Auto)” when it was picked automatically, and counts loaded LOD groups next to meshes. Evidence:editor::tests::project_area_shows_lod_groups_and_the_resolved_auto_quality.
-
Remove shader variants as user-facing performance controls; choose implementation variants automatically. The ECS/editor path (
SceneRenderer) exposes no performance variant:- It builds one scene pipeline.
- Materials choose appearance through
MaterialModel::{Pbr, Unlit}, never cost. - Cost is set only through
QualityProfile, whichAutoresolves from capabilities (item above). The legacyEnginepath’s publicShaderType(including the benchmark-onlyHeavy) was removed by the Milestone 4 item “Replace publicShaderTypewithMaterialModel…”. Solver selection (PhysicsSolver) is a semantic choice, and “Keep manual CPU/GPU and solver selection available” keeps it manual on purpose. Evidence: code audit only; no code changed.
Exit gate
- No normal-frame CPU fence waits remain.
- Validation reports no synchronization or resource-lifetime errors.
- Two/three frames in flight can run for 10,000 frames without UBO, indirect-buffer, or descriptor races.
- Culling enabled and disabled produce equivalent visible geometry.
Milestone 4: Rendering baseline
Goal: deliver a coherent, production-shaped forward renderer for the vertical slice.
Pass schedule
- Batch opaque ECS renderables by mesh and material and draw them through a cached GPU instance buffer.
- Directional shadow-map pass.
SceneRendererrecords a depth-only pass into a 2048x2048D32_SFLOATshadow map before the main pass, drawing opaque/mask batches withcast_shadows(others collapse to a zero-area triangle in the shadow vertex shader). It shares the main pipeline layout and set 0, uses slope-scaled depth bias, and is always cleared so the main pass samples initialized depth. The orthographic light box coversSHADOW_DISTANCE(50 m) in front of the camera. Evidence: GPU testdirectional_light_shadow_darkens_receivers_only_when_enabled(off-view caster darkens the floor; non-caster, non-receiver, andshadows: falsestay lit); fails with r=253 when the shadow factor is forced to 1. Known ceilings: one cascade, no texel snapping (edges shimmer as the camera moves), masked casters cast fully opaque, blended casters none. - Opaque forward PBR pass.
The ECS fragment shader is metallic-roughness Cook-Torrance (GGX, Smith-
Schlick, Schlick Fresnel) for every light kind, plus emissive. Light units
are unchanged: a white Lambert surface facing a unit light still reflects
MaterialModel::Unlitoutputs the base color. Built-in primitives now get real tangents fromgenerate_tangentsinstead of placeholders. Ambient became a sky/ground hemisphere with the sky/environment item. Evidence: GPU testpbr_maps_and_unlit_model_shape_the_lit_color(unlit ignores light; PBR without light is black; emissive factor and map; the occlusion map darkens ambient; a rough metal is darker than a dielectric and a metallic-roughness map with blue 0 restores the dielectric; a normal map tilted toward a grazing light brightens the surface). Disabling the normal, metallic-roughness, occlusion, or emissive sample in the shader each fails it.
- Transparent forward pass with back-to-front sorting.
Already delivered by Milestone 2 “Support alpha opaque, mask, and blend
modes”: second pipeline, per-frame back-to-front sort. Evidence: GPU test
alpha_modes_render_opaque_mask_and_sorted_blend(fails with sorting disabled), unit testblended_objects_render_last_unbatched_and_back_to_front. - HDR render target and tone-mapping pass. The scene pass lights into an
R16G16B16A16_SFLOATtarget (subpass 0); subpass 1 reads it as an input attachment and maps it into the output with aToneMappingcomponent (Linearclip by default like Godot,Reinhard,Aces, plus exposure; saved asrusting.tone_mapping). Debug views and editor lines skip the curve. Evidence: GPU testhdr_values_above_one_survive_until_tone_mapping(fails with an 8-bit target, with the Reinhard branch removed, and with the debug-view bypass removed); scene-file round trip includes the component; all earlier renderer GPU tests pass unchanged. - Bootstrap Vulkan egui overlay/compositing pass for the runnable editor.
- Engine-owned egui compositing pass shared with the production renderer.
rendering::egui_painter::EguiPainterdraws egui meshes in its own load/store render pass over any target, including theSceneRendereroutput;egui_winit_vulkanois removed. It is behind theeditorfeature because that feature owns the egui dependency. Evidence: GPU testsmeshes_blend_over_the_target_in_points_and_respect_clip_rectsandtexture_patches_update_one_region_and_freed_textures_stop_drawing(they fail with the sRGB output conversion removed, with the clip scissor widened to the full target, and with partial updates written at offset 0); the editor ran 8 s on Linux/Wayland and drew its UI with no validation panic. - Explicit pass resources, transitions, and debug labels.
rendering::frame_passesdeclares the frame’s passes in order (Uploads, Physics, Shadow, Scene, ToneMap, DebugOverlay), the resources each one reads and writes, and the image layout it needs.layout_transitions()derives the three layout changes (material textures and the shadow map to shader-read before Scene, HDR color to shader-read before ToneMap). The shadow render pass takes its final layout from the table, so the render pass does that transition; vulkano still inserts the remaining barriers until the Milestone 13 render graph. Each pass is recorded under its own debug-utils label inside the frame’sSceneRenderer::renderlabel, andSceneRenderer::last_frame_passes()reports the recorded order. The headless test instance now enablesext_debug_utilswhen the driver has it, so every GPU test records the labels. Evidence: unit tests inframe_passes(every read follows a write, unique labels, exact transition list); GPU testframes_record_the_declared_passes_with_their_layouts(pass order, shadow final layout, HDR input layout; fails with the shadow final layout removed); the 38scene_rendererGPU tests underVK_LAYER_KHRONOS_validationon an RTX 3060 report no validation error, while a removed Shadow label begin reportsVUID-vkCmdEndDebugUtilsLabelEXT-commandBuffer-01912.
Materials and lighting
- Replace public
ShaderTypewithMaterialModel::{Pbr, Unlit}plus internal shader variants. The legacyMaterialnow carriesmodel: MaterialModel(builder.model(...)), andEngine::set_scene_shadertakes aMaterialModel.ShaderTypeis no longer re-exported from the crate root; it stays inrendering::shader_registryas the legacy renderer’s internal variant enum, mapped byFrom<MaterialModel>. TheEmissive,NormalDebug, andHeavyvariants are no longer selectable from the public API; the Pbr variant already renders emissive. Evidence: unit testsmaterial_builder_sets_model,material_builder_model_can_be_overridden, andinstance_material_copy_preserves_every_property(an Unlit material maps to the Unlit variant); doc tests onMaterialandMaterialBuilder::model. - Complete metallic-roughness PBR texture binding and sampling. Set 1 is now one cached descriptor set per material holding its five maps (white where absent), rebuilt only when a map is re-uploaded. Metallic and roughness factors, emissive, and the model travel in the instance buffer, which is already rebuilt on material revision changes. Evidence: as above.
- Support base color, normal, metallic-roughness, occlusion, and emissive maps. Normal maps use the vertex tangent and handedness (glTF convention); metallic-roughness reads blue and green, occlusion red. Evidence: as above.
- Support alpha cutoff and alpha blending.
Already delivered by Milestone 2 “Support alpha opaque, mask, and blend
modes”: second pipeline, per-frame back-to-front sort. Evidence: GPU test
alpha_modes_render_opaque_mask_and_sorted_blend(fails with sorting disabled), unit testblended_objects_render_last_unbatched_and_back_to_front. Later: blending is premultiplied. Diffuse and emissive scale by alpha, specular and environment reflections do not, so glass at alpha 0 still mirrors its surroundings. Known ceiling: no refraction or colored tint of what is behind; that needs a copy of the opaque color (Milestone 13 “Refractive glass”). Evidence: GPU testclear_glass_shows_what_is_behind_it_and_reflects_the_environment(fails with the oldSrcAlphacolor factor: no red reflection). - Add one shadowed directional light.
The first uploaded
DirectionalLightwithshadows: trueis shadowed; its index reaches the fragment shader inlight_info.y, and receivers (receive_shadows) apply 3x3 PCF through a comparison sampler. Evidence: same GPU test as the shadow-map pass. - Add several point lights with capability-appropriate limits.
Point (and spot) lights share the light storage buffer with directional
lights and fade to zero at
range(squared linear fade). The per-frame budget comes from the resolvedQualityProfile: Eco 16, Balanced 32, High 64 (MAX_LIGHTS), withAutoresolved from device capabilities. Lights past the budget are dropped and counted inRenderCapacityDiagnostics::dropped_lights. Known ceiling: every fragment loops over every uploaded light; no clustered culling yet. Evidence: GPU testseveral_point_lights_add_up_and_fade_out_at_their_range(a second light adds its color, a light past its range adds nothing, two equal lights are brighter than one); forcing the range fade to 1 or capping the shader loop at one light each fails it. GPU testlights_over_capacity_render_first_max_lights_and_report_the_restand unit test for the ordered profile budgets cover the limits. - Add sky/environment ambient lighting.
New
SkyLight { sky_color, ground_color, intensity }component (registered asrusting.sky_light, so the editor can add it and scenes round-trip it) adds a hemisphere on top of anyAmbientLight: +Y faces see the sky, -Y faces the ground. Diffuse samples it along the normal and specular along the reflection with roughness-aware Fresnel, so metals are no longer black without direct light. The 0.12 gray fallback applies only when neither component exists. The camera push constant grew to 128 bytes (ground_ambient), the guaranteed minimum. Known ceiling: two colors, no cubemap or HDRI; the sky system milestone brings image-based lighting. Evidence: GPU testsky_light_lights_up_faces_with_sky_and_down_faces_with_ground(top face red sky, bottom face blue ground, smooth metal reflects the sky, gray fallback without either light); ignoring the normal’s Y, using the normal’s Z, or dropping the environment specular each fails it. Unit testall_authored_light_types_round_trip_togetherround-trips a sky light.base_color_texture_tints_the_surface_and_reloads_next_framenow uses an unlit material, since a lit dielectric correctly reflects 4% of the environment. - Add material/texture fallback behavior and diagnostic rendering.
Missing meshes draw the fallback cube and missing materials draw
magenta (both existed). New: a referenced map that is missing or failed
to upload binds a per-slot stand-in (magenta base color, flat normal,
white for the rest) instead of white, so a broken base color map is
visible and a broken normal map no longer tilts the shading.
RenderCapacityDiagnosticsnow countsmissing_meshes,missing_materials, andmissing_textures.SceneRenderOptionsgaineddebug_view: SceneDebugView::{Lit, Unshaded, Normals}(shader reads it fromlight_info.z); the editor’s viewport settings popup has a “Viewport shading” section, and only the Scene workspace uses it. Evidence: GPU testsmissing_assets_draw_visible_fallbacks_and_are_counted(a removed or malformed base color map draws magenta; a missing normal map under a grazing light matches no normal map; a removed material and mesh draw a magenta cube; all counters) anddebug_views_show_unshaded_base_color_and_normals. Replacing the magenta or flat-normal stand-in with white, or disabling the normals view, each fails them. Unit testscene_view_shading_applies_only_in_the_scene_workspace.
Visibility and quality
-
Add
RenderBoundsas a rendering-only component independent from the physicsCollider.rusting_core::components::RenderBounds(default: the unit cube’s box), saved asrusting.render_bounds. Evidence: scene-file round-trip test includes it. -
Support local bounding spheres and axis-aligned boxes, transformed into world bounds during render preparation.
RenderBounds::{Sphere, Aabb}are local;RenderBounds::transformedscales a sphere’s radius by the largest axis scale and turns a box into the axis-aligned box of its transformed corners (Arvo). Extraction carries the local override inExtractedRenderable::bounds;SceneRenderertransforms it (or the mesh box) into world bounds when it prepares instances. Evidence: unit testsrender_bounds_stay_conservative_under_transforms(rotated, non-uniformly scaled, translated sphere and box; fails without the per-axis min/max) andrender_bounds_overrides_are_extracted_and_track_edits(fails when theRenderBoundschange tick is not tracked). -
Generate default render bounds automatically for primitives and imported meshes, with an explicit editor override for unusual meshes and animations. An object without
RenderBoundsgets the box around the vertices of the mesh the renderer actually uploaded (PreparedMesh::bounds), so built-in primitives, imported glTF meshes, the fallback mesh shown while a mesh loads, and reloaded meshes are all covered. The prepared instances record the mesh revisions their bounds came from and rebuild when one changes, without a scene change. ARenderBoundscomponent overrides the mesh box. Inspector editing of that override is the later “Render Bounds editing” item. Evidence: GPU testculling_follows_the_camera_and_mesh_edits_without_scene_changes(a mesh edit alone un-culls an object; fails when the mesh revisions are not checked). -
Add
CullingMode::{Auto, Disabled, Frustum, FrustumAndOcclusion}. Start with the first three modes; reserve occlusion culling for a later pass.RenderSettings::culling(defaultAuto) is extracted intoRenderWorld::culling. When culling is on,SceneRenderertests each instance’s world bounds against the Gribb-Hartmann planes of the camera clip matrix on the CPU. It compacts the survivors into a visible list: each batch stays contiguous and draws once, and each kept blended instance gets its own slot. The main and blend vertex shaders read the instance index from that list as a per-instance vertex attribute. The list is cached until the camera clip matrix, the culling mode, or the prepared instances change, so a still camera costs nothing. The shadow pass still draws every caster from the instance buffer directly.FrustumAndOcclusionculls by frustum for now. HowAutopicks a path is described in the selection and small-scene items below. The CPU path never culls GPU-physics bodies, because their CPU transform is stale. Frames with GPU bodies take the GPU path, which culls them. Evidence:- Unit tests:
frustum_test_keeps_bounds_that_touch_the_view_and_drops_the_rest(sphere and box on each side of the side, near, and far planes) andcompaction_keeps_batches_contiguous_and_slots_blended_instances. - GPU tests:
culling_follows_the_camera_and_mesh_edits_without_scene_changes(the kept instance of a partly culled batch draws, a mesh edit un-culls, a camera move re-culls);directional_light_shadow_darkens_receivers_only_when_enabled(an off-view caster is culled and still casts,Disabledculls nothing, an override outside the view removes the floor from the image); andgpu_bodies_are_not_culled_by_their_stale_cpu_transform. - Mutations that fail these tests: the shader indexing with
gl_InstanceIndex, the cache ignoring mesh revisions, the cache ignoring the camera, drawing whole batches, culling GPU bodies, dropping plane normalization for spheres, and the OpenGL near plane (w + z). - The validation layer reports 0 errors across 42
scene_renderertests.
- Unit tests:
-
Select CPU or GPU frustum culling by object count, simulation ownership, device capability, and quality profile.
select_culling_path(src/rendering/scene_renderer.rs) returns aCullingPatheach frame:DisabledgivesDirect: every instance is drawn.- Any rendered GPU-owned body gives
Gpu, because only the GPU has its newest transform. - Otherwise
GpufromGPU_CULL_MIN_INSTANCES(4096) instances, or four times that on integrated GPUs and with the resolvedEcoprofile. - Below the threshold,
Cpu.
The threshold is untimed and marked with a
ponytail:comment; the profiler item replaces it with measured cull times.SceneRenderer::last_culling_path()reports the choice.last_frame_culled()is nowOption<usize>:Noneon the GPU path, whose count stays on the GPU. Evidence: unit testculling_path_follows_mode_ownership_count_device_and_quality, which covers the threshold edge, GPU ownership, integrated GPUs,Eco, andDisabled. -
Cull GPU-owned objects directly from their newest GPU physics transforms. Do not read every transform back to the CPU merely to decide visibility. The
cull_shadercompute pass (FramePass::Culling, after Physics) takes each instance’s model from the physics state when it is a GPU body, and from the instance buffer otherwise. It transforms the local bounding sphere and tests the six clip planes. Nothing is read back to the CPU. The GPU tests a sphere around the mesh orRenderBoundsbox, so it keeps some instances that the CPU box test drops (ponytail:comment). Evidence: GPU testgpu_culling_counts_visible_instances_from_gpu_transforms_into_indirect_draws. A GPU body has its stale CPU transform in the view center and its GPU state at x = -3. The cull pass counts only the static cube in that batch. With the camera moved to x = -3, it counts only the body, and the body draws at its GPU position.gpu_bodies_are_not_culled_by_their_stale_cpu_transformnow runs on the GPU path and still draws the body. -
Make GPU culling write a compact visible-instance list and indirect draw commands consumed by the normal instanced renderer. Each GPU-culled frame allocates fresh buffers:
- an indirect command per opaque batch and per blended instance, written
by the host with
instanceCount0 - a visible list sized like the instance buffer
The pass
atomicAdds each visible instance into its draw’s count and writes the instance index into that draw’s range of the list. The main and blend pipelines are unchanged. They readinstance_indexfrom the list as a per-instance vertex attribute. Each draw binds the list sliced at its group’s start and callsdraw_indexed_indirectwithfirstInstance0. This avoidsdrawIndirectFirstInstance, which is not enabled. The CPU path now draws through the same slices. Evidence:- The GPU test above reads back the commands after the fence:
[1, 1, 1](one opaque batch, two blended draws), then[1, 0, 0]after the camera moves. The frame’s passes includeCulling. - Unit test
cull_gpu_layouts_match_shader_structschecks the struct sizes against the std430 and push-constant layouts, and the box-to-sphere radius. - Mutation checks that fail the tests: the shader ignoring the physics model, the shader skipping the plane test, all blended instances counting into one draw, and selection ignoring GPU ownership.
- Not caught by a test: drawing the full batch range directly instead of indirectly, and ignoring the list slot base. Both leave the tested pixels unchanged.
- The validation layer reports 0 errors across 45
scene_renderertests.
- an indirect command per opaque batch and per blended instance, written
by the host with
-
Let
Autobypass the culling dispatch for small scenes where direct rendering is cheaper, using a tested threshold before timing-based selection exists.select_culling_pathreturnsDirectforAutobelowAUTO_DIRECT_MAX_INSTANCES(64) instances, even when there are GPU bodies: drawing everything is correct whoever owns the transform. An explicitFrustumstill culls small scenes. The threshold is untimed (ponytail:comment); the profiler item replaces it with the measured break-even. TheSlabSceneGPU tests setFrustum, because every test scene is below the threshold. Evidence:- Unit test
culling_path_follows_mode_ownership_count_device_and_qualitycovers 63 instances (Direct, with or without GPU bodies), 64 (Cpu), andFrustumat 10 instances (Cpu, orGpuwith a GPU body). - GPU test
gpu_bodies_are_not_culled_by_their_stale_cpu_transformswitches toAuto. The frame takesDirectwith noCullingpass, culls nothing, and still draws the body. - Mutation checks that fail the tests: removing the bypass, and
<=in place of<at the threshold.
- Unit test
-
Keep render visibility separate from simulation activity: a culled object continues physics unless an explicit simulation-distance policy says otherwise. Culling results live only inside
SceneRenderer: the visible list, the indirect counts, andlast_frame_culled. No simulation code reads them. A search forCullingPath,last_frame_culled, andVisibilityoutside the renderer finds only extraction, the scene file, and the editor. The GPU physics dispatch runs over every body in the physics buffer before the cull pass, and the CPU physics systems do not queryVisibility. No simulation-distance policy exists yet. Evidence: GPU testculled_gpu_bodies_keep_simulating. A GPU body sits at y = 5, above the ±1 view. On each of 4 ticks its draw count is 0 (culled), and itsposition_y < 5rule fires, so gravity keeps moving it. With gravity removed, the test fails. -
Add Render Bounds editing and debug visualization to the Inspector and viewport. The Mesh Renderer section has a “Render Bounds” choice: Mesh (no override), Box (Min/Max), or Sphere (Center/Radius). A new Box or Sphere starts from the mesh box, so the volume does not jump. Edits use the same Inspector snapshot path as every other field, so Undo covers them; choosing Mesh removes the component. With “Selected Bounds” on, the viewport outline of a selected object with an override shows the world-space volume that culling tests: the world box around the transformed corners, or three great circles of the scaled sphere. Without an override it still shows the mesh box. Evidence: unit tests
render_bounds_kinds_start_from_the_mesh_box,render_bounds_outline_uses_the_culled_world_volume(box corners and sphere radius after translation and non-uniform scale), andselected_outline_shows_the_render_bounds_override(12 mesh-box lines become 96 sphere lines of radius 3). fmt and clippy are clean in all three configurations. Tests: 190+49+15, and 226+49+15 withgpu-tests. Not covered by a test: driving the egui combo box through a real frame. Later: a selected override shows a handle on each box face (or at the sphere’s radius on each local axis). Dragging one moves that face along the object’s local axis, stopping at the opposite face, or sets the radius; each drag is one Undo step. “Fit to Mesh” wraps the override around the mesh box again. Evidence: unit testdragging_render_bounds_handles_moves_one_face_in_local_space(a 2x X-scaled box’s +X face dragged 2 m moves local max X by 1, min stays, face clamping and sphere radius). -
Report submitted, visible, and culled instance counts plus culling compute time in the profiler.
SceneRenderer::culling_stats()returnsCullingStats: the path, submitted, visible, and culled counts, and the culling time. TheDirectandCpupaths time the CPU visibility pass. TheGpupath writes two timestamps around the cull dispatch and sums the indirect commands’ instance counts. Both are read back once that frame’s fence signals, without waiting, so the numbers lag a frame or two. The time isNonewhen the queue has no timestamp support. The editor binary stores the stats as a resource each frame. The editor stats line then shows “Culling Gpu | 4 submitted, 3 visible, 1 culled | 0.012 ms”. There is no dedicated profiler panel yet; that is backlog work indocs/editor-overhaul.md. TheGPU_CULL_MIN_INSTANCESandAUTO_DIRECT_MAX_INSTANCESthresholds are still untimed. Tuning them needs these numbers from real hardware. Evidence: GPU testgpu_culling_counts_visible_instances_from_gpu_transforms_into_indirect_drawschecks the read-back counts (4/3/1, then 1 visible and 3 culled) and that the time isSome. It fails whenculling_statsskips the readback, and when the end timestamp goes to the wrong query. The CPU-path counts (2/1/1) and time are checked in the shadow-caster culling GPU test. Unit testculling_stats_label_shows_counts_and_timecovers the label. The validation layer reports 0 errors across the 46scene_renderertests. fmt and clippy are clean in all three configurations. Tests: 191+49+15, and 227+49+15 withgpu-tests. -
Add hierarchical-Z occlusion culling after frustum culling, depth-pyramid generation, and conservative-bound tests are stable.
CullingMode::FrustumAndOcclusionnow selectsCullingPath::GpuOcclusion, a two-phase GPU cull. The early phase draws the opaque instances that passed the frustum and were visible last frame. A compute pass then builds a farthest-depth pyramid from that depth. The late phase tests every frustum-visible instance against the pyramid. It writes this frame’s visibility history and draws only the instances the early phase skipped, in a late render pass that loads HDR and depth. Blended instances skip the history and go through the late phase. The occlusion test projects the corners of each bounding sphere’s box and treats a box that crosses the near plane as visible. It then picks the mip where the rectangle covers at most two texels per axis and compares the nearest box depth with the farthest pyramid depth. The frame schedule gains theDepthPyramid,OcclusionCulling, andLateScenepasses. The culling time sums both cull dispatches. Evidence: GPU testocclusion_culls_hidden_objects_and_draws_revealed_ones_the_same_framehides a cube behind a wall. It checks the early and late draw counts over three frames ([0,2], [2,0], [1,0]) and the stats (2 submitted, 1 visible, 1 culled). After the camera moves past the wall, the cube draws in the late phase of the same frame and the center pixel is red. GPU testdepth_pyramid_mips_hold_the_farthest_depth_below_themreads back every mip of a 7×5 pyramid and compares it with a CPU max reduction, including the odd last row and column. Mutations the tests catch:occluded()always false, the early phase ignoring history, the late phase drawing early instances again, dropping the odd-edge fold, last texel instead of max, and the copy writing cleared depth. The validation layer, with synchronization validation on, reports 0 errors and no hazards across the 48scene_renderertests. fmt and clippy are clean in all three configurations. Tests: 191+49+15, and 229+49+15 withgpu-tests. Known limits: mip 0 of the pyramid is full resolution, a render-world instance rebuild resets the visibility history (one frame of extra late-phase draws), and the stats do not split frustum-culled from occluded counts. -
LOD asset groups and distance/error-based selection.
LodGroupAssetlists mesh levels finest first. Each level has a hand-over value. TheDistancemetric hands over at a world distance. TheScreenSizemetric hands over when the bounding sphere covers less than a fraction of the view height, which tracks projected error across fields of view.load_meshalso loads a.rlodJSON file next to the mesh. Its level paths are relative to that file. A group applies to every renderable whose mesh is its finest level. The renderer expands each such renderable into one instance per level and gives each one a[start, end)range of a LOD value that grows with distance. The CPU cull and the GPU cull shader keep the instance whose range holds the value, so exactly one level draws, and nothing draws past the last level. Scenes with LOD groups skip theDirectpath, andDisabledculling still selects levels. Only the finest level casts shadows. Evidence: GPU testlod_groups_draw_the_level_for_the_camera_distance_on_every_pathmoves an orthographic camera through level 0, level 1, and past the group on theCpu(frustum andDisabled) andGpuOcclusionpaths. It checks the drawn mesh per batch, the center pixel, the stats (2 submitted, 1 visible, 1 culled), and that only level 0 casts shadows. Unit testlod_value_grows_with_distance_like_group_rangeschecks level choice for perspective and orthographic cameras andworld_sphere. Unit testlod_group_beside_a_mesh_loads_with_it_and_rejects_bad_hand_overscovers.rlodloading, deduplication, and the rejection of empty groups and of hand-overs that do not grow. Mutations the tests catch: the shader ignoring the range, the CPU cull ignoring the range, every level casting shadows, and path selection ignoring LOD groups. The validation layer, with synchronization validation on, reports 0 errors across the 50scene_renderertests. fmt and clippy are clean in all three configurations. Tests: 193+49+15, and 232+49+15 withgpu-tests. Known limits: level changes pop with no cross-fade or hysteresis, the shadow pass draws level 0 at every range,.rlodfiles do not hot reload, and glTFMSFT_lodis not imported. Editor authoring is in thedocs/editor-overhaul.mdbacklog. -
Transparent sorting. Already delivered by Milestone 2 “Support alpha opaque, mask, and blend modes”: second pipeline, per-frame back-to-front sort. Evidence: GPU test
alpha_modes_render_opaque_mask_and_sorted_blend(fails with sorting disabled), unit testblended_objects_render_last_unbatched_and_back_to_front. -
Shadow resolution and distance scaling by quality profile.
shadow_settingsmaps the resolved profile to the directional shadow map size and the view distance it covers:Eco1024 texels over 30 units,Balanced2048 over 50, andHigh4096 over 80. The renderer recreates the shadow map and its framebuffer when the size changes. In-flight frames keep the old map alive. The shadow viewport and the light-space box follow the new values. Evidence: GPU testdirectional_light_shadow_darkens_receivers_only_when_enabledmoves the camera 40 units from a shadowed floor. It checks thatEcoleaves the floor lit (outside its distance) and thatBalancedandHighshadow it. It also checks the map size of each profile, including a switch back toEco. Mutations the test catches: a fixed shadow distance, no map recreation, and a fixed viewport size. The validation layer, with synchronization validation on, reports 0 errors across the 50scene_renderertests. fmt and clippy are clean in all three configurations. Tests: 193+49+15, and 232+49+15 withgpu-tests. Known limits: still one cascade with no texel snapping, and the sizes are fixed per profile rather than scaled with the output resolution. -
Optional anisotropic filtering based on device support. Every device path (
init_vulkan,init_vulkan_headless, andrendering::vulkano_configfor the editor and exported games) enablessamplerAnisotropywhen the device has it.vulkano_configprobes the deviceVulkanoContextwill pick first, becauseVulkanoContextpanics on a requested feature the device lacks. Texture samplers with linear minification filter at up to 16x, capped by the device limit; nearest filtering stays unfiltered.RendererCapabilities::sampler_anisotropyreports it. Evidence: unit testanisotropy_needs_the_feature_and_linear_filtering_and_caps_at_16, and GPU testdevices_enable_anisotropy_when_the_driver_supports_it(fails when the headless device does not enable the feature, checked on an RTX 3060). The editor starts without a panic with the feature on. Known limit: textures still have no mip chain, so distant textures alias. -
Optional MSAA based on device support. The scene subpass renders into multisampled HDR and depth targets that resolve into the existing single-sample ones, so tone mapping, editor lines, and the depth pyramid are unchanged.
RendererCapabilities::msaa_samplesis 4 or 2 when the device can multisample both targets and resolve depth (Vulkan 1.2 orVK_KHR_depth_stencil_resolve), else 1. The MSAA passes and pipelines are built once at startup and share the single-sample pipeline layouts; each frame picks them unless the resolved quality isEco, and the multisampled targets are freed when a frame goes back to 1x. Evidence: unit testmsaa_takes_the_largest_shared_count_up_to_four_with_a_depth_resolve, and GPU testmsaa_smooths_edges_except_on_eco(a slanted edge has only two shades onEcoand blended shades onHigh), plus the full GPU suite, all passing on an RTX 3060 and on llvmpipe. The NVIDIA driver lost the device (Xid 69) when the occlusion early pass ended its empty tone mapping subpass with the multisampled pipeline still bound; binding the tone mapping pipeline there fixes it. Known limits: depth resolves from sample 0, so the occlusion pyramid can be slightly non-conservative at silhouettes; 8x is not offered; no per-frame toggle other than the quality profile.
Profiling
- Vulkan timestamp queries per pass.
Every
FramePasswrites a start and end timestamp (queries2pand2p + 1of a per-frame-context pool, reset at the start of the command buffer) through the newPassRecorder, which also does the debug labels. Once the frame’s fence signals,SceneRenderer::gpu_pass_timesreturnsGpuPassTimes, the time of each pass in recording order. The editor stats line shows the total and each pass.CullingStats::timenow comes from the Culling, DepthPyramid, and OcclusionCulling pass times instead of its own timestamp pairs. Evidence: GPU testframes_record_the_declared_passes_with_their_layoutschecks that every recorded pass, including those inside the render pass, has a time and that the total is above zero;gpu_culling_counts_visible_instances_...still checks the cull time. Both pass on an RTX 3060 and llvmpipe, and with the Khronos validation layer. Unit testgpu_pass_times_label_shows_total_then_each_pass. Known limits: times arrive one or two frames late; pass timestamps useAllCommands, so overlap between passes counts toward the earlier one; timestamp wrap-around reads as zero for one frame. - CPU timings for extraction, preparation, command recording, physics, and editor.
CpuFrameTimingsis a resource:App::updatefillsphysics(all FixedUpdate steps) andextraction(RenderExtract);SceneRenderer::write_cpu_timingsfillspreparation(fence wait to command buffer start) andrecording(builder creation to build); the editor fillseditor(egui run and tessellation) and adds its secondextract_render_worldtoextraction. The editor stats line shows all five. Evidence: unit testsupdate_times_physics_and_extractionandcpu_timings_label_shows_each_part; GPU testframes_record_the_declared_passes_with_their_layoutschecks that preparation and recording are above zero on an RTX 3060 and llvmpipe. Known limits:physicscovers every FixedUpdate system, not only physics; the fence wait and the egui paint are not counted. - Counters for draws, dispatches, triangles, visible instances, upload bytes, and GPU memory.
SceneRenderer::render_countersreturns theRenderCountersresource. Draws, dispatches, and direct-draw triangles are counted as commands are recorded; indirect draws add the triangles read back from their GPU-culled commands. Visible instances come fromCullingStats. Upload bytes add up every host-written buffer of the frame: meshes, texture staging, instance and visibility lists, lights, cull commands, physics state, and debug lines. GPU memory sums the device memory blocks in the renderer’s allocator pools. The editor stats line shows all six. Evidence: GPU testframes_record_the_declared_passes_with_their_layoutschecks exact draw, dispatch, and triangle counts plus nonzero upload and memory;gpu_culling_counts_visible_instances_...checks that the triangle count includes the read-back indirect triangles and that 3 instances are visible. Both pass on an RTX 3060 and llvmpipe, and with the Khronos validation layer. Unit testrender_counters_label_shows_each_counter. Known limits: indirect triangles arrive a frame or two late; GPU memory leaves out vulkano’s dedicated allocations (large images) and other allocators such as the egui painter’s. - Named GPU allocations/resources where practical.
name_objectsets aVK_EXT_debug_utilsobject name whenever the instance enables the extension. Named: the scene HDR and depth targets (with “(MSAA)” on the multisampled ones), the shadow map, the depth pyramid, every texture image and mesh vertex/index buffer (by asset path, or#<handle key>for assets built in code), the lights and GPU physics buffers, every render pass and pipeline, and the timestamp query pools. Evidence: headless GPU tests enableext_debug_utilswhenever the driver has it, andname_objecthas adebug_asserton the driver’s result, so all 249 tests (RTX 3060 and llvmpipe) name every object without a rejection. The Khronos validation layer reports nothing on thescene_renderertests. Unit testasset_names_use_the_path_or_the_handle_key. Known limits: suballocator arenas (instance and visibility lists, cull commands, per-frame uploads) stay unnamed. The windowed editor enablesext_debug_utilsonly with validation (debug builds with the layer installed, or--features validation). Names in RenderDoc or Nsight are not checked here.
Exit gate
- Golden images cover primitive normals, PBR reference spheres, texture channels, alpha modes, shadow depth, and tone mapping.
- The renderer remains validation-clean through resize and asset reload.
- GPU timings and memory usage are visible to runtime code and the editor.
Milestone 5: Hybrid physics
Goal: let each body use the processing unit and solver that fits its job while preserving a practical two-way connection between GPU simulation and normal Rust gameplay code.
Determinism constraints, required from the start
Milestone 8 defines the full determinism requirements and owns their acceptance gate. Three of those rules cannot be retrofitted without rewriting the solver, so they apply while this milestone is implemented:
- Never accumulate simulation state through order-dependent atomic operations. Use per-body accumulation slots or fixed-order reductions. Atomics remain acceptable for counters that do not feed simulation results. Audit: the ECS physics shader integrates each body alone and uses atomics only for the event counter and overflow count;
cull.compuses one for the visible list.basic.compsolved grid contacts in atomic cell-slot order, and each contact changesvel/posfor the next, so results changed with the grid build’s thread order. It now visits each cell in ascending body index. Verified:compute_registry::tests::grid_contacts_do_not_depend_on_cell_insertion_orderruns one step with the cell listed forward and reversed and requires bit-identical output; it failed before the fix and passes on llvmpipe and the RTX 3060. Known limit: a cell over 128 bodies still drops an arbitrary set; overflow handling is a later item. Rules recorded inAGENTS.md“Physics determinism”. - Give body iteration and constraint ordering a stable sort key that survives buffer compaction, re-sorting, and insertion order.
basic.comporders contacts by body index (see above);space.compalready loops bodies in index order. GPU events reach Rust in atomic append order, soroute_gpu_physics_eventsnow sorts them by tick,PhysicsIdslot and generation, event ID and flags; the slot is the stable key that survives buffer rebuilds. Verified:runtime::tests::gpu_events_are_delivered_in_tick_and_body_order. - Source any randomness inside a solver from a seeded, tick-indexed stream. Never read a global or thread-local RNG from simulation code. Audit: no solver, physics shader or runtime physics code uses randomness today (
grepforrand,random,thread_rngfinds no use; theranddependency inCargo.tomlis declared but unused). The rule is recorded inAGENTS.mdso the first solver that needs randomness follows it.
Ownership and public model
- Replace the old effect-only distinction with
SimulationClass::{Static, Cpu, Gpu}. The selected class says where the newest runtime physics state lives.Gameplayis nowCpuandGpuDynamicis nowGpu;Nonestays for bodies no solver touches. Old scene files load through#[serde(alias)], and old Rust code compiles through deprecated associated constants. TheGpuEffectBodymarker is removed: the editor and the game runner inserted it exactly whenuses_gpu()was true, and nothing read it. The Inspector names the classes “CPU” and “GPU”. Verified:scene_file::tests::scenes_saved_with_old_simulation_names_still_loadand the existing physics routing, extraction and scene round-trip tests. - Add
PhysicsSyncMode::{None, Events, SelectedState, FullState}. Synchronization is an explicit cost chosen per body or group, not a hidden full-scene copy. Evidence: per-bodyPhysicsSyncModecomponent (defaultEvents, saved asrusting.physics_sync);Nonedrops watch rules at extraction, onlySelectedState/FullStatebodies get a readback copy.runtime::tests::sync_mode_controls_rules_and_state_mirrorand GPU testscene_renderer::tests::only_state_synchronized_bodies_read_back_their_state(llvmpipe and RTX 3060). Group-level selection is left to classes/prefabs setting the component. - Give GPU-simulated bodies a stable, generation-checked
PhysicsIdthat is valid in ECS, GPU buffers, and events even after bodies are removed or buffers are sorted. Extend the same ID to the command bridge when commands are added. - Keep authored settings and identity in ECS while recording when mirrored transform/velocity data was produced and how many frames old it is. Evidence:
apply_gpu_state_sampleswrites aGpuStateMirror { tick, transform, velocities, custom_values }and never touches the authoredTransform;GpuStateMirror::age_ticksgives the age; stale generations are counted and older samples never rewind the mirror (sync_mode_controls_rules_and_state_mirror). - Allow CPU and GPU bodies in the same scene and allow static colliders to be consumed by both solvers. Evidence: the CPU solver skips GPU bodies (
runtime::tests::cpu_physics_is_deterministic_and_ignores_gpu_bodies) and usesStaticcolliders. Extraction sends every non-sensor CPU/static collider (PhysicsWorld::gpu_colliders) and each GPU body’s own shape to the native shader, which pushes GPU bodies out of them with friction, restitution and collision layers (runtime::tests::gpu_bodies_extract_their_collider_and_see_cpu_colliders, GPU testscene_renderer::tests::gpu_bodies_rest_on_cpu_colliders_unless_filtered_or_no_collision, llvmpipe and RTX 3060). Limit: GPU bodies do not collide with each other yet. - Show simulation owner, synchronization mode, readback age, and approximate synchronization cost in the editor. Evidence: the Inspector Physics section shows the owner (
Simulation: CPU/GPU/Static), and for GPU bodies aSyncchoice (undoable, stored asrusting.physics_sync),Sync Cost(bytes per tick fromPhysicsSyncMode::STATE_READBACK_BYTES, compile-time checked against the GPU body layout), andLast Readbacktick and age fromGpuStateMirror.editor::tests::inspector_shows_gpu_sync_mode_and_readback_age. The editor preview does not simulate yet, so the age updates only where the mirror is applied (native Play).
Programmable GPU condition and event bridge
- Define a compact
GpuPhysicsEventlayout shared by Rust and every physics shader: body ID, registered event ID, tick, flags, and a configurable small payload. - Provide a typed Rust condition builder for common GPU state fields, comparisons, boolean combinations, ranges, collision state, sleeping state, timers, and per-body custom values. This is a Rust API, not a separate scripting language.
- Define event modes such as
OnEnter,OnExit,WhileTrue,Once, and cooldown/rate-limited emission so conditions do not accidentally flood the readback buffer. - Upload built-in per-body condition instructions and rule parameters as compact GPU buffers. CPU-only custom-value uploads remain part of the command bridge.
- Allow shared rules to target explicit multi-class object groups while keeping separate edge and cooldown state per body. Unrelated GPU bodies are not affected, and only matching body IDs and selected payloads return to Rust.
- Add a custom condition-compute hook for arbitrary GLSL logic over GPU-accessible state. Custom physics and condition shaders use the same
emit_event(...)ABI as built-in rules. Evidence:GpuConditionShadersholdsGpuConditionShader { events, glsl }; the GLSL definescondition(inout PhysicsState body)and runs over every GPU body after each fixed step. It includes the samesrc/shaders/physics_abi.glslas the built-in shader (body layout, push constants,emit_event(body, event_id, payload_kind, payload)), andEVENTS[i]holds the registered IDs, so its events arrive as ordinaryGpuPhysicsEvents. The renderer compiles sources at runtime with the systemglslc(no new crate;RUSTING_GLSLCoverrides the path), caches them by source, and skips a shader that fails to compile, keeping the message inSceneRenderer::condition_shader_errors. GPU testscene_renderer::tests::custom_condition_shaders_emit_events_and_skip_broken_sources(llvmpipe and RTX 3060);runtime::tests::condition_shaders_reach_the_render_world_with_registered_events. Needsglslcinstalled at runtime; precompiled SPIR-V input is not supported yet. - Allow events to return selected position, velocity, angular velocity, or custom-value payloads so a second state readback is often unnecessary. Add collision/contact payloads with the spatial solver.
- Treat
Y < -100only as the first end-to-end acceptance example. The implementation must not hard-code an axis, threshold, or event meaning. Evidence:-100appears only in tests and examples; the shader reads anyGpuStateFieldby code and compares with the uploaded threshold. GPU testscene_renderer::tests::conditions_use_any_field_and_see_custom_value_commandsfires anOnEnterrule onPositionX > 10 || Custom(2) >= 3, once from the start pose and once after aSetCustomValuescommand (llvmpipe and RTX 3060). - Compare previous and current condition results so edge-triggered events are emitted exactly when a condition changes state.
- Let the native physics compute shader append events with an atomic counter into a per-frame event buffer.
- Keep event output in per-frame mapped readback allocations so only emitted records cross into Rust; move the write target fully device-local if profiling shows mapped writes are costly on discrete GPUs.
- Consume completed readback buffers asynchronously after their frame fence signals. Normal frames never wait for unfinished physics work.
- Convert
PhysicsIdvalues back into live ECS entities and expose events to Rust systems through the engine event API. - Define event latency clearly: GPU events normally reach CPU gameplay one to three frames later. Logic requiring same-tick answers must use CPU simulation or an explicit blocking query. Evidence:
runtime::hybrid_physicsmodule docs, section “Latency” (events andGpuStateMirrorcarry the tick they describe; same-tick logic usesSimulationClass::Cpu). The blocking query is the open “explicit blocking readback API” item below. - Track event-buffer overflow, resize it within the configured memory budget, and provide an overflow fallback. Events must never disappear silently. Evidence: the per-frame event buffer is sized rules × fixed steps in the frame (each rule fires at most once per tick), so it cannot overflow below the
MAX_PHYSICS_EVENTSbudget (12 MiB). Past the budget, lost events are counted inRenderCapacityDiagnostics::physics_events_dropped, logged, and sent to gameplay asGpuPhysicsEventsLostso it can resynchronize from body state. GPU testscene_renderer::tests::event_buffer_fits_every_step_and_reports_losses_past_budget(three ticks in one frame deliver all 300 events; with a 60-event budget, 40 are reported lost). The budget is a constant, not derived from device memory yet.
CPU to GPU command bridge
- Add a compact command stream for spawn, despawn, teleport, velocity change, force, impulse, wake, solver change, and watch-condition updates. Evidence:
GpuPhysicsCommands::push(PhysicsId, GpuBodyCommand)withTeleport,SetVelocity,Impulse,Force(one tick), andSetCustomValues, 80 bytes each. Spawn, despawn, solver, and watch changes go through the ECS components: extraction rebuilds the tables andcarry_regionskeeps surviving bodies’ simulated state, so they need no command. Wake is left out until bodies can sleep. GPU testscene_renderer::tests::commands_apply_once_before_the_step_and_reject_stale_bodies(llvmpipe and RTX 3060) andruntime::tests::gpu_commands_reach_the_render_world_once_per_batch. - Upload commands in batches through each frame context instead of mapping or rewriting the complete physics buffer. Evidence: one batch per frame in the frame context’s transient allocator, bound as binding 5 of the physics shader; the state buffer is never rewritten for a command. GPU test
scene_renderer::tests::commands_apply_once_before_the_step_and_reject_stale_bodies(llvmpipe and RTX 3060) andruntime::tests::gpu_commands_reach_the_render_world_once_per_batch. - Apply commands before the fixed GPU step and reject commands whose body generation is stale. Evidence: the shader applies each body’s commands (binary search in the index-sorted batch, submission order kept) before integrating on the frame’s first step only; stale or unknown bodies are rejected on the CPU and counted in
RenderCapacityDiagnostics::physics_commands_rejected, and the shader rechecks the generation. Commands wait while no fixed step runs and a batch is applied once. GPU testscene_renderer::tests::commands_apply_once_before_the_step_and_reject_stale_bodies(llvmpipe and RTX 3060) andruntime::tests::gpu_commands_reach_the_render_world_once_per_batch. - Support CPU-controlled kinematic bodies that collide with GPU bodies without transferring every GPU body to the CPU. Evidence: kinematic CPU colliders are uploaded with their velocity each frame, and the GPU contact uses the relative velocity, so a moving wall carries GPU bodies without any GPU state coming back. GPU test
scene_renderer::tests::kinematic_cpu_colliders_push_gpu_bodies(llvmpipe and RTX 3060). Limit: one-way; GPU bodies do not push CPU bodies back. - Record command count and uploaded bytes for profiling. Evidence:
RenderCounters::physics_commandsandphysics_command_bytes, asserted in GPU testscene_renderer::tests::commands_apply_once_before_the_step_and_reject_stale_bodies(llvmpipe.
Selective state synchronization
- Support asynchronous requests for selected transforms, velocities, sleeping state, or contact data by stable body ID. Evidence:
GpuPhysicsCommands::push(id, GpuBodyCommand::ReadState)reads that body’s full state back once after the next step, arriving as aGpuStateMirrorlike per-tick sync; stale IDs are rejected. Sleeping and contact data do not exist on the GPU path yet. GPU testscene_renderer::tests::one_shot_state_reads_return_requested_bodies_once(llvmpipe and RTX 3060). - Provide batched region/group snapshots for gameplay systems that need more than events. Evidence:
request_gpu_class_snapshot(world, class)queues oneReadStateper GPU body in an object class; the whole group reads back in one frame and arrives asGpuStateMirrors with the same tick.runtime::tests::class_snapshots_request_one_read_per_member; delivery is covered by GPU testscene_renderer::tests::one_shot_state_reads_return_requested_bodies_once. Regions are selected on the GPU instead: aWhileTruerule withinsideposition conditions and a position payload, or aGpuConditionShader, returns only the bodies inside. - Keep full-state readback available for debugging, save-state capture, editor inspection, and tests, but keep it off the normal gameplay path. Evidence:
GpuPhysicsCommands::read_all_statesis a one-shot flag (default off) that copies every body once; the per-tick path copies onlySelectedState/FullStatebodies. GPU testscene_renderer::tests::one_shot_state_reads_return_requested_bodies_once(llvmpipe and RTX 3060). - Triple-buffer readback storage with frame contexts so GPU writes, transfer copies, and CPU reads never race. Evidence: event and state readback slices come from the per-frame-context transient allocator and are read only after that context’s fence signals (
collect_physics_readbacks);only_state_synchronized_bodies_read_back_their_stateruns2 * FRAMES_IN_FLIGHTticks and checks every sample is from its own tick. - Add an explicit blocking readback API only for tooling and exceptional cases, with a name and warning that make its performance cost obvious. Evidence:
SceneRenderer::block_until_physics_readbacks_complete, documented as slow and not for gameplay; GPU testscene_renderer::tests::one_shot_state_reads_return_requested_bodies_once(llvmpipe and RTX 3060) uses it instead of waiting on the frame. - Support play/stop snapshots without requiring continuous full-state synchronization. Evidence: the authored ECS scene is the play snapshot, since GPU state only reaches
GpuStateMirrorand never the authoredTransform. Stop setsGpuPhysicsCommands::reset_to_authored, which restarts every GPU body from its authored components with rule state cleared, and reads nothing back. A mid-play state captured once withread_all_statesresumes throughGpuPhysicsCommands::restorein the same batch as the reset. GPU testscene_renderer::tests::reset_restarts_from_authored_state_and_restore_resumes_a_snapshot(llvmpipe and RTX 3060);runtime::tests::gpu_commands_reach_the_render_world_once_per_batch.
Self-written CPU physics and queries
- Add an engine-owned CPU
PhysicsWorldfor bodies that require immediate gameplay answers. Evidence:runtime::cpu_physicsruns in everyApp’sFixedUpdateforSimulationClass::Cpubodies, withStaticbodies as colliders. It integrates gravity and velocity, resolves contacts with 8 sequential-impulse iterations (restitution, friction, position correction), writesTransformandRigidBody::linear_velocityback on the same tick, and keeps aPhysicsWorldresource withcontacts(),raycast, andoverlap_sphere.CollisionEventnow comes from real contacts instead of bounding spheres. GPU bodies are ignored. Tests:runtime::tests::cpu_boxes_fall_and_rest_in_a_stack_on_static_ground,cpu_restitution_bounces_and_sensors_only_report,cpu_physics_is_deterministic_and_ignores_gpu_bodies, and the pair and ray tests inruntime::cpu_physics::tests. Limits: contacts apply no torque yet and child bodies are treated as fixed colliders. The pair search is now a sort-and-sweep broad phase (see the GPU solvers section). - Support fixed, dynamic, and kinematic rigid bodies plus boxes, spheres, capsules, convex meshes, and static triangle meshes. Evidence: fixed, dynamic, and kinematic bodies with box (oriented, SAT), sphere, and capsule colliders work (see
PhysicsWorldabove).ColliderShape::ConvexMeshuses the convex hull of the entity’sMeshRenderermesh andColliderShape::TriangleMeshits triangles (static only; a body with it never moves), both scaled by the transform and cached per mesh revision. Pairs with a mesh collider use GJK on the shape cores plus EPA for overlaps; rays hit hull faces from outside and triangle meshes from either side; resting mesh contacts get up to four points. The Inspector offers both shapes. Tests:runtime::cpu_physics::tests::mesh_colliders_match_primitives_and_report_normals_from_a_to_b,rays_hit_hulls_from_outside_and_triangle_meshes_from_either_side, andruntime::tests::cpu_convex_mesh_lands_flat_on_a_triangle_mesh_floor. Limits: each triangle of a mesh is tested (no BVH yet), GPU bodies see a mesh collider as its bounding box, andshape_cast/move_charactertake primitive shapes only. - Add triggers, collision layers, raycasts, shape casts, overlap queries, character movement, and the joints required by the vertical slice. Progress: triggers (
Collider::sensorreports without a response),PhysicsWorld::raycast, andPhysicsWorld::overlap_sphereare done.CollisionLayersfilter contacts and query masks (runtime::tests::cpu_collision_layers_filter_pairs_and_queries).PhysicsWorld::shape_castsweeps an unrotated box, sphere or capsule and stops just before the first solid collider, andPhysicsWorld::move_charactermoves a character shape with collide-and-slide and reportsgrounded(runtime::tests::cpu_shape_casts_stop_before_colliders_and_characters_slide). Joints: the Milestone 7 vertical slice lists none, so none are required here; the full joint set is its own later item (“Joints: fixed, hinge, slider, …”). - Add sleeping, continuous collision detection, stable contact generation, and iterative solving. Progress: iterative solving is done (sequential impulses with accumulated clamping; a three-box stack rests within 3 cm). Sleeping is done: still bodies get
SleepingafterSLEEP_STEPSand wake on touch or edit (runtime::tests::cpu_bodies_sleep_when_still_and_wake_on_touch_or_edit). CCD for fast bodies uses a center-ray sweep (runtime::tests::fast_cpu_bodies_do_not_tunnel_through_thin_walls, which fails without the sweep). Contact manifolds are done: two boxes touching face to face get up to four points (incident face clipped to the reference face), and the solver applies impulses with angular response, per-point Coulomb friction, and warm starting from the last step (runtime::tests::cpu_tilted_box_tips_over_onto_a_face,runtime::tests::cpu_sphere_sliding_on_the_ground_starts_rolling; the stack test now also checks tilt below 0.01 rad). - Allow selected GPU events to create, update, or remove CPU proxy bodies when gameplay needs an approximate local query representation. Evidence:
GpuQueryProxyon a GPU body names aplace_onevent (with aPositionpayload) and an optionalremove_onevent;sync_gpu_query_proxieskeeps oneStaticproxy entity markedGpuProxyOf, removes it with the body or the component, and GPU bodies skip proxies. Testgpu_events_place_move_and_remove_a_cpu_query_proxyroutes raw events and raycasts the proxy after it is placed, moved and removed. Limit: the proxy pose is as old as the event.
GPU solvers and custom allocation
- Connect per-object
ComputeShaderTypeselection to the ECS/editor game runner instead of only the compatibilityEnginepath. Evidence: the ECS path selects per body throughPhysicsBody::solver(the Inspector’s GPU Solver), which the shared native shader reads fromcustom_values.x: Full, Simplified, NoCollision (skips collider contacts) and Space (point attractor).Customnow works too: the body’s hook file definesvoid solve(inout PhysicsState body), extraction reads each distinct file once (RenderWorld::gpu_solver_shaders), and the renderer compiles it like a condition shader that runs only for bodies whoseproperties.wholds that file’scustom_solver_id; the built-in step leaves their motion alone. The Inspector’s Open in Code Editor starts a missing file from a template. GPU testscene_renderer::tests::custom_solvers_move_only_their_own_bodies(llvmpipe and RTX 3060) andruntime::tests::custom_solver_files_reach_the_render_world_once_per_path. Limits: Simplified behaves like Full, and solver files reload only when GPU bodies are re-extracted. - Preserve mixed
Static,NoCollision, simplified, full, spatial-grid, and custom compute batches in one scene. A scene-wide override remains a debugging tool only. Evidence: in the ECS path Static colliders and NoCollision, Full, Simplified, Space and Custom GPU bodies mix in one scene (gpu_bodies_rest_on_cpu_colliders_unless_filtered_or_no_collision,custom_solvers_move_only_their_own_bodies), and Full, Simplified and Space bodies now also collide with each other through a spatial-hash grid (src/shaders/compute/physics_contacts.comp) while NoCollision and Custom bodies skip it. GPU testscene_renderer::tests::gpu_bodies_collide_with_each_other_through_grid_and_fallback(llvmpipe). - Rename the stable form of
ComputeShaderType::Testto describe its actual solver while keeping a temporary compatibility alias. Evidence: the variant is nowComputeShaderType::GridCollision(the spatial-hash collision solver inbasic.comp);ComputeShaderType::Testremains as a deprecated associated constant that still works in expressions and patterns (compute_registry::tests::old_test_name_still_selects_the_grid_solver). Examples use the new name. - Add a true spatial broad phase to every built-in collision solver. Broad phases prune separated bodies, but dense overlapping scenes can still require quadratic contact work.
- Make the CPU
PhysicsWorldsweep the axis with the greatest center spread and reject pairs separated on either other axis before the exact shape test. Pairs return in stable body order. Evidence:broad_phase_finds_the_same_pairs_as_testing_every_pairmatches brute force on 200 scattered bodies;broad_phase_prunes_a_tall_column_without_changing_pair_orderreduces a 200-body X-overlapping column to its one actual candidate pair. - GPU body-to-body contacts use a spatial hash with a cell size derived from body sizes (
contact_grid_cell_size), checking 27 neighbouring cells plus the fallback list. The legacy shader path is no longer compiled. - Give the native GPU shader a spatial broad phase for CPU/static colliders. The renderer builds a stackless bounding-volume tree from each frame’s collider snapshot, in stable collider order, and uploads it beside the colliders. The shader traverses from each GPU body’s current pose, skipping separated subtrees; after a contact changes the pose it restarts at the root after the last processed collider, preserving the old list-order semantics. A CPU test checks 128 separated colliders yield one candidate and fewer than 20 visited nodes; the headless GPU tests
gpu_bodies_rest_on_cpu_colliders_unless_filtered_or_no_collisionandgpu_collider_tree_rechecks_later_branches_after_a_pushcheck late collider discovery, layers/solver filtering, and correction into a previously separated branch. TheColliderNodeupload has a shader-reflected layout assertion. The tree is rebuilt each physics frame so kinematic colliders move with their CPU poses.
- Make the CPU
- Replace fixed hash capacities with device-budgeted growable buffers. The active
PhysicsContactGridis device-local, sized from the body count (two hash cells per body), and grows by doubling. Its hash is capped at a sixteenth of the largest device-local heap (contact_grid_hash_stays_inside_its_memory_budget); bodies that do not fit spill into a fallback list that preserves all bodies. GPU testgpu_bodies_collide_with_each_other_through_grid_and_fallbackreruns with a zero budget (one hash cell) and gets bit-identical results, with overflow reported. The fixed-capacity legacybasic.comppath was removed from compilation in Milestone 1. Verified with default and serialgpu-testsworkspace suites. - Track grid-cell overflow, hash collisions, oversized-body count, and total fallback work. Evidence: the contact passes add them to
contactsin the event header, andRenderCapacityDiagnosticsreportsphysics_grid_overflow,physics_oversized_bodies,physics_grid_hash_collisionsandphysics_fallback_testsfor the latest completed physics frame.gpu_bodies_collide_with_each_other_through_grid_and_fallbackasserts overflow, oversized and fallback counts;hybrid_physics_gpu_layouts_match_shader_structschecks the header layout. Limit: the legacyGridCollisionpath is not tracked. - Implement a tested overflow fallback that preserves every body. Evidence: in the ECS contact grid, a body whose cell is full or which is bigger than one cell goes to a fallback list that every body tests, and oversized bodies test every body, so no contact is skipped.
gpu_bodies_collide_with_each_other_through_grid_and_fallbackcrowds twelve overlapping pebbles into one eight-slot cell and puts a small sphere on an oversized one: every pebble separates, the small sphere rests on top, and a second run matches bit for bit. Limit: the legacyGridCollisionshader still drops bodies pastMAX_PER_CELL. - Define a versioned custom-compute ABI for instance state, commands, condition inputs, custom values, and event output. Evidence:
src/shaders/physics_abi.glslis included by the built-in shader and every custom condition shader. It holds thePhysicsStatelayout (withcustom_values), theBodyCommandlayout andCOMMAND_*kinds, thePhysicsEventlayout, bindings 0/3/4, the push constants, andemit_event, and it definesRUSTING_PHYSICS_ABI_VERSIONso a shader can#erroron a mismatch. Rust exposes the same number asruntime::GPU_PHYSICS_ABI_VERSION. GPU testscene_renderer::tests::hybrid_physics_gpu_layouts_match_shader_structschecks every Rust mirror against the reflected shader structs and the version against the GLSL define. Built-in condition instructions and rule state stay private to the built-in shader; custom shaders receive their inputs ascustom_valuesand push constants. - Let custom shaders emit the same typed events as built-in solvers so Rust gameplay can react without downloading complete buffers. Evidence: custom condition shaders call the shared
emit_eventwith IDs fromEVENTS[i], registered by name inGpuEventRegistry, and the events arrive as ordinaryGpuPhysicsEvents. GPU testscene_renderer::tests::custom_condition_shaders_emit_events_and_skip_broken_sources(llvmpipe and RTX 3060).
Profiling and automatic allocation
- Measure CPU physics time, GPU physics time, dispatch count, command bytes, event bytes, selected-state bytes, synchronization latency, and overflow counts. Evidence: CPU physics time is
CpuFrameTimings::physicsand GPU physics time theFramePass::Physicstimestamp pair;RenderCountersaddsphysics_dispatches,physics_commands/physics_command_bytes,physics_event_bytes,physics_state_bytesandphysics_readback_latency_frames, andRenderCapacityDiagnosticsholds event loss and contact-grid overflow, oversized, hash-collision and fallback counts. The editor’s Stats panel shows them on a “GPU Physics” line (editor::view::gizmo_tests::physics_counters_label_shows_traffic_and_fallbacks);gpu_bodies_collide_with_each_other_through_grid_and_fallbackchecks the dispatch, event-byte and latency counters on a real frame. Limit: latency is counted in rendered frames, not milliseconds. - Add repeatable 1K, 10K, and 100K body benchmark scenes covering falling, stacking, debris, and mixed solvers. Evidence:
runtime::PhysicsBenchmark(Falling,Stacking,Debris,Mixed) builds any body count as a pure function with no RNG;runtime::physics_benchmark::testschecks 1K/10K/100K counts, identical output across calls, no starting overlaps, and the Mixed solver interleave.cargo run --release --example physics_bench -- <scene> <count>runs one in the game window and prints ms/frame. GPU testscene_renderer::tests::benchmark_scenes_run_on_the_gpu_and_repeat_exactly(1K bodies, 90 ticks, headless Vulkan) requires colliding bodies to stay on the ground, ten-cube towers to stand, and Debris and Mixed to repeat bit for bit. It found two bugs, both fixed: towers sank through each other (the contact pass now uses shock propagation: a supported body below counts as immovable), and crowded cells broke determinism (contact sums are now fixed-point integers, so atomic list order cannot change them). Limit: 10K/100K frame times are real-hardware numbers and are not recorded here. - Add an optional
Autoallocation policy that uses body requirements, query needs, hardware capabilities, transfer cost, and measured timings. Evidence: theruntime::AutoSimulationcomponent (scene keyrusting.auto_simulation) opts a body in;allocate_auto_simulationruns in PostUpdate before GPU IDs are assigned. Requirements come first: non-dynamic bodies, no GPU backend, custom solvers (GPU), kinematic bodies, sensor and mesh colliders, and sync modes that read state back every tick (query needs and transfer cost) all force a class. Flexible bodies go to the GPU when there are at leastAutoAllocationPolicy::gpu_min_bodiesof them or when the lastCpuFrameTimings::physicsexceededcpu_physics_budget, else to the CPU. Testruntime::tests::auto_simulation_picks_cpu_or_gpu_and_stays_overridablecovers every reason. Limit: decisions are sticky; a decided body never migrates, and transfer cost is judged by sync mode, not by measured bytes. - Keep manual CPU/GPU and solver selection available; automatic allocation must be observable and overridable. Evidence: bodies without
AutoSimulationkeep their manualPhysicsBody::simulationand solver; the decision and itsAllocationReasonare public on the component, and clearing it makes the body decide again (runtime test above). The Inspector’s Physics section has an “Auto CPU/GPU” checkbox (undoable component add/remove) that shows the decision in place of the manual choice (editor::tests::inspector_shows_the_auto_simulation_decision).
Exit gate
- A scene with at least 10,000 GPU-simulated cubes can evaluate a user-configured condition and emit a Rust event when any cube crosses
Y = -100without copying all cube transforms or blocking the frame loop. Replacing that rule with another supported or custom condition does not require engine changes. - Tests cover triggers, raycasts, stacking, tunneling, collision layers, fixed-step independence, stale IDs, and CPU/GPU event delivery.
- GPU event and grid overflow are observable and their fallbacks never silently omit bodies or events.
- CPU/GPU commands and events remain correct with multiple frames in flight.
- Immediate gameplay queries never pretend that delayed GPU mirrors are current; state age is available to callers.
- Per-body ownership, solver, synchronization mode, traffic, latency, and overflow are visible in runtime diagnostics and the editor.
Milestone 6: Integrated egui editor
Goal: allow scenes to be built, inspected, saved, and played without leaving the engine.
The editor should use egui and egui-winit. Rendering should go through an engine-owned Vulkan painter/integration layer so renderer compatibility and resource lifetime remain under project control.
Editor shell
- Add a startup Project Manager with create, open, folder selection, validation, and recent projects.
- Create complete standalone Cargo game templates without overwriting existing folders.
- Store project format versions and reject projects made by a newer editor.
- Add a feature-gated editor plugin that can be excluded from runtime builds.
- Build the initial egui shell with toolbar, hierarchy, transform inspector, viewport placeholder, and structured console.
- Connect play, pause, single-step, and stop controls to runtime time control.
- Show typed asset inventory, mesh/material handles, and live render-extraction dirty ranges in editor panels.
- Add a runnable Vulkan editor window with winit input, resize handling, DPI-aware egui rendering, and presentation.
- Render extracted ECS meshes as a depth-tested Vulkan scene beneath the editor UI.
- Add revision-aware GPU mesh preparation so asset mutation invalidates cached buffers without changing handles.
- Drive the scene viewport and camera aspect ratio from the DPI-aware central-panel pixel rectangle.
- Expose camera FOV, clipping planes, priority, and active state in the inspector.
- Add Scene, Game, and Code workspaces with editor/game camera selection.
- Add a project-local Rust/GLSL editor with open, validation, and save actions.
- Run Cargo checks and Debug/Release builds in a worker and show compiler output inside Code Editor.
- Export a native release folder with the executable, cooked scene, project assets, license, and run instructions.
- Store portable scene-relative asset paths and resolve packaged scene data beside the executable.
- Add a separate native Rust Cargo game project, project-local source editing, and Debug/Release build/run from the editor.
- Add a concise native Rust scene API for common transform operations without hiding the ECS from advanced games.
- Add a dockable area-tree layout with selectable editor types and project-local persistence.
- Replace the bootstrap Vulkan egui integration with an engine-owned texture/mesh upload path and render pass. Evidence: see Milestone 4 “Engine-owned egui compositing pass” (
EguiPainterGPU tests, editor smoke run). - Add a central editor-shortcut action map;
Numpad 0toggles Scene View fly-camera pointer capture while Escape remains a normal UI key. - Add a Settings panel for rebinding and persisting shortcuts, then route every editor command through the same action map. Evidence: the “Keyboard Shortcuts” area type lists every action by context (Editor, Scene View, Transform, Fly Camera); clicking a key waits for the next key press, Escape cancels, and a key taken from another action in the same context leaves that action unbound. The map is saved to
editor_shortcuts.jsonin the user config folder and loaded over the defaults at startup. Undo, Redo, Save scene, Delete selection and Rename are nowEditorActions (Ctrl+Z, Ctrl+Shift+Z, Ctrl+S, Delete, F2) that the window-event handler queues and the view runs like the menu entries; the Hierarchy’s hard-coded Delete/X/F2 keys are gone. Tests:editor::shortcuts::tests::{editor_actions_need_their_exact_modifiers, captured_key_binds_the_waiting_action_and_escape_cancels, saved_shortcuts_load_over_the_defaults},editor::tests::{queued_shortcuts_delete_and_undo_like_the_menu, shortcuts_area_lists_every_action_with_its_key}; the editor starts cleanly. Each action can also take a second key (right-click removes it); Redo also answers Ctrl+Y. A file saved before second keys existed keeps the default ones (testsecond_keys_trigger_actions_and_move_between_actions). Limits: at most two keys per action, and X does not delete by default because it picks the X axis in the Scene View; widget-local keys (Enter/Escape in the rename field, Escape to cancel a gizmo drag) stay fixed. Follow-ups are indocs/editor-overhaul.md. - Route keyboard and mouse focus correctly between all viewport navigation modes and UI. Evidence: the Scene View takes pointer events only when egui’s topmost layer under the cursor is the viewport (
EditorViewport::hovered, testeditor::shortcuts::tests::covered_viewport_does_not_take_pointer_events), and keys only when no text field wants them. Losing window focus ends fly and orbit navigation (release_editor_navigation). Pointer capture falls back fromLockedtoConfinedon Windows and X11. While fly or orbit navigation is active, the editor now keeps presses, pointer motion, the wheel and text away from egui, so they cannot click, scroll or type into UI under the hidden cursor; releases still pass, so egui never keeps a button held (ui_receives_during_navigation, testnavigation_keeps_presses_and_motion_away_from_the_ui). The view also drops text-field focus while navigating. Editor commands run only when no text field has focus and no navigation or transform is active. Limit: the grab fallback and focus loss need a real window to verify by hand. - Add DPI scaling, font configuration, and theme persistence.
Evidence: OS display scale reaches egui through egui-winit (initial
scale_factorplusScaleFactorChanged);EditorPreferencesgainedfont_scalebesideui_scale, View menu has TEXT SIZE choices, and both persist ineditor_preferences.jsonwithout dropping each other (editor_preferences_round_trip_and_fall_back_to_defaults,font_scale_multiplies_every_default_text_size). Limits: one built-in palette, so there is no theme choice to persist yet; widgets that build their ownFontIdkeep a fixed size.
Core panels
- Add project asset file import, filtering, typed texture loading, glTF primitive import, and selected-object assignment.
- Persist imported glTF geometry as reloadable engine-native
.rmeshassets. - Scene viewport rendered to an editor texture.
Evidence: the editor renders the live 3D view into an offscreen image
sized to the view (
scene_view_targetinsrc/bin/editor.rs) and egui draws it asSCENE_VIEW_TEXTUREthroughEguiPainter::set_native_texture(native_textures_show_an_image_rendered_elsewhere, gpu-tests;scene_area_draws_the_offscreen_view_image_over_its_viewport). Editor smoke run is clean. Limits: one live view as before; with no 3D area the scene still renders into the window under the panels, so GPU physics keeps running. - Entity hierarchy with filtering, selection, reparenting, and drag/drop.
Evidence: search keeps matches and their ancestors
(
search_keeps_matches_and_their_ancestors_only), Ctrl/Shift multi-select with a primary object (clicks_select_toggle_and_extend_with_a_primary_object), drag/drop rejects cycles (drag_parenting_rejects_self_and_descendant_cycles), and dragging a selected row reparents the whole selection in one undoable scene edit that keeps world positions and nested children (reparenting_a_selection_keeps_world_positions_and_nested_children). Limits: no sibling reordering; after a reparent only the first moved object stays selected. - Component inspector driven by an allowlisted reflection/editor registry.
Evidence: only components in the
SceneComponentRegistryallowlist reach the Inspector;InspectorRegistry::register::<T>(name, draw)gives one a typed section, and the rest are edited as JSON fields (serde is the reflection). Typed edits go through the registry and the Inspector undo snapshot (registered_inspectors_draw_and_edit_their_component). Limits: built-in components (Transform, lights, physics) keep their hand-written sections instead of registry entries. Reflected fields show theirdochint under the label in the label’s tooltip, as Godot shows property descriptions. Evidence:editor::inspector::widgets::tests::a_described_row_shows_its_doc_in_the_label_tooltip. A field drawn as a foldable group (a nested struct, list or map) shows its doc in the group header’s tooltip. Evidence:editor::inspector::reflected::tests::a_described_section_shows_its_doc_on_the_header. A string-keyed map field adds an entry from a key field and removes one with its button; an empty or existing key adds nothing. Evidence:editor::inspector::reflected::tests::map_entries_can_be_added_by_key_and_removed. - Asset browser with folders, thumbnails, filtering, and drag/drop assignment.
Evidence: the Assets area is a foldable FileSystem tree with a filter
(
rows_put_folders_first_hide_caches_and_fold). Image rows show a decoded preview, two new decodes per frame, with a larger one on hover (image_rows_decode_a_few_thumbnails_per_frame). Models drag into the Scene View; images drag onto a Hierarchy object (base color) or an Inspector texture slot, as one Undo step each (dropping_an_image_on_hierarchy_rows_and_texture_slots_assigns_it). Limits: list view only (no grid), previews decode on the UI thread and do not refresh when the file changes, and dropped models land at the origin. - Console with structured logs, filtering, warnings, and asset/validation errors.
Evidence:
ConsoleEntrycarries a level, a source (“Assets”, “Build”, “Scene”, “Code”) and a repeat count; the Console area has Info/Warnings/ Errors toggles with counts, a text filter, and Clear. Asset request results, hot reload failures, and scene load/save/validation status lines are logged (console_groups_repeats_filters_and_records_status_lines). Limits: status lines get their level from keywords, the log keeps the last 2000 entries, and engine code outside the editor has no log sink yet. - Profiler with CPU spans, GPU pass timings, counters, and memory usage. The dockable Profiler area shows a 180-render-frame CPU/GPU history graph, five measured CPU spans, every available completed GPU pass timestamp, draw/dispatch/triangle and culling counts, upload bytes, GPU allocator-pool memory, and physics overflow/event loss. Pause and Clear operate on the bounded history. The editor records one sample after publishing renderer diagnostics. Evidence:
history_is_bounded_and_pause_freezes_the_visible_frame,profiler_area_shows_timing_and_memory_sections, and the full workspace and serial GPU test suites. GPU times can lag CPU frames; the allocator-pool figure excludes dedicated allocations. - Add dedicated render settings and physics diagnostics panels. The area switcher now opens Render Settings (scene quality/culling with snapshot undo, display pacing, resolved device profile, culling and missing-asset counts) and Physics Diagnostics (backend status, body/tick counts, GPU work/readback, overflow and rejected-event counts). Evidence:
panels_expose_missing_assets_and_physics_overflow,scene_render_settings_edits_are_undoable_scene_changes, and the full workspace checks. - Add a typed physics inspector for simulation ownership, GPU solver profile, rigid body, and collider settings.
Scene editing
- Add editor-only Scene View overlays: XZ grid, selected-object local axes, and real mesh-bounds box.
- Select the nearest rendered object by clicking Scene View, using an editor ray against transformed mesh bounds instead of requiring a physics collider.
- Add a selection outline that remains readable when the object is behind other geometry. Selected mesh bounds and camera/light wire shapes now use the depth-independent debug pass with thicker lines; grid and unselected helpers remain depth tested. Evidence:
selection_outline_ignores_depth_without_changing_other_helpersand the full workspace checks, including serial GPU tests. This is a wire outline of the authored bounds, not a pixel silhouette. - Translate, rotate, and scale gizmos with local/global modes and snapping. The Scene View toolbar now toggles world or object axes and snaps move to 1 unit, rotation to 15°, and scale to 0.1 factor steps. Global scale projects the chosen world axes onto local scale axes because
Transformhas no shear representation. Each drag still takes one snapshot undo step. The menu beside Snap sets the move, rotation and scale increments. Evidence:gizmo_snap_quantizes_move_scale_and_rotation,global_gizmo_axes_ignore_object_rotation,global_rotation_uses_parent_space_axis,global_scale_on_rotated_object_uses_matching_local_axis, and the full workspace checks. - Add FPS-style Scene View fly camera: Numpad 0 captures/releases the pointer, mouse changes yaw/pitch, WASD moves, Space/Ctrl move vertically, and Shift boosts speed.
- Add camera orbit, pan, focus-selection, and framing controls after fly camera input is stable. Middle drag orbits around the view pivot, Shift+middle drag pans, the wheel dollies, and F or the Scene View Frame button centers and fits the selected mesh or object. Evidence:
orbit_keeps_the_pivot_fixed_and_dolly_moves_toward_it,focus_places_selected_object_in_front_of_camera, and the full workspace checks. Home frames every rendered object, and Numpad 1, 3 and 7 turn to the front, right and top views around the same pivot. Evidence:numpad_views_keep_the_pivot_and_frame_all_sees_every_mesh. Ctrl turns to the opposite views, Numpad 9 looks at the pivot from the other side of the current view (evidence: the same test, which checks right to left and a tilted view to its mirror), and Numpad 5 (or VIEW > Orthographic in the viewport menu) toggles an orthographic view sized to the orbit pivot. Evidence:orthographic_view_matches_the_pivot_size_and_follows_the_dolly. - Create empty, cube, and camera objects; duplicate, rename, delete, and reparent entities.
- Add/remove/edit registered compiled components through the generic JSON inspector.
- Assign meshes, materials, textures, and physics shapes by typed handle. Mesh Renderer now lists saveable
Handle<MeshAsset>and loadedHandle<MaterialAsset>choices with snapshot undo; the Material section already picksHandle<TextureAsset>values, and the typed ColliderShape picker selects primitive or mesh collider shapes (mesh shapes use the renderer’s typed mesh handle). Evidence:mesh_picker_excludes_transient_unsaved_handles,typed_asset_assignment_snapshots_once_and_rejects_unsaved_meshes, and the full workspace checks. Physics shapes are enum variants, not standalone shape assets. - Scene new/open/save/save-as operations with native file pickers.
Play workflow and history
- Make Play save and cook the scene, compile the real Rust project, and launch its native game window.
- Use fast Debug builds by default and allow optimized Release play tests.
- Add stop and restart controls for the native game process. Stop already cancels Cargo or the child game; Restart is now available in the toolbar and Code area, waits for worker exit, then saves, cooks, builds, and launches again. A later Stop cancels a queued restart. Evidence:
restart_waits_for_worker_exit_and_stop_cancels_pending_restartand the full workspace checks. - Add an optional embedded preview for workflows that do not need compiled Rust systems. Preview resumes the editor’s ECS runtime in the Game area without a build; Pause, Resume, and Step control fixed time. Stop restores the authored scene, removes preview-spawned entities, and restores Undo/Redo history and the dirty flag. Evidence:
embedded_preview_restores_authored_scene_after_simulationand the full workspace checks. Project Rust binaries still require native Play. - Make runtime-spawned entities visually distinct where useful. Entities created after embedded preview starts carry a
[Runtime]badge in Hierarchy and a preview-only warning in Inspector; they disappear when preview stops. Evidence:only_entities_created_during_preview_get_runtime_badges,embedded_preview_restores_authored_scene_after_simulation, and the full workspace checks. - Implement snapshot-based undo/redo for scene and Inspector edits.
- Group continuous Inspector field edits into single undo transactions.
- Mark scenes dirty and prompt before destructive new/load/project-switch actions.
Exit gate
- A user can construct, save, reload, play, pause, step, and restore a scene entirely through the editor.
- Undo/redo covers transforms, hierarchy, entity lifecycle, and component edits.
- Editor compositing has a golden-image test.
- Runtime-only builds do not depend on editor code.
Milestone 7: Vertical slice
Goal: prove that the engine architecture works as a usable game-development stack.
Required content
-
One representative imported environment with PBR assets.
samples/vertical_slice/environment.gltfis a reproducible courtyard fixture with 11 nodes, three PBR materials, and embedded base-color and metallic/roughness maps; the normal glTF importer creates typed cooked meshes and textures for it. Evidence:pbr_environment_imports_geometry_materials_and_maps. The sample README records the import path and asset provenance. -
Player camera/controller.
PlayerController(src/runtime/player.rs, built into everyAppand saved asrusting.player_controller) walks a body entity withPhysicsWorld::move_charactereach fixed step, as itsCollidershape or a default 1.8 m capsule, with gravity, jump, and sprint from theplayer.*actions (default WASD/arrows, Space, Shift). Mouse look yaws the body and pitches itsCamerachildren; left click captures the cursor and Escape releases it; pitch clamps to ±89°. Evidence:player_controller_falls_walks_jumps_and_stops_at_walls,player_look_needs_captured_cursor_and_clamps_pitch,player_controller_settings_round_trip_through_scene_registry, and the full workspace checks including serial GPU tests. Limits: no step climbing or slope limit beyond sliding, collider scale is ignored, and a ceiling does not cancel upward speed. -
CPU collisions, triggers, and at least one immediate physics query. A player body with a kinematic CPU capsule collider is stopped by a fixed wall, reported by a sensor it walks through (
CollisionEventwithsensor), and the wall is found by an immediatePhysicsWorld::raycaston the same tick. Evidence:player_controller_falls_walks_jumps_and_stops_at_walls, plus the existingcpu_restitution_bounces_and_sensors_only_report,cpu_collision_layers_filter_pairs_and_queries, andcpu_shape_casts_stop_before_colliders_and_characters_slide. Sensors report every touching step; there are no separate enter/exit events. -
Directional shadows, point lights, sky/environment light, and transparent content. One headless frame combines a shadowed directional light, a point light, a
SkyLight, and an alpha-blended pane; each light uses its own color channel, and turning each feature off removes only its own contribution while the floor stays visible through the pane. Evidence: GPU testvertical_slice_lighting_combines_shadow_point_sky_and_blend(llvmpipe), on top of the per-feature tests from Milestone 3. The scene is test slabs, not the imported courtyard; shadows remain one directional map without cascades. -
At least 10,000 GPU bodies that evaluate configurable conditions and send typed events to Rust gameplay without full-state readback. Evidence: GPU test
ten_thousand_gpu_bodies_report_condition_events_without_state_readback(llvmpipe) runs 10,000 fallingEvents-sync bodies for 30 ticks with anOnEnterposition_y < 0rule: exactly the 5,000 bodies that cross report one event each with their crossing position, nothing is dropped, andphysics_state_bytesstays 0 whilephysics_event_bytescounts the event traffic. Delivery to typedGpuPhysicsEvents on live entities is covered byraw_gpu_events_reach_the_live_ecs_entityandgpu_events_are_delivered_in_tick_and_body_order; custom condition shaders bycustom_condition_shaders_emit_events_and_skip_broken_sources.cargo run --example hybrid_10kshows the same rule in a window. Frame time at 10,000 bodies is a hardware number and is not recorded here. -
CPU-to-GPU commands that alter selected GPU bodies while the simulation is running. Evidence: GPU test
commands_alter_only_selected_bodies_mid_simulation(llvmpipe) lets four bodies fall for 10 ticks, then sends anImpulseto one andSetVelocityto another: only those two change on tick 11, the others keep plain gravity, and all four keep simulating from the new state.commands_apply_once_before_the_step_and_reject_stale_bodiescovers teleport, force, custom values, once-only application, and stalePhysicsIdrejection;gpu_commands_reach_the_render_world_once_per_batchcovers the ECSGpuPhysicsCommandspath. -
Runtime UI and editor UI. Runtime UI: the new
uifeature (egui and egui-winit, already dependencies;editorenables it) gives everyAppaRuntimeUiresource.App::updateopens one egui pass aroundUpdateandPostUpdate, so gameplay systems draw HUDs and menus withui.context()(orGameScene::ui()), and headless runs behave the same. The game window runner feeds window input to that pass, keeps presses the UI uses away fromRuntimeInput, and paints the result over the scene with the sharedEguiPainter. Editor UI is the Milestone 6 egui editor. Evidence:runtime_ui_systems_draw_and_receive_clicks_each_update(an ECS system draws a label and button, and one press and release reaches it as one click), GPU testruntime_ui_drawn_by_an_ecs_system_paints_over_the_game_view(llvmpipe), and a 10 s windowed run ofcargo run --example hybrid_10k, whose HUD now shows FPS and fallen cubes, with no error output (RTX 3060, Hyprland). The HUD was not checked by eye. Embedded editor Preview does not showRuntimeUiyet; that is logged indocs/editor-overhaul.md. The Milestone 16 UI toolkit (layout containers, themes, localization) is still to come. -
Scene persistence and live asset reload. Evidence: integration test
vertical_slice_scene_persists_and_reloads_assets_liveimports the courtyard glTF, adds a player body withPlayerControllerand a child camera plus a directional light, saves the scene, and loads it into a freshAppwithAssetPlugin: every name, the controller settings, the camera parent link, the light, and the mesh handles come back. It then rewrites the floor’s cooked.rmeshfile, and the running app’s hot-reload scan publishes the new mesh with a higher revision on a laterupdate, with no restart. The Milestone 3 hot-reload items cover worker decoding, failed reloads, and GPU resource lifetime. Editing the source.gltfstill does not re-import it. -
Runtime editing through play/pause/stop. In the embedded preview, a dynamic CPU body falls while playing, holds still while paused, takes a transform edit made through the editor’s snapshot-undo path, advances one fixed step from the edited pose on Step, keeps falling from there on Resume, and returns to its authored pose on Stop with the preview’s undo entries dropped. Pause and Resume share
set_embedded_preview_paused. The test found a bug: bevy 0.19 stores resources and observers as entities, so Stop despawned the ones that systems created during the preview and panicked on a stale command. Stop now skipsIsResourceandObserverentities. Evidence:preview_edits_while_paused_drive_the_simulation_until_stop(panicked before the fix),embedded_preview_restores_authored_scene_after_simulation, andonly_entities_created_during_preview_get_runtime_badges. Native Play runs a separate process and has no live editing; that is the Milestone 20 remote inspector. -
Eco, Balanced, and High quality comparisons. GPU test
quality_profiles_compare_on_one_scenerenders one 32x32 scene (a floor under a red shadowed sun and 40 green point lights) at each profile and prints the comparison. On llvmpipe:Profile Lights kept (dropped) Shadow map MSAA Mean red Mean green Eco 16 (25) 1024 px 1x 130.0 27.9 Balanced 32 (9) 2048 px 4x 130.0 56.2 High 41 (0) 4096 px 4x 130.0 69.5 The sun counts toward the light budget. The test checks that the sun looks the same on every profile, that each higher profile keeps more point lights and lights more of the floor, and the shadow map size and MSAA sample count for each profile (MSAA follows the device’s sample count). Shadow distance follows
shadow_settings(30, 50 and 80 m); it is not rendered at range in a test. Per-feature tests:msaa_smooths_edges_except_on_eco,lights_over_capacity_render_first_max_lights_and_report_the_rest,culling_path_follows_mode_ownership_count_device_and_quality, andauto_quality_resolves_from_device_class_and_concrete_profiles_pass_through. Frame-time cost per profile is a hardware number and is not recorded here.
Performance target
Milestone 7 and Milestone 29 have deliberately different hardware targets. The engine vertical slice must run on low-end integrated graphics. Sundering must not: continuous destruction with thousands of debris bodies and GPU navigation has a hardware floor, and pretending otherwise would distort the engine’s quality profiles. Neither target is allowed to weaken the other.
- Define a fixed benchmark scene and camera path. Evidence:
spawn_render_benchmarkandrender_benchmark_camera(src/runtime/render_benchmark.rs) build a fixed scene. It has a ground, 40 pillars with spheres in five PBR materials, 1,000PhysicsBenchmark::MixedGPU bodies with landing events, a shadowed sun, and 24 point lights. The camera path orbits once in 600 frames with two dives, from 40 m to 16 m. Unit testscamera_path_is_closed_repeatable_and_faces_the_sceneandscene_spawns_the_same_fixed_content_every_timepass. - Target 1920×1080 at 60 FPS on approximately Intel UHD 620-class hardware using Eco/Auto settings. Not verifiable here: this machine has no integrated GPU. For reference, the benchmark at 1080p Eco reaches a frame-time p95 of 3.0 ms on an RTX 3060 and 62.6 ms on llvmpipe (
benchmarks/README.md). Runcargo run --release --example render_bench -- ecoon UHD 620-class hardware to close this item. - Record CPU frame time, GPU pass time, draw/dispatch count, triangles, memory, visible instances, upload bytes, physics bodies, event/readback bytes, synchronization latency, and overflow. Evidence:
run_render_benchmark(src/rendering/benchmark.rs) records each of these into a JSONRenderBenchmarkReport. The report gives frame, CPU and GPU times as mean, p95 and max, and mean GPU time per pass. The GPU testbenchmark_records_every_metric_along_the_camera_pathchecks that every counter is populated on a 30-frame run, including the 9 lights Eco drops. The editor profiler shows the same counters live. - Store performance baselines and reject material regressions rather than relying only on FPS logs. Evidence:
render_bench <quality> <baseline.json>writes a missing baseline, or compares against an existing one and exits 1 on any regression.RenderBenchmarkReport::regressionsapplies the rules. Work counters may grow at most 10% when extent and quality match. Times are compared only on the same device and driver, and may grow at most 10% and 0.5 ms. Any new overflow is a regression. Unit testregressions_flag_material_growth_onlycovers these rules.benchmarks/rtx3060-linux-eco.jsonis stored, and two later runs reported no regressions against it. CI enforcement waits for the scheduled performance job in the CI matrix. - Document tested drivers, operating systems, resolutions, and quality settings. Evidence:
benchmarks/README.mdlists the RTX 3060 (NVIDIA 615.71.09) at every profile and llvmpipe (Mesa 26.2.3) on Eco. All runs were 1080p on CachyOS Linux 7.2.8. The README also names the configurations not yet tested: UHD 620, Windows, AMD, and presented frame pacing.
Fast game creation and agent feedback loop
A small browser game can feel more polished during development because code changes are quick to preview, input can be automated, and the resulting frame is easy to inspect. RustingEngine needs an equivalent edit → run → observe → revise loop for native games. This is a workflow and game-feel target, not a requirement to add an AI model to the engine. The detailed backlog is in missingFeatures.md; the items below are the Milestone 7 acceptance path. Later milestones can deepen each subsystem without postponing its first usable slice.
- Provide the first window-free
rustingCLI slice:doctor,new, project/scene inspection, stable ID/name/class/component queries,validate, andcook, using shared project and scene library operations. Evidence:tests/cli.rscoverstestGame, generated projects, malformed input, missing assets, query ordering, JSON errors, and exit codes;cargo test --workspacepassed, andcargo test --workspace --features gpu-tests -- --test-threads=1passed. - Complete the scriptable project loop with
check,run, andexportcommands. Reuse the editor’s build/play/export operations; return the same versioned result envelope and verify the exported game from an isolated folder. See Milestone 21.- Verified:
rusting check,rusting run [--release] [--ticks N] [--timeout S]andrusting export <root> <parent> [--target T]live insrc/cli.rsand return theschema_version1 envelope. Export uses the editor’sexport_built_game, now insrc/project.rs, and the editor export test still passes.run --ticks NsetsRUSTING_HEADLESS_TICKS, whichrun_projectreads to callrun_project_headless. A native export is copied to a fresh temporary folder and must run 60 headless ticks with exit code 0; a cross-target export is not run and carries anEXPORT_NOT_VERIFIEDwarning.generated_project_checks_runs_and_exports_a_verified_game(ignored, real cargo builds; run withcargo test --test cli -- --ignored) passed: check, a 30-tick run with exit 0, a verified export containing the cooked scene, and a corrupted scene that makesrunexit 1.run_and_export_reject_malformed_flags_as_usage_errorscovers flag errors with exit 2.
- Verified:
- Add a public offscreen
capturecommand that renders a specified camera and tick to PNG, reports camera and render metadata, and maps picked pixels to persistent scene IDs. Keep CPU-only validation available when Vulkan is absent. See Milestones 4 and 20.- Verified:
rusting capture <scene> <out.png> [--camera ID|NAME] [--tick N] [--size WxH] [--pick X,Y]...(capture_sceneinsrc/cli.rs) loads the scene, renders every tick up to N offscreen so GPU physics advances, and writes the last frame as a PNG. It reports camera ID, name, projection, transform and world matrix; device, driver, requested and resolved quality, draws, triangles, visible instances and dropped lights; and, per pick, the persistent ID, name, hit distance, world position and rendered RGBA. Picks use CPU rays against mesh bounds (the same method as gameplay clicks), so a rendered ID buffer is still missing for exact silhouettes.try_init_vulkan_headlessreturns an error instead of panicking, and without a device the command still reports the camera and picks with aVULKAN_UNAVAILABLEerror. Game code is not run. Tests:capture_without_vulkan_still_reports_camera_and_picks_per_tick(the device selector is forced to miss; a falling CPU-physics cube is under the pixel at tick 0 and gone at tick 60; also covers an unknown camera and an out-of-range pick) andcapture_renders_a_png_and_maps_pixels_to_scene_ids(--features gpu-tests; a 320x180 PNG whose pixel matches the reported pick color, and camera selection by name and by ID).
- Verified:
- Add input-driven
testscenarios: named actions at fixed ticks, assertions on reflected game state and events, optional captures, reproducible seeds, and a compact failure trace with the first failing tick. This is the first slice of the broader replay work in Milestone 8.- Verified:
src/scenario.rsdefines the JSONScenario(name,seed,ticks,capture_size,steps) withpress/releaseof namedActionMapactions,expect(a JSON pointer into the entity’s reflected scene form, with registered components parsed;equalswithtolerance,greater_than,less_than, anduntilto hold a check across ticks),expect_events(collision counts, optionally per entity), andcapture(offscreen PNG through the sharedHeadlessCapture, skipped without Vulkan).run_scenariosets the newRandomSeedresource (splitmix64 indexed by fixed tick and stream, inrusting-core), advances exactly one fixed step per tick, stops at the first failure, and reportsfirst_failurewith tick, step index, message and the observed value.run_projectruns a scenario whenRUSTING_TEST_SCENARIOis set and writes the report toRUSTING_TEST_REPORT;rusting test <root> <scenario.json> [--release] [--timeout S]builds the game, runs it, and returnsSCENARIO_FAILEDwithtick T step S: .... Tests:scenario::tests(actions change game state at the pressed tick, a falling cube and collision events pass, an out-of-range step and an unbound action fail, anuntilcheck reports its first failing tick and stops the run there, and the same seed reproduces a seeded game value while another seed changes it) andgenerated_game_passes_and_fails_scenarios_with_a_tick_trace(ignored, real cargo build; passed with a capture step and a failing run that exits 1). Only CPU collision events are countable so far; GPU physics and click events are left to Milestone 8 replay work. Full check passed: fmt, clippy in all three configurations,cargo test --workspace(lib 266 passed), and--features gpu-tests -- --test-threads=1(lib 328 passed).
- Verified:
- Publish a generated capability and scene/component schema catalog. Give each operation defaults, examples, units, valid ranges, and GPU/readback cost; test examples against the parser and editor reflection registry. See Milestone 9.
- Verified:
rusting schema [--json]printsrusting_engine::schema::catalog()(src/schema.rs, catalog version 1). It lists the 13 CLI operations from oneOPERATIONStable that now also buildsrusting --help, each with usage, summary, flag defaults, an example, and GPU/readback cost; all 16 scene entity sections and all 7 registered components, each with summary, GPU cost, default, example, and per-field unit, range and notes; the scenario format; and the 144-byte physics-sync readback size. Defaults are not hand-written: they are the scene form of each component’sDefault, saved throughscene_documentand the component registry. Tests:every_saved_field_is_documented_and_every_section_is_listed(the entity keys the scene writer saves equal the documented sections, the catalog’s components equal theSceneComponentRegistry, every saved leaf field is documented and every documented field exists; removing one field’s docs makes it fail),examples_load_through_the_scene_parser_and_registry_unchanged(all examples form one scene that parses, loads, and saves back identically, and each component example restores throughset_registered_component; this caught thatBuiltinPrimitive: Cubesaves asBuiltinCube),catalog_lists_every_operation_with_defaults_and_cost, andschema_catalog_examples_parse_and_help_lists_every_operationintests/cli.rs(every example runs without a usage error and every usage appears in--help). The editor’s typed inspectors are not part of the catalog; they draw the same registered components. Full check passed: fmt, clippy in all three configurations,cargo test --workspace(lib 269 passed), and--features gpu-tests -- --test-threads=1(lib 331 passed).
- Verified:
- Support atomic scene patch batches with persistent IDs, dry-run diffs, revision checks, validation, and editor snapshot undo. An agent and a person editing the same open scene must see conflicts instead of silently replacing each other’s work. See Milestones 6 and 9.
- Verified:
rusting scene patch <scene> <patch.json> [--dry-run]usessrc/scene_patch.rs. Operationscreate,set,remove(with optional per-fieldexpected),reparent,duplicate(one entity) anddelete(with descendants) address entities by persistent ID and fields by JSON pointer into the scene form with registered components parsed;/idcannot change. The patched document must deserialize as aSceneDocument, passvalidate_scene_structure, and restore every built-in component through the registry; unknown components are written with aPATCH_UNVALIDATED_COMPONENTwarning. Nothing is written on any error, and the file is re-read before the atomic write. The result carriesrevision_before/revision_after(FNV-1a 64 of the file bytes,scene_revision), created and deleted IDs, and per-leaf{id, name, path, before, after}changes.scene inspectandscene queryreportrevision; a staleexpected_revisionorexpectedvalue givesSCENE_CONFLICT. The editor records the revision on load, project open and save (SceneFileRevision). Each frame in Edit mode, a changed file reloads behind aremember_scene_before_editsnapshot when the scene is clean, keeping the selection; with unsaved edits it keeps them and reports the conflict, andsave_editor_scenereturnsSceneIoError::Conflictonce before it overwrites. Tests:scene_patch::tests(a six-operation batch and its diff; eight failing batches, including a failure after an earlier operation succeeded, a stale revision, a field conflict, duplicate names, a cycle, a bad type, a bad built-in component value and an/idchange, all leave the file byte-identical; a dry run does not write),scene_patch_dry_runs_writes_and_reports_revision_conflictsintests/cli.rs, andoutside_scene_patch_reloads_under_undo_or_conflicts_with_unsaved_editsin the editor tests. The editor watch compares the file time first, so a writer on a file system with a coarse clock can be missed until the next change; there is no merge UI yet. Full check passed: fmt, clippy in all three configurations,cargo test --workspace(lib 272 passed), and--features gpu-tests -- --test-threads=1(lib 334 passed).
- Verified:
- Add a fast preview/restart path for gameplay edits, with measured save-to-first-playable-frame time and clear diagnostics when Rust, shaders, or assets fail to reload. State-preserving Rust reload can follow in Milestone 9; a reliable clean restart is the initial gate.
- Verified: the clean restart path is the editor’s existing Restart (save, stop the game, wait for the worker to confirm the exit, rebuild, relaunch;
restart_waits_for_worker_exit_and_stop_cancels_pending_restart). A game now printsFIRST_FRAME_MARKER([rusting] first playable frame after N ms, to standard error) once, after its first presented frame or first headless tick, counted fromrun_project. The editor’sEditorBuildStatetimes Play/Restart from the save to that line and writesFirst playable frame X s after save (build Y s, game startup N ms)to the Console.rusting runreturnstimings(build_ms,game_first_frame_ms,command_to_first_frame_ms).crate::project::reload_diagnosticssplitscargo --message-format shortoutput into per-file Rust errors and shader errors (vulkano_shaderscompiles shaders at build time, so they are build errors that name a shader file) and readshot reload failed:lines from the game; the asset server already keeps the last good asset after a failed reload. The editor shows one Console error per Rust or shader error and one per failed asset, and the CLI returnsRUST_BUILD_ERROR,SHADER_BUILD_ERRORandASSET_RELOAD_FAILEDdiagnostics with the file. Tests:reload_output_is_split_into_file_diagnostics_and_first_frame_time,play_reports_first_frame_time_and_per_file_reload_errors, andgenerated_project_checks_runs_and_exports_a_verified_game(ignored, real cargo builds; passed with first-frame timings and a Rust error that namessrc/main.rs). Measured here withrusting run --ticks 1on a generated project (debug, headless, shared target folder): cold build 3228 ms to first frame, no change 96 ms, after touchingsrc/main.rs638 ms, with game startup 7–8 ms each time. Windowed first-frame time includes Vulkan and window setup and needs a real display to measure. State-preserving Rust reload stays in Milestone 9. Full check passed: fmt, clippy in all three configurations,cargo test --workspace(lib 274 passed), and--features gpu-tests -- --test-threads=1(lib 336 passed).
- Verified: the clean restart path is the editor’s existing Restart (save, stop the game, wait for the worker to confirm the exit, rebuild, relaunch;
- Deliver a small game-feel kit: one reusable camera/controller recipe, input actions, tween/easing, event-triggered sound, burst effects, and a minimal runtime HUD with text and buttons. Expose these through ordinary scene data and Rust APIs so a generated game is inspectable and editable. Expand them in Milestones 9 and 14–17.
- Verified: the camera/controller recipe is the existing
rusting.player_controller(PlayerControllerplus a childCamera), and input actions are the existingActionMapwith theplayer.*defaults. New insrc/runtime/game_feel.rs, each a registered scene component with a Rust API and a schema catalog entry:rusting.tween(Tween: Position, Rotation or Scale fromfromtotooverdurationafterdelay, eightEasingcurves, Once/Loop/PingPong),rusting.sound_cue(SoundCuesends aSoundEvent{entity, clip, volume}when its body starts touching another collider or aftertrigger()),rusting.burst_emitter(BurstEmitterspawnscountBurstParticleentities that copy the emitter’sMeshRenderer, fly out, fall and shrink, then despawn), andrusting.hud(HudElementtext or button at an anchor, drawn throughRuntimeUi; a click sendsHudButtonPressed). Tweens, contact triggers and particles run on the fixed step after CPU physics, and burst directions come fromRandomSeedindexed by the fixed tick and the emitter’sSceneId. The engine has no audio output: playing the clip needs an audio crate, which is a new dependency that needs the owner’s approval, so a game playsSoundEventitself for now. Tests:easing_curves_start_at_zero_and_end_at_one,tweens_play_once_loop_and_ping_pong_on_the_fixed_step,landing_fires_one_sound_and_a_seeded_burst_that_expires(one sound on landing, five particles within launch speed, identical velocities on a second run, none left after their lifetime, manual triggers),hud_draws_scene_text_and_reports_button_clicks(egui pointer press and release over the button), and the schema tests, whose examples load through the scene parser and registry. Full check passed: fmt, clippy in all three configurations,cargo test --workspace(lib 278 passed), and--features gpu-tests -- --test-threads=1(lib 340 passed).
- Verified: the camera/controller recipe is the existing
- Provide a quick 2D creation path for games that do not need 3D: sprites, a simple tile grid, orthographic camera, screen-space UI, and a playable 2D starter scene. Keep this a first slice of Milestone 17 rather than requiring its entire renderer and physics scope.
- Verified: 2D games live in the XY plane with the existing renderer and CPU physics. Sprites are
MeshRenderers with the newPrimitiveShape::Quad(upright, facing +Z, UV origin top-left) and an unlit material; the camera is the existing orthographicCamera; screen-space UI isrusting.hud. New insrc/runtime/two_d.rs, both registered scene components with schema entries:rusting.tile_map(TileMap: text rows, oneTileKind{color, texture, solid}per character;build_tile_mapsspawns one unsaved quad per tile and one fixed box collider per run of solid tiles in a row, and respawns on change or removal) andrusting.platformer_controller(PlatformerController: run, jump and gravity throughmove_characteron the fixed step, with a 0.1 s jump buffer and ledge grace).rusting new <parent> <name> --template 2dwrites a side-view platformer: a tile level, a player with an orange sprite and a following orthographic camera, a sensor goal with a burst emitter, and a HUD hint. Generated projects now enable theuifeature so the HUD draws. Tests:tile_maps_spawn_merged_colliders_and_rebuild_or_clean_up,platformer_runs_jumps_and_lands_on_tiles,the_2d_template_is_a_playable_side_view_level(the generated scene loads, the player rests on the ground, seven merged colliders, and running with a jump clears the two-tile step and lands more than 3 m right), and the schema tests.rusting captureof the generated scene at tick 60 on an RTX 3060 showed the ground, steps, platform and player sprite. The editor does not build tile maps in Edit mode yet (backlog indocs/editor-overhaul.md). Full check passed: fmt, clippy in all three configurations,cargo test --workspace(lib 281 passed), and--features gpu-tests -- --test-threads=1(lib 343 passed).
- Verified: 2D games live in the XY plane with the existing renderer and CPU physics. Sprites are
- Make assets easy to replace and polish: import images and glTF with stable IDs, settings, dependency reporting, reimport, and source/license provenance. Provide starter art-direction presets for lighting, camera, typography, and color that remain editable rather than baking a style into the engine. See Milestones 2, 13, and 16.
- Verified:
src/asset_import.rsimports png/jpeg/bmp/tga/gltf/glb files into<project>/assets/<folder>/after loading them as the runtime would (image decode,AssetServer::import_gltf), copies a glTF’s external buffers and images (URIs that leave its folder are rejected), and writes<file>.rmetawith a new UUID,ImportSettings(max_sizedownscales images), dependencies, an FNV-1a content hash, andAssetProvenance(original, author, license, url, generator, notes).reimport_assetfinds the asset by ID or path, replaces it from--from, the recorded original, or itself, and keeps the ID; given provenance fields and settings replace the recorded ones.list_assetsreports each asset with the project scenes that reference it, and flags missing files and dependencies, invalid or duplicate metadata, files changed since import, files without.rmeta, and missing licenses;validatenow fails on the error-level ones. CLI:rusting asset import|reimport|list, in--helpand the schema catalog.src/art_direction.rshas four presets (daylight,golden_hour,night,flat_toy) thatrusting preset apply <scene> <name> [--dry-run]writes as one revision-checked scene patch: the sun (aSunentity is created when the scene has none), ambient and sky light, tone mapping, the newrusting.backgroundcomponent (SceneBackground, copied intoRenderSettingseach frame), perspective camera field of view, and HUD font size plus the newHudElement::color. Reapplying edits the same entities, and the editor sees the change as an outside patch under undo. Tests:images_import_with_settings_provenance_and_a_stable_id_on_reimport,gltf_import_copies_and_reports_dependencies,presets_patch_lighting_camera_text_and_background_and_reapply_in_place,every_preset_has_a_unique_name_and_sane_values,assets_import_list_and_reimport_with_a_stable_idandart_presets_list_and_apply_as_scene_patchesintests/cli.rs, and the schema tests.rusting captureof the 3D template on an RTX 3060 before and after each preset showed distinct backgrounds, light, and field of view. Editor import dialog, Replace action, and preset picker are in thedocs/editor-overhaul.mdbacklog. Full check passed: fmt, clippy in all three configurations,cargo test --workspace(lib 285 passed), and--features gpu-tests -- --test-threads=1(lib 347 passed).
- Verified:
- [x Expose optional, model-agnostic asset-generator hooks that accept generated images, models, and audio through the normal importer, then validate file type, dimensions/geometry, dependencies, and license/provenance metadata. Give the editor and CLI the same preview and replace operation; game projects must remain usable without an AI service.
- Verified:
ProjectManifest.generators(optional, omitted fromproject.jsonwhen empty) maps a hook name toGeneratorHook{command, license, settings}.asset_import::generate_assetruns the command from the project root without a shell, withRUSTING_PROMPTand an emptyRUSTING_OUTPUT_DIR, reads the last stdout line as JSON (fileplus anyAssetProvenancefield), and passes the file toimport_asset, or toreimport_assetfor--replace, so it gets the same file type, image size, glTF geometry and dependency, and metadata checks. It recordsoriginal: "generator:<hook>", the hook as generator, and the prompt as notes. Output without a license from the hook or its config is rejected (GENERATOR_FAILED), as are failed runs and unparsable output; unknown hooks giveGENERATOR_UNKNOWN. The importer now accepts WAV (sample rate and length are read from thefmtanddatachunks) and Ogg (magic only; aponytail:note defers length to Milestone 15).import_assetandreimport_assettakedry_run, which runs every check on a staged copy in the temp folder and returns the report withdry_run: true. CLI:rusting asset generate <project> <hook> <prompt> [--to F | --replace ASSET] [--dry-run], plus--dry-runonasset importandasset reimport, in--helpand the schema catalog. Editor: an imported file’s right-click menu has Replace…, which picks a file of the same type, shows the dry-run preview (size, referencing scenes, license, copied dependencies, or the blocking error) in a modal, and calls the samereimport_asseton confirm; the asset server’s hot reload picks up the file. The Assets tree hides.rmetasidecars. Nothing at runtime depends on a hook. A Generate dialog in the editor is in thedocs/editor-overhaul.mdbacklog. Tests:audio_imports_and_dry_runs_write_nothing,generator_hooks_import_and_replace_through_the_importer(ashhook: preview, import with hook settings, replace keeping the ID, and unlicensed, silent, failing and unknown hooks),generator_hooks_preview_and_import_through_the_cliintests/cli.rs,replacing_an_imported_asset_previews_then_writes_on_confirm(the editor’s dialog result, preview modal, and Replace click), and the schema tests. Full check passed: fmt, clippy in all three configurations,cargo test --workspace(lib 288 passed), and--features gpu-tests -- --test-threads=1(lib 350 passed).
- Verified:
- Ship one small, complete starter game and a repeatable acceptance exercise: from a one-paragraph request, create, modify, run, visually inspect, test, and export it using public tools. Record time to first playable frame, manual interventions, failed edits, and whether the requested mechanics and presentation are visible. Keep the CLI, editor, and optional MCP adapter on the same service layer; MCP is an adapter after these operations work locally.
- Verified:
rusting new --template startercreates Coin Run (tile map, platformer, fiverusting.pickupcoins,rusting.counterHUD with{name}placeholders, flag gated byrequires, “You win!” HUD).samples/starter_game/request.mdis the one-paragraph request andwin.scenario.jsonplays it to the win. Tests:pickups_count_hide_and_unlock_in_scene_id_order,hud_text_fills_counter_placeholders,the_starter_template_wins_its_sample_scenario, plus the ignoredtests/starter_game.rsexercise (new, preset, query, patch dry-run and apply, validate, run, capture with pick, test, export).HeadlessCapturenow paints the runtime HUD, checked in the captured win frame.benchmarks/starter-game-rtx3060-linux.json: game first frame 20 ms, command to first frame 4.9 s warm (22.3 s cold build), 0 failed edits, 0 manual interventions, 2 corrective edits (background, tone mapping aftergolden_hour), mechanics visible, first coin picked on screen, win frame captured. The MCP adapter stays deferred (missingFeatures.md section 9); the CLI and editor share the library calls. Full check: fmt, clippy default / no-default-features / gpu-tests,cargo test --workspace(lib 291), gpu-tests (lib 353).
- Verified:
The fast-feedback bar is informed by Vite’s hot module replacement and Playwright’s action, screenshot, and error traces. These are workflow references, not dependencies or a plan to turn the native engine into a web stack.
Exit gate
- The vertical slice is playable from a clean checkout on Windows and Linux.
- Its scenes can be edited and saved in the integrated editor.
- Automated smoke, visual, physics, serialization, and performance tests pass.
- Packaging produces a runnable build with required assets and licenses.
- A fresh project can be created, edited, run, captured, tested with named input, and exported from public commands; a human can inspect and undo an agent’s scene changes.
- The starter game has visible UI, audio/feedback, and a coherent editable visual style, verified by a capture and a scenario rather than by compilation alone.
Milestone 8: Deterministic simulation and replay
Goal: make the simulation produce bit-identical results from the same inputs on every supported device, and make any divergence detectable and locatable. Determinism is an engine feature available to every game, not only to Sundering, and it is one of the physics pillars.
Engine-level opt-in
- Expose
DeterminismMode::{Off, Local, CrossPlatform}per project, so games that do not need determinism do not pay for fixed-point math or fixed-order reductions.Localguarantees identical results on one machine and build;CrossPlatformenforces every rule in this milestone.- Verified:
determinisminproject.json(Offdefault, omitted when Off).cook_scenecopies the nearestproject.jsonvalue into the cooked scene (scene format 7, new last fieldsimulation; cooked v6 is dispatched on its version so its render settings survive), and a replacing load inserts theDeterminismModeresource.Offruns no check and adds no work. Tests:cook_copies_project_determinism_and_replacing_loads_insert_it,version_six_cooked_scenes_keep_render_settings. Full check: fmt, clippy in all three configurations,cargo test --workspace(lib 295, cli 13), gpu-tests (lib 357).
- Verified:
- Validate at startup that every registered solver, system, and custom compute shader supports the selected mode, and fail with a structured error naming the offender otherwise.
- Verified:
check_determinismruns inload_project_runtime(windowed, headless, and scenario runs) and returns aDeterminismError { mode, offenders: [{part, supports}] }sorted by part name. Parts:cpu_physicsand eachgpu_solver:*supportLocal(body-ordered, no order-dependent atomics, float math not pinned yet); a custom shader declares its level with a// rusting: determinism = <mode>line and otherwise supportsOff; game systems declare theirs in theDeterminismSupportresource (a second declaration keeps the weaker mode). Undeclared game systems are not detected automatically.rusting validatereports oneDETERMINISM_UNSUPPORTEDerror per part with the first body’s scene location. Tests:determinism_check_names_every_part_below_the_project_mode,shader_pragmas_declare_determinism, CLIvalidate_names_parts_below_the_project_determinism_mode. Same full check as above.
- Verified:
Deterministic math
- Choose and document the simulation number format: fixed-point integer math, or strictly constrained IEEE-754 with fast-math disabled, fused-multiply-add behaviour pinned, and operation order fixed.
- Verified:
docs/determinism.mdchooses constrained IEEE-754f32over fixed-point (keeps the existing solvers; noshaderInt64need), lists the correctly rounded operations allowed as hardware ops, the ones replaced by shared routines (division,sqrt,inversesqrt,dot/cross/length/normalize, transcendentals), and the forbidden ones (FMA contraction, fast-math and relaxed precision, reassociation, NaN, subnormals;CrossPlatformneeds Vulkan float controls for denormal preservation and RTE rounding). It records that the solvers meetLocalonly today. Documentation item; no code change.
- Verified:
- Implement the chosen format once in a shared math module used by the CPU solver and every physics compute shader.
- Verified:
src/shaders/sim_math.glsl(sim_recip,sim_div,sim_rsqrt,sim_sqrt,sim_dot,sim_length,sim_normalize,sim_mul,sim_mul_transposed; bit-trick seed, three Newton steps, then the best of the result and its two neighbours, allprecise) replaces every GLSL division,sqrt,dot,length,normalize, and matrix-vector product in the live physics shaders (physics.comp,physics_contacts.comp,physics_shapes.glsl).runtime::sim_mathis the bit-exact Rust reference. The CPU solver keeps Rust’s native/andsqrt, which IEEE-754 already defines exactly on every supported CPU (documented indocs/determinism.md); its transcendental calls move to the module in the transcendental item. Tests: GPUshader_sim_math_matches_the_rust_reference_bit_for_bit(64 cases, including one that differs under FMA contraction; removingprecisemakes it fail on lavapipe), unitround_values_are_exact_and_the_rest_stay_within_two_ulp;commands_apply_once_before_the_step_and_reject_stale_bodiesstill gets exact3.0from3 / 1. The routines always run, even withOff; they add a few ALU operations per contact; the GPU physics cost has not been re-measured on hardware. Full check: fmt, clippy in three configurations,cargo test --workspace(lib 296), gpu-tests (lib 359).
- Verified:
- Forbid relaxed precision and fast-math in simulation shaders. Both remain available to rendering shaders.
- Verified: every float operation in
physics.comp,physics_contacts.comp(both passes), andphysics_shapes.glslisprecise, withpreciselocals for values that only feed comparisons;roundbecameroundEven.simulation_shaders_are_precise_and_never_relaxed(gpu-tests, needsglslc, documented indocs/dev-environment.md) compiles those shaders andsim_math_test.compand fails on any float arithmetic withoutNoContraction, anyRelaxedPrecisiondecoration, or any GLSL.std.450 call outside the exact set.simulation_violations_flag_fusable_relaxed_and_inexact_codefeeds the checker hand-written SPIR-V with each violation. Removing theradius_squaredprecisemakes the shader test fail. Rendering shaders are not checked. Full check: fmt, clippy in three configurations,cargo test --workspace(lib 297), gpu-tests (lib 361).
- Verified: every float operation in
- Replace transcendental functions in simulation paths with tabulated or polynomial implementations that have defined results on every device.
- Verified:
runtime::sim_mathgainssin_cos,atan2, andasin(Cephes polynomials using only+,-,*,/,sqrt, and the exact%), plusrotation_from_euler,euler_from_rotation,rotation_from_scaled_axis, andtransform_matrix. The CPU solver uses them for loading body rotations, integrating angular velocity, and writing rotations back; GPU bodies uploadtransform_matrix. The GPU physics shaders call no transcendental function, andsimulation_shaders_are_precise_and_never_relaxedrejects any GLSL.std.450 call outside the exact set. Tests:transcendentals_stay_close_to_f64_and_keep_their_bits(8001 points; within 4e-7 off64andsin_cosfor|x| <= 8192; a checksum of every result bit is pinned and passes in debug and release),rotation_helpers_match_nalgebra(within 1e-6 of nalgebra; Euler round trip within 1e-5). Existing CPU physics tests pass unchanged. Not done: the checksum has only run on x86-64 here; ECS readback, player movement, and tween easing moved tosim_mathin the simulation-state item below. Full check: fmt, clippy in three configurations,cargo test --workspace(lib 299), gpu-tests (lib 363).
- Verified:
- Document which engine values are simulation state, which must be deterministic, and which are presentation only and may differ per machine.
- Verified:
docs/determinism.md“Simulation state” lists the components, resources, GPU buffers, events, and input that must be deterministic, the presentation-only values, the rule that presentation never writes simulation state, and the known gap (GPU events, proxies, and mirrors arrive one to three frames late). Fixes found while listing: the GPU clockpc.elapsedwas the frame-timed game clock and is nowfixed_tick * fixed_delta(GPU testgpu_timers_count_simulated_ticks_not_the_frame_clocksets the frame clock to 1000 s and sees the 0.04 s timer fire only from tick 3; it fails on the old code); teleport commands and GPU readback now convert withsim_math::transform_matrixandtransform_from_matrix(round trip checked inrotation_helpers_match_nalgebra); player movement and tween easing usesim_math::sin_cosand explicit products instead ofpowi. Full check: fmt, clippy in three configurations,cargo test --workspace(lib 299), gpu-tests (lib 364).
- Verified:
Deterministic execution order
- Make broad-phase cell assignment, pair generation, and constraint ordering deterministic under any workgroup or thread scheduling.
- Verified: found and fixed thread-dependent system order.
FixedUpdateused bevy’s multi-threaded executor, and five engine systems that writeTransform(player and platformer movement, bursts, burst particles, tweens) had no order against CPU physics or each other.engine_fixed_update_systems_have_one_orderbuilds the schedule with ambiguity detection set to error; it failed with 12 conflicts before the fix. The engine’s fixed systems are now one chain, andFixedUpdateruns on the single-threaded executor, so unordered game systems follow the topological sort instead of thread timing. Already deterministic and now documented indocs/determinism.md“Execution order”: the CPU solver sorts bodies byEntityand pairs into body order (broad_phase_finds_the_same_pairs_as_testing_every_pair,broad_phase_prunes_a_tall_column_without_changing_pair_order); GPU bodies upload by slot and colliders by entity; GPU contact cells and fallback fill in atomic order but sum in integers, andgpu_bodies_collide_with_each_other_through_grid_and_fallbackgets the same bits from two runs and from a run with no hash memory. The chain updates existing burst particles before spawning new ones (landing_fires_one_sound_and_a_seeded_burst_that_expirescaught the other order). Not covered: which events a GPU event-buffer overflow drops (counted and logged). Full check: fmt, clippy in three configurations,cargo test --workspace(lib 300), gpu-tests (lib 365).
- Verified: found and fixed thread-dependent system order.
- Make solver iteration count and convergence criteria independent of timing, frame rate, and hardware.
- Verified: already true, now documented in
docs/determinism.md“Execution order”: the CPU solver runs a fixedSOLVER_ITERATIONS(8) with no convergence exit, sleep countsSLEEP_STEPSticks, the GPU runs one contact pass and one step per tick, and every fixed system (CPU physics, player, platformer, tweens, burst particles) readsfixed_delta, never the frame delta. New testsimulation_bits_depend_on_ticks_not_frame_pacingruns a spinning three-box stack for 120 ticks at one tick per frame and in uneven frames (3, 0.5, 0.5, 0, 5 ticks) and compares everyTransformandRigidBodybit; making CPU physics read the frame delta makes it fail. Not covered:player_lookandplatformer_jumpsample input per frame (Replay items). Full check: fmt, clippy in three configurations,cargo test --workspace(lib 301), gpu-tests (lib 366).
- Verified: already true, now documented in
- Apply CPU-to-GPU commands in a deterministic order that does not depend on arrival time within a tick.
- Verified: found and fixed three frame-dependent paths. (1)
time::advanceadded all of a frame’s steps toFrameTime::fixed_tickbeforeFixedUpdateran, so every step in a multi-tick frame saw the same tick (and tick-seeded bursts repeated);App::updatenow sets each step’s tick. (2) The renderer applied a frame’s whole command batch before its first GPU step;GpuPhysicsCommands::apply_ticksnow records the GPU tick each command applies before (stamped after every fixed step and at extraction), the renderer sorts uploads by step and body, and the new push constantcommand_firstgives each step its own range. (3) Ticks pastMAX_PHYSICS_STEPS_PER_FRAMEwere dropped; they now wait for the next frame. Tests:fixed_steps_see_their_own_tick_and_stamp_gpu_commands_for_the_next(a three-tick frame sees ticks 0, 1, 2; stamps are 1, 2, 3 for fixed-step commands and 4 for anUpdatecommand), GPUcommands_apply_on_their_tick_however_frames_batch_ticks(commands for ticks 3 and 6 give the same bits with one tick per frame, frames of three, and a ten-tick frame carrying two over; it fails when commands apply at the frame’s first step). Documented indocs/determinism.md“Execution order”. Full check: fmt, clippy in three configurations,cargo test --workspace(lib 302), gpu-tests (lib 368).
- Verified: found and fixed three frame-dependent paths. (1)
- Add a seeded, tick-indexed RNG with independent streams per subsystem, and route all simulation randomness through it.
- Verified:
RandomSeed::value(tick, stream)(seeded splitmix64, already present) gainsRandomSeed::stream(subsystem, key), which hashes the subsystem name (FNV-1a) into the stream so subsystems that pick the same keys stay independent. Bursts, the only engine simulation randomness, now draw from"bursts"keyed bySceneId, or by entity when there is none; before, every emitter without aSceneIdshared stream 0 and fired identical bursts. The previous item made the tick index correct in multi-tick frames. Audit: no simulation code readsrand(unused dependency), a thread-local generator,Uuid::new_v4, orHashMapiteration order, and the GPU physics shaders draw no random values. Tests:subsystem_streams_are_independent_and_fixed(value pinned),emitters_without_scene_ids_draw_their_own_bursts(fails with the old shared stream),landing_fires_one_sound_and_a_seeded_burst_that_expires(same seed and tick repeat). Documented indocs/determinism.md“Execution order”. Full check: fmt, clippy in three configurations,cargo test --workspace(core 54, lib 303), gpu-tests (lib 369).
- Verified:
- Make floating-point-to-integer conversions, clamping, and saturation behaviour explicit and identical across backends.
Verified:
sim_to_intinsim_math.glslmirrors Rustas i32(sim_math::to_int: truncate, saturate, NaN to 0); the contact grid’scell_ofandto_fixeduse it;shader_sim_math_matches_the_rust_reference_bit_for_bitcovers NaN, infinities,±2^31,±3e9, and halves; rules indocs/determinism.md. Full check green (lib 303, gpu 369).
Verification harness
- Compute a world-state hash every tick over all simulation state, cheap enough to leave enabled in release builds.
Verified:
world_state_hashruns after every fixed step intoStateHashes(last 1024 ticks);state_hashes_cover_every_tick_and_only_simulation_state(pacing-independent, one-ULP change detected from its tick on, GPU readbackTransformignored; fails when readback copies are hashed); release cost about 0.4 ms per tick for 10,000 CPU bodies. CPU-visible state only; GPU buffers are the next item. Full check green (lib 304, gpu 370). - Include GPU body buffers in the world-state hash: hash each body’s
PhysicsStateon the GPU, reduce in slot order, and read it back tagged with its tick. Verified:physics_hash.compruns after every GPU tick and sums per-body hashes (state and rules) with integer atomics, so the order cannot change the result (an integer sum, not a slot-order fold); results reachStateHashes::gputagged with their tick;commands_apply_on_their_tick_however_frames_batch_ticksasserts one hash per tick, the same bits for 1/3/8+carry-over batching, and a changed hash from the tick of a one-ULP command change. Full check green (lib 304, gpu 370). - Add a headless simulation mode that runs with no window, surface, or renderer.
Verified:
simulate_project_headless(behindrun_project_headless,RUSTING_HEADLESS_TICKS, andrusting run --ticks) builds only the ECS and asset stack, steps onefixed_deltaper update, warns that GPU bodies stay still, and returns theApp;headless_simulation_runs_exact_ticks_from_a_cooked_scenesaves, cooks, and runs a scene for exactly 30 ticks with 30 recorded hashes. Full check green (lib 306, gpu 372). - Add a determinism runner that executes the same inputs for N ticks twice and compares per-tick hashes.
Verified:
compare_runssteps two apps in lockstep and compares every tick’s hash;compare_runs_reports_the_first_divergent_tick_and_bodypasses identical runs, finds a velocity change on tick 6, and a seed change on tick 1. In-process only; separate processes come with the configurations item. Full check green (lib 306, gpu 372). - Extend the runner across configurations: debug against release, differing CPU thread counts, differing GPU vendors, differing driver versions.
Partial:
rusting determinism <project> [--ticks N]builds debug and release, runs each headless in its own process withRUSTING_STATE_HASH_OUT, reruns release pinned to one CPU withtaskset(one bevy and rayon thread), and compares every tick. Starter template: debug, release, and one-CPU runs give the same 120 hashes; a debug-only1e-4nudge ofPlayeron tick 30 fails withDETERMINISM_DIVERGED, tick 31,Playerand itsSceneId.first_divergent_tickchecked incompare_runs_reports_the_first_divergent_tick_and_body. Open: GPU vendors and driver versions, which need GPU bodies in the headless hash and real hardware. Full check green (lib 306, gpu 372). - Report the first divergent tick and the first divergent body, not merely that a mismatch occurred.
Verified: in process,
compare_runsreports tick, entity,Name, andSceneId(test above). Across processes,rusting determinismreruns both configurations up to the divergent tick and compares their per-entity hashes (first_divergent_entity); the injected nudge above names tick 31 andPlayer. Full check green (lib 306, gpu 372). - Run the determinism suite in CI on every change to physics, math, or shader code.
Verified locally:
.github/workflows/determinism.ymlruns on pushes and pull requests touchingsrc/runtime,src/shaders,src/rendering,src/project_runner.rs,crates/rusting-core, orCargo.lock. It installs lavapipe andglslc, runscargo test --workspace --features gpu-testson lavapipe (bit-for-bit shader math,precisescan, batched GPU hashes), thenrusting determinismfor 600 ticks on a fresh starter project. Both steps run here as written under bash: lavapipe (llvmpipe, Mesa 26.2.3) gpu-tests lib 372 passed; the determinism step exits 0. Not yet run on GitHub runners. Full check green (lib 306, gpu 372).
Replay
- Record match input streams with tick numbers, a seed, and a format version.
- Verified:
src/runtime/replay.rsdefines the JSONReplay(format_version1,seedfromRandomSeed,start_tick, per-frametick,delta_nanosandRuntimeInputstored only when it changes, plus every tick’s hash).App::start_recording/finish_recordinghold the recorder outside the World, since a bevy resource allocates an entity and would shift entity order and hashes.RuntimeInputnow usesBTreeSetand derives serde (winitserdefeature). A game run withRUSTING_REPLAY_OUTwrites its replay on exit (windowed path not exercised here, no display test). Test:replays_reproduce_recorded_hashes_and_find_changed_inputrecords 90 uneven frames with held and pressed keys and stores under 20 inputs. Full check green (lib 307, gpu 373).
- Verified:
- Replay a recorded stream and verify it reproduces the recorded per-tick hash sequence.
- Verified:
play_replaychecks the format version and start tick, sets the seed, feeds each frame’s delta and input, and returns the first divergent tick. The same test round-trips the replay through JSON and reproduces all 140 hashes; removing one jump press reports the tick after that frame (the press is buffered inUpdate), and version 0 is rejected.RUSTING_REPLAY_PLAYplays a replay headless and fails with the divergent tick. Full check green (lib 307, gpu 373).
- Verified:
- Support replay seeking by re-simulating forward from periodic full snapshots.
- Verified:
src/runtime/snapshot.rsaddsApp::snapshot/restore: every entity (resources included) with its registered components, plus the entity allocator’s state, including bevy’s hidden local free list, read by allocating and freeing, then rebuilt. Unregistered types fail with their names (bevy_ecsdebugfeature).ReplaySeekersnapshots every N ticks and seeks by restoring the nearest snapshot into a new app and re-simulating, still checking each tick’s hash. Tests:snapshots_restore_entity_ids_and_state_into_a_new_app(particles reuse ids at raised generations; the restored app matches ids, allocator and 52 frames of hashes),snapshots_name_unregistered_types, andreplays_of_the_starter_template_seek_through_snapshots(seeks 240, 10, 190, 60, 240 with 4 snapshots, checks the counters, and reports a tampered hash at tick 170). Full check green (lib 311, gpu 377).
- Verified:
- Keep replays independent of render settings, resolution, quality profile, and window size.
- Verified by construction: playback runs headless with no renderer, and the viewport size that click picking reads is part of the recorded
RuntimeInput, so the playback window never enters the simulation. GPU physics bodies are not in the world-state hash yet, so replays cover CPU simulation only. Full check green (lib 307, gpu 373).
- Verified by construction: playback runs headless with no renderer, and the viewport size that click picking reads is part of the recorded
Exit gate
- 10,000 GPU bodies simulated for 10,000 ticks produce identical per-tick world-state hashes on at least two GPU vendors, and on both debug and release builds.
- A recorded replay reproduces its original hash sequence exactly.
- A deliberately introduced divergence is reported with its first divergent tick and body.
- If bit-identical cross-vendor results prove unachievable, that result is documented with evidence, and Milestones 19 and 24 proceed with server-authoritative networking without cross-vendor rollback.
Milestone 9: Scene composition, reflection, and iteration speed
Goal: give the engine Godot’s authoring model — reusable scenes, signals, groups, data resources, and fast code iteration — expressed through ECS instead of a node tree.
Depends on: Milestones 1, 2, and 6.
Reflection
- Add a reflection registry for components, resources, and asset types: field names, types, ranges, defaults, and editor hints, derived from Rust types rather than hand-maintained tables.
Verified:
src/reflect/adds theReflecttrait and thereflect!macro (our own, no new crate). The macro destructures every field and matches every variant, so a description that misses or misnames a field does not compile. Hints areunit,min,max,doc, andcolor; defaults come fromDefault. Components register throughregister_scene_component(which now requiresReflect); resources and asset types register inTypeRegistry(rusting.render_settings,physics_settings,random_seed,determinism, and therusting.materialasset). All 16 built-in components are described insrc/reflect/builtin.rs, andrusting schema(catalog version 2) derives component fields, resources, and asset types from them instead of the old hand-written table. Tests:the_macro_describes_structs_newtypes_and_enums,builtin_types_are_described_and_listed,every_saved_field_is_documented_and_every_section_is_listed(checks pitch range-1.55..1.55,/tiles/*/solid,/alpha_mode/Mask/cutoff). Full check: fmt, clippy in three configurations,cargo test --workspace(core 54, lib 317, doc 16), gpu-tests (lib 383). - Drive scene serialization, the Inspector, undo/redo, and animation property tracks from the same registry. Replace the generic JSON inspector path once parity is reached.
Partial: scene save and load run through the registration’s
TypeInfo(walk, migrate, entity and handle conversion); the Inspector’s generic JSON editor (inspector/json.rs) is replaced byinspector/reflected.rs, which draws typed widgets (numbers with ranges and units that do not clamp saved values on draw, linear and sRGB colors, enums, options, lists with add/remove, and drop-downs of scene objects and loaded assets for references, so a reference is never a half-typed ID) and applies edits through the existing snapshot undo path.registered_component_fieldandset_registered_component_fieldread and write one field by JSON pointer with type checks; this is the path API animation property tracks will use, but animation tracks themselves do not exist yet (Milestone 14 owns them), so this item stays open. Map key add/rename, doc tooltips, and an asset picker for assets not yet loaded are in thedocs/editor-overhaul.mdbacklog. Tests:field_paths_read_and_write_checked_values,field_names_become_captions,references_start_at_the_first_object_or_stay_empty,drawing_without_input_leaves_the_value_unchanged,registered_inspectors_draw_and_edit_their_component. Full check: fmt, clippy in three configurations,cargo test --workspace(core 54, lib 317, doc 16), gpu-tests (lib 383). - Support reflected enums, nested structs, collections, typed handles, and entity references.
Verified:
TypeInfocovers unit, struct, and tuple enum variants, nested structs, arrays,Vec,Option, string-keyedBTreeMap/HashMap,Handle<T>for texture and mesh assets, andEntity. AnEntitysaves as the target’sSceneIdand is remapped on load; a handle saves as{"$asset": path}and loads the asset. A reference to an entity that is gone or has noSceneIdsaves asnull;nullor an ID no object has loads asNonein anOption<Entity>and asEntity::PLACEHOLDERotherwise, so deleting a referenced object, a despawned target, or a preview-only object never blocks saving, undo, or loading. Handle assets load inpreload_component_assetsbefore the open scene is replaced, so a missing one leaves it untouched. Tests:entity_references_and_handles_save_as_ids_and_asset_paths,the_macro_describes_structs_newtypes_and_enums. Full check: fmt, clippy in three configurations,cargo test --workspace(core 54, lib 317, doc 16), gpu-tests (lib 383). - Reject or migrate renamed/removed reflected fields with a structured, versioned error instead of silently dropping data.
Verified: unknown fields and variants fail the load with
ReflectError { component, saved_version, current_version, path, problem }(CLI codeSCENE_COMPONENT_FIELD); the message suggests a migration.App::migrate_scene_componenttakesFieldMigration::renameorremove; each raises the component version, saves write"$version", older saves run the missed migrations, and newer ones fail withNewerVersion. Tests:unknown_fields_and_variants_are_rejected_with_their_path,migrations_rename_and_remove_fields_and_newer_versions_fail. Documented in tutorial 3 anddocs/concepts.md. Full check: fmt, clippy in three configurations,cargo test --workspace(core 54, lib 317, doc 16), gpu-tests (lib 383).
Prefabs and scene instancing
- Instance a saved scene as a child subtree of another scene, keeping a link to its source asset.
Verified: the built-in
rusting.scene_instancecomponent (SceneInstance { source }, insrc/runtime/scene_instance.rs) links an object to a scene file, saved relative to the scene that holds it.load_scene_documentadds the source scene’s objects under that object before validation, with IDs frommember_id(root, source_id)(FNV-1a, stable across loads and platforms) and entity references inside their components pointed at the copies. Added objects carryInstanceMemberand therusting.instance_membermarker, so text saves drop them and keep only the link, while editor undo snapshots and cooked scenes keep them without reading the source again; a cooked game ships without the source scenes. A missing source fails the load withSceneIoError::Instanceand leaves the open scene untouched. The editor’s Assets panel instances a scene by double-click orInstance in Scene, through snapshot undo, and refuses the open scene. Changingsourcetakes effect on the next load. Tests:instances_add_the_source_scene_under_the_root_with_stable_ids,saves_keep_the_link_and_snapshots_keep_the_members_once,cooked_scenes_load_without_the_source_scene,instances_nest_and_a_scene_cannot_contain_itself,a_missing_source_fails_the_load_and_keeps_the_open_scene,an_empty_source_places_nothing,scenes_instance_as_one_linked_root_but_not_into_themselves. Full check: fmt, clippy in three configurations,cargo test --workspace(core 54, lib 324, doc 16), gpu-tests (lib 390). - Store per-instance overrides as property diffs against the source, so source edits propagate to every instance that did not override that property.
Verified: a text save (
fold_instance_members) expands each instance’s source again and stores, on the root underrusting.instance_overrides, a sparse JSON diff per member keyed by source object ID: only changed fields (component fields separately),{"$removed": true}for a removed component or map entry, andnullfor a deleted object. Loading merges the diffs into the source as it is now, so unchanged fields follow source edits; deleting an object removes its source children too. Objects the user adds under an instance’s objects save normally, because member IDs are stable; if a later source drops their parent they move to the top of the scene instead of failing the load. Asset paths inside diffs are saved relative like every other path. Expanded roots carryrusting.instance_expandedwith the source they came from, so snapshots never expand twice (also when every member was deleted), and a changed link saves no stale diffs. Tests:overrides_are_saved_and_source_edits_reach_the_rest,a_changed_link_saves_no_overrides_for_the_old_members,diffs_merge_back_to_the_current_value,override_asset_paths_are_converted. Full check: fmt, clippy in three configurations,cargo test --workspace(core 54, lib 328, doc 16), gpu-tests (lib 394). - Support nested prefabs and prefab variants (inheritance), with cycle rejection.
Verified: nested instances expand recursively and a scene that contains itself, directly or through other scenes, fails with
SceneIoError::InstanceCycle(instances_nest_and_a_scene_cannot_contain_itself). A variant is an ordinary scene whose one object instances the base scene, so it needs no new file format: its own edits save as overrides on that object, and a scene that places the variant saves its overrides against the expanded variant. Overrides therefore stack base, then variant, then level, and a base edit reaches every field neither layer changed.save_scene_variant(base, path)writes one, and first expands it once, so a missing base or a base that already containspathfails without writing. The editor’s Assets menu for a.rscenefile has New Variant, which writes<name>_variant.rscene(or_variant_2, …) next to it and selects it; it does not change the open scene, so it adds no undo step. Structure note: the variant’s objects sit under its one root, so a placed variant is one level deeper than a placed base. Tests:variants_inherit_base_edits_under_their_own_overrides(base, variant and level each change a different field; the base edit shows where no layer overrode it, and variants of themselves or of a scene built from them are rejected with the base unchanged) andscenes_instance_as_one_linked_root_but_not_into_themselves(New Variant names and content). Full check: fmt, clippy in three configurations,cargo test --workspace(core 54, lib 329, doc 16), gpu-tests (lib 395). - Revert, apply-to-source, and unpack-instance operations with undo.
Verified:
edit_instance(document, id, InstanceEdit)insrc/runtime/scene_instance.rsworks on a document of the open scene, andidmay be the instance root or any object it placed.RevertObjectreplaces one placed object with its source values.Revertreplaces all of them, which brings deleted ones back and keeps objects added under them.ApplyToSourcemaps the placed objects back to their source IDs (object references inside components too), restores the member markers of instances inside the source, folds those into overrides, validates and expands the result once (so an added instance that contains the source is rejected before writing), and writes the source file. Before writing, it saves the rest of the open scene’s overrides against the old source and then expands again, so other instances pick up the change and keep their own overrides, and the applied instance keeps none.UnpackCompletelydrops the link and every member marker, including those of instances inside it, and numbers names that now clash. Right-clicking an instance root or a placed object in the Hierarchy shows an INSTANCE section with the four actions. They run asEntityRequest::Instancebehind the usual undo snapshot and keep the selection. Undo restores the open scene, but it does not restore a source file that Apply to Source wrote. Tests:revert_resets_one_object_or_the_whole_instance,apply_writes_the_source_and_other_instances_keep_their_overrides(including a source made of nested instances, which stay linked),unpack_turns_an_instance_into_ordinary_objects(the scene loads after the source is deleted), andscenes_instance_as_one_linked_root_but_not_into_themselves(the editor actions reload and keep the object). Full check: fmt, clippy in three configurations,cargo test --workspace(core 54, lib 332, doc 16), gpu-tests (lib 398). - Instance prefabs at runtime from gameplay code through typed handles.
Verified:
AssetServer::load_prefab(path)reads a.rscenefile once into aPrefabasset inAssetServer::prefabsand returns aHandle<Prefab>; loading the same path again returns the same handle.spawn_prefab(world, handle, transform)insrc/runtime/scene_instance.rsadds an instance root named"<file stem> #<n>"attransformwith the prefab’s objects under it, through the sameload_scene_documentpath as scene loading (additive), so object references inside the prefab point at the new copies. Root IDs come from a per-world placement counter (member_id(nil, n)), so a replay that places the same prefabs in the same order gets the same IDs, and member IDs follow from the root as in saved scenes. A saved scene keeps each runtime instance as a link like any other. Systems call it throughcommands.queue(move |world: &mut World| spawn_prefab(world, handle, transform).map(drop)); a stale handle fails withSceneIoError::MissingPrefab. Limits: a changed prefab file is not reloaded while the game runs, runtime prefabs must live underassets/to be exported, and each placement scans for the root bySceneId. Test:gameplay_code_places_loaded_prefabs_with_repeatable_ids(one load per path, placement at the given transform, a second placement queued from commands after the file was renamed away, references pointing at each placement’s own objects, identical IDs on replay, links kept on save, missing handle error). Full check: fmt, clippy in three configurations,cargo test --workspace(core 54, lib 333, doc 16), gpu-tests (lib 399).
Signals, groups, and observers
- Add entity-targeted observers that react to component insertion/removal and to typed events, as the ECS equivalent of Godot signals.
Verified:
src/runtime/signals.rs.App::add_signal_handler(name, system)registers a named Rust system whose input says the signal it answers:In<Signal<E>>for a typed bevyEntityEvent(sent withcommands.trigger),In<Signal<Added<T>>>orIn<Signal<Removed<T>>>for componentT(a despawn counts as a removal). The emitting entity carries aConnectionscomponent (handler name plus receiver entity;App::connectchecks both), and one bevy observer per signal type queues the matching handlers in connection order;Signalcarries source, target and event. Bevy’s per-entityobserveis not used, because its observer entities hold boxed systems that a snapshot cannot copy;Connectionsis plain data, so snapshots and replays keep it, andApp::restoremutes signals so writing components back runs no handler. Handlers must be registered during setup; a handler that re-emits its own signal does not run inside itself.SignalErrorconverts toAppErrorforPlugin::build. Tests:connected_handlers_answer_events_and_component_changes(typed event through commands, two handlers in order, unconnected entity silent, add and despawn),snapshots_keep_connections_and_restores_fire_nothing(fails if the mute is removed),connections_name_registered_handlers_and_live_entities. Documented indocs/concepts.md. Full check: fmt, clippy in three configurations,cargo test --workspace(core 54, lib 336, doc 16), gpu-tests (lib 402). - Allow scene files to store event-to-handler connections between entities by
SceneId, with registered Rust handler names, validated at load time. Verified:Connections { list: [Connection { handler, target }] }is a reflected built-in scene component,rusting.connections; the reflection savestargetas the object’s scene ID and loads it back to the entity, so the Inspector, scene patches,rusting schema, prefab ID remapping and cooked scenes handle it like any other object reference.load_scene_documentchecks every connection of the objects it spawned, in object-ID order, before replacing the open scene: an unregistered handler or a target that is not in the world fails withSceneIoError::Connection { object, problem }and leaves the open scene as it was. Tools that load scenes without the game’s code (keep_unregistered: the editor,rusting capture) skip the check. Test:scenes_save_connections_by_object_id_and_check_them_on_load(save writes the target’s ID, a new app loads it and the handler runs with the loaded entities, unknown handler and dangling target fail with the object ID and keep the scene, tools mode loads). The schema catalog lists the component with an example (every_saved_field_is_documented_and_every_section_is_listed). Full check: fmt, clippy in three configurations,cargo test --workspace(core 54, lib 337, doc 16), gpu-tests (lib 403). - Add entity groups/tags with fast membership queries and editor assignment.
Verified: groups and tags are the existing object classes (
ObjectClasses), which scenes already save per object and the Inspector’s Classes section already assigns with undo. New insrc/runtime/classes.rs: theClassIndexresource maps each class name to its members in ascending entity-index order, kept current byon_insertandon_discardhooks onObjectClasses, so inserts, replacements, removals, despawns and scene loads (including the editor’s undo, which reloads the scene) update it. Snapshots skip the index andApp::restorerebuilds it, because a restore overwrites components in place, past the hooks. Read it withRes<ClassIndex>(members,contains) orApp::class_members. EditingObjectClassesthroughMutbypasses the index; this is documented. Test:class_index_follows_insert_replace_remove_despawn_and_restore. Documented indocs/concepts.md. Full check: fmt, clippy in three configurations,cargo test --workspace(core 54, lib 338, doc 16), gpu-tests (lib 404). - Add a typed scene-tree query API for common “find child / find in group / find by name” operations without hiding ECS queries.
Verified:
src/runtime/scene_tree.rs.SceneTreeis a bevySystemParambuilt from plain queries overName,ParentandChildrenplusRes<ClassIndex>. Its methods return only entities, so component access stays in the system’s own queries. Methods:find_by_name(the lowest entity index among objects with the name, so storage order does not change the answer; scans every named object),child,find_path(Godot-styleArm/Handwith..),parent,children,descendants(depth first, in hierarchy order),name,in_classandis_in_class. Test:scene_tree_finds_children_paths_names_and_classes(duplicate names, missing child,..path, empty path, descendants order, class members); the doc example compiles as a doctest. Documented indocs/concepts.md. Full check: fmt, clippy in three configurations,cargo test --workspace(core 54, lib 339, doc 17), gpu-tests (lib 405).
Data resources
- Add user-defined typed data assets (the equivalent of Godot
Resource/.tres), serialized through reflection and editable in the Inspector. Evidence:DataAsset,App::register_data_assetandAssetServer::load_data/save_data/reload_datainsrc/assets/data.rsstore.rdataJSON through the reflection walker. Unit testdata_assets_load_share_save_and_reload_through_handlescovers shared handles, nested references, relative paths, scene save and load of aHandlefield, and reload.bad_data_files_fail_with_the_reasoncovers a wrong type, an unknown field and a reference cycle. The editor’s AssetsNewmenu and the Inspector’s data asset view are covered bydata_assets_are_created_edited_and_reloaded_from_the_editor.cargo fmt --all --check, clippy with-D warningsin all three configurations,cargo test --workspace(lib 346) andcargo test --workspace --features gpu-tests -- --test-threads=1(lib 412) passed. Undo for data asset edits and a picker for unloaded files are in thedocs/editor-overhaul.mdbacklog. - Support shared versus per-instance-unique resource references. Evidence: a data asset handle saves as
{"$asset": path}when it points at a file and is shared, or as{"$data": value}when its value has no file, and each$dataloads as its own copy (handle_to_scene/handle_from_sceneinsrc/reflect/mod.rs, used by scenes and.rdatafiles).AssetServer::duplicate_datamakes a unique copy in game code, and the Inspector’sUniquechoice makes one from the current file and edits it in place. Unit testunique_data_assets_are_private_copies_saved_in_placecovers duplicate independence, a unique value inside a.rdatafile, a scene with one shared and two unique references loading as one file handle and two distinct copies, and a file reload that leaves the copies alone. Full check passed: fmt, clippy with-D warningsin all three configurations,cargo test --workspace(lib 347), and gpu-tests (lib 413). Saving a unique value out as a file is in thedocs/editor-overhaul.mdbacklog. - Hot reload data assets into running play sessions. Evidence:
AssetPlugin’spoll_asset_loadsscan (everyHOT_RELOAD_SCAN_INTERVAL, 500 ms) now also callsDataAssetTypes::reload_changed. It uses the same file-time check as meshes and textures to find changed.rdatafiles and reads each one again into its existing handle, which raises the asset’s revision. Unique copies are left alone. Reloads and failures go throughAssetServer::take_reloadedandtake_reload_failures, which the editor Console andproject_runner(hot reload failed:) already report. Unit testchanged_data_files_reload_into_a_running_gamecovers a rewrite picked up byApp::update, a raised revision, the reported path, an untouched unique copy, and a broken rewrite that keeps the last value and reports one failure. Full check passed: fmt, clippy with-D warningsin all three configurations,cargo test --workspace(lib 348), and gpu-tests (lib 414). Decoding runs on the main thread (ponytail:note; data files are small JSON).
Iteration speed
- Hot reload game Rust code during play, preserving ECS state through the reflection registry. Fall back to a clean restart with a clear message when the change is layout-incompatible. The owner chose a state-keeping restart instead of a dynamic-library game module (no new dependency, no ABI risk; costs one window and Vulkan start per reload). Evidence: the editor’s
Reload Code(EditorBuildState::request_code_reload) sendssave-stateon the game’s standard input. The game writes ascene_documentof every object plus its finishedoncekeys tobuild/code_reload_state.json(CODE_RELOAD_STATE_ENV) and exits. The editor then rebuilds and starts it again, andrun_projectrestores that state over the cooked scene with startup systems skipped.load_scene_documentis transactional, so a saved component that no longer fits its type leaves the fresh scene and printscode reload restarted clean:with the reason, which the Console shows as a warning. A failed build keeps the saved state for the next Play, and a game that does not save within 10 s is killed and started clean. Unit testcode_reload_keeps_the_scene_and_runs_the_new_coderuns one game build, saves, then restores into a build with a changed update system: the position from the old code is kept and the new code moves the object on, a runtime-added component keeps its value, and neither theonceblock nor the startup system runs again. A changed component type starts clean with the error naming the component. Editor testsstreamed_game_gets_the_save_command_on_standard_inputandcode_reload_saves_a_running_game_and_resumes_after_a_failed_buildcover the process and state wiring. A manual end-to-end check used a generated project on Wayland with an RTX 3060. The old code moved a spawned cube along x for 1 s, and after the save and rebuild the new code moved it along y from the same x (4.26). The first frame came back 1.35 s after the reload started, and both processes exited 0. This run found and fixed an exit crash in the egui clipboard on Wayland. Full check passed: fmt, clippy with-D warningsin all three configurations,cargo test --workspace(lib 351), and gpu-tests (lib 417). Automatic reload on save and keeping resources are in thedocs/editor-overhaul.mdbacklog. - Measure and report edit-to-running-game latency for a representative project; keep incremental Debug builds under a documented target. Evidence:
tests/iteration_latency.rs(ignored;cargo test --test iteration_latency -- --ignored --nocapture) creates the starter template project, runsrusting run --ticks 1 --jsonand reports the median of three samples ofbuild_msandcommand_to_first_frame_msfor no change and for an edit tosrc/main.rs. It fails when the Rust edit takes more than the documented 3000 ms target. The target and results are indocs/dev-environment.md“Iteration speed”. Run on 2026-09-28 (Ryzen 5 7600X, headless, sharedtarget/cli-games): first build 40259 ms, no change 129 ms, Rust edit 853 ms (build 824 ms); test passed. Windowed Reload Code measured 1.35 s to the first frame (previous item). - Define an optional sandboxed WASM scripting host (
rusting-script) exposing the reflected ECS API, for modding and designer-level logic. Native Rust remains the primary gameplay API. Evidence: new workspace cratecrates/rusting-script(the owner approved thewasmi2.0 dependency, with itsdeterministicfeature). Games opt in withScriptPlugin, which registers therusting.scriptcomponent (a.wasmor.watmodule path) and runs each script’sstart()once andupdate(delta_seconds)every frame, in entity order. Host functions in modulerusting(log,entity,find,action,get,set) read and write fields by component name and JSON pointer throughregistered_component_fieldandset_registered_component_field, so values are checked against the reflected type;transformaddresses the object’s transform. Sandbox: no WASI imports, 10 million fuel per call, 16 MiB memory, and a trap, limit or load error stops only that script with a[rusting] script <path> stopped:log line. Unit tests incrates/rusting-script/src/tests.rs:scripts_read_and_write_reflected_fields(start moves the object, update copies another object’s field through JSON),bad_requests_return_a_status_instead_of_stopping_the_script(unknown component and wrong kind return -3, a missing entity -1, and a changed module path reloads),sandbox_limits_stop_only_the_offending_script(an endless loop, a 64 MiB memory, a non-Wasm.wasm, and a missing file each stop while another script keeps running), andremoved_scripts_are_dropped. The host API, limits and examples are indocs/scripting.md. The Rust guest example there was not compiled, because thewasm32-unknown-unknowntarget is not installed on this machine. Full check passed: fmt, clippy with-D warningsin all three configurations,cargo test --workspace(lib 351, rusting-script 4), and gpu-tests (lib 417). - Deliver project templates for 3D first-person, 3D third-person, 2D platformer, and physics sandbox games. Evidence:
ProjectTemplategainsFirstPerson3d(first-person),ThirdPerson3d(third-person) andPhysicsSandbox(sandbox) next to the existing2dplatformer, withALL,nameandlabel;rusting new --templatetakes every name and the editor’s Create Project form has a Template picker (ProjectManagerState::template). The player templates build a lit floor with fixed crates and a kinematic capsulerusting.player_controllerwith a child camera; third person adds a visible body and the newPlayerController::camera_distance/camera_height, which orbit the camera behind the body. The sandbox drops nine dynamic boxes and two balls onto a walled floor under a fixed camera. Unit tests:third_person_camera_orbits_behind_the_body; insrc/project.rs,template_names_round_trip,the_player_templates_stand_walk_and_frame_the_camera(both player templates land grounded at y 0.9, walk forward with W, and the camera sits at eye height or behind and above), andthe_sandbox_template_settles_its_pile_on_the_floor(after 20 s every box is still and every body is above the floor and inside the walls); the existing 2D template test still passes. Real GPU (RTX 3060)rusting capture --tick 120of each new template showed the expected frames, andrusting run --ticks 60of a generated third-person project built and exited 0 (first frame 15 ms). Full check passed: fmt, clippy with-D warningsin all three configurations,cargo test --workspace(lib 355, rusting-script 4), and gpu-tests (lib 421).
Exit gate
- A prefab edited in the editor updates every instance that did not override the edited property, and overrides survive save and reload.
- An observer connected in a scene file fires the registered Rust handler at runtime.
- A reflected component added in game code appears in the Inspector, serializes, and animates without editor changes.
- Changing a gameplay system during play reloads it without losing scene state.
Milestone 10: Advanced rigid-body physics
Goal: exceed Godot’s built-in 3D physics feature set on the CPU path while keeping every feature available to hybrid CPU/GPU scenes.
Depends on: Milestones 5 and 8 (all new solver work follows the determinism rules).
Constraints and articulations
- Joints: fixed, hinge, slider, ball-socket, cone-twist, distance, spring, and a generic 6-DOF joint with per-axis limits, springs, and motors. Evidence: the
Jointcomponent (rusting.joint,src/runtime/joints.rs) links a CPU body to another CPU body or to the world. EveryJointKindis a preset of six axes (locked, free, or limited, each with an optional soft spring and a force-capped motor), plus a cone limit and an anchor-distance limit or spring. The CPU solver builds one velocity row per constraint in joint entity order, warm-starts them from the last step (PhysicsWorldstate, hashed in entity order), and solves them before contacts with 4 passes per iteration. Jointed pairs skip contacts unlesscollide_connectedis set, and a moving or motor-driven side wakes the other. The component is reflected, listed inrusting schema, snapshot-registered, and savestargetas a scene ID. Tests:runtime::tests::hinge_pendulum_keeps_its_pivot_and_swings_in_its_plane,hinge_limits_stop_the_swing_and_motors_drive_it,sliders_move_along_their_axis_only,fixed_joints_hold_a_cantilever_in_place,ropes_cap_the_distance_and_springs_settle_at_their_stretch,cone_twist_joints_keep_the_swing_inside_the_cone,joints_link_bodies_without_contacts_and_round_trip_through_scenes(also checks determinism), andruntime::cpu_physics::joints::tests::swing_twist_splits_rotations_about_each_axis. Full check passed: lib 363 tests, withgpu-tests429. Limits: CPU bodies only (GPU solvers have no joints), and the anchor rows are iterated rather than solved as one 3×3 block. - Breakable joints with force/torque thresholds that emit typed events. Evidence:
Joint::break_force(N) andJoint::break_torque(N·m), 0 meaning unbreakable, are reflected and listed inrusting schema. After the velocity solve, each breakable joint’s load is its accumulated linear-row impulse (force) and purely angular-row impulse (torque) over the step length. A joint past either limit has itsJointcomponent removed and sends a typedJointBroken { joint, target, force, torque }throughEventQueue<JointBroken>, in joint entity order. The queue is registered byApp::newand captured by snapshots. Test:runtime::tests::joints_break_past_their_force_or_torque_and_send_an_eventcovers a hanging ball whose joint breaks at 1 N and reports its weight, then falls; the same joint at twice its weight holds; and a 1 m cantilever breaks below its gravity torque and holds above it. Full check passed: lib 364 tests, withgpu-tests430. Limit: the joint breaks after the step that overloaded it, so that step’s impulse is still applied. - Reduced-coordinate articulations (Featherstone) for robots, chains, and ragdolls that need exact joint limits without drift. Evidence:
rusting.articulation(Articulation, reflected, inrusting schema, captured by snapshots) on a root body turns theFixed,Hinge,Slider, andBallSocketjoints of its tree (breadth-first, joint entity order) into joint coordinates; other joints stay maximal and couple through the tree.src/runtime/articulation.rsreads the coordinates from the link poses each step and places every link by forward kinematics, builds the joint-space mass matrix from each link’s Jacobian, and applies gravity and velocity-product forces from a recursive pass, taken at the midpoint speeds so fast chains do not gain energy. Contacts and joint rows on a link useGᵀ H⁻¹ Ras effective mass and move the tree throughH⁻¹; hinge and slider limits, springs, and motors are joint-space rows, and each coordinate is clamped to its limit after integration. A dynamic root floats with six coordinates. Tests:runtime::tests::articulated_chains_swing_without_their_joints_drifting(8-link ball-socket and hinge chains, every link exactly 0.5 m from its parent within 1e-4 on every step for 4 s, energy never above 106% of the start, the chain stays in its plane);articulated_hinge_limits_hold_exactly_and_motors_drive_them(the angle never passes its −0.5 rad limit by more than 1e-4, where the maximal joint allows 0.05; a motor reaches 2 rad/s);floating_articulations_land_rest_and_round_trip_through_scenes(a floating torso with two ball-socket links lands on the ground with its links exactly in place, is bit-identical across runs, and the component round-trips through a scene). Full check passed: lib 367 tests, withgpu-tests433. Limits: denseH(O(n³) per step; articulated-body algorithm for trees past ~30 links), no cone limits on ball sockets in a tree, reduced joints do not break, articulations never sleep, and a whipping chain tip overshoots its energy by up to about 5%. - Stable long joint chains and high mass ratios verified by regression scenes.
Bodies and characters
- Kinematic character controller with slopes, steps, ground snapping, moving platforms, and push interaction with dynamic bodies.
- Physics-based (dynamic) character option for games that want fully simulated movement.
- Raycast and wheel-collider vehicle models with suspension, tire friction curves, differential, and drivetrain.
- Ragdolls generated from skeletons, with a handoff API to and from animation (consumed by Milestone 14).
- Compound shapes, convex hulls, heightfields, and triangle meshes with per-triangle materials.
Materials, forces, and fields
- Physics materials with friction, restitution, combine modes, and per-material contact events.
- Force fields: directional, radial, vortex, wind with turbulence, and custom field functions; usable by CPU and GPU bodies alike.
- Buoyancy and drag against water volumes.
- Gravity volumes and per-body gravity scale (planetary gravity, zero-g zones).
Solver quality
- Simulation islands with sleeping and wake propagation.
- Speculative contacts plus swept CCD for fast bodies; bullets through thin walls never tunnel in regression scenes.
- Substepping with a documented stability/cost trade-off per quality profile.
- Contact caching and warm starting for stable stacks.
- Multithreaded CPU solver whose results are identical for any worker count.
2D physics
- 2D rigid bodies, shapes (circle, capsule, box, convex polygon, segment chain), joints, and queries, sharing the solver architecture and determinism rules with 3D.
- Hybrid 2D GPU bodies with the same condition/event/command bridge as 3D.
Exit gate
- A regression scenario suite (pyramid stacks, joint chains, high mass ratios, CCD bullets, ragdoll piles, vehicles on uneven terrain) runs headless in CI with recorded expected results.
- Every joint type, character controller behaviour, and vehicle model has a test and a demo scene.
- CPU results are identical across worker-thread counts.
- 2D and 3D physics run in the same project without interference.
Milestone 11: Deformable, continuum, and large-scale GPU physics
Goal: provide physics Godot does not have — deformables, fluids, granular media, runtime fracture, and million-body scale — all coupled in one world.
Depends on: Milestones 5, 8, and 10.
Deformables
- XPBD soft bodies from tetrahedral meshes with volume preservation, attachment to rigid bodies, and tearing.
- Cloth with self-collision, wind interaction, attachment/pinning, and tearing.
- Ropes and cables with rigid-body attachment and correct tension transfer.
- GPU execution path for deformables with the same event and readback bridge as rigid GPU bodies.
Fluids and granular media
- Particle-based fluid (PBF or SPH) with two-way rigid-body coupling and buoyancy.
- Step 1, the solver core:
runtime::fluid(Fluid,FluidSettings) is a CPU position based fluid with a hash-grid neighbor search, sorted neighbor lists, Jacobi passes into separate buffers, XSPH viscosity, a box container andstate_hash. No atomics, no randomness. Tests:the_same_start_gives_the_same_bits(30 steps, equal hashes),a_column_falls_settles_and_stays_inside_the_container(768 particles, 240 steps, all finite and inside the box, top below 1 m, speed below 3 m/s),a_settled_pool_keeps_its_density_near_rest(mean density ratio in 0.5 to 1.6). Full check passed. Limits: the pool settles about 1.6 times denser than rest (walls have no ghost particles and only over-density is pushed apart); artificial pressure is off (SCORR, its scale was wrong for these units); results are deterministic for one particle order but not invariant under reordering the array (float sum order). Open: scene format, rigid-body coupling, buoyancy, rendering, GPU port. - Step 2, the ECS component:
FluidVolume { settings, fluid }steps in theFixedUpdatechain after CPU physics (step_fluids). Testruntime::tests::fluid_volumes_step_with_the_fixed_tick(64 particles fall 0.3 m or more in 30 ticks). Full check passed. Limits: runtime only, made by game code; not reflected, not saved in scenes, not in snapshots. - Step 3, drawing:
FluidVolume::visual(aMeshRenderer) makessync_fluid_visualskeep one entity per particle, moved each fixed tick, and despawn them when the visual is removed. Testruntime::tests::fluid_volumes_with_a_visual_own_one_entity_per_particle(8 particles give 8 renderers; removing the visual gives 0). Full check passed. Limits: one entity per particle, so a few thousand particles is the practical ceiling until fluids get an instanced draw path or a surface mesh; the particle entities carry aFluidParticlemarker, and the editor Hierarchy and Scene View picking skip them (testseditor::tests::fluid_particles_are_not_listed_in_the_hierarchy, and the marker count in the visual test; full check passed with all three feature sets). - Step 4, rigid-body coupling and buoyancy:
couple_fluidsruns beforestep_fluids. A dynamic body with a sphere collider gets an upward force of rest density times the sphere volume its overlapping particles fill times gravity, plus drag, and particles inside the sphere are pushed out to its surface. Bodies and volumes go in entity order. Testruntime::tests::a_light_ball_floats_in_a_fluid_and_a_heavy_one_sinks(a 7 kg ball of 0.15 m radius floats at y 0.25 in a pool about 0.3 m deep; a 50 kg ball rests on the floor at y 0.145). Full check passed. Box colliders work the same way (rotation and scale respected; particles leave through the nearest face). Testruntime::tests::a_light_box_floats_in_a_fluid_and_a_heavy_one_sinks(5 kg floats above a 100 kg box resting on the floor); full check passed. Capsules too (testa_light_capsule_floats_in_a_fluid_and_a_heavy_one_sinks; the capsule is a core segment along its local Y with a radius). Limits: sphere, box and capsule colliders only (meshes are skipped); every body checks every particle (O(bodies x particles)); the force is analytic, so the fluid does not push the body sideways and the body’s momentum reaches the fluid only through displacement; a body far lighter than the water it displaces is launched hard (that is the physics, but the explicit step is stiff). - Step 5, the scene component:
rusting.fluid_block(FluidBlock: spacing,count_x/y/z,container_half_extents, iterations, viscosity,visible) is reflected, listed inrusting schema, saved in scenes and registered for snapshots.spawn_fluid_volumesturns each block into aFluidVolume(box centered on the entity, particles on its floor, spheres with the fallback material when visible). Testruntime::tests::fluid_blocks_round_trip_through_scenes_and_become_volumes(a 3x2x4 block round-trips through a scene document and becomes 24 particles in a box whose floor is at y 1.5). Full check passed. Limits: the particles themselves are not in scenes or snapshots, the editor shows no fluid preview until play. (Snapshots now do keep the fluid: see Step 6.) - Step 6, review fixes.
couple_fluidsvisits bodies and volumes inSpawnOrder(entity order only for objects without one), and returns at once with no volume.FluidParticle(owner)names the volume that made a particle: removing a volume despawns its particles, and a copied volume makes its own.FluidVolumeandFluidParticleare registered for snapshots, so a restore resumes the same fluid. Cooked scenes are format 8 (SCENE_FORMAT_VERSION); v7, v6 and v5 files load through legacy material structs. Tests:despawning_a_fluid_volume_removes_its_particle_entities,a_copied_fluid_volume_gets_its_own_particle_entities,fluids_snapshot_and_restore_to_the_same_future,version_seven_cooked_scenes_read_materials_without_the_new_fields. Also fixed from the same review: swapchain image count clamps to the surface maximum (probe_swapchain_images); the CCD sweep adds its pad only for grown shapes;slidemeasures step height at the contact position; apush_bodies: falseplayer keeps its contacts (crates rest on it,a_crate_dropped_on_a_player_that_pushes_nothing_rests_on_it) but shoves nothing; the slow-motion clamp scales withtime_scaleand replay skips it (update_exact);set_scenerefuses the root path;schema::defaultsis cached and itsmesh_rendererdefault comes fromMaterialAsset::default(); whole numbers compare exactly in scenarioequals(whole_numbers_above_the_f32_range_match_exactly); headless ms per tick excludes scene load; reflection probes and lights sort by entity index;pointerlisted under scenario steps. Full check: fmt, clippy in all three configurations,cargo test --workspace(lib 445, cli 13), gpu-tests (lib 520, cli 13). - Material names.
MaterialAsset::name(reflected, scene-saved; cooked format 9, format 8 loads throughLegacySceneDocumentV8; glTF import keeps the glTF name). Mesh Renderer combos show names or “Material N”/“Mesh N”. Tests:version_eight_cooked_scenes_read_materials_without_a_name,material_names_survive_the_scene_form. Full check in the same run as the Project Settings item. - Fluid surface.
fluid_surface::surface_meshsplats particles on a grid in index order and extracts the surface by marching tetrahedra (outward faces from the density gradient);sync_fluid_surfaceskeeps one entity and one mesh per volume and rewrites the mesh each fixed tick (the renderer re-uploads on the asset revision). Tests:a_block_of_particles_gets_one_closed_outward_surface,the_surface_is_reproducible_and_empty_fluid_still_draws,a_fluid_surface_is_one_entity_whose_mesh_follows_the_particles. How it looks on screen is not judged here. Deferred: GPU surface pass, screen-space fluid, shared vertices. - Water body and opacity 0.
rusting.water(runtime::water):sync_waterrewrites a wave grid mesh each fixed tick,float_in_watergives buoyancy, drag and a current to dynamic bodies. Tests:waves_are_reproducible_and_bounded,the_mesh_grid_matches_the_rectangle,a_water_body_draws_one_surface_floats_a_light_ball_and_carries_it. The scene shader discards Blend fragments with alpha 0 and no transmission; gpu testa_blend_material_at_opacity_zero_draws_nothing(the clear-glass test now uses alpha 0.02). Deferred: rotated water, wave torque on boxes, shore foam, river splines, water material look (no screenshot taken). - Editor redesign, part 1. The Project panel is a Project Settings page (
src/editor/project_settings.rs) built from newgui_elements::kitwidgets: category rail (Overview, Rendering, Display, Export, Diagnostics), cards, setting rows, switch, segmented control, stat tiles, CPU frame bar. The Console has a framed toolbar, striped rows and level stripes. Opacity below 1 turns an Opaque material into Blend. Add Component is a searchable grouped picker (inspector/add_component.rs); Inspector sections are bordered cards. Testproject_settings::tests::every_category_draws_its_cards; the two editor text tests were updated for the new layout. Full check passed: fmt, clippy in all three configurations,cargo test --workspaceand with gpu-tests. Not judged by eye: no screenshot was taken, so spacing and colors need a look in the editor.
- Step 1, the solver core:
- Grid-based or hybrid (FLIP/APIC) option for large water volumes, chosen by the simulation class.
- Granular material (sand, gravel, rubble) with piling and angle-of-repose behaviour.
- Fluid surface extraction for rendering through extraction, never by rendering reading solver buffers directly.
Fracture and destruction
- Pre-fractured assets authored in the editor (Voronoi and slicing) with connectivity graphs.
- Runtime fracture from impact energy and stress, deterministic under the seeded tick RNG.
- Debris lifetime, budget, sleeping, and merge policies. Never drop bodies arbitrarily at the cap.
- Destruction events consumable by audio, particles, navigation, and gameplay.
Scale
- One unified GPU broad phase shared by rigid, deformable, particle, and fluid solvers.
- Multi-GPU-queue scheduling: async compute for physics overlapping graphics where supported, with a fallback on single-queue devices.
- GPU simulation LOD: distant or unobserved regions step at reduced rates or freeze, with explicit, deterministic policies.
- Streaming simulation regions in and out of GPU memory within a device budget.
Coupling
- Two-way coupling between rigid, articulated, deformable, fluid, and granular simulation in one scene.
- Mixed CPU/GPU ownership for coupled objects with defined latency and state age.
Exit gate
- A demo scene combines cloth, soft bodies, fluid, granular media, and runtime fracture interacting with rigid bodies, with correct events delivered to Rust gameplay.
- Deformable, fluid, and fracture scenarios have headless GPU tests under lavapipe.
- A 1,000,000-body GPU scene runs within the hardware budget recorded for the benchmark machine.
- Fracture is deterministic across runs.
Milestone 12: Physics tooling, authoring, and evidence
Goal: make physics the best-understood part of the engine — visible, debuggable, authorable, and benchmarked against competitors.
Depends on: Milestones 5, 6, and 10. Deformable and fluid tooling follows Milestone 11.
Physics debugger
- Debug draw for shapes, contacts, normals, impulses, joints, islands, sleeping state, broad-phase cells, and GPU ownership.
- Record physics sessions (from play mode or a running game) and scrub a timeline tick by tick, using Milestone 8 replay.
- Per-body inspector showing state history, contacts, applied forces, owning solver, sync mode, and readback age.
- Remote physics debugging of a running game process from the editor.
- Heatmaps for solver cost, contact density, and overflow by region.
Authoring tools
- Collider editing gizmos, automatic collider generation, and convex decomposition (V-HACD-class) for imported meshes.
- Joint placement and limit-editing gizmos with live preview.
- Ragdoll creation wizard from a skeleton.
- Physics material library, collision-layer matrix editor, and force-field visualization.
- Simulate-in-editor: run physics on selected objects without entering play mode, then keep or discard the result (for placing props naturally).
- Fracture authoring preview.
Profiling
- Physics profiler panel: CPU/GPU solver time per stage, body/contact/constraint counts, command/event/readback bytes, overflow, and synchronization latency.
- Automatic-allocation diagnostics explaining why each body was placed on CPU or GPU.
Benchmarks and evidence
- Standard benchmark scenes: stacking stability, 100K pile, ragdoll count, joint-chain stability, CCD bullets, vehicle stress, cloth, fluid, and fracture.
- Reference implementations of the same scenes on Jolt, PhysX, Rapier, and Godot Physics, run by the same harness.
- Publish results with hardware, driver, and settings; regressions against stored baselines fail CI.
Exit gate
- A physics bug in a recorded session can be located by scrubbing to the tick and inspecting the body in the editor.
- Colliders, joints, ragdolls, and materials can be authored entirely in the editor.
- The comparative benchmark report is generated reproducibly and checked into the repository.
Milestone 13: Advanced rendering
Goal: reach Godot’s Forward+ visual feature set and give users programmable shading.
Depends on: Milestones 3 and 4.
Global illumination and reflections
- Reflection probes with box projection and blending.
rusting.reflection_probeholds the half size of a world-aligned box around its object. When a probe is added or changes, a temporary renderer draws six 90-degree faces from its center, so the main renderer’s caches, GPU physics and occlusion history stay untouched. The faces are copied into one mipmapped array image with six layers per probe. Up to four probes are used (MAX_REFLECTION_PROBES). The fragment shader’ssurroundingscasts the reflection direction to the box wall, looks up the face toward that point, fades each probe in over the outer tenth of its box, and blends the result over the environment. Rough surfaces use lower mips. Limits (ponytail:in code): probes are static captures, faces are tone mapped with Linear so captured light clamps at 1, and moving a probe rebuilds the capture pipelines. Evidence:reflection_probe_shows_the_scene_behind_the_camerarenders a mirror floor under an unlit red ceiling that covers the -X half and is behind the camera. With no probe, both sides are dark. With a probe, the -X side is red and the +X side is not. Flipping the face lookup’s x axis fails the test. Full check: fmt, clippy three ways,cargo test --workspace(474 passed, 76 ignored), and GPU tests (546 passed). Editor: a selected probe shows a handle at each box face; dragging one sets that axis ofextents(the box stays centered), as one Undo step. Evidence: unit testdragging_scene_handles_resizes_a_probe_and_thins_fog. - Refractive glass:
transmission,iorandthicknesson materials, sampling a copy of the opaque scene color so glass bends and tints what is behind it. The scene render pass must split after the opaque draws in every variant (MSAA, occlusion culling). Materials carrytransmission,iorandthickness. A frame with a transmissive draw ends the scene pass after the opaque draws, copies HDR into a mipmappedscene_colorimage (FramePass::SceneColor), and draws the blended list in the late pass (FramePass::Transparent). The shader refracts the view ray once at the entry face, offsets it bythickness, and samples the copy at miproughness * last_mip, tinted by base color. Limits: no exit-face refraction, and blended objects behind glass are not in the copy. glTFKHR_materials_transmissionimport needs a newgltfcrate feature and is not done. Evidence: GPU testtransmissive_glass_tints_the_refracted_backdropunder frustum and occlusion culling (fails with the refraction path turned off: 255 255 255);transitions_list_every_layout_change_between_passes. - Baked lightmaps with a GPU lightmapper and light probes for dynamic objects.
- One real-time GI technique (probe-based DDGI or voxel/SDF GI) with a quality-profile fallback to ambient probes.
- Sky system: procedural physical sky, HDRI skies, and sky-derived ambient/specular.
Partial:
EnvironmentMap { texture, intensity }(rusting.environment_map) loads an equirectangular image and replaces theSkyLighthemisphere as the ambient source. Diffuse samples the second-smallest mip along the normal; specular samples miproughness * last_mipalong the reflection. Textures now get a linear-blit mip chain, and samplers now allow every mip (the old default clamped the LOD to 0). Known ceilings: 8-bit images only, so no.hdr/EXR and no light brighter thanintensity; box-filtered mips instead of GGX prefiltering; the map is not drawn as the background. Evidence: GPU testsmooth_metal_reflects_the_environment_map(a smooth metal slab reflects the red upper half from above and the blue lower half from below; fully rough, it shows a blend of both). It failed before the pole clamp (a repeating sampler blended the top row with the bottom one) and before the mip chain (rough metal stayed pure red). Unit testenvironment_maps_resolve_their_texture_and_reach_the_render_world.
Screen-space and post effects
- SSAO, SSR, and screen-space indirect lighting, each independently toggled by quality profile.
Partial (SSR): frames with a PBR batch under roughness 0.5
(
SSR_MAX_ROUGHNESS) split like refraction frames. TheSceneColorpass also copies opaque depth into depth pyramid mip 0; theTransparentpass redraws the glossy batches with a reflection overlay pipeline (fragment specializationPASS = 2,LessOrEqualdepth, additive blend) that marches the reflected ray through that depth and adds the difference between the traced color and the environment reflection. Blended and transmissive surfaces trace inline. The weight fades at the screen edge, at the end of the march, and toward the roughness limit.Ecoskips it. Limits: linear march, no Hi-Z; glossy surfaces do not reflect each other or blended objects. Evidence: GPU testmirror_floor_reflects_the_scene_in_screen_space(red wall in the floor on High, plain gray hemisphere on Eco); pass tabletransitions_list_every_layout_change_between_passes. Partial (SSAO):rusting.ambient_occlusion(radius, intensity) turns it on. ADepthPrepasspass draws the early-culled (else GPU-culled) batches into scene depth with alpha-mask discard; theAmbientOcclusioncompute pass traces 16 rotated hemisphere samples per pixel against that depth, then a 4x4 depth-aware blur writes an R32F factor that set 2 binding 6 multiplies into ambient, sky and environment light. The main pass then loads the prepassed depth. Off onEco, in probe captures and in non-Lit views. Limits: objects that come into view on an occlusion-culling frame miss the prepass for one frame; no temporal accumulation. Evidence: GPU testsambient_occlusion_darkens_surfaces_next_to_occluders(floor next to a box darker than open floor, unchanged with the component removed) andatmosphere_effects_combine_with_every_scene_path(frustum and occlusion culling, MSAA off and 4x, opaque and glossy); pass tabletransitions_list_every_layout_change_between_passes. Screen-space indirect lighting is not started. - Bloom/glow, depth of field, motion blur, auto-exposure, color grading LUTs, vignette, and chromatic aberration.
Partial (bloom):
rusting.bloom(intensity, threshold, spread). After the transparent pass, theBloomcompute pass prefilters the HDR target to half resolution (13-tap with Karis weights against fireflies, soft knee at the threshold), downsamples it through up to 7 mips, and upsamples back with a 3x3 tent mixed by spread. Tone mapping adds mip 0 times the intensity before the curve. Frames with bloom split the main pass like refraction frames. Off in probe captures and non-Lit views. Evidence: GPU testsbloom_spreads_bright_light_into_its_surroundings(dark pixels beside an emissive quad brighten only with bloom on) andatmosphere_effects_combine_with_every_scene_path. The other effects are not started. - Temporal anti-aliasing and FXAA; optional upscaling (FSR-class) behind capability checks.
- Volumetric fog with light scattering and fog volumes.
Partial (height fog):
rusting.fog(color, density, height, height_falloff, sun_scatter, sky_affect).src/shaders/fog.glslintegrates exponential height fog along the camera ray in closed form, with in-scattering toward the shadow-casting (or first) directional light. Opaque, blended and transmissive surfaces apply it in the fragment shader; a full-screen sky pass at the far plane fades the background. Evidence: GPU testfog_hides_distant_surfaces_and_the_sky(distant red wall and the sky turn fog-colored, near surfaces keep their color) andatmosphere_effects_combine_with_every_scene_path; unit testsatmosphere_settings_come_from_the_lowest_entityandatmosphere_settings_go_on_one_environment_object. Not started: froxel volumetrics, light shafts through shadows, local fog volumes. Editor: the Scene view’s viewport menu has Scene Effects toggles that leave fog, bloom or ambient occlusion out of the Scene view only (Play and games draw them all), and a selected Fog draws squares at its full-density height and its 1/e height. Dragging the first square setsheight; dragging the second setsheight_falloff; each drag is one Undo step. Evidence: unit testsscene_view_shading_and_effects_apply_only_in_the_scene_workspace,fog_height_squares_mark_full_density_and_the_thinned_heightandfog_squares_are_grabbed_and_dragged_to_a_height; the fog, bloom and ambient occlusion GPU tests render each effect switched off.
Lighting and shadows
- Cascaded shadow maps for directional lights; shadows for point and spot lights.
- Clustered lighting for many point and spot lights.
- Area-light approximation and light cookies.
Geometry and effects
- GPU particle system reusing the physics compute infrastructure, with collision against physics shapes, attractors, sub-emitters, and trails.
- Particle emission driven by physics events: impacts, fracture, debris settling.
- Decals, including decals that survive or are invalidated by fracture.
- Instanced multi-mesh rendering and foliage scattering with wind driven by physics force fields.
- Heightmap terrain rendering with texture splatting and LOD.
- Automatic mesh LOD generation at import.
- Rendering of deformables, fluid surfaces, and debris produced by Milestone 11.
- Effect budget and quality-profile scaling for particles and decals.
Programmable shading
- A versioned engine shader language (GLSL-based with engine includes) for surface, unlit, sky, particle, fog, and post-process shaders, with stable uniforms/built-ins.
- A visual shader graph that compiles to the same language.
- Shader hot reload with error reporting in the editor.
- Material instancing with per-instance parameter overrides.
Frame structure
- A render graph that schedules passes, transient resources, and barriers automatically.
- Frame pacing that holds a stable presentation cadence under variable GPU load.
- Measure and report end-to-end input latency.
Exit gate
- Golden images cover probes, GI fallback, SSAO/SSR, fog, bloom, tone mapping, particles, decals, and custom shaders.
- A user-written surface shader and a visual-graph shader render correctly without engine changes.
- Every effect degrades gracefully on the low-end baseline defined in Milestone 3.
Milestone 14: Animation
Goal: match Godot’s animation system and make physics-driven animation a first-class feature.
Depends on: Milestones 2, 9 (reflection), and 10 (ragdolls).
Core animation
- Skeletal animation with GPU skinning and blend shapes (morph targets).
- glTF skin, morph target, and animation import, completing the Milestone 2 deferral.
- Property animation of any reflected field (the equivalent of
AnimationPlayer), including method-call and event tracks. - Tweens for scripted interpolation with easing curves.
Blending and control
- Animation state machine with transitions, conditions, and sync groups.
- 1D/2D blend spaces, additive layers, and bone masks.
- Root motion with an explicit policy that never feeds back into deterministic simulation unless routed through the physics command bridge.
- Two-bone, FABRIK, look-at, and foot-placement IK.
- Skeleton retargeting between humanoid rigs.
Physics-driven animation
- Ragdoll handoff between animation and physics on physics events, with blend-back.
- Active ragdolls: powered articulations that track animation targets while reacting physically to hits.
- Procedural secondary motion (jiggle bones, tails, hair) through the physics solver rather than a separate spring system.
- Cloth attached to skinned characters (from Milestone 11).
Exit gate
- An imported, skinned character blends through a state machine, uses IK on uneven ground, and hands off to an active ragdoll when hit.
- A property track animates a custom reflected component.
- Animation playback results are identical across runs.
Milestone 15: Audio
Goal: provide Godot-equivalent audio, driven naturally by physics.
Depends on: Milestone 1. Physics-driven audio depends on Milestones 5 and 10.
- Audio device management with hot-plug, output selection, and a no-device fallback.
- Mixer with buses, sends, volume/mute/solo, and bus effects (reverb, delay, EQ, compressor, limiter, filters).
- WAV, OGG Vorbis, and FLAC import; streaming playback for long assets.
- 3D spatial audio with attenuation curves, doppler, and an occlusion approximation using physics raycasts.
- Reverb zones tied to physics volumes.
- Event-driven playback triggered by gameplay events and by GPU physics events.
- Physics-driven impact, scrape, and roll sounds parameterized by contact impulse, relative velocity, and physics material.
- Voice limiting and priority so large destruction events do not exhaust the mixer.
- Offline render path for deterministic audio tests.
- Audio state is presentation only and never affects simulation or replay hashes.
Exit gate
- An offline-rendered mix matches a reference buffer within tolerance.
- A physics scene with thousands of contacts produces voice-limited impact audio without dropouts.
- Disabling all audio does not change replay hashes.
Milestone 16: Runtime UI, text, and localization
Goal: give games a runtime UI toolkit equivalent to Godot’s Control system.
Depends on: Milestones 1, 3, and 9.
- Runtime UI layer independent of the editor’s egui usage, rendered through extraction.
- Layout containers: box, grid, margin, scroll, split, tab, and anchors/offsets for free placement.
- Widgets: label, button, toggle, slider, text input, dropdown, list, tree, progress bar, image, and panel.
- Text shaping with Unicode, bidirectional text, font fallback, SDF font rendering, and rich text markup.
- Themes and styles editable as data assets.
- Focus navigation for keyboard and gamepad, and input routing between UI and gameplay.
- Data-bound HUD elements and world-space indicators.
- Scaling across resolutions, aspect ratios, and DPI settings.
- Localization: translation tables, pluralization, locale switching at runtime, and extraction of translatable strings.
- Accessibility: screen-reader metadata, scalable text, and color-blind-safe defaults.
- Input remapping UI component built on the action map.
- Decouple input sampling rate from the simulation tick without introducing nondeterminism.
Exit gate
- A menu, settings screen with rebinding, and HUD can be built from data assets and work with mouse, keyboard, and gamepad.
- Golden images cover layout, text shaping (including right-to-left), and theming.
- Switching locale at runtime updates every translated string.
Milestone 17: 2D engine
Goal: make RustingEngine a complete 2D engine, not a 3D engine with a flat camera.
Depends on: Milestones 3, 4, and 10 (2D physics).
- 2D renderer with sprites, sprite batching, z-ordering, and canvas layers.
- Sprite sheets and sprite animation.
- Tilemaps with multiple layers, autotiling rules, tile collision shapes, and tile navigation data.
- 2D lights, shadows from occluder polygons, and normal-mapped sprites.
- 2D GPU particles with physics-event emission.
- 2D camera with smoothing, limits, and pixel-perfect mode.
- 2D hybrid GPU physics showcases (thousands of physics sprites with events to Rust gameplay).
- Mixing 2D layers over or inside 3D scenes.
Exit gate
- A 2D platformer template with tilemap, lights, particles, and physics is playable and editable in the editor.
- Golden images cover sprites, tilemaps, and 2D lighting.
Milestone 18: Navigation
Goal: provide Godot-equivalent navigation for 2D and 3D, with runtime updates that respond to physics.
Depends on: Milestones 5 and 10.
- Navigation mesh baking from collision geometry for 3D, and from tilemaps/polygons for 2D.
- Runtime incremental re-baking of changed tiles within a per-tick budget.
- Path queries, off-mesh links (jumps, ladders, doors), and area costs.
- Navigation agents with path following and velocity-obstacle avoidance.
- Obstacles derived from physics bodies, including moving and sleeping bodies.
- Navigation debug visualization and profiler counters.
- Deterministic query and avoidance results for deterministic projects.
Exit gate
- Agents route around physics bodies that are knocked into their path at runtime.
- Re-baking stays within its per-tick budget and reports overruns.
- Navigation results are identical across runs.
Milestone 19: Networking and multiplayer
Goal: provide Godot-equivalent high-level multiplayer, plus deterministic rollback that Godot cannot offer.
Depends on: Milestones 8 and 9. The Milestone 8 result decides whether rollback is offered across vendors.
- Transport abstraction with UDP (reliable and unreliable channels), WebSocket, and loopback for tests.
- Versioned wire protocol with compatibility rejection at connect time.
- Remote procedure calls on entities with authority checks and reliability modes.
- Replicated spawning and component synchronization, configured through reflection, with delta compression and quantization.
- Client-server and listen-server topologies; headless dedicated-server builds.
- Client-side prediction and reconciliation for player-controlled entities.
- Deterministic rollback netcode for physics-heavy games, using Milestone 8 snapshots and input streams.
- Physics-aware replication: replicate commands and events instead of full body state wherever determinism allows.
- Network simulation (latency, jitter, loss) for testing, and a network profiler.
- HTTP client for services and downloads.
Exit gate
- A physics sandbox runs with four clients under simulated 150 ms latency and 2% loss without visible desync.
- Rollback reproduces identical simulation state on every peer in a deterministic test.
- RPC, spawning, and synchronization have loopback tests.
Milestone 20: Editor parity
Goal: bring the editor to Godot-level completeness on top of the Milestone 6 foundation.
Depends on: Milestone 6, and the milestone that owns each edited subsystem.
Workflow
- Remote scene tree and inspector for a running game process.
- Runtime debugger panel: errors with stack traces, monitors (FPS, memory, physics, rendering counters), and custom monitors.
- Unified profiler with CPU spans, GPU pass timings, physics stages, network traffic, and memory.
- Project settings editor, input map editor, and per-asset import settings dock with re-import.
- Multiple scene tabs and multiple viewports (split views, orthographic views).
- 2D viewport with snapping and pixel grid.
- Search across the project: files, entities, components, and settings.
- Version-control integration showing changed files and scene diffs.
Specialized editors
- Animation editor with timeline, curves, onion skinning, and state-machine graph.
- Shader editor and visual shader graph editor.
- Tilemap and tile-set editor.
- Grid/modular level editing and constructive solid geometry blocking tools.
- Particle system editor with live preview.
- Theme and UI layout editor.
- Audio bus editor.
- Navigation baking controls and visualization.
Extensibility
- Editor plugin API for custom panels, inspectors, gizmos, importers, and tools, loaded from project crates.
- Asset library browser for installing templates, plugins, and assets into a project.
- In-editor API documentation for engine and reflected project types.
Exit gate
- Every subsystem milestone’s authoring tasks can be completed without leaving the editor.
- A third-party editor plugin adds a panel, a custom inspector, and an importer without engine changes.
- Editor state, layouts, and settings persist across sessions.
Milestone 21: Platforms, export, and distribution
Goal: ship games to every supported platform from the editor.
Depends on: Milestones 3 and 6.
- Export presets per platform with feature flags, asset filters, and encryption of packed data.
- Packed asset archives with compression and streaming.
- Texture compression per platform (BCn desktop, ASTC/ETC2 mobile) through KTX2.
- Windows and Linux release polish: installers/archives, icons, and code-signing hooks.
- Steam Deck/Proton verification.
- macOS through MoltenVK, with capability fallbacks for missing Vulkan features.
- Android export with touch input, lifecycle handling, and mobile quality profiles.
- Platform services abstraction for save paths, achievements, and storefront SDK hooks.
- Crash reporting with symbolicated stack traces.
- Record web, iOS, and console export as post-1.0 decisions with the blocking technical reasons.
Exit gate
- A demo project exports and runs from the editor on Windows, Linux, and macOS.
- An Android build runs the physics sandbox template on a mid-range device.
- Crash reports from exported builds resolve to source locations.
Milestone 22: Documentation, samples, and ecosystem
Goal: make the engine learnable and adoptable the way Godot is.
Depends on: the features being documented. Documentation for each feature lands with that feature; this milestone covers the structure and the gaps.
- User manual covering every subsystem, with a dedicated physics guide that explains ownership, synchronization, determinism, and solver choice.
- Generated API reference for engine crates and reflected types.
- Step-by-step tutorials: first 3D game, first 2D game, physics sandbox, multiplayer physics game.
- Demo projects per feature area, with a physics showcase gallery (destruction, fluids, cloth, vehicles, ragdolls, million-body scenes).
- Migration guide for Godot users mapping nodes, signals, resources, and scripts to RustingEngine concepts.
- Contribution guide, plugin authoring guide, and release notes process.
Exit gate
- A new user can build and export the first-game tutorial from a clean install using only the documentation.
- Every demo project builds and runs in CI.
Milestone 23: Engine 1.0 release
Goal: declare Godot-class parity with physics leadership, backed by evidence.
Depends on: Milestones 9-22.
- Every row in the Godot parity map has a passing exit gate.
- Every physics pillar has its test or benchmark evidence checked in.
- Reference games built entirely with the engine: a 3D action game, a 2D platformer, a vehicle/physics sandbox, and a multiplayer physics game.
- Public API stability policy, semantic versioning, and a deprecation process.
- Performance baselines recorded for the low-end, mid-range, and high-end reference machines.
Exit gate
- All reference games are playable from a clean checkout, editable in the editor, and exportable to every supported platform.
- The comparative physics benchmark report shows where RustingEngine leads, and any area where it does not is documented with a follow-up item.
Sundering track
Milestones 24-29 build Sundering on top of the engine. They consume the determinism, fracture, GPU-scale, navigation, and networking milestones and must not fork parallel versions of those systems; gaps found here become engine items in the owning milestone.
Milestone 24: Competitive networked authority
Goal: run the authoritative simulation on a server and keep many clients consistent with it under real network conditions.
Depends on: Milestones 8 and 19. Milestone 19 provides the general transport, protocol, RPC, replication, and rollback machinery; this milestone adds the competitive-authority, anti-cheat, and destruction-scale requirements Sundering needs on top of it.
The networking model depends on the Milestone 8 result. Bit-identical determinism permits lockstep and rollback. Without it, the server simulates and clients render, predicting only their own hero.
Server
- Headless server binary running the fixed-tick simulation with no renderer or window.
- Tick-scheduled loop with drift correction, overload detection, and reported tick-budget usage.
- Client lifecycle: join, ready, in-match, drop, reconnect.
- Server-side input validation and rate limiting. The server never trusts a client-reported position, velocity, or terrain state.
Transport and protocol
- Reuse the Milestone 19 transport and versioned protocol; do not fork a second networking stack.
- Clock synchronization and per-client tick-offset estimation.
- Input buffering with configurable delay and jitter absorption.
State replication
- Snapshot encoding with delta compression against the last client-acknowledged snapshot.
- Quantize replicated values with documented precision per field.
- Interest management: replicate only what a client’s team can currently see, using the vision system from Milestone 27.
- Per-client bandwidth budget with measured usage and an enforced cap.
- Replicate terrain modification events, not individual debris transforms. Clients re-simulate debris locally from the same events. This is the only approach that keeps bandwidth bounded during heavy destruction, and it depends on Milestone 8.
Client
- Interpolate remote entities with a documented, configurable interpolation delay.
- Predict only the local hero. Never predict terrain destruction results unless determinism is proven.
- Reconcile prediction error with presentation-only smoothing that never feeds back into simulation state.
- Detect desync by periodic state-hash comparison, report it, and recover through full resynchronization.
- Support spectator clients that receive state and send no input.
Exit gate
- Ten clients complete a full match against a headless server at 200 ms round-trip latency with 2% packet loss, without desync.
- Per-client bandwidth stays within budget with the terrain fully destroyed.
- A client disconnects mid-match, reconnects, and resynchronizes correctly.
- Server tick-budget usage and desync events are observable in operations tooling.
Milestone 25: Destructible terrain and Scar
Goal: make terrain a first-class simulated, modifiable, and permanently scarred entity rather than authored static geometry.
Depends on: Milestones 8, 11 (fracture, debris, GPU scale), and 13 (terrain rendering).
Terrain representation
- Chunked volumetric terrain with an authoring format, streaming, and a documented chunk size.
- GPU surface extraction with per-chunk LOD and crack-free transitions between levels.
- Derive terrain collision from the same volume data as rendering. One source of truth, never two.
- Editor tools to author, sculpt, and paint terrain volumes.
- Report chunk count, resident memory, extraction time, and streaming state in the profiler.
Fracture and debris
- Promote static terrain chunks to dynamic GPU rigid bodies when destroyed.
- Use deterministic fracture patterns driven by the seeded tick RNG.
- Enforce a debris budget with a deterministic, documented policy at the cap. Never drop bodies arbitrarily.
- Settle and re-freeze resting debris back into static terrain, restoring collision and navigation.
- Merge small settled debris into larger static aggregates so long-running matches stay bounded.
Structural load
- Build a support graph over connected terrain chunks.
- Solve support incrementally on the GPU when the graph changes, within a bounded per-tick cost.
- Collapse unsupported spans under their own mass.
- Expose per-chunk stress to gameplay and to debug visualization.
Scar
- Make settled debris permanent by default, with an explicit per-region regeneration policy.
- Ensure settled debris affects collision, navigation, and vision, not only rendering.
- Persist terrain state in save, replay, and network resynchronization formats as modification history where practical rather than full volumes.
Terrain commands
- Extend the CPU-to-GPU command bridge with terrain modification: carve, fracture, displace, freeze.
- Apply terrain commands at deterministic points in the tick.
- Account modification cost against a per-team mass-budget resource.
Exit gate
- A cliff collapses onto a 10,000-body scene deterministically, producing identical hashes on two GPU vendors.
- Settled debris becomes walkable, occluding, static terrain.
- Frame time and memory stay within budget after a one-hour continuous-destruction soak test.
- Terrain state round-trips correctly through save, replay, and network resynchronization.
Milestone 26: Dynamic navigation and crowds
Goal: let thousands of agents move sensibly over geometry that changes every tick.
Depends on: Milestones 18 (engine navigation) and 25. The engine navigation handles incremental re-baking of ordinary scenes; this milestone handles terrain that changes every tick at crowd scale.
Navigation representation
- GPU navigation volume or flow field derived from live terrain collision data.
- Incremental rebuild limited to changed regions, with a bounded and enforced per-tick cost.
- Deterministic rebuild results, independent of how many chunks changed in a single tick.
- Reachability queries answering whether a team can reach a given point at all.
- Report navigation build time, dirty-region count, and budget overruns in the profiler.
Agents
- GPU agent steering with local avoidance at thousands of units.
- Deterministic agent update order and avoidance resolution.
- Agents fall, are buried, and are displaced by debris rather than ignoring it.
- Make path failure an observable gameplay state, never a silent stall.
- Keep a CPU pathing path for the small number of agents that need immediate answers.
Exit gate
- 1,000 agents cross a map while it is actively being destroyed, without stalling or tunnelling.
- Navigation rebuild stays within its per-tick budget during a full cliff collapse.
- Agent behaviour is bit-identical across runs and across vendors.
- Blocking every route is detected and reported rather than producing stuck agents.
Milestone 27: Competitive gameplay framework
Goal: provide the game-specific systems Sundering needs, built entirely on deterministic simulation.
Match and teams
- Team membership, match phases, and authoritative match state owned by the server.
- Objective and structure entities with destruction-aware state.
- Resource and mass-budget accounting per team.
- Match start, end, surrender, and result reporting.
Abilities as forces
- Express ability definitions as deterministic physical effects: impulses, sustained forces, tethers, mass changes, area displacement.
- Cooldowns, costs, cast time, interrupts, and targeting resolved on the fixed tick.
- Area queries against live terrain and bodies, executed deterministically.
- Require every ability effect to be expressible as commands through the physics bridge.
- Expose telegraph data to presentation without letting presentation affect simulation.
Dynamic vision
- Per-team visibility computed on the GPU from actual terrain geometry, not from authored vision blockers.
- Recompute visibility when terrain changes, within a bounded per-tick budget.
- Feed visibility into network interest management from Milestone 24.
- Derive presentation-side fog rendering from the same data.
- Keep visibility deterministic and identical between server and every client.
Bots
- Bot controllers that use the same input interface as human players.
- Deterministic bot decisions driven by the seeded tick RNG.
- Difficulty levels with a documented behaviour set.
- Bots handle a reshaped map, including blocked routes and newly opened ones.
Exit gate
- A match can be played to completion against bots with no human opponents.
- Vision is computed from destroyed geometry and matches exactly between server and clients.
- Every ability is expressible as a deterministic physical effect and replays identically.
Milestone 28: Balance and operations tooling
Goal: make a game whose map differs every match tunable with evidence rather than intuition.
Headless experimentation
- Run matches headless, faster than real time.
- Batch runner executing many matches across varied seeds, configurations, and bot profiles.
- Deterministic seeding so any interesting match is exactly reproducible from its seed.
Telemetry
- Structured per-match telemetry: terrain modified, routes opened and closed, fight locations, resource use, outcome.
- Aggregate reporting across batch runs.
- Automatic detection of degenerate states: total lockout, unreachable objectives, runaway debris count, stalled navigation.
Replay analysis
- Replay browser with seeking, speed control, and a free camera.
- Terrain-state timeline showing how the map evolved through the match.
- Export a match’s terrain modification history for offline analysis.
Server operations
- Match hosting, lifecycle management, and health reporting.
- Crash and desync capture producing reproducible artifacts.
- Per-map performance baselines with regression rejection.
Exit gate
- One thousand headless bot matches run unattended and produce an aggregate balance report.
- Any reported degenerate state reproduces exactly from its recorded seed.
- A match replay can be inspected tick by tick alongside its terrain timeline.
Milestone 29: Sundering vertical slice
Goal: prove the whole stack with the smallest piece of the real game. Each slice answers exactly one question and adds nothing beyond it.
Slice 1: pathing survives destruction
- One lane, one tower, one destructible cliff.
- Two heroes, one able to destroy terrain.
- Creeps pathing from spawn to tower.
- Acceptance: creeps route correctly after the cliff collapses, deterministically, on two GPU vendors.
Slice 2: Scar is legible
- Debris persists and blocks both vision and movement.
- Per-team dynamic vision active.
- Acceptance: a spectator can read the map state correctly after sustained destruction.
Slice 3: structural constraints hold
- Structural load, mass budget, and debris settling all active.
- Two players attempt to wall themselves in, and to dig directly to the enemy base.
- Acceptance: both degenerate strategies fail for physical reasons, not through rule-based prohibitions.
Slice 4: networked match
- 3v3 against a mix of bots and humans on a headless server.
- Full replay recorded and reproduced.
- Acceptance: a complete match at 200 ms latency with no desync.
Performance target
- Define the Sundering benchmark: fixed map, fixed input replay, scripted destruction sequence.
- Target 1920x1080 at 60 FPS on mid-range hardware with destruction active.
- Record simulation tick time, navigation rebuild time, debris count, per-client bandwidth, and input latency.
- Store baselines and reject regressions.
Exit gate
- A 3v3 match is playable from a clean checkout on Linux, with bots filling empty slots.
- The match replays identically from its recorded input stream.
- Performance baselines are recorded and enforced in CI.
Continuous test and CI plan
Unit tests
- Transform composition and decomposition.
- Hierarchy propagation and cycle rejection.
- Material default consistency and copying.
- Stable generational asset handles.
- Scene round trips and schema migration.
- Asset path canonicalization and deduplication.
- Input action mapping.
- Quality-profile selection.
- Stable ECS/GPU
PhysicsIdconversion, generation rejection, and entity mapping.
Layout and shader tests
- Rust physics storage-buffer sizes and offsets.
- Common instance field order across compute shaders.
- Common physics push-constant declaration across compute shaders.
- Shader reflection compared with every Rust storage, uniform, vertex, and push-constant type.
- Shader compilation as a dedicated CI job.
Renderer integration tests
- Resize, minimize, restore, and zero-sized surface handling.
- Unsupported present-mode and format fallback.
- Frames-in-flight reuse and validation-clean shutdown.
- Asset unload/reload while referenced by submitted frames.
- Culling enabled/disabled equivalence.
- Automatic primitive/imported-mesh bounds contain their complete source geometry.
- GPU-owned objects are culled from the current GPU transform without full-state CPU readback.
- Objects outside the camera stop producing render work but continue physics simulation.
-
CullingMode::Autoskips culling overhead below its tested scene-size threshold. - Capability fallback behavior on the low-end baseline.
Golden-image tests
- Primitive normals.
- glTF texture channels and color spaces.
- Alpha mask and blend modes.
- Directional shadow depth.
- PBR reference spheres.
- Tone mapping.
- Editor compositing.
Physics tests
- Triggers and collision events.
- Raycasts and filtered queries.
- Stable stacking.
- Tunneling/CCD cases.
- Fixed-step independence from render frame rate.
- GPU grid overflow and fallback.
- GPU event overflow and fallback.
- Built-in boolean/range conditions and custom shader conditions emit according to
OnEnter,OnExit,WhileTrue,Once, and cooldown modes. - Condition events reach the correct ECS entity with their registered event ID and requested payload.
- CPU-to-GPU commands reject stale body generations.
- Asynchronous selected-state readback reports its source tick and frame age.
- Explicit CPU/GPU simulation ownership and synchronization modes.
Physics scenario and benchmark suite
- Joint, articulation, character-controller, vehicle, and ragdoll regression scenes with recorded expected results.
- Soft-body, cloth, rope, fluid, granular, and fracture scenarios under lavapipe.
- Two-way coupling scenarios between rigid, deformable, and fluid simulation.
- 2D physics scenarios mirroring the 3D suite.
- Comparative benchmark harness against Jolt, PhysX, Rapier, and Godot Physics with stored baselines.
Engine feature tests
- Reflection round trip for every registered component and data asset.
- Prefab override propagation, nesting, variants, and cycle rejection.
- Observers and scene-file event connections fire the registered handlers.
- Animation sampling, blending, IK, and ragdoll handoff are deterministic.
- Offline audio render compared against reference buffers.
- UI layout, text shaping, theming, and localization golden images.
- 2D sprite, tilemap, and 2D lighting golden images.
- Navigation baking, re-baking budget, and agent avoidance.
- Networking RPC, replication, prediction, and rollback over loopback with simulated loss.
- Export smoke test for every supported platform preset.
- Every demo project and template builds and runs headless.
Determinism tests
- Per-tick world-state hashes match between two runs with identical inputs.
- Hashes match between debug and release builds.
- Hashes match across differing CPU worker-thread counts.
- Hashes match across at least two GPU vendors.
- A recorded replay reproduces its original hash sequence.
- An injected divergence is reported with its first divergent tick and body.
- Disabling audio, particles, and all presentation systems does not alter hashes.
Networking tests
- Full match completes at 200 ms latency with 2% packet loss without desync.
- Delta snapshot encoding and decoding round-trip correctly.
- Bandwidth stays within the per-client budget during maximum destruction.
- Mid-match disconnect and reconnect resynchronizes correctly.
- Protocol version mismatch is rejected at connect time.
- A client reporting an impossible position or terrain state is rejected by the server.
Terrain and navigation tests
- Chunk fracture is deterministic across runs and vendors.
- Debris settling restores static collision and navigation.
- The debris budget cap never drops bodies non-deterministically.
- Unsupported spans collapse; supported spans do not.
- Navigation rebuild stays within its per-tick budget during a cliff collapse.
- Agents re-route correctly when a route is closed mid-path.
- Fully blocking every route is reported rather than producing stuck agents.
- One-hour destruction soak test holds frame time, memory, and debris count within budget.
CI matrix
- Linux software Vulkan runner (lavapipe) running the
gpu-testsfeature on every pull request. This is the primary GPU verification path; hardware runners confirm it, they do not replace it. - Linux hardware runner.
- Windows hardware runner.
- Formatting, strict clippy, unit tests, and docs on Linux and Windows for every pull request.
- Scheduled validation and performance runs with stored artifacts.
- Second-vendor GPU runner dedicated to cross-vendor determinism verification.
- Scheduled soak-test job for long-running destruction and memory growth.
- Scheduled headless batch-match job producing aggregate balance reports.
- macOS (MoltenVK) runner.
- Android build job.
- Scheduled comparative physics benchmark job publishing its report as an artifact.
Cross-cutting engineering rules
- Public fallible operations return structured
Resultvalues; panics are reserved for internal invariant violations. - ECS entities and typed asset handles are the only canonical identities exposed to gameplay/editor code.
- GPU resource destruction is deferred until all referencing frames complete.
- No fixed-capacity structure may silently drop work.
- Optional Vulkan features always have a documented fallback or a clear startup error.
- Debug builds enable validation by default when layers are available.
- Runtime and editor code must expose useful diagnostics instead of relying on ad-hoc FPS/debug printing.
- Every milestone must leave the project compiling and its completed acceptance gates automated where practical.
- Simulation state and presentation state are separate. Presentation code may read simulation state and may never write it.
- Anything affecting simulation must be deterministic: no wall-clock time, no frame-rate dependence, no unseeded randomness, no order-dependent accumulation.
- Network code never trusts a client for authoritative state.
- Systems that run for hours need soak tests. Correct for one minute and degrading over one hour is not correct.
- Anything that moves things — animation, particles, characters, vehicles, navigation obstacles, audio occlusion — integrates with physics through the typed bridge rather than a parallel ad-hoc simulation.
- Physics features are judged against the best standalone physics engines, with benchmark evidence. Other features are judged against Godot parity.
- Every user-facing feature ships with documentation and a demo scene in the same change or the next one.
Recommended implementation order
Work on one vertical path at a time rather than creating empty crates for every eventual subsystem.
Foundation (Milestones 0-7):
- Finish Vulkan/winit modernization and validation.
- Add the workspace plus
rusting-core, ECS schedules, components, and compatibility facade. - Add typed assets and static scene serialization.
- Add render extraction and correct frames-in-flight synchronization.
- Complete the forward PBR pass schedule and profiling.
- Build the self-written hybrid physics bridge: stable IDs, GPU events, CPU commands, selective readback, and mixed CPU/GPU ownership.
- Add the minimal egui shell, viewport, hierarchy, and inspector.
- Build the vertical slice while filling in editor, rendering, asset, and physics gaps.
- Add hot reload, undo/redo, polish, packaging, and performance gating.
Physics leadership and Godot parity (Milestones 8-23):
- Establish determinism: simulation math, ordering rules, per-tick state hashing, and the verification harness. Every later solver depends on these rules.
- Add reflection, prefabs, observers, data resources, and Rust hot reload. Every later editor and animation feature depends on reflection.
- Complete rigid-body physics: joints, articulations, characters, vehicles, ragdolls, fields, CCD, and 2D physics.
- Build the physics debugger and the comparative benchmark harness early, so every later solver is measured from its first commit.
- Add deformables, fluids, granular media, fracture, and million-body GPU scale.
- In parallel, reach parity in advanced rendering, animation, audio, runtime UI, 2D, and navigation, integrating each with physics as it lands.
- Add networking with prediction and deterministic rollback.
- Grow editor parity, platforms/export, and documentation continuously, closing them out at the 1.0 release gate.
Sundering (Milestones 24-29):
- Add competitive authority on top of the engine networking, keeping clients as renderers until determinism is proven.
- Build chunked destructible terrain, structural load, and Scar persistence on the engine fracture system.
- Add navigation and GPU crowd agents over terrain that changes every tick.
- Add the competitive gameplay framework: teams, abilities as forces, dynamic vision, and bots.
- Build the Sundering slices in order, adding balance and operations tooling as each slice requires it.
The next concrete editor task is a Shortcuts settings panel. It must display the central action map, let a user rebind one action at a time, detect duplicate bindings, and persist choices inside project editor settings. Then add focus-selection and interactive translate/rotate/scale handles. Explicit frame contexts and an offscreen scene viewport target remain the next renderer-architecture task.
Review-fix pass verification
- fmt, clippy (default,
--no-default-features,--features rusting_engine/gpu-tests) clean. cargo test --workspace: 457 lib tests pass; withrusting_engine/gpu-tests: 533 pass, 0 failed.- New tests: fixed GPU box solid to CPU queries, switched-off fluid surface reaped, scene
InputActionunbinds, mistyped scene setting errors, huge fluid block cap.