Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. Engine users — anyone writing a game on top of Kóoch. Start with Your First Project, then The Editor.
  2. 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 build is yours to run (#158).
  • Reflection is shallow. No Vec<T>, no HashMap, 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

LayerCrate / Library
GPUwgpu 29 (Vulkan / DX12 / Metal)
Windowingwinit 0.30
Mathglam 0.33
Physicsrapier3d 0.34
Audiokira 0.9
Inputgilrs 0.11 (gamepad), winit (keyboard/mouse)
Gameplay codePlain Rust — native plugin via kooch_plugin_api
Editor UIegui 0.35 + egui_dock 0.20
Meshgltf 1.4
Serialisationserde 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:

  1. SceneBootstrapPlugin::with_scene(path), if your main.rs sets one explicitly.
  2. --scene <path> on the command line — absolute, or relative to the working directory.
  3. 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:

PluginRole
CorePluginTime, the AppExit event
EcsPluginStorage, SceneManager, built-in components, transform propagation
WindowPluginWinit window and GPU surface
RenderPluginThe mesh and sky pipelines
SceneBootstrapPluginLoads 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 dylib to 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 dylib gets 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. See docs/MEMORY.md for the full reasoning.

The Hub — the window you get from cargo run -p kooch_editor — is where projects are created and opened.

The Hub

The panels

PanelWhat it is for
ViewThe 3D viewport. Selection, transform gizmos, the physics debug overlay, and the meshlet debug-view dropdown. Owns the editor camera.
GameWhat 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.
WorldThe entity hierarchy of every loaded scene. Selecting here selects in View.
InspectorThe 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.
ComponentsEvery component type the engine and your project registered.
ArchetypesWhich combinations of components actually exist, and how many entities are in each. A debugging view of how the ECS stored your scene.
Asset BrowserThe project’s assets and the engine’s, as two roots.
Input MapEdits a .inputaction asset: bindings, the five composites, processors. An action is an asset, not an entry in a map — see Writing a System.
ConsoleStructured logs from the editor and the launched project, filterable. Text is selectable and copyable.
PerformanceFrame 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 build yourself (#158).
  • No reload. The project’s dylib loads 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 Resources with 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

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

The Hub — new, open, and recent projects

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, Mat3 and Mat4 come through kooch::prelude, and the whole glam crate is reachable as kooch::glam. Adding your own glam dependency is the one thing to avoid: a Quat from 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:

  1. Select an entity in World (or spawn one).
  2. Add ComponentGameplaySpinner.
  3. Set speed in the Inspector — try 90.
  4. 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.

If something did not work

SymptomCause
The component is not in the Add Component menuRegister Scripts not pressed, or the project not rebuilt and reopened
A field is not in the InspectorIt is private, has #[reflect(skip)], or is a type reflection does not support yet (#649)
The derive does not compileA field’s type is not supported — Vec<T>, HashMap, your own enums. Mark it #[reflect(skip)]
The system never runsIt 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 minutesThe 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 },
        );
    }
}
CommandWhat you get
cargo runThe editor, with your components in it
cargo run -- --gameThe game
cargo run -- --remoteA 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 component
  • pub 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

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 typeWidget
f32, f64Drag value
u8u64, i8i64Drag value, clamped to the type
boolCheckbox
StringText field
Vec2, Vec3, Vec4Component-wise drag values
QuatEuler angles, in degrees
Mat4Decomposed 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 ReflectNested, drawn inline

The maths types come from the prelude. Vec3, Quat and Mat4 are glam types, and kooch::prelude re-exports them so a project never declares its own glam dependency — which is the point, since a Quat from 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 what EntityRef::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

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:

CallUse
.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:

StageFor
StartupOnce, at startup
FirstBeginning of frame
InputInput processing
PreUpdatePreparation
UpdateYour game logic — the default choice
PostUpdateCleanup after update
GpuSync, GpuGPU sync and submission
Physics, PostPhysicsFixed timestep
PreRender, RenderRendering

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. spawn returns a valid Entity straight away, but the inserts are queued until apply. Querying that entity before apply finds nothing on it.
  • Put Commands back. remove takes it out of Resources; 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.

