Introduction
Kóoch is a GPU-driven game engine written in Rust, with an editor.
The rendering path is a Nanite-style GPU-driven meshlet pipeline: the hot loop runs in compute on the GPU, and the CPU only coordinates. The ECS stays on the CPU — that is a deliberate, settled decision, not a stage on the way to something else. What goes to the GPU is graphics, effects and their derivatives; physics will follow if and when rapier3d gains GPU support.
This is a personal, experimental project — not a stable production tool. The documentation reflects that: it explains what is, not what should be. Where something is missing or broken, this book says so and links the issue.
Audience
Two readers, with overlapping needs:
- Engine users — anyone writing a game on top of Kóoch. Start with Your First Project, then The Editor.
- Engine contributors — anyone touching
crates/*. Start with Crate Graph and the Decisions Log.
Status
Early development. The pieces work together end to end — window, ECS, scene serialisation, meshlet renderer, sky, physics, editor — but the feature surface is narrow on purpose and APIs break freely.
What works today:
- An 18-crate Rust workspace plus a facade, edition 2024.
- GPU-driven meshlet rendering with a LOD chain, plus glTF mesh loading. A frame is a list of views, so the editor’s viewport and the game’s camera render from one stage.
- Cook-Torrance lighting driven by the light components — see Lighting. New as of #441; before it, the renderer painted the world-space normal as colour and a scene with lights looked exactly like one without.
- Rigid-body physics on rapier3d: colliders, joints, collision events, sensors, materials, and custom gravity fields that sum.
- Procedural sky and volumetric clouds.
- Scene serialisation (
.scene, RON) driven by reflection, with more than one scene loadable at once. - Input as data: an action is an asset, with composites and processors, editable in a panel.
- An editor: viewport, hierarchy, Inspector, Console, asset browser, drag-and-drop, dockable layout, undo/redo, project Hub, a Game panel beside the View panel, and Play/Stop that snapshots and restores the authored world. Hovering a field shows its doc comment, units included.
- A project’s own components and systems, written in Rust, loaded into the editor as a
dylib.
What does not work yet, stated plainly:
- No hot reload. Seeing a code change means rebuilding and reopening the editor (#648).
- No build button.
cargo buildis yours to run (#158). - Reflection is shallow. No
Vec<T>, noHashMap, no user enums in components (#649). - No shadows. Lit with no shadows is where the renderer is: better than a normal painted as colour, and it cannot tell you where anything is touching (#476).
- No global illumination, which is why punctual light defaults are larger than physics says they should be (#450). The Lighting page explains the trade rather than hiding it.
Stack
| Layer | Crate / Library |
|---|---|
| GPU | wgpu 29 (Vulkan / DX12 / Metal) |
| Windowing | winit 0.30 |
| Math | glam 0.33 |
| Physics | rapier3d 0.34 |
| Audio | kira 0.9 |
| Input | gilrs 0.11 (gamepad), winit (keyboard/mouse) |
| Gameplay code | Plain Rust — native plugin via kooch_plugin_api |
| Editor UI | egui 0.35 + egui_dock 0.20 |
| Mesh | gltf 1.4 |
| Serialisation | serde 1 + ron 0.8 |
License
All Rights Reserved. Copyright (C) 2025-2026 Matías Galarza (“Lobinux”, lobinuxsoft).
The repository is public so the work can be read and so the project can use branch protection
and Pages. That is not a licence to use it: see
LICENSE.md.
How to read this book
User Guide and Scripting are the public API surface. Architecture covers internals. Reference holds the long-form material: the Decisions Log is a chronological record of architectural choices, why they were made, and what was traded away.
Two files in the repository outrank this book when they disagree:
docs/MEMORY.md
is canonical on decisions, and
docs/ROADMAP.md
is canonical on order.
Getting Started
You need a recent Rust toolchain (edition 2024) and this repository cloned somewhere.
cargo run -p kooch_editor
That opens the Hub, where projects are created and opened. Everything else follows from there:
- Your First Project — the whole loop end to end: a component, a system, and Play. Start here.
- Creating a Project — what the scaffold generates and why each file exists.
- The Editor — the panels, how Play works, and what is not built yet.
The rest of this page is the handful of things that are easy to trip over and do not belong to any one of those.
Loading a scene
The boot scene is resolved in this order:
SceneBootstrapPlugin::with_scene(path), if yourmain.rssets one explicitly.--scene <path>on the command line — absolute, or relative to the working directory.scenes/default.scene, relative to the working directory.
So cargo run -- --game from the project root just works: the default path resolves because
the working directory is the project. A different level is
cargo run -- --game --scene scenes/Level1.scene.
Component registration runs before the scene loads
SceneBootstrapPlugin loads at Stage::First, which runs after every Stage::Startup
system has completed on the first frame. Component registration is a Startup system, so the
registry is fully populated by the time the scene is deserialised.
Flip that order and you get unknown component type: … and a scene that does not load. The
generated registrations.rs already puts registration in Startup; this matters only if you
register something by hand.
A scene with no camera renders black
The editor’s own camera is filtered out of a saved scene, so a scene needs to spawn its own
PerspectiveCamera. The default scene template includes one. Build a scene from scratch
without one and you get the clear-to-black fallback.
This is deliberate, not a bug. Injecting the editor camera as a temporary play camera — what Unity and Unreal do — is a possible future change, not current behaviour.
Running without the editor
cargo run -- --game
DefaultPlugins is the group that makes this a game rather than a collection of crates:
| Plugin | Role |
|---|---|
CorePlugin | Time, the AppExit event |
EcsPlugin | Storage, SceneManager, built-in components, transform propagation |
WindowPlugin | Winit window and GPU surface |
RenderPlugin | The mesh and sky pipelines |
SceneBootstrapPlugin | Loads the boot scene at startup |
Your project’s own main.rs is what runs, so any plugin you add there is picked up.
The Editor
The editor is where a project is authored: entities are spawned, components are attached and tuned, and the result is played back without leaving the window.
It is one program that runs in two arrangements, and the difference matters more than it looks.
Two arrangements, one editor
Embedded — the project’s own binary opens the editor:
cargo run # inside your project directory
The editor runs inside the project, so the project’s components are simply linked in. This is the simplest arrangement and needs no socket, no second process, and nothing to go wrong between them.
Standalone — the editor opens, and you point it at a project:
cargo run -p kooch_editor # from the engine repo
# then: Open Project
Here the editor is a separate program that does not have your project’s types compiled in. It gets them two ways at once:
- It loads the project’s
dylibto learn what components exist and what fields they have, which is what fills the Add Component menu and the Inspector. - It launches the project as a headless host (
--remote) and drives it over a local socket. That host is where the world actually lives, so a system you wrote runs in its process while you watch the result in the editor’s viewport.
Why the split exists. Rust has no stable ABI, so a pre-built editor cannot simply link a project compiled separately. The
dylibgets around that by requiring the same compiler for both — fine for code the editor itself built. The remote host covers the rest: running your systems against a live world. Seedocs/MEMORY.mdfor the full reasoning.
The Hub — the window you get from cargo run -p kooch_editor — is where projects are created
and opened.

The panels
| Panel | What it is for |
|---|---|
| View | The 3D viewport. Selection, transform gizmos, the physics debug overlay, and the meshlet debug-view dropdown. Owns the editor camera. |
| Game | What the game’s own camera sees — no gizmos, no selection outlines. A sibling tab of View, and only rendered while its tab is visible. Play does not switch you here or take the editor camera away; the two views coexist because a frame is a list of views. |
| World | The entity hierarchy of every loaded scene. Selecting here selects in View. |
| Inspector | The selected entity’s components and their fields. Where authoring happens. Hover a field name and its doc comment appears as a tooltip — units included, which is how you find out that a directional light’s intensity is in lux and a point light’s is in lumens. |
| Components | Every component type the engine and your project registered. |
| Archetypes | Which combinations of components actually exist, and how many entities are in each. A debugging view of how the ECS stored your scene. |
| Asset Browser | The project’s assets and the engine’s, as two roots. |
| Input Map | Edits a .inputaction asset: bindings, the five composites, processors. An action is an asset, not an entry in a map — see Writing a System. |
| Console | Structured logs from the editor and the launched project, filterable. Text is selectable and copyable. |
| Performance | Frame timings, and per-stage counters where they exist. |
Play
Play does not rebuild anything and does not open a second window.
Pressing Play snapshots the authored world, flips the Playing gate so gameplay systems
start running, and simulates in the editor’s own viewport. Stop lowers the gate and restores
the snapshot, so the world goes back exactly as authored — you do not lose your scene by
testing it.
#![allow(unused)]
fn main() {
// kooch_remote::handlers::set_playing, in essence
if playing {
resources.insert(PlaySnapshot(WorldSnapshot::capture(resources)));
Playing::set(resources, true);
}
// Stop: the gate goes down *before* the restore, so no system
// observes a half-rebuilt world.
Playing::set(resources, false);
if let Some(snapshot) = resources.remove::<PlaySnapshot>() {
snapshot.0.restore(resources);
}
}
This is why your project’s systems register with run_systems: false while authoring: they
are registered either way and skipped per frame, so Play can flip them on live rather than
recompiling.
Known rough edge. A locally-opened project’s Play button still has an older path that shells out to
cargo run -- --game, which builds the project and opens a second window — minutes of nothing, and no snapshot. Tracked in #633.
What the editor does not do yet
Honest list, so nothing below is mistaken for a bug in your setup:
- No build button. Changing Rust code means running
cargo buildyourself (#158). - No reload. The project’s
dylibloads once, when the project opens. Seeing a code change means reopening the editor (#648). - No New Scene. Scenes have to exist on disk already (#619).
- Exposure and ambient light have no panel. Both are engine
Resourceswith sane defaults and no way to change them from the editor, so a scene that reads too bright or too flat cannot be corrected without editing code. See Lighting. - Reconnecting discards unsaved changes silently. Relaunching the host reloads the scene from disk; anything not saved is gone, and nothing warns first.
Your First Project
- 1. Open the Hub
- 2. Write a component
- 3. Write a system
- 4. Register and build
- 5. Use it
- What to read next
- If something did not work
A complete pass through the loop: create a project, write a component and a system, and watch them run. Roughly fifteen minutes, most of it the first compile.
1. Open the Hub
cargo run -p kooch_editor

Create a project, or open one you have. The first build of a new project compiles the engine too — several minutes, once.
A project made with an older editor is migrated on open. You do not have to do anything, but that first build will also be a full one.
2. Write a component
New Component from the editor, named Spinner, then open src/spinner.rs and fill it in:
#![allow(unused)]
fn main() {
use kooch::kooch_ecs::Reflect;
use kooch::kooch_ecs::component::Component;
/// Makes an entity rotate. Attach it and set the speed in the Inspector.
#[derive(Default, Reflect)]
#[reflect(category = "Gameplay")]
pub struct Spinner {
/// Degrees per second around the Y axis.
pub speed: f32,
}
impl Component for Spinner {}
}
speed is public, so the Inspector will draw a drag value for it. Nothing else is needed —
see Writing a Component for the attributes that change how it is drawn.
3. Write a system
This one touches Transform, whose fields are glam types. The prelude re-exports them, so
there is nothing to add to Cargo.toml:
Vec2,Vec3,Vec4,Quat,Mat3andMat4come throughkooch::prelude, and the wholeglamcrate is reachable askooch::glam. Adding your ownglamdependency is the one thing to avoid: aQuatfrom a different version is a different type, and the compiler error names two types spelled identically.
New System, named spin, then open src/spin.rs:
#![allow(unused)]
fn main() {
use kooch::kooch_ecs::Query;
use kooch::kooch_ecs::transform::Transform;
use kooch::prelude::*;
use crate::spinner::Spinner;
/// Rotates every entity that has a `Spinner`.
pub fn spin(resources: &mut Resources) {
let dt = resources
.get::<Time>()
.map(|t| t.delta_secs())
.unwrap_or(1.0 / 60.0);
let query = Query::<(&Spinner, &mut Transform)>::new(resources);
query.for_each(|(spinner, transform)| {
transform.rotation *= Quat::from_rotation_y(spinner.speed.to_radians() * dt);
});
}
}
The query matches only entities that have both components, so a Spinner on an entity
with no Transform is simply skipped rather than being an error.
4. Register and build
Press Register Scripts. The editor scans src/, finds impl Component for Spinner and
pub fn spin(_: &mut Resources), and rewrites registrations.rs with both.
Then build. Today that means a terminal:
cargo build
and reopening the editor, because the project’s library is loaded once when the project opens. A build button and a live reload are #158 and #648; until they land, this step is manual and it is the slow part of the loop.
5. Use it
With the project reopened:
- Select an entity in World (or spawn one).
- Add Component → Gameplay →
Spinner. - Set
speedin the Inspector — try90. - Press Play.
It spins. Press Stop and the world returns exactly as you authored it — Play snapshots before it starts and restores on stop, so testing never costs you your scene.
What to read next
- Writing a Component — every field type the Inspector can draw, and the attributes that control it
- Writing a System — queries, stages, spawning
- Creating a Project — what each generated file is for
- The Editor — the panels, and what is not built yet
If something did not work
| Symptom | Cause |
|---|---|
| The component is not in the Add Component menu | Register Scripts not pressed, or the project not rebuilt and reopened |
| A field is not in the Inspector | It is private, has #[reflect(skip)], or is a type reflection does not support yet (#649) |
| The derive does not compile | A field’s type is not supported — Vec<T>, HashMap, your own enums. Mark it #[reflect(skip)] |
| The system never runs | It is in Update behind the Playing gate; press Play. Or its signature does not match pub fn f(_: &mut Resources) exactly, so the scanner missed it |
| Play opens a second window and takes minutes | The old local-Play path (#633) |
Creating a Project
A project is an ordinary Cargo crate that depends on the engine. The editor scaffolds it, but nothing about it is magic — you can read every generated file, and most of them you will never touch.
What the editor generates
MyGame/
├── Cargo.toml
├── assets/
├── scenes/
└── src/
├── main.rs # generated, yours to edit
├── lib.rs # generated, editor-managed
└── registrations.rs # generated, editor-managed — do not edit
Cargo.toml
The one line worth understanding:
[lib]
crate-type = ["rlib", "dylib"]
Two artefacts from one crate. The dylib is what the standalone editor loads to learn
your component types without compiling them. The rlib beside it is what your binary links,
so the shipped game is an ordinary statically linked executable — no dynamic loading at
runtime.
The engine dependency carries feature flags, and each one buys something specific:
kooch = { path = "…", features = [
"editor", # the embedded editor, so `cargo run` opens it
"physics", # rigid bodies — without it, PhysicsBody is inert
"gravity", # gravity sources — without it, PointGravity pulls on nothing
"remote", # `--remote`, so the standalone editor can drive this project
"physics-debug-render", # the solver's own account of itself, for the overlay
"dynamic", # the plugin API — without it, lib.rs does not compile
] }
dynamic is the one that is not optional in practice: leave it out and lib.rs fails to
build, because kooch::kooch_plugin_api is compiled out.
main.rs — three ways to run
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.iter().any(|a| a == "--game") {
// The game. Systems run.
let mut app = App::new();
app.add_plugins(DefaultPlugins);
app.add_plugin(registrations::ProjectRegistrations { run_systems: true });
app.run();
} else if args.iter().any(|a| a == "--remote") {
// Headless authoring host for the standalone editor.
// Systems register but start paused; the editor's Play flips them on.
let mut app = App::new();
app.add_plugins(RemoteHostPlugins);
app.add_plugin(registrations::ProjectRegistrations { run_systems: false });
app.add_plugin(kooch::kooch_remote::RemotePlugin::new());
app.run();
} else {
// The editor, embedded in your project.
kooch::kooch_editor_core::run_editor_with(
registrations::ProjectRegistrations { run_systems: false },
);
}
}
| Command | What you get |
|---|---|
cargo run | The editor, with your components in it |
cargo run -- --game | The game |
cargo run -- --remote | A headless host for the standalone editor to drive |
--remote is headless on purpose: the editor draws that world in its own viewport, so a
window here would show the same scene twice.
registrations.rs — do not edit
The editor regenerates this file whenever you create or register a script. It scans src/
for two patterns and wires up what it finds:
impl Component for X→ a componentpub fn f(_: &mut Resources)→ a system
Detection is line-based rather than a full parse — enough for the generated templates and for typical hand-written code, but it does mean an unusual formatting of those signatures can go unnoticed. If a script you wrote does not show up, that is the first thing to check.
lib.rs — the editor’s entry point into your project
Also generated. It exports one plugin whose only job is to describe your components to a
standalone editor that loaded this dylib:
#![allow(unused)]
fn main() {
impl kooch::kooch_plugin_api::KoochPlugin for ProjectPlugin {
fn name(&self) -> &str { "my_game" }
fn build(&mut self, engine: &mut dyn kooch::kooch_plugin_api::Engine) {
registrations::declare_components(engine);
}
}
kooch::kooch_plugin_api::export_plugin!(ProjectPlugin);
}
Opening an older project
Projects made with earlier versions of the editor are migrated on open: the dylib crate
type, the dynamic feature, and the registrations wiring are all added if missing. You do
not have to do anything, but the first build afterwards will be a full one.
The compiler has to match
The dylib boundary carries Rust types directly rather than going through a C interface —
that is what makes the API pleasant. The price is that the project and the engine must be
built by the same rustc. A mismatch is refused with a clear message (the engine records
rustc -V -v at build time) rather than crashing, but it is refused.
In practice this is invisible, because the editor builds both. It becomes visible if you update your toolchain and rebuild only one side.
Writing a Component
- The smallest one that works
- What the Inspector can draw
- Attributes
- Pointing at another entity
- Registration
- What survives a save
A component is a plain struct that derives Reflect and implements Component. That is the
whole contract — the Inspector, scene serialisation and the Add Component menu all follow from
the derive.
The smallest one that works
#![allow(unused)]
fn main() {
use kooch::kooch_ecs::Reflect;
use kooch::kooch_ecs::component::Component;
/// How much damage this entity can still take.
#[derive(Default, Reflect)]
pub struct Health {
pub current: f32,
pub max: f32,
}
impl Component for Health {}
}
Create it from the editor (which drops this scaffold in src/ and regenerates
registrations.rs), or write the file yourself and press Register Scripts.
Public fields show up in the Inspector automatically. No attribute is required to opt in; attributes exist to opt out, or to say something the type alone cannot.
What the Inspector can draw
Each field’s Rust type maps to a FieldKind, and the kind decides the widget:
| Rust type | Widget |
|---|---|
f32, f64 | Drag value |
u8…u64, i8…i64 | Drag value, clamped to the type |
bool | Checkbox |
String | Text field |
Vec2, Vec3, Vec4 | Component-wise drag values |
Quat | Euler angles, in degrees |
Mat4 | Decomposed to translation / rotation / lossy scale, read-only |
Option<Guid> + #[reflect(asset = "…")] | Typed asset picker |
Option<EntityRef> | Entity picker, and a drop target for a drag from the World panel |
Entity, Option<Entity> | Same widget, but see “Pointing at another entity” below |
A struct that also derives Reflect | Nested, drawn inline |
The maths types come from the prelude.
Vec3,QuatandMat4areglamtypes, andkooch::preludere-exports them so a project never declares its ownglamdependency — which is the point, since aQuatfrom a different version is a different type and the compiler error would name two types spelled identically.
Hover a field name in the Inspector to see its doc comment. The derive harvests
///straight off the field, so documenting a component for the next reader also documents it for whoever is authoring the scene — there is no second place to write it and no second place for it to go stale.
Anything outside that list — Vec<T>, HashMap<K, V>, your own enums — is not supported
yet. Recursive reflection for nested types and collections is
#649. Until it lands, a field of an
unsupported type needs #[reflect(skip)] or the derive will not compile.
Attributes
On the struct
#![allow(unused)]
fn main() {
#[derive(Default, Reflect)]
#[reflect(category = "Gameplay")] // groups it in the Add Component menu
#[reflect(inspector = "read_only")] // "hidden" | "read_only" | "editable" (default)
pub struct Health { … }
}
On a field
#![allow(unused)]
fn main() {
#[derive(Default, Reflect)]
pub struct Weapon {
/// Not shown, not serialised. Use for runtime caches and for types
/// the Inspector has no representation for.
#[reflect(skip)]
cached_target: Option<Entity>,
/// A typed asset picker instead of a raw Guid text field.
#[reflect(asset = "Mesh")]
pub projectile: Option<Guid>,
/// A dropdown of named values instead of a bare integer.
#[reflect(choices = FIRE_MODE_CHOICES)]
pub fire_mode: u32,
/// A row of checkboxes instead of a bitmask you compute in your head.
#[reflect(bits = DAMAGE_TYPE_BITS)]
pub damage_types: u32,
/// Only drawn when another field says it is relevant.
#[reflect(shown_when = BURST_ONLY)]
pub burst_count: u32,
/// A reference the picker will only let you point at an entity
/// carrying a `PhysicsBody`.
#[reflect(requires = "PhysicsBody")]
pub anchored_to: Option<EntityRef>,
}
}
choices, bits and shown_when take a path to a constant, not a string literal — so the
same table is used by the Inspector and by your code, and they cannot drift apart.
shown_when is what keeps a component with many mutually-exclusive fields readable: the
engine’s own Joint uses it so a hinge does not show you spring stiffness.
requires names a component, by its short name, that the target has to carry. The picker
filters by it and refuses a drop that fails it, saying why — a reference accepted but inert
is indistinguishable from a broken one.
Pointing at another entity
Use Option<EntityRef>.
#![allow(unused)]
fn main() {
use kooch_ecs::reflect::EntityRef;
#[derive(Default, Reflect)]
pub struct Turret {
pub target: Option<EntityRef>,
}
}
Three things assign it, and all three write the same value: your code
(turret.target = Some(EntityRef::live(entity))), the Inspector’s picker, and dragging an
entity from the World panel onto the field.
EntityRef is two states, because a reference means two different things depending on where
it lives:
Live— an index and a generation. What a running component holds, and whatEntityRef::entity()gives you back for a query or a lookup.Persistent— an identity that survives a reload. What a scene file holds.
You do not convert between them. Saving resolves live to persistent, loading resolves back,
and a reference whose target’s scene is not open stays persistent until it is — which is why
the field is Option<EntityRef> and not Option<Entity>. An Entity field has nowhere to
put an unresolved reference, so it loses the link instead of keeping it.
Entity and Option<Entity> still reflect, for a handle the engine resolves itself
(Parent is one). They refuse to store anything but a live reference.
Registration
You do not write it. The editor scans src/, finds impl Component for Health, and
regenerates registrations.rs with both halves:
#![allow(unused)]
fn main() {
// Registers the type with the running ECS — scene save/load and the Inspector.
registry.register_cpu_reflected::<Health>();
// Describes the type to a standalone editor that loaded this dylib.
declare_component::<Health>(engine);
}
The component’s name comes from std::any::type_name::<T>(), so there is exactly one name
for a type and no way for two sides to disagree about it.
What survives a save
A component is saved as its reflected fields. Two consequences worth knowing before you design a component:
#[reflect(skip)]fields are not saved. They are reconstructed by your code, or they are gone.- A reference to another entity is saved as an identity, not as a handle. The save path
resolves it and assigns the target a persistent id if it has none, which is why saving a
scene can modify the world. Nothing is asked of you beyond using
Option<EntityRef>; a handle reaching a file is refused by name, and the save fails rather than writing a reference that would load pointing at some other entity.
Writing a System
- The smallest one that works
- Reading and writing components
- Stages
- The
Playinggate - Spawning and despawning
- Registration
A system is a function. That is the entire type:
#![allow(unused)]
fn main() {
pub fn my_system(resources: &mut Resources) { … }
}
No trait to implement, no macro, no parameter-injection magic. Resources is the world, and a
system does whatever it wants with it.
The smallest one that works
#![allow(unused)]
fn main() {
use kooch::prelude::*;
/// Ticks every entity's regeneration.
pub fn regenerate_health(resources: &mut Resources) {
let _ = resources;
}
}
The editor’s New System command drops this scaffold in src/ and regenerates
registrations.rs, which picks it up by its signature.
Reading and writing components
Components are reached through Query, which is constructed from Resources and borrows what
it names:
#![allow(unused)]
fn main() {
use kooch::prelude::*;
use kooch::kooch_ecs::query::Query;
use crate::health::Health;
pub fn regenerate_health(resources: &mut Resources) {
let dt = resources
.get::<Time>()
.map(|t| t.delta_secs())
.unwrap_or(1.0 / 60.0);
let query = Query::<&mut Health>::new(resources);
query.for_each(|health| {
health.current = (health.current + 5.0 * dt).min(health.max);
});
}
}
A few shapes worth knowing:
#![allow(unused)]
fn main() {
Query::<&Health>::new(resources) // read one component
Query::<&mut Health>::new(resources) // write one component
Query::<(&Transform, &mut Health)>::new(res) // entities that have both
}
and on the query itself:
| Call | Use |
|---|---|
.iter() | An iterator, when you want to collect, sum, filter |
.for_each(|item| …) | The common case |
.for_each_entity(|entity, item| …) | When you need the entity id too, e.g. to look up an optional component |
.get(entity) | One specific entity, None if it does not match |
.is_empty() | Cheap early-out |
Conflicting borrows panic rather than corrupt. Holding &mut Health in two live queries
at once is caught by the access tracker. Scope a query with a block when you need to release
it before building the next one.
Stages
A system is registered into a stage, and stages run in a fixed order every frame:
| Stage | For |
|---|---|
Startup | Once, at startup |
First | Beginning of frame |
Input | Input processing |
PreUpdate | Preparation |
Update | Your game logic — the default choice |
PostUpdate | Cleanup after update |
GpuSync, Gpu | GPU sync and submission |
Physics, PostPhysics | Fixed timestep |
PreRender, Render | Rendering |
If you do not have a reason, Update is the reason.
Physics runs on a fixed timestep, so a system in Physics or PostPhysics should use
Time::fixed_delta_secs() rather than delta_secs(). Using the wrong one is a bug that only
shows up when the frame rate changes.
The Playing gate
Your systems are registered whether or not the game is running, and skipped per frame while it is not. That is what lets the editor’s Play button start gameplay without a rebuild.
registrations.rs does it by wrapping each of your systems:
#![allow(unused)]
fn main() {
app.insert_resource(Playing(self.run_systems)); // false while authoring
app.add_system(Stage::Update, run_if_playing(my_system)); // skipped while the gate is down
}
What it means in practice: a system must not assume it runs every frame from startup. It may start running at any moment, against a world somebody has been editing by hand — and stop again when Stop restores the authored snapshot underneath it.
Spawning and despawning
Structural changes go through Commands, not through Query, because adding a component
moves an entity between archetypes and that cannot happen while a query is iterating it.
Commands::spawn needs &mut Resources itself, so it cannot be borrowed out of resources
while it is used. Take it out, use it, put it back:
#![allow(unused)]
fn main() {
use kooch::kooch_ecs::commands::Commands;
pub fn spawn_a_pickup(resources: &mut Resources) {
let Some(mut commands) = resources.remove::<Commands>() else { return };
let entity = commands
.spawn(resources)
.insert(Health { current: 100.0, max: 100.0 })
.id();
commands.apply(resources);
resources.insert(commands);
let _ = entity;
}
}
Two things that catch people:
- The id is allocated immediately; the components are not.
spawnreturns a validEntitystraight away, but the inserts are queued untilapply. Querying that entity beforeapplyfinds nothing on it. - Put
Commandsback.removetakes it out ofResources; anything running later that expects it there will not find it.
To despawn:
#![allow(unused)]
fn main() {
commands.entity(target).despawn();
}
Registration
You do not write it. The editor finds pub fn regenerate_health(_: &mut Resources) and
regenerates registrations.rs:
#![allow(unused)]
fn main() {
app.add_system(Stage::Update, run_if_playing(health::regenerate_health));
}
Update is the only stage it picks, and it is not configurable from the editor. To run
somewhere else, register the system by hand in your own plugin rather than fighting the
generated file — registrations.rs is overwritten without warning.
Crate Graph
Kóoch is a Cargo workspace of 18 internal crates plus one top-level facade crate. The structure is intentionally fine-grained: each subsystem lives in its own crate so that downstream crates only depend on what they actually need. This keeps compile times low when iterating on a single subsystem and makes the dependency surface auditable at a glance.
Layers at a glance
The 18 internal crates (plus the top-level kooch facade) sit in eight
layers. Each layer may only depend on layers below it.
The layer of a crate is its longest path to a crate with no internal
dependencies — derived from cargo metadata, not assigned by hand. That
matters: it means a crate moves layer when its dependencies change, whether
or not anyone updates this page.
| Layer | Crates | Role |
|---|---|---|
| L0 · foundation | kooch_plugin_api, kooch_ecs_macros | No internal deps. Type vocabulary + proc-macros. |
| L1 · core | kooch_core | App, Plugin, Schedule, Resources, GpuContext. |
| L2 · primitives | kooch_ecs, kooch_window, kooch_input, kooch_audio | ECS, windowing, input, audio. Depend on kooch_core only. |
| L3 · domain | kooch_physics, kooch_camera, kooch_lighting, kooch_world, kooch_remote | Built on the ECS. Simulation, lighting data, scene organisation, remote protocol. |
| L4 · built on domain | kooch_render, kooch_gizmos, kooch_gravity | The renderer needs Inti’s shading model; gizmos need the renderer; gravity needs the solver. |
| L5 · gizmo interaction | kooch_gizmos_handles | Draggable handles on top of gizmo drawing. |
| L6 · editor | kooch_editor_core | Editor logic as a library. Depends on 11 internal crates — the widest surface in the workspace. |
| L7 · binary + facade | kooch_editor, kooch | The editor main(), and the facade user projects depend on. |
Inter-layer flow
The arrow direction reads as “A depends on B”. Within a layer, crates are siblings.
flowchart TD
L7["L7 · binary + facade<br/>kooch_editor · kooch"]
L6["L6 · editor<br/>kooch_editor_core"]
L5["L5 · gizmo interaction<br/>kooch_gizmos_handles"]
L4["L4 · built on domain<br/>kooch_render · kooch_gizmos · kooch_gravity"]
L3["L3 · domain<br/>kooch_physics · kooch_camera<br/>kooch_lighting · kooch_world · kooch_remote"]
L2["L2 · primitives<br/>kooch_ecs · kooch_window · kooch_input · kooch_audio"]
L1["L1 · core<br/>kooch_core"]
L0["L0 · foundation<br/>kooch_plugin_api · kooch_ecs_macros"]
L7 --> L6
L6 --> L5
L6 --> L4
L6 --> L3
L6 --> L2
L5 --> L4
L4 --> L3
L3 --> L2
L2 --> L1
L1 --> L0
Detailed dependency table
Per-crate internal dependencies (external deps like wgpu, winit, etc.
are omitted).
| Crate | Depends on |
|---|---|
kooch_plugin_api | — |
kooch_ecs_macros | — |
kooch_core | kooch_plugin_api |
kooch_ecs | kooch_core, kooch_ecs_macros, kooch_plugin_api |
kooch_window | kooch_core |
kooch_input | kooch_core |
kooch_audio | kooch_core |
kooch_camera | kooch_core, kooch_ecs |
kooch_lighting | kooch_core, kooch_ecs |
kooch_world | kooch_core, kooch_ecs |
kooch_remote | kooch_core, kooch_ecs |
kooch_render | kooch_core, kooch_ecs, kooch_lighting |
kooch_physics | kooch_core, kooch_ecs |
kooch_gizmos | kooch_core, kooch_ecs, kooch_render |
kooch_gravity | kooch_core, kooch_ecs, kooch_physics |
kooch_gizmos_handles | kooch_gizmos |
kooch_editor_core | kooch_camera, kooch_core, kooch_ecs, kooch_gizmos, kooch_gizmos_handles, kooch_gravity, kooch_physics, kooch_remote, kooch_render, kooch_window, kooch_world |
kooch_editor | kooch_core, kooch_ecs, kooch_editor_core, kooch_render, kooch_window, kooch_world |
kooch | kooch_core, kooch_ecs (always); kooch_audio, kooch_camera, kooch_editor_core, kooch_gizmos, kooch_gravity, kooch_input, kooch_lighting, kooch_physics, kooch_plugin_api, kooch_remote, kooch_render, kooch_window, kooch_world (optional, feature-gated) |
Crate roles
Foundation (L0)
Crates with no internal dependencies. They can be built in isolation and form the type vocabulary the rest of the engine uses.
| Crate | Role |
|---|---|
kooch_plugin_api | Stable ABI types for dynamic plugins (loaded via libloading), including the KoochPlugin trait a project implements. Lives below kooch_core so plugins compiled against an old engine can still be probed. |
kooch_ecs_macros | Procedural macros for the ECS: #[derive(Reflect)], #[derive(Component)]. Standalone proc-macro crate. |
Core (L1)
| Crate | Role |
|---|---|
kooch_core | App, Plugin, PluginGroup, Stage, Schedule, Resources, Time, GpuContext, event system, asset server, pipeline cache, power profile detection, and scene_paths — the file names three crates have to agree on. The minimum any Kóoch binary needs. |
Primitives (L2)
Small purpose-built crates that depend on kooch_core and nothing else.
| Crate | Role |
|---|---|
kooch_ecs | The ECS itself: archetype storage, Entity/Component traits, Query, Reflect, SceneDocument/SceneManager, hierarchy, transforms, built-in components (Transform, Name, PerspectiveCamera, OrthographicCamera, Mesh, lights, sky, etc). |
kooch_window | Winit integration, WindowPlugin, surface configuration, raw event dispatch. |
kooch_input | Gamepad / keyboard / mouse abstraction. |
kooch_audio | Kira-based audio playback. Sits here rather than beside the ECS crates because it does not depend on the ECS: there is no AudioSource component yet. |
Domain (L3)
Crates built directly on the ECS.
| Crate | Role |
|---|---|
kooch_render | The GPU work: meshlet pipeline (cull, visibility buffer, two-pass material shading, Hi-Z), SkyRenderPass, RenderPlugin, materials, glTF loading. Moved up a layer when it started reading kooch_lighting — a renderer without a shading model paints normals. |
kooch_physics | Physics simulation. Rapier is the backend, behind kooch_physics’s own types. |
kooch_camera | Camera components and VirtualCamera (follow / look-at with damping). |
kooch_lighting | Inti — the shading model, the GPU light record, extraction, exposure and ambient. The light components live in kooch_ecs beside every other component; what lives here is everything that turns them into pixels. See Lighting. |
kooch_world | Scene/world organisation, chunk streaming and activation. |
kooch_remote | The local-socket protocol that lets the standalone editor drive a running project’s ECS. |
Built on domain (L4)
| Crate | Role |
|---|---|
kooch_render | See above. Sits here rather than in L3 because it depends on kooch_lighting. |
kooch_gizmos | Immediate-mode gizmo drawing. Needs kooch_render to submit geometry. |
kooch_gravity | Multi-gravity system (Mario Galaxy-style fields). Needs kooch_physics to apply forces. |
Gizmo interaction (L5)
| Crate | Role |
|---|---|
kooch_gizmos_handles | Draggable translate / rotate / scale / plane handles, with snapping and Local/World modes. Split from kooch_gizmos because drawing a gizmo and interacting with one are different problems. |
Editor (L6)
| Crate | Role |
|---|---|
kooch_editor_core | All editor logic as a library: panels (hierarchy, inspector, viewport, console, asset browser), undo/redo, project state and manifest, scene and prefab save/load, play/stop, the launch screen. Used by the editor binary AND callable as a plugin from custom hosts. |
Binary and facade (L7)
| Crate | Role |
|---|---|
kooch_editor | The editor main(). Imports kooch_editor_core and runs an App with the editor plugins wired. |
kooch | Top-level facade that re-exports the others under one name and defines DefaultPlugins (Bevy-style PluginGroup). User project crates depend on kooch rather than picking subcrates directly. Cargo features (window, render, audio, editor, dynamic…) gate which sub-crates pull in. |
Layering rules
Important: Lower layers must not depend on higher layers. If you find yourself wanting
kooch_coreto know aboutkooch_render, you have an inversion. Common fixes: introduce a trait at the lower layer and implement it at the higher layer, or pass behavior in via a generic / closure.
The dependency graph is acyclic by construction. CI does not enforce this yet — manual review during code review.
Why so many crates?
Three reasons:
-
Compile-time isolation. Iterating on
kooch_renderdoes not recompilekooch_ecsorkooch_window. Cargo’s incremental compilation benefits from real crate boundaries far more than from module boundaries inside one giant crate. -
Feature gating. The facade can compose user-facing builds (a headless server build skips
kooch_renderandkooch_window; an editor build pulls inkooch_editor_core). Single-crate builds cannot do this ergonomically. -
Reasoning surface. Knowing that
kooch_worldcannot accidentally reach intokooch_renderbecause Cargo enforces it makes refactors safer than relying on lint rules.
Tradeoff: more Cargo.toml files to maintain, more pub use re-exports
when types need to cross crate boundaries, slightly higher first-build time.
For an engine in early development the compile-time win outweighs the
ergonomic cost.
Adding a new crate
- Create
crates/kooch_yourthing/withCargo.tomlextendingworkspace.packagefields andCargo.toml[workspace.dependencies]for external deps. - Add it to the
membersarray in the rootCargo.toml. - Add it under
[workspace.dependencies]so other crates can depend on it viaworkspace = true. - If it’s user-facing, re-export from
kooch::*and add a feature flag to the top-levelCargo.toml[features]section. - Document its role in this page.
Render Pipeline
Kóoch renders through a GPU-driven meshlet pipeline, Nanite-style: the CPU uploads a flat array of instances and dispatches, and every decision about what to draw — frustum, backface, occlusion, level of detail — is taken on the GPU by a compute shader reading that array.
The CPU never walks a scene graph deciding what is visible. That is the whole point, and it is what “GPU-driven” means here.
This page describes what the code does today. Where something is missing the page says so and links the issue.
A frame is a list of views
MeshletRenderStage owns one geometry pool and a SlotMap<ViewId, MeshletView>. Each view has its own render targets, its own cull state
and its own camera; the pool, the instance buffer and the pipelines are
shared.
That split is deliberate and was not always true. Cull state is per view by definition — what survives a frustum test depends on where the camera is — and sharing it across views produces an over-cull that only appears once a second view exists, or once shadow cascades do, where it reads as “the shadows are wrong” rather than as a shared-state bug.
Two views run today: the editor’s View panel and its Game panel. Shadow cascades and virtual-shadow-map pages will be views too.
Each view records and submits its own command encoder. Several per-frame buffers are shared across views on exactly that basis: a write followed by a submit is ordered on the queue, so view B’s camera cannot reach view A’s pass.
The frame, pass by pass
Which path runs depends on one capability: 64-bit texture atomics. The
device either has TEXTURE_INT64_ATOMIC + SHADER_INT64 +
SHADER_INT64_ATOMIC_MIN_MAX or it does not.
flowchart TD
START([Frame begins]) --> EXTRACT[CPU: walk the ECS<br/>MeshRenderer + GlobalTransform → instances<br/>lights → Inti's GPU buffer]
EXTRACT --> UPLOAD[Upload instances, grow buffers to fit]
UPLOAD --> R64{64-bit texture<br/>atomics?}
R64 -- yes --> A0[Cull: one thread per instance-meshlet<br/>frustum · backface cone · LOD chain descent]
A0 --> A1[Clear the R64 visibility buffer]
A1 --> A2["Raster: draw_indirect the survivors<br/>fragment does atomicMax(depth << 32 | ids)"]
A2 --> A3[Resolve material id into a depth target]
A3 --> A4[Shade: one fullscreen pass per material,<br/>depth-tested Equal → Inti]
R64 -- no --> B0[Cull A against last frame's Hi-Z]
B0 --> B1[Raster A into the R32 visibility buffer]
B1 --> B2[Build the Hi-Z pyramid: SPD]
B2 --> B3[Cull B: what pass A occluded]
B3 --> B4[Raster B]
B4 --> B5[Shade: one compute dispatch → Inti]
A4 --> BLIT[Blit the stage's colour over the sky]
B5 --> BLIT
BLIT --> PRESENT([Present])
style A2 fill:#1e5f3a,stroke:#4dbe8f,color:#fff
style A4 fill:#5f3a1e,stroke:#be8f4d,color:#fff
style B5 fill:#5f3a1e,stroke:#be8f4d,color:#fff
style EXTRACT fill:#1e3a5f,stroke:#4d8fbe,color:#fff
Cull
One compute thread per (instance × meshlet). Each thread tests its own
meshlet and, if it survives, appends its (instance_id, meshlet_id) to a
visible_meshlets buffer with an atomic bump. The draw that follows is
draw_indirect off a count the GPU wrote — the CPU never learns how many
meshlets survived, and does not need to.
Tests, in order: frustum against the meshlet’s AABB, backface via its normal cone, and LOD chain descent — a meshlet is drawn when its own screen-projected error falls under the target and its parent’s does not.
🔴 The LOD selector read the projection scale from a single matrix element for a long time. That element is
f × (camera up · world up), so it is correct for a level camera, smaller for a tilted one, and zero at 90° of roll or looking straight down — which switched the selector off entirely. It now takes the norm of the row that producesclip.y. Any non-level view had been losing detail since continuous LOD shipped, degrading smoothly enough to read as “that is how the model looks”.
Visibility buffer
Instead of shading during rasterisation, the raster pass writes only which triangle covered this pixel. Shading happens afterwards, once per pixel, for the triangle that won.
R64 path. The fragment shader does one
textureAtomicMax((depth << 32) | ids) into an R64Uint storage
texture. Depth in the high bits means the atomic max resolves depth and
identity in a single operation — no depth buffer, no z-fighting between
coplanar meshlets, no ordering.
R32 path. Without 64-bit atomics the same idea runs in two passes
against a Hi-Z pyramid built with single-pass-downsample: pass A draws
what was visible last frame, the pyramid is rebuilt from that depth, and
pass B recovers whatever pass A wrongly occluded. Metal has no
atomic_uint64, so this path is not legacy — it is the Apple path.
Shading
Both paths reconstruct the surface the same way, through
surface_reconstruct.wgsl: perspective-correct barycentrics from the
triangle’s three world-space positions, giving world position, normal,
uv, tangent and analytical uv derivatives — the automatic ones are
wrong here, because neighbouring fragments in a 2×2 quad may come from
different triangles.
Only the visibility-buffer read differs between the paths. That was not true until #441: the R32 path averaged the triangle’s three vertex normals and never computed a world position at all, which was invisible while shading was a function of the normal alone and would have lit the centroid of every triangle the moment a point light needed a distance.
- R64 shades with one fullscreen fragment pass per material,
depth-testing
Equalagainst a target holding each pixel’s material id. The depth test is the per-material cull, in hardware, with early-Z. Each pass binds its own textures. - R32 shades with one compute dispatch. No texture sampling: a
compute shader has no implicit derivatives, and
textureSampleGradis a fragment-stage call. Scalars only.
Then Inti — Cook-Torrance driven by the scene’s lights.
Sky and composite
The sky is a fullscreen pass: procedural gradient plus volumetric clouds
(3D value noise FBM, Beer–Lambert transmittance, Henyey–Greenstein
phase, in-scattering toward the sun). It draws first, and the meshlet
stage’s colour is blitted over it — alpha = 0 is the background
sentinel, so pixels no meshlet covered keep the sky.
⚠️
GpuContextdeliberately selects a non-sRGB surface format, on the reasoning that “most renderers handle gamma correction in the shader”. Inti does. The sky pass does not. If the two disagree on brightness, that is the sky’s half of a decision taken long ago and never finished.
Debug views
MeshletDebugMode is a Resource the editor sets per frame; the shaders
branch on a single u32. Off is the production path.
| Mode | Shows |
|---|---|
MeshletIds / InstanceIds | Cluster boundaries; per-entity coverage |
TriangleDensity | Triangles drawn per pixel — calibrates target_error_pixels. Anything brighter than green is sub-pixel triangle territory |
Overdraw | Visibility-buffer atomic writes per pixel |
FrustumRejected / BackfaceRejected / HiZRejected | What each cull stage discarded |
CullPassthrough | Everything that survived every stage |
OnlyLod0 / OnlyRoots | The two extremes of the LOD chain, in isolation |
Normals | The world-space normal as colour |
Normals deserves a note: until #441 it was the shading model. The
renderer computed normal * 0.5 + 0.5 and multiplied by albedo, which is
why a scene with lights and a scene without them rendered identically.
It survives as a debug view because it is a genuinely useful look at the
geometry — it just stopped being what you get by default.
The atomic-counter modes need TEXTURE_ATOMIC; the editor’s dropdown
hides what the adapter cannot run rather than offering a mode that
silently falls back.
Limits worth knowing
- 🔴 65 536 instances. The visibility buffer packs
(instance_id << 16) | meshlet_id. A chunk of vegetation exhausts this. Bevy removed their equivalent limit in 0.17 with BVH culling. - 🔴 Six bind groups, six used. The two-pass shading pipeline uses
every group
TARGET_MAX_BIND_GROUPSallows. Shadow maps have to go inside Inti’s group — which is where they belong anyway, since a shadow map without its light is not a thing any shader wants. Raising the target to 8 would work on desktop and drop a baseline Vulkan only guarantees at 4. - Skinned meshes cull against their bind pose (#453), so an animation that reaches outside the rest volume culls a character who is on screen.
- No motion vectors, which blocks temporal upscaling, TAA and motion blur at once (#732).
Not in the pipeline yet
- Shadows — cascades (#476), contact shadows (#735), VSM (#477).
- Global illumination (#450) — surfel + voxel, not raytraced. Its absence is why punctual light defaults are larger than physics says they should be; see Lighting.
- Atmosphere (#250, #248) — correct from orbit, and tinting the sunlight.
- Post-processing and auto exposure (#254). Inti ships a fixed exposure and an ACES approximation as placeholders.
- Light clustering. The shader loops over every light for every pixel: honest for tens, wrong for thousands.
Why there is no render graph
There was one — kooch_render::graph, 497 lines, cycle detection and
topological sort — and nothing ever instantiated it. The real
renderer was built beside it.
The decision not to revive it is not laziness. Bevy 0.19 deleted their
RenderGraph and replaced it with ECS schedules, because the graph ran
as an exclusive system and was single-threaded — the engine that made
the pattern canonical retired it. Kóoch already has the replacement half
written: kooch_core’s scheduler batches GPU systems into a shared
encoder. What it needs is before / after ordering, not a second
scheduler that looks official and is not
(#392).
Lighting — Inti
Inti is Kóoch’s lighting system, named for the Inca sun.
The name covers the whole thing, not one crate: extraction, the GPU light record, the shading model, shadows when they land, clustering, light textures, global illumination. When something says “the Inti path” it means the same way “the meshlet path” does.
The crate is still called kooch_lighting. Renaming a crate rewrites
every serialised type_name in every .scene and .prefab in every
project — silently, since nothing checks that a type it cannot find used
to exist.
Until #441,
kooch_lighting/src/lib.rswas nine lines: a doc comment promising point, spot, directional and area lights, volumetrics and bloom, and aninit()that logged. The three light components existed, the editor drew their gizmos, the Inspector edited them, the remote protocol mirrored them — and no render crate read one. You could place a light and nothing on screen would change.
How a light reaches a pixel
flowchart LR
C["DirectionalLight<br/>PointLight<br/>SpotLight<br/>+ GlobalTransform"] --> E[extract_lights<br/>pure, no GPU]
E --> B[GpuLights<br/>storage buffer,<br/>grows geometrically]
B --> S["inti_shade()<br/>in both shading paths"]
S --> T[inti_tonemap<br/>exposure → ACES → sRGB]
style C fill:#1e3a5f,stroke:#4d8fbe,color:#fff
style S fill:#5f3a1e,stroke:#be8f4d,color:#fff
The light component is the source. Not the sky’s sun direction — an
earlier draft of #441 drove shading from SkyRenderer.sun_direction,
which would have delivered a lit-looking scene and left the three light
components exactly as inert as they were.
A directional light’s direction comes from its transform’s -Z, never from a field. A light that ignores its own rotation is a second source of truth, and the editor’s gizmo already draws the arrow from the first one.
A light with no GlobalTransform is skipped, not defaulted: it has no
direction and no position, and putting it at the origin pointing down
would be an invention that renders.
The shading model
Cook-Torrance, ported from Bevy 0.19’s pbr_lighting.wgsl — read from
source, not reconstructed from memory. Which matters, because three of
their fixes are baked in from the start rather than rediscovered later:
| Term | What it is |
|---|---|
| D | GGX / Trowbridge-Reitz, in Filament’s reassociated form. The naïve expression loses catastrophic f32 precision at low roughness and the highlight breaks into visible blocks |
| V | Height-correlated Smith (Heitz 2014), returning G / (4·NoV·NoL) combined — so the specular term must not divide again |
| F | Plain Schlick, with f90 derived from f0. A near-black dielectric with f90 = 1 grows a white rim at grazing angles that no real material has |
| Diffuse | Burley / Disney. Lambert is flat; this brightens the grazing edge on rough surfaces the way cloth and unfinished wood do |
| Multiscatter | Single-scattering GGX loses energy on rough metals — they go grey. Compensated by the split-sum integral’s analytic fit |
Two decisions worth stating because they diverge from something:
- The diffuse is weighted by
(1 - F). Energy the specular layer reflected is energy the diffuse layer underneath never receives. Bevy’s forward path still adds the two lobes unweighted, the way Filament does; their path tracer does the layering. We took the path tracer’s form, because a mirror is where the difference shows and a mirror is not an edge case. - Point lights have no radius yet, so there is no sphere-light
a_primeto get wrong. Bevy got it wrong and fixed it in 0.18: they applied base roughness where the solid angle demanded a widened one, and highlights stayed sharp and far too bright with distance. The trap is recorded in the shader against the dayPointLightgrows a radius.
Ambient
A hemisphere lerp between a sky colour and a ground colour. It is a placeholder for image-based lighting (#450) and it is not cosmetic: with no ambient term, a metal facing away from every light renders pure black — correct for the model, and indistinguishable from a bug to whoever is looking at it.
⚠️ It lerps on world up, which stops meaning anything on the far side of a planet. A known limit of the placeholder. The replacement is a probe, not a smarter up vector.
Units, and why the defaults are not physical
Lights carry real photometric units:
| Light | Unit | Default |
|---|---|---|
DirectionalLight | lux (illuminance) | 10 000 — lux::AMBIENT_DAYLIGHT |
PointLight / SpotLight | lumens (luminous flux) | 32 000 — lumens::ROOM_LIGHT_NO_GI |
The directional default is a physical fact and matches Bevy’s. The punctual default is forty times a real 9 W bulb, and that deserves an explanation rather than a shrug.
An 800 lm bulb three metres away really does deliver about 7 lux. An office reads 320 lux, and the other 313 are bounces — light off the ceiling, the walls, the desk. Kóoch computes direct light only, so the physically honest number renders as almost nothing.
Bevy has the same gap and resolved it by defaulting PointLight to
VERY_LARGE_CINEMA_LIGHT, one million lumens, with the comment “capable
of registering brightly at Bevy’s default exposure level”. That is a
confession, not a unit. Kóoch’s fudge is named after the compromise —
ROOM_LIGHT_NO_GI — and its own doc comment says it goes back to a real
bulb the day global illumination lands.
kooch_ecs::light_consts holds the named values, so an author picks a
situation instead of guessing a magnitude: lux::OFFICE,
lux::OVERCAST_DAY, lux::DIRECT_SUNLIGHT, lumens::CANDLE,
lumens::CAR_HEADLIGHT.
Hover a field in the Inspector and its doc comment appears as a tooltip, units included.
intensityon a directional light says LUX; on a point light it says LUMENS. They are different units with different magnitudes and they used to look identical.
Exposure
Physical light units need an exposure step or every channel clips to white and the model looks broken rather than unexposed.
Exposure carries an EV100, and PhysicalCamera is the control worth
using: aperture, shutter and ISO. f/16, 1/125, ISO 100 says
something to anyone who has held a camera; EV100 = 9.7 says nothing
about which way is brighter or what a step is worth.
| Preset | Settings | EV100 |
|---|---|---|
PhysicalCamera::sunny() | f/16, 1/125 s, ISO 100 | ≈ 15 |
PhysicalCamera::default() | f/2.8, 1/125 s, ISO 100 | ≈ 9.9 |
PhysicalCamera::indoor() | f/1.0, 1/125 s, ISO 100 | ≈ 7 |
The default is a middle setting, not a real situation: bright enough that a default sun does not clip, dim enough that a punctual light is visible. It lands near Bevy’s 9.7 so a scene authored against their numbers reads the same here — and note that their 9.7 is not “sunny 16” despite being described that way; sunny 16 is EV 15, and they calibrated theirs against Blender’s implicit exposure.
Tone mapping is an ACES approximation (Narkowicz 2015), then the sRGB transfer function. Both are provisional and belong to #254, which owns the real tonemapper and the auto exposure that lets a sunlit surface and a planet’s night side coexist in one frame.
⚠️
ExposureandAmbientLightareResourcesthat no editor surface reaches. The control exists and is not in your hands yet.
The GPU record
GpuLight is 64 bytes, #[repr(C)], mirroring IntiLight in
inti_pbr.wgsl byte for byte. Nothing checks that correspondence at
compile time on either side of the boundary — a reordered field reads a
light’s range as its intensity and renders something plausible and
wrong — so a test pins the size.
Array of structs, not struct of arrays, against the engine’s usual
rule, and for a reason that survives scrutiny: every shader invocation
touching light i reads all of light i’s fields within a few
instructions. Splitting into parallel arrays turns one cache line into
six scattered fetches. SoA pays when a pass reads one field across many
records — which is what light culling does, positions and ranges
only, so when clustering lands its input is a separate pair of arrays,
not a reinterpretation of this one.
Spot cones are stored pre-packed as the multiply-add the shader
evaluates, saturate(cos_angle · scale + offset) — one MAD per light per
fragment instead of a subtract and a divide. The authored half-angles are
recoverable from it.
Angles are half-angles, measured axis to edge, like Unreal rather
than Unity’s single full spotAngle. gizmos/lights.rs chose that
convention when it drew the cone and wrote down that the lighting work
would either honour it or draw a cone half the width it lights.
What Inti does not do yet
- No shadows. Lit-with-no-shadows is an honest intermediate state and already looks far better than a normal painted as colour, but nothing tells you where anything is touching.
- No clustering. The shader loops over every light for every pixel.
extract_lightswarns past 256 and never clips — silently dropping a scene’s lights is worse than rendering it slowly. Bevy moved theirs to the GPU and measured ~20× on theirmany_lightsbenchmark. A universe has stars. - No environment map, no IBL, no area lights, no volumetrics, no bloom. The crate’s original doc comment promised the last three. It now promises what it has.
Frame Pacing
A game loop is supposed to spin. An editor showing a still image is not.
Until #656 the engine made no distinction: the winit handler asked for the next redraw at the end of every frame, unconditionally, so the loop fed itself forever. Vsync capped it at the refresh rate, which is the only reason it cost one core per process rather than all of them. Idle, with a project open and nothing happening, that measured at two pinned cores and 51.8 W on a 9800X3D — to display an image that was not changing.
The contract
Two types in kooch_core::frame_pacing:
FrameRequest— what this frame decided the next one needs. Systems raise it; the runner reads it once per frame and resets it to a baseline. Raising is monotonic within a frame: the most urgent request wins, so draw order can never talk a system out of a repaint it asked for.FrameWaker— a clonable handle any thread can use to break the loop out of a sleep. The wake is sticky, so one that lands between the end of a frame and the moment the runner commits to sleeping is not lost.
Three paces, in order of urgency:
| Pace | Means | ControlFlow |
|---|---|---|
Continuous | Something is animating or simulating | Poll + request_redraw |
After(d) | Something is on a timer | WaitUntil(now + d) |
Wait | Nothing to draw | Wait |
An app that inserts no FrameRequest keeps spinning. That is not an
oversight — it is what a shipped game wants, and it means the opt-in is
explicit at every call site that needs it.
Who asks for what
The editor (FrameRequest::new(FramePace::Wait)) takes its answer
from egui, which already computes one: run_ui returns a
repaint_delay per viewport, ZERO while something animates and
Duration::MAX when the UI has drawn everything it has. Two things egui
cannot see are folded in on top:
- Play — the viewport texture changes from another process, with no
widget to notice it through.
Continuousfor as long as Play lasts. - A live remote session — the project’s stdout arrives on a socket,
not as a window event, so a fully asleep editor would hold its Console
output until the user happened to move the mouse.
After(250 ms).
A frame that failed to present asks for another unconditionally: what is on screen is not what that frame drew.
A project under an editor (RemotePlugin) sleeps by default and is
woken by its own socket. Between edits nothing simulates, so a frame
nobody asked for is a core spent mirroring a still scene; Playing
raises the pace for as long as Play lasts. The listener thread parks on
a reply only the main thread can produce, which is why the wake is not
optional — without it, an editor asking a perfectly healthy project a
question would hang until something unrelated produced a frame.
Frames stopped being a clock
Anything that said “every N frames” was reading a clock that no longer ticks at a fixed rate. An idle editor draws roughly four frames a second, so “every thirtieth frame” went from half a second to seven and a half.
The remote snapshot pull was the one such cadence in the tree, and it is
now expressed as a Duration. Any new cadence should be too — frame
counts were always a stand-in for time, and they are no longer even a
good one.
Input never waits
While the loop is idle, an input event is the only thing that will
produce a frame, so every window event other than RedrawRequested asks
for one. A WaitUntil deadline expiring reports through
StartCause::ResumeTimeReached, and a cross-thread wake arrives as a
winit user event — the proxy rather than request_redraw, because the
proxy is the API documented to be callable from another thread.
Retired
Pages describing code that no longer exists.
They are kept because the reasoning in them was real and cost something to arrive at, and because a decision is easier to revisit when you can still read what it replaced. Nothing here describes the engine as it is.
SDF ray-marching and its BVH
The engine’s original rendering path was signed-distance-field ray-marching, accelerated by a
BVH that several consumers shared. Both crates — kooch_sdf and kooch_bvh — were deleted in
July 2026.
The technique died; the data did not. Signed distance fields remain the representation behind the voxel and dual-contouring work, where they are extracted to meshes that go through the same GPU-driven meshlet pipeline as everything else. What was retired is the renderer that marched them directly.
The current path is described in Render Pipeline.
BVH-Driven Ray Marching
This chapter documents how the SDF ray-marcher integrates with the
GPU LBVH builder shipped in kooch_bvh (issue #115 PR-3) to skip
evaluating primitives whose AABB does not contain the current
sample point. The integration is the subject of #115 PR-4.
Why a BVH at all
Without spatial culling, eval_scene(p) had to evaluate every SDF
primitive at every sphere-tracing step. A planet-scale scene with
~1 M primitives would burn ~1 M transform_point + sdf_* calls
per ray per step, and a ray takes up to 256 steps. The
arithmetic is unforgiving: even at 50 ns per primitive eval the
shader would not converge inside a frame budget.
A BVH lets each ray query “which primitives are near p” in
O(log N) and limits the per-step work to exactly the leaves the
ray currently overlaps.
Data flow
┌─────────────┐ ┌───────────────┐ ┌────────────────────┐
│ ECS │ ─► │ update_scene │ ─► │ BvhState (S4) │
│ (SDF │ │ (Vec<Aabb>, │ │ ├─ BvhGpuBuilder │
│ compos) │ │ Vec<LeafA…>) │ │ ├─ slot_a / slot_b│
└─────────────┘ └───────────────┘ │ └─ pending build │
└─────────┬──────────┘
│ kick_if_dirty
▼
┌────────────────────┐
│ Bvh::build_gpu │
│ (PR-3 — Morton + │
│ onesweep + Karras)│
└─────────┬──────────┘
│ poll_swap
▼
┌──────────────────────────────────────┐
│ slot[current_slot]: nodes + indices │
│ + leaf_aabbs (stable, not pending) │
└─────────┬────────────────────────────┘
│ bind to fragment shader
▼
┌──────────────────────────────────────┐
│ raymarch_main.wgsl::eval_scene_bvh │
│ per-step stack walk, per-role acc │
└──────────────────────────────────────┘
The two-slot pattern is the answer to the read-after-write hazard
on a single shared GPU buffer. While the renderer reads
slot_a.nodes_buffer for frame N, a new build can write into
slot_b for frame N+1 — the swap happens at poll_swap after the
build’s submission resolves. wgpu would otherwise insert a
synchronisation barrier to serialise the read against the write,
stalling the frame pipeline.
Traversal-driven CSG composition
Earlier drafts of PR-4 considered building a per-ray hit list of
primitive indices and then iterating the existing postfix CSG token
stream with a “skip if not in hit list” check. The parallel auditor
flagged this as marketing: it still iterates O(N) tokens per
sample, and an array<u32, 256> thread-local hit list spills 1 KiB
per ray into private memory — register-file death on RDNA 2 / 4.
The shipped design replaces the postfix token stream entirely. The BVH traversal is the evaluation loop. Each leaf carries its CSG role (ADD / INTERSECT / SUBTRACT) and per-instance smoothness, and the traversal accumulates per-role distances inline:
fn eval_scene_bvh(p: vec3<f32>) -> f32 {
var add_acc = ACC_UNION_IDENTITY; // +1e10
var int_acc = ACC_INTERSECT_IDENTITY; // -1e10
var sub_acc = ACC_UNION_IDENTITY; // +1e10
// ... stack walk, point-in-aabb cull, per-leaf eval + combine ...
var result = add_acc;
if scene_meta.has_intersects != 0u {
result = sdf_smooth_intersection(result, int_acc, scene_meta.k_int_scene);
}
if scene_meta.has_subs != 0u {
result = sdf_smooth_subtraction(result, sub_acc, scene_meta.k_sub_scene);
}
return result;
}
The “default tree” shape (smooth_subtract(smooth_intersect(adds, ints), subs)) is preserved — it is now expressed structurally by
the per-role accumulators + the fixed final combination. Per-role
k_max lives in SceneMeta; per-instance smoothness lives in
each LeafAabb.
Identity elements
| Role | Combinator | Identity value | Why |
|---|---|---|---|
| ADD | smooth_union | +∞ (1e10) | smooth_union(+inf, x, k) ≈ x |
| INTERSECT | smooth_intersection | -∞ (-1e10) | smooth_intersection(-inf, x, k) ≈ x |
| SUBTRACT | smooth_union | +∞ (1e10) | subs are unioned, then subtracted |
Picking 1e10 (rather than f32::INFINITY) is deliberate — keeps
the smooth-blend math NaN-free under all inputs.
Determinism
smooth_union and smooth_intersection are not strictly
associative in float32. The cull-vs-cull byte-identity regression
test (cull_vs_cull_byte_identical_n_*) requires the per-role
accumulator visit order to be a function of BVH topology only —
never of runtime ray geometry.
The traversal pushes left BEFORE right on its 32-deep stack, so
pop order is right-first and stable across frames. Do not switch
to a t-near-sorted children push without re-deriving the
determinism story from scratch — the regression test will catch it,
but the failure mode is “single-pixel-bit mismatches that confuse
post-processing”, not a clean panic.
AABB inflation
A primitive’s AABB is computed from its analytic shape, scaled by
the entity’s scale, rotated, translated, and inflated by its
role’s k_max. Smooth blends extend the support beyond the raw
geometry; without the inflation, a primitive whose surface lies
exactly on its AABB would have its smooth-union tail truncated at
the cull boundary.
Per-role inflation (rather than per-instance) keeps the AABB tight:
a primitive in a scene where every other ADD has k = 0.1
inflates by 0.1 even if its own smoothness = 0, because the
operator between them carries the larger k.
Performance scaling
Measured on a Ryzen 9 9800X3D + RX 9070 XT, Bazzite F43 / Mesa
radv (#115 PR-4 S11 bench output):
| N primitives | BVH (cs_main) | Fullscan (cs_fullscan) | Speedup |
|---|---|---|---|
| 1 024 | 3.79 ms | 3.55 ms | 0.94× |
| 10 240 | 5.93 ms | 6.88 ms | 1.16× |
| 65 000 | 4.57 ms | 14.56 ms | 3.19× |
Small-N is dominated by stack-walk overhead. The cross-over sits
between 1 k and 10 k in this configuration; from 65 k upwards the
cull is the dominant cost saver, exactly as the
O(N) → O(log N) goal predicts.
What lives where
crates/kooch_bvh/— the GPU LBVH builder (PR-3). Public API:Bvh::build_gpu,BvhGpuBuilder,BvhGpuBuild,GpuBvhHandle.crates/kooch_render/src/raymarch/bvh.rs—BvhState: double- buffered slots + dirty hash + kick / poll_swap lifecycle.crates/kooch_render/src/raymarch/aabb.rs—primitive_aabb: per-type local half-extents → world-space inflated AABB.crates/kooch_render/src/raymarch/instance.rs—LeafAabb(32 B std430),SceneMeta(64 B uniform), CSG role constants.crates/kooch_render/shaders/raymarch_main.wgsl—eval_scene_bvhtraversal + per-role accumulators + fixed final combination.
Out of scope (filed as follow-ups)
- Refit BVH (#115 checkbox 110) — incremental updates without a full rebuild. Useful for scenes with constant primitive count and small per-frame motion.
- OBB-exact AABBs (vs the current
abs(rot_matrix) · half_extentsenclosing OBB-AABB). Tighter cull at the cost of per-frame CPU work. - Workgroup-shared bitmap cull (vs the current per-thread stack walk) — blocked on tile-based shading, which itself is blocked on the G-Buffer (#132).
- Archetype-level dirty marker (vs the current
u64hash of primitive bytes + leaf metadata).
Multi-consumer BVH
This chapter documents the engine-shared GPU BVH that backs three
consumers in lockstep: the SDF raymarch culling from PR-4, the
physics broadphase from kooch_physics::broadphase, and the GPU
frustum cull behind the mesh pass. It is the subject of #115 PR-5,
the closing PR of issue #115.
The previous chapter (BVH-Driven Ray Marching)
introduced the GPU LBVH builder and the two-slot double-buffer that
sidesteps wgpu’s read-after-write hazard. PR-5 generalises that
state into kooch_bvh::SharedBvhState — a single resource the rest of
the engine binds against.
Why one structure for three consumers
Acceptance criterion 116 of #115 demands: “multiple systems use the same structure.” Independently, each consumer wants the same set of spatial queries — find leaves overlapping an AABB, a frustum, a ray. Building three private BVHs over the same scene each frame would triple the GPU build cost (and, more painfully, the per-frame readback the CPU mirror requires) without any algorithmic gain.
The shipped architecture builds one BVH per scene-dirty frame, lets
every consumer bind the same nodes / sorted_indices / leaf_aabbs
buffers, and hides the lifecycle behind a single resource. Per-
consumer side-payloads (raymarch’s per-instance smoothness, future
collider mass / restitution) live in the consuming crate and ride
the same kick → swap pulse via the
type-state BuildToken.
flowchart LR
Scene["ECS scene<br/>(SDF / collider / mesh)"] --> Hash["scene_hash<br/>(folds side payloads)"]
Hash --> Kick["SharedBvhState::kick_auto"]
Kick -->|build| Build["BvhGpuBuilder<br/>(Morton + Karras)"]
Kick -->|refit| Refit["refit_gpu<br/>(leaves + AABB only)"]
Build --> Slot[("slot[i].nodes<br/>slot[i].sorted_indices<br/>slot[i].leaf_aabbs")]
Refit --> Slot
Slot --> Raymarch["raymarch_main.wgsl<br/>traversal-driven CSG"]
Slot --> Broadphase["BroadphasePairs::collect<br/>(CPU mirror)"]
Slot --> Frustum["frustum_cull.wgsl<br/>→ DrawIndexedIndirectArgs[]"]
style Raymarch fill:#ff7f50,color:#000
style Broadphase fill:#90ee90,color:#000
style Frustum fill:#87ceeb,color:#000
The four-buffer slot
OutputSlot is the per-side double-buffer the orchestrator rotates.
Each side owns four parallel buffers, all sized for the current
primitive count and grown on demand:
| Buffer | Owner | Producer | Consumed by |
|---|---|---|---|
nodes | shared | GPU build / refit | every consumer (BVH walk) |
sorted_indices | shared | GPU sort | raymarch leaf payload lookup, refit topology |
leaf_aabbs | shared | CPU kick(...) | raymarch (gating), frustum cull |
| side payloads | private | per-consumer kick | raymarch fragment shader (RaymarchPayload[]) |
The first three live in kooch_bvh::shared::OutputSlot. Side payloads
live in the consuming crate (kooch_render::raymarch::bvh::PayloadSlot
holds RaymarchPayload[] at binding 5 of the raymarch pipeline). The
private buffers’ double-buffer mirrors the shared one — when
poll_swap flips current_slot, every parallel double-buffer must
flip alongside it or the renderer reads stale-paired data.
LeafAabb: per-leaf metadata + flag scheme
Every leaf carries 32 bytes of std430-clean metadata (mirrors the
WGSL LeafAabb byte-for-byte; offsets pinned by an offset_of!
test):
#![allow(unused)]
fn main() {
#[repr(C)]
pub struct LeafAabb {
pub aabb_min: [f32; 3],
pub flags: u32,
pub aabb_max: [f32; 3],
pub entity_id: u32,
}
}
The flags field is the multi-consumer contract — each consumer
filters by its own bit during traversal:
| Bit | Constant | Consumer |
|---|---|---|
| 0–1 | ROLE_RAYMARCH_* | Raymarch CSG role (ADD / INTERSECT / SUBTRACT) — only meaningful when IS_RAYMARCH is set. |
| 2 | IS_RAYMARCH | Leaf participates in the SDF raymarch traversal. |
| 3 | IS_COLLIDER | Physics broadphase (#42). |
| 4 | IS_VISIBLE_MESH | Frustum / occlusion culling (#91). |
| 5 | IS_LIGHT | Reserved for the light culling consumer (#27). Defined here so no future consumer accidentally claims the bit. |
| 6–31 | free | Future consumers. |
entity_id is the ECS entity index broadphase / frustum cull use
to return entity-keyed pair lists and visibility sets. Raymarch
ignores it.
Note: AABBs are inflated by the per-role smooth-blend
k_maxso the cull stays conservative under raymarch smooth blends. The S7 bench measured an envelope/tight pair-count ratio of 2.086 in a synthetic mixed scene (broadphase false-positives bench), justifying the per-role tighter AABBs follow-up filed at the close of #115.
Lifecycle: kick → poll_swap → bind
SharedBvhState::kick_auto is the production entry point per frame.
It picks between rebuild and refit using the should_refit
heuristic over the previously-mirrored leaf AABBs:
flowchart TD
Start["kick_auto(items, leaves, hash)"] --> Pending{"pending in flight?"}
Pending -- yes --> N1["return None"]
Pending -- no --> HashCheck{"hash == last?"}
HashCheck -- yes --> N2["return None"]
HashCheck -- no --> First{"cpu_mirror = None?"}
First -- yes --> Kick["kick → full rebuild"]
First -- no --> Card{"cardinality match?"}
Card -- no --> Kick
Card -- yes --> SR{"should_refit(prev, curr,<br/>0.25, 10.0)?"}
SR -- false --> Kick
SR -- true --> KR["kick_refit → fast path"]
Kick --> Token1["BuildToken<'_>"]
KR --> Token2["BuildToken<'_>"]
Token1 --> Attach["token.attach_payload(...)"]
Token2 --> Attach
Attach --> Drop["token drops"]
Drop --> Frame["next frame:<br/>poll_swap drains payloads"]
Suppression cases (pending in flight, hash unchanged) return None
before any state mutates — the consumer’s parallel buffers don’t
regrow either. This is the lesson from a footgun that earlier drafts
ate: see Type-state BuildToken.
Type-state BuildToken: enforcing the side-payload invariant
Before S3.5 the orchestrator returned bool from kick. Each
consumer maintained a pending_payload: Option<...> field that it
had to keep in lockstep with the orchestrator’s pending. That
invariant was implicit — and held only as long as a single consumer
played by the rules.
The footgun the type-state refactor closed was subtler than the “forgot to clear pending_payload on failure” scenario: the buffer-regrow on suppressed kick.
Note: Original behaviour:
kick_if_dirty(...)calledpayload_slot.ensure_capacity(n)before asking the orchestrator whether the kick was committed. If a previous kick was still pending, the second call would still grow the payload buffer — reallocating thewgpu::BufferArc — while the closure registered by the first kick still held a refcounted clone of the old buffer. On the eventual swap, the closure uploaded the captured payload into the orphaned buffer and the renderer kept reading the regrown one. Silent stale data, no panic, no warning.
SharedBvhState::kick and kick_refit now return
Option<BuildToken<'_>>. Some(token) is the only path that
exposes target_slot and n and admits an attach_payload(closure)
registration; None means the kick was suppressed and the consumer
mutates nothing. With ensure_capacity deferred until after the
token arrives, suppressed kicks no longer regrow buffers the
orchestrator will not write to. The invariant is type-enforced
instead of convention-enforced.
#![allow(unused)]
fn main() {
// production raymarch path (BvhState::kick_auto_if_dirty)
let scene_hash = Self::hash_scene(&items, &leaf_aabbs, &payloads);
let Some(mut token) = self.shared.kick_auto(
device, queue, items, leaf_aabbs, scene_hash, 0.25, 10.0,
) else {
return false; // suppressed → nothing to do
};
// from here, kick is guaranteed committed:
// token.target_slot() and token.n() are stable
// attach_payload runs on the matching swap, or drops on failure
attach_payload_upload(&mut self.payload_slots, device, &mut token, payloads);
}
On poll_swap success every attached uploader fires in registration
order with (queue, target_slot). On failure each uploader is
dropped without running, so the captured payload Vec and the
cloned buffer Arc are released cleanly — there is no
“who-clears-up-stale-pending” question.
CPU mirror: free byte-identical mirror from the build’s readback
The GPU build path always reads back the resolved nodes array and
the sorted_indices permutation — BvhGpuBuild::poll needs the
permutation to produce Bvh::leaves in Morton order. Pre-S4 the
orchestrator threw both away. S4 captures them in CpuMirror, owned
by SharedBvhState and refreshed on every successful build / refit.
CPU consumers (today: physics broadphase; tomorrow: debug tooling,
authoring traversals) walk the mirror with Bvh::for_each_aabb /
for_each_sphere / friends. No second build — the readback was
already paid for.
The byte-level invariant: cpu_bvh.nodes is bit-identical to the
GPU’s current_nodes() buffer after every swap, build or refit.
The Karras AABB union is element-wise (union(min, min) = min,
union(max, max) = max) and order-independent, so the GPU’s parallel
multi-dispatch propagation and the CPU’s post-order DFS produce the
same BvhNode array down to the bit pattern. The S7 sync goldens
in crates/kooch_bvh/src/shared/sync_tests.rs field-by-field
compare each BvhNode post-build and post-refit; any divergence
is a CPU↔GPU desync bug, not a precision issue.
Refit fast path
kick_refit rewrites only the leaves and re-propagates internal
AABBs over the existing topology. It skips Morton encoding,
the onesweep sort, and Karras’ internal-node construction
entirely. For a scene where centres did not move (or moved within
the heuristic threshold), this is the one-pass cost rather than the
full ~5-pass pipeline.
The refit is fence-only — a 4-byte staging copy at the end of
the encoder signals “submission completed”. No nodes readback per
frame; the production hot loop never pays the (2N-1)·32 B cost.
The CPU mirror updates in place via Bvh::refit_in_place, which
applies the new leaf AABBs through the stored sorted_indices
permutation and re-propagates internals on the CPU. O(N) work, no
GPU traffic.
should_refit(prev, curr, move_threshold_ratio, change_threshold_pct)
is the cheap predicate. Defaults from the PR-5 plan: 0.25 and
10.0 — refit is OK when fewer than 10 % of the AABBs moved their
centre by more than 25 % of their largest extent. Tighter values
land via the S7 bench results once a real workload tells us what
“moderate movement” means in practice.
Three consumers
Raymarch (PR-4)
The raymarch fragment shader binds nodes + sorted_indices +
leaf_aabbs + the raymarch-only RaymarchPayload[]. Each ray
walks the BVH in a 32-deep stack, gates leaves by IS_RAYMARCH,
reads the role bits and per-instance smoothness, and accumulates
per-role distances inline. The traversal is the evaluation loop —
there is no separate hit-list pass. Postfix CSG token streams from
#307 do not apply to this path.
Physics broadphase (S4 of #115 PR-5, #42)
kooch_physics::broadphase::BroadphasePairs::collect(&shared) walks
the CPU mirror, filters leaves by IS_COLLIDER, queries
Bvh::for_each_aabb for every collider, and returns canonical
(low, high) entity-id pairs deduplicated across the symmetric
query. CPU-first because narrowphase (#40) is still CPU; the
GPU broadphase path is filed for when narrowphase moves to the GPU
and the readback round-trip becomes the constraint instead of the
optimisation.
Frustum cull (S5 of #115 PR-5, #91)
kooch_render::frustum::FrustumCull dispatches a compute pass over
the GPU’s leaf_aabbs buffer and writes one
DrawIndexedIndirectArgs per leaf in original input order. Visible
leaves get instance_count = 1; culled or non-IS_VISIBLE_MESH
leaves get 0. The mesh pass consumes the buffer via
draw_indexed_indirect; the GPU command processor skips zero-
instance entries with no shader work. Zero CPU readback per
frame — the camera writes the frustum uniform once per change and
that is the only CPU→GPU traffic.
The shader is the per-leaf parallel positive-vertex slab test against the 6 frustum planes:
for (var i: u32 = 0u; i < 6u; i = i + 1u) {
let plane = frustum.planes[i];
let n = plane.xyz;
let pv = vec3<f32>(
select(aabb_min.x, aabb_max.x, n.x >= 0.0),
select(aabb_min.y, aabb_max.y, n.y >= 0.0),
select(aabb_min.z, aabb_max.z, n.z >= 0.0),
);
if (dot(n, pv) + plane.w < 0.0) { return false; } // cull
}
return true;
The 10k-cube AC test in
crates/kooch_render/src/frustum/tests/cull.rs runs the same
algorithm on the CPU and asserts byte-perfect agreement on every
one of the 10000 leaves — the GPU cull is the same computation
parallelised, not an approximation.
Module layout
| Path | Role |
|---|---|
kooch_bvh::shared::state::SharedBvhState | orchestrator + counters |
kooch_bvh::shared::pending::{BuildToken, SwapInfo, Pending} | type-state lifecycle handles |
kooch_bvh::shared::mirror::CpuMirror | CPU mirror struct + from_build / apply_refit |
kooch_bvh::shared::heuristic::{should_refit, kick_auto} | rebuild-vs-refit policy |
kooch_bvh::shared::slot::OutputSlot | per-slot stable buffer set |
kooch_bvh::leaf::LeafAabb | per-leaf metadata + flag bits |
kooch_bvh::Bvh::refit_in_place | CPU refit over an existing topology |
kooch_render::raymarch::bvh::BvhState | raymarch consumer wrapper |
kooch_render::frustum::FrustumCull | frustum cull GPU compute |
kooch_physics::broadphase::BroadphasePairs | CPU broadphase consumer |
kooch_render/shaders/frustum_cull.wgsl | the compute shader |
Out of scope (filed as follow-ups)
- Tighter per-role AABBs. The S7 bench measured a synthetic envelope/tight pair-count ratio of 2.086 — above the 1.5× action threshold. Filed as a priority issue at the close of #115.
- GPU broadphase. Stays CPU-first until narrowphase (#40) moves
to the GPU; the API surface (
BroadphasePairs::collect(&shared)) stays the same, only the body offrom_cpu_mirrorswaps for a compute dispatch. - Mesh pass GPU-driven integration. S5 ships the indirect-args
buffer; wiring the mesh pass to consume it via
draw_indexed_indirect(and to maintain a per-leaf mesh metadata buffer once entities ship distinct meshes) is filed separately — it depends on the engine’s mesh atlas design. - Archetype-level dirty marker. The
u64hash_scenefold is conservative; an ECS-side change-detection signal would let kick decisions skip the hash computation entirely.
Decisions Log
Chronological record of architectural decisions. Each entry captures what was decided, why, and what it cost (or commits us to). Reading this is faster than reading 12 PR descriptions to figure out why a function exists.
Format:
YYYY-MM-DD · Title (refs)
Decision: one-paragraph summary. Why: the constraint or insight that drove it. Consequence: what this commits the project to, or what it rules out.
Coordinate system
Permanent · Right-handed, -Z forward, Y up
Decision: Use the same convention as glTF, OpenGL, Vulkan, Blender, Maya, and
glam’s default view/projection matrices. -Z is forward, +Y is up, +X is right. Why: Going against the grain means flipping Z at every loader boundary (glTF importer, USD, FBX, exported camera transforms). Unity picked left-handed because of DirectX heritage; they pay the flip cost in every importer. We don’t want to. Consequence: Identity quaternion(0,0,0,1)faces -Z. Anyone coming from Unity (left-handed +Z forward) needs to mentally flip when authoring scenes.
SDF ray-marching as primary render path
Permanent · Sphere tracing, not rasterization
Decision: Primary render pipeline is SDF sphere tracing. Mesh rasterization is a secondary pass layered on top. Why: Want experimentation latitude (Mario Galaxy gravity, infinite procedural geometry, smooth blends) that rasterization can’t give cheaply. SDFs are also a clean GPU-resident data model that pairs well with the hybrid ECS. Consequence: Performance ceiling is lower than a modern PBR rasterizer at the same hardware budget. Hybrid mesh+SDF (Dreams style) is the long-term escape hatch but ~3000 LOC away.
Hierarchical coordinate scales (NOT floating origin)
2026-04 · Issue #50 (blocks #51, #52, #54, #90)
Decision: Universe (i64 sector + f64 offset) → Solar system (f64) → Planet (f32) → Surface (f32) → Camera-relative render (f32). Origin rebasing is a trigger when the player gets far from origin, not the sole mechanism. Why: Inspired by No Man’s Sky, Star Citizen, KSP. Pure floating origin works for one scale (Outer Wilds) but breaks down across astronomical ↔ surface transitions. Consequence: Multiple
Transformtypes, conversion at scale boundaries, but precision stays bounded inside each scale. Locks in design for camera-relative transforms (#51), sector boundaries (#52), world streaming (#54), and navigation (#90).
SDF tracer roadmap
2026-04-22 · Step count bump 128 → 256 (PR #227 closes #221)
Decision: Quick fix raised
max_stepsdefault from 128 to 256. Visible gaps in concave SDF necks reduced to imperceptible. Why: Two attempts at smarter tracers failed: Enhanced Sphere Tracing (PR #222, branch killed) and the iq closed-form ellipsoid (#229, killed) both produced gray patches at CSG seams. Brute force was the surgically minimal change that worked. Consequence: ~2× iteration cost on the GPU, predictable. Locked until Segment Tracing (Galin et al. 2020, issue #224) lands and lets us drop back to ~128 steps with correct Lipschitz bounds.
2026-04-22 · ESL is dead (#222 killed twice)
Decision: Do not retry Enhanced Sphere Tracing while the shader uses the
s_minworkaround for non-uniform scale. Two attempts on 2026-04-22 confirmed it cannot work. Project memory documents the dead ends. Why: Naive ESL with non-Lipschitz CSG produces poly-edged gray patches near silhouettes (calc_normal crosses gradient discontinuities at seams). Lowering omega from 1.6 to 1.3 makes it worse, not better. Hybrid (over-relax only in far field) doesn’t fix it either. Consequence: Segment Tracing (#224) is the only open path forward for tracer optimization.
Editor camera as ephemeral ECS entity
2026-04-22 ·
EditorCamera + EditorOnly + PerspectiveCamera + Transform(#199 → PR #219)Decision: The editor camera is a regular ECS entity, not a
Resource. It carries anEditorOnlymarker that theEphemeralComponentsfilter checks during scene serialization — the entity is invisible tofrom_ecsand todespawn_all. Why: The renderers already iterate cameras by priority. Resource approach would require a special code path in every renderer and a manual mode swap. Entity approach lets future editor-only entities (gizmos, grid, debug lights) ride the sameEditorOnlyfilter without new plumbing. Consequence: Play mode strips the editor camera. Scenes need their own non-ephemeral activePerspectiveCamerato render anything in play. UX feature “Play uses editor view” is a separate future issue.
2026-04-22 · Quat internally for cameras, Euler-cached for inspector
Decision: Cameras store
Quatinternally for orbit/fly rotations. The inspector caches Euler angles per-field to avoid gimbal lock and to keep each X/Y/Z field stable while the others are edited. Why: Inspector UX needs each axis editable independently of the others — aQuatround-trip mangles two axes when you edit the third. Cameras need continuous quaternion math for cinematics. Different contexts, both correct, do not unify. Consequence: Two rotation representations exist in the codebase. Documented; do not “simplify”.
2026-04-22 · Fly-mode pivot is camera position, NOT focus point (#199 PR #219)
Decision: In orbit mode, rotation pivots around
focus_point. In fly mode, it pivots around the camera itself;focus_pointis re-anchored tocamera_position + forward * distanceafter each rotation. Why: Bug found in manual testing — without this invariant, fly mode would drift laterally as you looked around (your “feet” moved when you turned your head). Consequence:EditorCameraControllercarries explicit logic for the two modes; do not refactor toward a unified pivot.
Render orchestration
2026-04-23 · Editor is the render orchestrator for offscreen (PR #235 closes #129)
Decision:
kooch_editor_core::systems::startupinstantiatesRayMarchRenderer + MeshPassRenderer + SkyRenderPassdirectly asResourcesandviewport::render::render_viewportruns the three passes in one encoder against the offscreenViewportTarget. TheRayMarchPluginis not used by the editor. Why: Doing this through aRenderGraphabstraction would have been ~500 LOC for a 3-pass pipeline. Plain procedural orchestration wins until there are 5+ passes. Consequence: When a fourth pass (post-process composite, G-Buffer, shadow map) is added, re-evaluate building aRenderGraph.
2026-04-25 ·
RenderPluginIS the game render path (PR #267 closes #260)Decision:
RenderPlugin(inkooch_render) is the play-binary orchestrator. Same 3-pass pipeline as the editor’srender_viewport, but writing to the swapchain surface instead of an offscreen texture.RayMarchPluginstays as the standalone demo path (raymarch_demo). Why: StubRenderPluginthat only cleared the screen was dead weight. The semantically right name for “the game’s render plugin” isRenderPlugin. No separateGameRenderPlugininvented. Consequence: Editor and play share one conceptual model with two orchestration callsites. A future regression in either path is immediately reproducible in the other.
2026-04-23 · Mesh pass: two pipelines, one target, one encoder (#129)
Decision: Raymarch pipeline runs first with
LoadOp::Clear, mesh pipeline runs second on the same target withLoadOp::Load. No shared shader, no unified material system — explicitly NOT unified. Why: Unifying the SDF shader and the mesh shader would have been a multi-week refactor for an MVP feature. Two pipelines is correct enough. Consequence: Material system per-pipeline grows independently until #130 PBR forces convergence.
2026-04-23 ·
Depth32Floatconstant +LessEqualfor sky (PR #237 closes #236)Decision: All passes share
VIEWPORT_DEPTH_FORMAT = Depth32Floatas a publickooch_renderconstant. Sky pipeline usesCompareFunction::LessEqual; mesh pipeline usesLess. Why: Sky writesfrag_depth = 1.0explicitly so meshes behind it can supersede. Depth clears to 1.0. WithLess,1.0 < 1.0is false and sky never draws — black viewport. Bug found in first manual test. Mesh keepsLessbecause no mesh is exactly at the far plane. Consequence: Future depth format change is one line inlib.rs. Documented in shader comments next to the comparison choice.
Sky / atmosphere
2026-04-23 ·
SkyRendererdoes NOT blend between multiple skies (PR #247 closes #246)Decision:
SkyRendereris a singleton-by-priority component. Highest-priority active wins; no crossfade composite pass. Day/night is animated within one shader, not by blending two materials. Why: Crossfading entire sky materials was scope creep. Unity, Unreal, and Bevy don’t do it natively either. Animated parameters within one material handle the real-world use case. Consequence: NoSkyCompositepass. If we ever need sky crossfade, that’s a new pass with explicit cost.
2026-04-23 ·
SkyRendererandAtmosphereVolumeare separate componentsDecision:
SkyRenderer= singleton ambient backdrop (deep space or default gradient).AtmosphereVolume= volumetric shell per-planet with scattering, N coexisting in the world. Why: Architecture ofstellar_deliveryand Unreal’sSkyAtmosphere. Singleton sky and per-planet atmosphere have different lifetimes, coordinate frames, and shader budgets. Forcing one component to do both invents complexity. Consequence: Two paths to maintain, both simpler than one overloaded path.AtmosphereVolumeships in a future PR (#248).
Scene management
2026-04-24 ·
SceneManageragnostic of component types (PR #266 closes #259)Decision:
SceneManagerlives inkooch_ecsand knows nothing about Camera, Sky, or any specific component. The default scene bootstrap (Camera + Sky entities written to disk on project create) lives inkooch_editor_core::project::ensure_default_scene. Why: Same split asEphemeralComponents: mechanism in core, policy in editor. LetsSceneManagerbe reused by headless tools that have a different “default scene” idea. Consequence:kooch_ecscannot be the place to teach the engine “every project starts with a Camera and a Sky.” That decision is the editor’s.
2026-04-25 · Scene bootstrap runs at
Stage::First, NOTStage::Startup(PR #267 closes #260)Decision:
SceneBootstrapPlugin::load_boot_sceneruns atStage::First, which fires once-per-frame after allStage::Startupsystems complete. TheBootSceneresource is consumed on first call so it’s effectively a one-shot. Why: Race detected in manual testing — if userregister_componentsand SceneBootstrap both ran atStage::Startup, scene deserialization happened beforePlayer(custom component) registered →unknown component type: Playererror.Stage::Firstguarantees a clean handshake. Consequence: Replicable pattern for any future plugin that depends on user-registered state. First frame waits one stage tick for the scene to appear; imperceptible at 60 FPS.
2026-04-25 · Play uses
cargo run --manifest-path, no exe-detection (PR #267 closes #260)Decision:
EditorAction::Playrunscargo run --manifest-path <project>/Cargo.toml -- --scene <abs>. The oldis_project_binaryflag andcurrent_exe.starts_with(target)guard are gone. Why: Cargo handles incremental build, caching, and run as one primitive. Custom exe detection only worked for the half of project launches that ran the binary directly; not for in-processOpenProjectflows. The new approach works for both. Consequence: First Play after a code change costs acargo build(~0.1–30s). Editor stays responsive (cargo runs as child). Async-build modal with cancel is a future UX issue, not architecture.
2026-04-25 · Project template is play-mode-only (PR #267 closes #260)
Decision: Generated
main.rsis ~10 lines:App::new() + DefaultPlugins + register_components. The dual editor/play branching the old template carried is gone — the editor is its own binary, never embedded in user crates. Why: Cleaner mental model, cleaner code. The “editor inside the user binary” pattern was a leftover from before the editor binary existed; it confused exe-detection and confused users. Consequence: Existing user projects need migration (one-line change inmain.rs+ Cargo.toml cleanup). New projects are clean.
wgpu strategy
2026-04-23 · Stay on wgpu 29 for 24 months minimum (PR #239 closes #238)
Decision: Do not migrate to ash / vulkano / dx12-rs. No active migration trigger. Hybrid wgpu + ash only if RT pipelines become a requirement and upstream issue
#8560(Metal pipelines design) stays unresolved past April 2028. Why: Bevy ships Solari (path-traced GI) on wgpu in September 2025. If they don’t migrate prematurely, we don’t either. The audit indocs/research/wgpu-capabilities.mdlists 5 concrete migration triggers; none are active. Consequence: No raw Vulkan / Metal escape hatch in user code. Specific blocked features (mesh shaders cross-backend, FSR 2 viable, 3D texture arrays, GPU memory reporting) work around or wait.
2026-04-24 ·
PipelineCachewithfallback: true(PR #257 closes #251)Decision: Enable
wgpu::PipelineCachekeyed on(adapter.name, driver_info, engine_version). Save onDrop for GpuContext; SIGKILL is tolerated. Why: 100–500 ms cold-start saving per pipeline. TheunsafeofDevice::create_pipeline_cacheis covered byfallback: true— driver rejects an invalid blob without UB. Hash key invalidates on driver upgrades. Consequence:~/.cache/kooch/pipeline_cache/<hash>.binfiles accumulate (they’re tiny). Deleting them is harmless; engine regenerates on next run.
2026-04-24 ·
PowerProfileenum lives inkooch_core::power(PR #258 closes #253)Decision:
PowerProfile::{Plugged, Balanced, Battery, Debug}as aResourceinkooch_core::power. Auto-detect on Linux via sysfs and$SteamDeckenv var. Override viaKOOCH_POWER_PROFILE. Why: The Steam Deck / OneXFly target makes battery awareness non-negotiable. Renderers will gate quality defaults (DoF, SSR, TAA off in Battery) per-feature in future PRs. Consequence:kooch_corecarries the policy enum but renderers do not yet read it. Integration is per-feature PR work, intentional.
Inspector / editor UX
2026-04 ·
GlobalTransformtolerates shear, inspector warns (PR #217 closes #214)Decision:
GlobalTransformis a 4×4 matrix that can carry shear (non-uniform scale through a rotated parent), but the inspector does not attempt to decompose it. Instead it shows a warning icon and exposes alossy_scale()helper. Why: Decomposing shear is ambiguous (multiple TRS triplets reproduce the same matrix). Hiding the issue creates worse bugs downstream. Educating the user is honest. Consequence: Users authoring shear-causing parent chains see the warning. No automatic “fix” is offered.
2026-04-25 · Three-system editor architecture: Gizmos / Editor / UI Toolkit (research #276, doc
docs/research/editor-three-system-architecture.md)Decision: Editor evolves into three separate, pure-Rust, custom-built subsystems:
kooch_gizmos(visual gizmo API + visualizer registry, usable at runtime too) +kooch_gizmos_handles(interactive translate/rotate/scale, editor-only);kooch_editor_api(user editor extensions: inspectors, panels, actions, loaded via libloading from a usereditor/crate);kooch_ui(declarative HTML-like UI Toolkit:.kooch_uimarkup +.kooch_styleCSS subset + Rust behavior, retained-mode with fine-grained signals, coexists withegui). Why: Godot’s self-hosted monolith couples concerns; Unity’s separation (Gizmos / Handles / Editor scripts / UI Toolkit) lets each evolve independently and gives users one mental model per need. We follow Unity’s separation. External libraries —transform-gizmo, Slint, Dioxus — rejected: only cover narrow slices, none address user-extensibility for custom component visualizers, and we want the engine to be self-contained pure Rust with no FFI. Consequence: A multi-quarter commitment. Three implementation epics (one per subsystem) replace the original gizmo epic #198 as sub-epic of the Gizmos one. The currentkooch_render::gizmosmodule (PR #277) migrates intokooch_gizmosin phase 1. Thekooch_uitoolkit is the heaviest piece (multi-month) and runs in parallel with the others.
2026-07-25 · Keep
kooch_ecs; do not adoptbevy_ecs(decision #605)Decision:
kooch_ecsstays and improves in place.bevy_ecsis the reference to steal individual designs from, never a dependency. Why: #603 removed the GPU component storages that had justified a custom ECS, so the justification was re-derived from measurements rather than repeated. No technical blocker was found —bevy_ecsis genuinely standalone (65 crates, nobevy_app/bevy_render), the GPU-driven renderer touches the ECS throughQueryin four places, andbevy_reflectexpresses our custom field attributes. What decided it: 42 call sites reach into component storage directly against 3 that useQuery, which is work required in every path and whichkooch_ecscan already express;EntityAllocator::revivepreserves entity identity across Play/Stop, whichbevy_ecsrefuses by design while 177 sites outside the crate hold anEntityin a field; and 51 of the 80 affected files arekooch_editor_core, the one area where Bevy offers no upstream design to copy because it has no editor. Consequence: improvements are ordered by demonstrated pain, not by feature parity. Encapsulating the ECS behindQueryis the prerequisite for any future backend change — today the contact surface is 80 files. The schedule graph belongs inkooch_core, not the ECS: the ordering bugs it would fix live inapp.rs.
2026-07-25 · Entities are referenced by a persistent id, not a handle or an index (feat #607)
Decision: a component may hold an
Entityand have it survive a save. Identity is an opt-inPersistentId(EntityGuid); the wire form isEntityRef, which isLive(Entity)in memory andPersistent { scene, id }on disk. Ids are scene-local and remapped per instance.Parentbecomes an ordinary component andparent_indexis legacy-read-only. Why: reflection had no way to express “points at an entity”, so the scene format carried the parent link out of band. That worked for one component and could not scale: joints hold two entities, and an index into one document cannot address another scene at all. Assets had already solved the same problem by addressing through aGuid. Scene-local ids follow Unity (SceneLoadFlags.NewInstance) and Unreal (Level Instances), and are what allows one scene to be instantiated twice without both copies claiming the same identity. Consequence: saving a scene mutates the world, because whether an entity is referenced is only known once references are written —SceneDocument::from_ecstakes&mut Resources.Entitystill does not implementSerialize, so serialising a live reference is an error rather than a handle written to disk. A reference whose target is absent saves and loads as unset, which is the normal state for a reference into a non-resident cell under #566. Unblocks #560 and cross-scene references.
2026-07-25 · The world is the container; scenes are content loaded into it (feat #609)
Decision:
SceneManagerbecomes a registry of open scenes with one active, instead of a single current path whose load replaced the world. Scenes carry aGuid;SceneMemberrecords an entity’s authoring home and is derived on load rather than serialised. Saving writes only one scene’s entities; closing despawns only its own. Why: the model #566 settled on. One scene per world is “the entire world in one section”, which cannot express a space station and an asteroid field as separate content occupying the same volume, nor make “close the station” different from “walk away from it”. #607 supplied the prerequisite by making entity references survive a save. Consequence: there is always a scene, even before the first save, and entities with no membership are adopted by the active scene when it saves — otherwise anything spawned in the editor would belong to nothing and be written to no file. The reference remap table is keyed by(scene, id), never by id alone: ids are scene-local, so two open scenes both numbering an entity 1 is ordinary. Opening the same file twice is refused, because two copies would share every entity id. Scene transforms and instancing are deliberately deferred — they need a decision on whether the transform bakes at load, as Unreal’s Embedded Level Instances do.
2026-07-26 · A scene is the prefab; instancing and editing are different operations (epic #611)
Decision: prefabs are scenes instanced with their entity ids remapped per instance. No separate format. Built in two phases: runtime instancing first, the linked-with-overrides prefab system after. Why: #609 refuses to open one file twice, which is right for editing and wrong as a limit on instancing — and entity ids were made scene-local in #607 precisely so instances could remap them. Unity’s prefab is a serialised scene file, and Godot says so outright with
PackedScene; both store an instance as a reference to the source plus a list of differences rather than a copy, which is what keeps editing the source propagating to its instances. Consequence: two things must be settled in phase A because they touch already-merged types — whether a scene must have a single root (instancing as a unit with a transform needs one, and our documents are a flat list), and how an outside reference names this instance rather than the prefab, sinceEntityRef::Persistent { scene, id }is ambiguous once a scene is instanced twice. Phase B waits on one decision: whether overrides are per field, as Unity and Godot both do, or whether editing an instance promotes it to its own scene.