LayerCratesRole
L0 · foundationkooch_plugin_api, kooch_ecs_macrosNo internal deps. Type vocabulary + proc-macros.
L1 · corekooch_coreApp, Plugin, Schedule, Resources, GpuContext.
L2 · primitiveskooch_ecs, kooch_window, kooch_input, kooch_audioECS, windowing, input, audio. Depend on kooch_core only.
L3 · domainkooch_physics, kooch_camera, kooch_lighting, kooch_world, kooch_remoteBuilt on the ECS. Simulation, lighting data, scene organisation, remote protocol.
L4 · built on domainkooch_render, kooch_gizmos, kooch_gravityThe renderer needs Inti’s shading model; gizmos need the renderer; gravity needs the solver.
L5 · gizmo interactionkooch_gizmos_handlesDraggable handles on top of gizmo drawing.
L6 · editorkooch_editor_coreEditor logic as a library. Depends on 11 internal crates — the widest surface in the workspace.
L7 · binary + facadekooch_editor, koochThe 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).

CrateDepends on
kooch_plugin_api
kooch_ecs_macros
kooch_corekooch_plugin_api
kooch_ecskooch_core, kooch_ecs_macros, kooch_plugin_api
kooch_windowkooch_core
kooch_inputkooch_core
kooch_audiokooch_core
kooch_camerakooch_core, kooch_ecs
kooch_lightingkooch_core, kooch_ecs
kooch_worldkooch_core, kooch_ecs
kooch_remotekooch_core, kooch_ecs
kooch_renderkooch_core, kooch_ecs, kooch_lighting
kooch_physicskooch_core, kooch_ecs
kooch_gizmoskooch_core, kooch_ecs, kooch_render
kooch_gravitykooch_core, kooch_ecs, kooch_physics
kooch_gizmos_handleskooch_gizmos
kooch_editor_corekooch_camera, kooch_core, kooch_ecs, kooch_gizmos, kooch_gizmos_handles, kooch_gravity, kooch_physics, kooch_remote, kooch_render, kooch_window, kooch_world
kooch_editorkooch_core, kooch_ecs, kooch_editor_core, kooch_render, kooch_window, kooch_world
koochkooch_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.

CrateRole
kooch_plugin_apiStable 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_macrosProcedural macros for the ECS: #[derive(Reflect)], #[derive(Component)]. Standalone proc-macro crate.

Core (L1)

CrateRole
kooch_coreApp, 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.

CrateRole
kooch_ecsThe 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_windowWinit integration, WindowPlugin, surface configuration, raw event dispatch.
kooch_inputGamepad / keyboard / mouse abstraction.
kooch_audioKira-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.

CrateRole
kooch_renderThe 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_physicsPhysics simulation. Rapier is the backend, behind kooch_physics’s own types.
kooch_cameraCamera components and VirtualCamera (follow / look-at with damping).
kooch_lightingInti — 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_worldScene/world organisation, chunk streaming and activation.
kooch_remoteThe local-socket protocol that lets the standalone editor drive a running project’s ECS.

Built on domain (L4)

CrateRole
kooch_renderSee above. Sits here rather than in L3 because it depends on kooch_lighting.
kooch_gizmosImmediate-mode gizmo drawing. Needs kooch_render to submit geometry.
kooch_gravityMulti-gravity system (Mario Galaxy-style fields). Needs kooch_physics to apply forces.

Gizmo interaction (L5)

CrateRole
kooch_gizmos_handlesDraggable 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)

CrateRole
kooch_editor_coreAll 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)

CrateRole
kooch_editorThe editor main(). Imports kooch_editor_core and runs an App with the editor plugins wired.
koochTop-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_core to know about kooch_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:

  1. Compile-time isolation. Iterating on kooch_render does not recompile kooch_ecs or kooch_window. Cargo’s incremental compilation benefits from real crate boundaries far more than from module boundaries inside one giant crate.

  2. Feature gating. The facade can compose user-facing builds (a headless server build skips kooch_render and kooch_window; an editor build pulls in kooch_editor_core). Single-crate builds cannot do this ergonomically.

  3. Reasoning surface. Knowing that kooch_world cannot accidentally reach into kooch_render because 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

  1. Create crates/kooch_yourthing/ with Cargo.toml extending workspace.package fields and Cargo.toml [workspace.dependencies] for external deps.
  2. Add it to the members array in the root Cargo.toml.
  3. Add it under [workspace.dependencies] so other crates can depend on it via workspace = true.
  4. If it’s user-facing, re-export from kooch::* and add a feature flag to the top-level Cargo.toml [features] section.
  5. 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 &lt;&lt; 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 produces clip.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 Equal against 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 textureSampleGrad is 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.

⚠️ GpuContext deliberately 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.

ModeShows
MeshletIds / InstanceIdsCluster boundaries; per-entity coverage
TriangleDensityTriangles drawn per pixel — calibrates target_error_pixels. Anything brighter than green is sub-pixel triangle territory
OverdrawVisibility-buffer atomic writes per pixel
FrustumRejected / BackfaceRejected / HiZRejectedWhat each cull stage discarded
CullPassthroughEverything that survived every stage
OnlyLod0 / OnlyRootsThe two extremes of the LOD chain, in isolation
NormalsThe 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_GROUPS allows. 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.rs was nine lines: a doc comment promising point, spot, directional and area lights, volumetrics and bloom, and an init() 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:

TermWhat it is
DGGX / 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
VHeight-correlated Smith (Heitz 2014), returning G / (4·NoV·NoL) combined — so the specular term must not divide again
FPlain 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
DiffuseBurley / Disney. Lambert is flat; this brightens the grazing edge on rough surfaces the way cloth and unfinished wood do
MultiscatterSingle-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_prime to 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 day PointLight grows 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:

LightUnitDefault
DirectionalLightlux (illuminance)10 000 — lux::AMBIENT_DAYLIGHT
PointLight / SpotLightlumens (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 compromiseROOM_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. intensity on 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.

PresetSettingsEV100
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.

⚠️ Exposure and AmbientLight are Resources that 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_lights warns 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 their many_lights benchmark. 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:

PaceMeansControlFlow
ContinuousSomething is animating or simulatingPoll + request_redraw
After(d)Something is on a timerWaitUntil(now + d)
WaitNothing to drawWait

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. Continuous for 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

RoleCombinatorIdentity valueWhy
ADDsmooth_union+∞ (1e10)smooth_union(+inf, x, k) ≈ x
INTERSECTsmooth_intersection-∞ (-1e10)smooth_intersection(-inf, x, k) ≈ x
SUBTRACTsmooth_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 primitivesBVH (cs_main)Fullscan (cs_fullscan)Speedup
1 0243.79 ms3.55 ms0.94×
10 2405.93 ms6.88 ms1.16×
65 0004.57 ms14.56 ms3.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.rsBvhState: double- buffered slots + dirty hash + kick / poll_swap lifecycle.
  • crates/kooch_render/src/raymarch/aabb.rsprimitive_aabb: per-type local half-extents → world-space inflated AABB.
  • crates/kooch_render/src/raymarch/instance.rsLeafAabb (32 B std430), SceneMeta (64 B uniform), CSG role constants.
  • crates/kooch_render/shaders/raymarch_main.wgsleval_scene_bvh traversal + 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_extents enclosing 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 u64 hash 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:

BufferOwnerProducerConsumed by
nodessharedGPU build / refitevery consumer (BVH walk)
sorted_indicessharedGPU sortraymarch leaf payload lookup, refit topology
leaf_aabbssharedCPU kick(...)raymarch (gating), frustum cull
side payloadsprivateper-consumer kickraymarch 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:

BitConstantConsumer
0–1ROLE_RAYMARCH_*Raymarch CSG role (ADD / INTERSECT / SUBTRACT) — only meaningful when IS_RAYMARCH is set.
2IS_RAYMARCHLeaf participates in the SDF raymarch traversal.
3IS_COLLIDERPhysics broadphase (#42).
4IS_VISIBLE_MESHFrustum / occlusion culling (#91).
5IS_LIGHTReserved for the light culling consumer (#27). Defined here so no future consumer accidentally claims the bit.
6–31freeFuture 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_max so 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(...) called payload_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 the wgpu::Buffer Arc — 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

PathRole
kooch_bvh::shared::state::SharedBvhStateorchestrator + counters
kooch_bvh::shared::pending::{BuildToken, SwapInfo, Pending}type-state lifecycle handles
kooch_bvh::shared::mirror::CpuMirrorCPU mirror struct + from_build / apply_refit
kooch_bvh::shared::heuristic::{should_refit, kick_auto}rebuild-vs-refit policy
kooch_bvh::shared::slot::OutputSlotper-slot stable buffer set
kooch_bvh::leaf::LeafAabbper-leaf metadata + flag bits
kooch_bvh::Bvh::refit_in_placeCPU refit over an existing topology
kooch_render::raymarch::bvh::BvhStateraymarch consumer wrapper
kooch_render::frustum::FrustumCullfrustum cull GPU compute
kooch_physics::broadphase::BroadphasePairsCPU broadphase consumer
kooch_render/shaders/frustum_cull.wgslthe 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 of from_cpu_mirror swaps 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 u64 hash_scene fold 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 Transform types, 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_steps default 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_min workaround 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 an EditorOnly marker that the EphemeralComponents filter checks during scene serialization — the entity is invisible to from_ecs and to despawn_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 same EditorOnly filter without new plumbing. Consequence: Play mode strips the editor camera. Scenes need their own non-ephemeral active PerspectiveCamera to 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 Quat internally 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 — a Quat round-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_point is re-anchored to camera_position + forward * distance after 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: EditorCameraController carries 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::startup instantiates RayMarchRenderer + MeshPassRenderer + SkyRenderPass directly as Resources and viewport::render::render_viewport runs the three passes in one encoder against the offscreen ViewportTarget. The RayMarchPlugin is not used by the editor. Why: Doing this through a RenderGraph abstraction 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 a RenderGraph.

2026-04-25 · RenderPlugin IS the game render path (PR #267 closes #260)

Decision: RenderPlugin (in kooch_render) is the play-binary orchestrator. Same 3-pass pipeline as the editor’s render_viewport, but writing to the swapchain surface instead of an offscreen texture. RayMarchPlugin stays as the standalone demo path (raymarch_demo). Why: Stub RenderPlugin that only cleared the screen was dead weight. The semantically right name for “the game’s render plugin” is RenderPlugin. No separate GameRenderPlugin invented. 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 with LoadOp::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 · Depth32Float constant + LessEqual for sky (PR #237 closes #236)

Decision: All passes share VIEWPORT_DEPTH_FORMAT = Depth32Float as a public kooch_render constant. Sky pipeline uses CompareFunction::LessEqual; mesh pipeline uses Less. Why: Sky writes frag_depth = 1.0 explicitly so meshes behind it can supersede. Depth clears to 1.0. With Less, 1.0 < 1.0 is false and sky never draws — black viewport. Bug found in first manual test. Mesh keeps Less because no mesh is exactly at the far plane. Consequence: Future depth format change is one line in lib.rs. Documented in shader comments next to the comparison choice.


Sky / atmosphere

2026-04-23 · SkyRenderer does NOT blend between multiple skies (PR #247 closes #246)

Decision: SkyRenderer is 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: No SkyComposite pass. If we ever need sky crossfade, that’s a new pass with explicit cost.

2026-04-23 · SkyRenderer and AtmosphereVolume are separate components

Decision: SkyRenderer = singleton ambient backdrop (deep space or default gradient). AtmosphereVolume = volumetric shell per-planet with scattering, N coexisting in the world. Why: Architecture of stellar_delivery and Unreal’s SkyAtmosphere. 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. AtmosphereVolume ships in a future PR (#248).


Scene management

2026-04-24 · SceneManager agnostic of component types (PR #266 closes #259)

Decision: SceneManager lives in kooch_ecs and knows nothing about Camera, Sky, or any specific component. The default scene bootstrap (Camera + Sky entities written to disk on project create) lives in kooch_editor_core::project::ensure_default_scene. Why: Same split as EphemeralComponents: mechanism in core, policy in editor. Lets SceneManager be reused by headless tools that have a different “default scene” idea. Consequence: kooch_ecs cannot 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, NOT Stage::Startup (PR #267 closes #260)

Decision: SceneBootstrapPlugin::load_boot_scene runs at Stage::First, which fires once-per-frame after all Stage::Startup systems complete. The BootScene resource is consumed on first call so it’s effectively a one-shot. Why: Race detected in manual testing — if user register_components and SceneBootstrap both ran at Stage::Startup, scene deserialization happened before Player (custom component) registered → unknown component type: Player error. Stage::First guarantees 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::Play runs cargo run --manifest-path <project>/Cargo.toml -- --scene <abs>. The old is_project_binary flag and current_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-process OpenProject flows. The new approach works for both. Consequence: First Play after a code change costs a cargo 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.rs is ~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 in main.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 in docs/research/wgpu-capabilities.md lists 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 · PipelineCache with fallback: true (PR #257 closes #251)

Decision: Enable wgpu::PipelineCache keyed on (adapter.name, driver_info, engine_version). Save on Drop for GpuContext; SIGKILL is tolerated. Why: 100–500 ms cold-start saving per pipeline. The unsafe of Device::create_pipeline_cache is covered by fallback: true — driver rejects an invalid blob without UB. Hash key invalidates on driver upgrades. Consequence: ~/.cache/kooch/pipeline_cache/<hash>.bin files accumulate (they’re tiny). Deleting them is harmless; engine regenerates on next run.

2026-04-24 · PowerProfile enum lives in kooch_core::power (PR #258 closes #253)

Decision: PowerProfile::{Plugged, Balanced, Battery, Debug} as a Resource in kooch_core::power. Auto-detect on Linux via sysfs and $SteamDeck env var. Override via KOOCH_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_core carries the policy enum but renderers do not yet read it. Integration is per-feature PR work, intentional.


Inspector / editor UX

2026-04 · GlobalTransform tolerates shear, inspector warns (PR #217 closes #214)

Decision: GlobalTransform is 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 a lossy_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 user editor/ crate); kooch_ui (declarative HTML-like UI Toolkit: .kooch_ui markup + .kooch_style CSS subset + Rust behavior, retained-mode with fine-grained signals, coexists with egui). 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 current kooch_render::gizmos module (PR #277) migrates into kooch_gizmos in phase 1. The kooch_ui toolkit is the heaviest piece (multi-month) and runs in parallel with the others.

2026-07-25 · Keep kooch_ecs; do not adopt bevy_ecs (decision #605)

Decision: kooch_ecs stays and improves in place. bevy_ecs is 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_ecs is genuinely standalone (65 crates, no bevy_app/bevy_render), the GPU-driven renderer touches the ECS through Query in four places, and bevy_reflect expresses our custom field attributes. What decided it: 42 call sites reach into component storage directly against 3 that use Query, which is work required in every path and which kooch_ecs can already express; EntityAllocator::revive preserves entity identity across Play/Stop, which bevy_ecs refuses by design while 177 sites outside the crate hold an Entity in a field; and 51 of the 80 affected files are kooch_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 behind Query is the prerequisite for any future backend change — today the contact surface is 80 files. The schedule graph belongs in kooch_core, not the ECS: the ordering bugs it would fix live in app.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 Entity and have it survive a save. Identity is an opt-in PersistentId(EntityGuid); the wire form is EntityRef, which is Live(Entity) in memory and Persistent { scene, id } on disk. Ids are scene-local and remapped per instance. Parent becomes an ordinary component and parent_index is 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 a Guid. 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_ecs takes &mut Resources. Entity still does not implement Serialize, 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: SceneManager becomes a registry of open scenes with one active, instead of a single current path whose load replaced the world. Scenes carry a Guid; SceneMember records 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, since EntityRef::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.