Decisions Log
Chronological record of architectural decisions. Each entry captures what was decided, why, and what it cost (or commits us to). Reading this is faster than reading 12 PR descriptions to figure out why a function exists.
Format:
YYYY-MM-DD · Title (refs)
Decision: one-paragraph summary. Why: the constraint or insight that drove it. Consequence: what this commits the project to, or what it rules out.
Coordinate system
Permanent · Right-handed, -Z forward, Y up
Decision: Use the same convention as glTF, OpenGL, Vulkan, Blender, Maya, and
glam’s default view/projection matrices. -Z is forward, +Y is up, +X is right. Why: Going against the grain means flipping Z at every loader boundary (glTF importer, USD, FBX, exported camera transforms). Unity picked left-handed because of DirectX heritage; they pay the flip cost in every importer. We don’t want to. Consequence: Identity quaternion(0,0,0,1)faces -Z. Anyone coming from Unity (left-handed +Z forward) needs to mentally flip when authoring scenes.
SDF ray-marching as primary render path
Permanent · Sphere tracing, not rasterization
Decision: Primary render pipeline is SDF sphere tracing. Mesh rasterization is a secondary pass layered on top. Why: Want experimentation latitude (Mario Galaxy gravity, infinite procedural geometry, smooth blends) that rasterization can’t give cheaply. SDFs are also a clean GPU-resident data model that pairs well with the hybrid ECS. Consequence: Performance ceiling is lower than a modern PBR rasterizer at the same hardware budget. Hybrid mesh+SDF (Dreams style) is the long-term escape hatch but ~3000 LOC away.
Hierarchical coordinate scales (NOT floating origin)
2026-04 · Issue #50 (blocks #51, #52, #54, #90)
Decision: Universe (i64 sector + f64 offset) → Solar system (f64) → Planet (f32) → Surface (f32) → Camera-relative render (f32). Origin rebasing is a trigger when the player gets far from origin, not the sole mechanism. Why: Inspired by No Man’s Sky, Star Citizen, KSP. Pure floating origin works for one scale (Outer Wilds) but breaks down across astronomical ↔ surface transitions. Consequence: Multiple
Transformtypes, conversion at scale boundaries, but precision stays bounded inside each scale. Locks in design for camera-relative transforms (#51), sector boundaries (#52), world streaming (#54), and navigation (#90).
SDF tracer roadmap
2026-04-22 · Step count bump 128 → 256 (PR #227 closes #221)
Decision: Quick fix raised
max_stepsdefault from 128 to 256. Visible gaps in concave SDF necks reduced to imperceptible. Why: Two attempts at smarter tracers failed: Enhanced Sphere Tracing (PR #222, branch killed) and the iq closed-form ellipsoid (#229, killed) both produced gray patches at CSG seams. Brute force was the surgically minimal change that worked. Consequence: ~2× iteration cost on the GPU, predictable. Locked until Segment Tracing (Galin et al. 2020, issue #224) lands and lets us drop back to ~128 steps with correct Lipschitz bounds.
2026-04-22 · ESL is dead (#222 killed twice)
Decision: Do not retry Enhanced Sphere Tracing while the shader uses the
s_minworkaround for non-uniform scale. Two attempts on 2026-04-22 confirmed it cannot work. Project memory documents the dead ends. Why: Naive ESL with non-Lipschitz CSG produces poly-edged gray patches near silhouettes (calc_normal crosses gradient discontinuities at seams). Lowering omega from 1.6 to 1.3 makes it worse, not better. Hybrid (over-relax only in far field) doesn’t fix it either. Consequence: Segment Tracing (#224) is the only open path forward for tracer optimization.
Editor camera as ephemeral ECS entity
2026-04-22 ·
EditorCamera + EditorOnly + PerspectiveCamera + Transform(#199 → PR #219)Decision: The editor camera is a regular ECS entity, not a
Resource. It carries anEditorOnlymarker that theEphemeralComponentsfilter checks during scene serialization — the entity is invisible tofrom_ecsand todespawn_all. Why: The renderers already iterate cameras by priority. Resource approach would require a special code path in every renderer and a manual mode swap. Entity approach lets future editor-only entities (gizmos, grid, debug lights) ride the sameEditorOnlyfilter without new plumbing. Consequence: Play mode strips the editor camera. Scenes need their own non-ephemeral activePerspectiveCamerato render anything in play. UX feature “Play uses editor view” is a separate future issue.
2026-04-22 · Quat internally for cameras, Euler-cached for inspector
Decision: Cameras store
Quatinternally for orbit/fly rotations. The inspector caches Euler angles per-field to avoid gimbal lock and to keep each X/Y/Z field stable while the others are edited. Why: Inspector UX needs each axis editable independently of the others — aQuatround-trip mangles two axes when you edit the third. Cameras need continuous quaternion math for cinematics. Different contexts, both correct, do not unify. Consequence: Two rotation representations exist in the codebase. Documented; do not “simplify”.
2026-04-22 · Fly-mode pivot is camera position, NOT focus point (#199 PR #219)
Decision: In orbit mode, rotation pivots around
focus_point. In fly mode, it pivots around the camera itself;focus_pointis re-anchored tocamera_position + forward * distanceafter each rotation. Why: Bug found in manual testing — without this invariant, fly mode would drift laterally as you looked around (your “feet” moved when you turned your head). Consequence:EditorCameraControllercarries explicit logic for the two modes; do not refactor toward a unified pivot.
Render orchestration
2026-04-23 · Editor is the render orchestrator for offscreen (PR #235 closes #129)
Decision:
kooch_editor_core::systems::startupinstantiatesRayMarchRenderer + MeshPassRenderer + SkyRenderPassdirectly asResourcesandviewport::render::render_viewportruns the three passes in one encoder against the offscreenViewportTarget. TheRayMarchPluginis not used by the editor. Why: Doing this through aRenderGraphabstraction would have been ~500 LOC for a 3-pass pipeline. Plain procedural orchestration wins until there are 5+ passes. Consequence: When a fourth pass (post-process composite, G-Buffer, shadow map) is added, re-evaluate building aRenderGraph.
2026-04-25 ·
RenderPluginIS the game render path (PR #267 closes #260)Decision:
RenderPlugin(inkooch_render) is the play-binary orchestrator. Same 3-pass pipeline as the editor’srender_viewport, but writing to the swapchain surface instead of an offscreen texture.RayMarchPluginstays as the standalone demo path (raymarch_demo). Why: StubRenderPluginthat only cleared the screen was dead weight. The semantically right name for “the game’s render plugin” isRenderPlugin. No separateGameRenderPlugininvented. Consequence: Editor and play share one conceptual model with two orchestration callsites. A future regression in either path is immediately reproducible in the other.
2026-04-23 · Mesh pass: two pipelines, one target, one encoder (#129)
Decision: Raymarch pipeline runs first with
LoadOp::Clear, mesh pipeline runs second on the same target withLoadOp::Load. No shared shader, no unified material system — explicitly NOT unified. Why: Unifying the SDF shader and the mesh shader would have been a multi-week refactor for an MVP feature. Two pipelines is correct enough. Consequence: Material system per-pipeline grows independently until #130 PBR forces convergence.
2026-04-23 ·
Depth32Floatconstant +LessEqualfor sky (PR #237 closes #236)Decision: All passes share
VIEWPORT_DEPTH_FORMAT = Depth32Floatas a publickooch_renderconstant. Sky pipeline usesCompareFunction::LessEqual; mesh pipeline usesLess. Why: Sky writesfrag_depth = 1.0explicitly so meshes behind it can supersede. Depth clears to 1.0. WithLess,1.0 < 1.0is false and sky never draws — black viewport. Bug found in first manual test. Mesh keepsLessbecause no mesh is exactly at the far plane. Consequence: Future depth format change is one line inlib.rs. Documented in shader comments next to the comparison choice.
Sky / atmosphere
2026-04-23 ·
SkyRendererdoes NOT blend between multiple skies (PR #247 closes #246)Decision:
SkyRendereris a singleton-by-priority component. Highest-priority active wins; no crossfade composite pass. Day/night is animated within one shader, not by blending two materials. Why: Crossfading entire sky materials was scope creep. Unity, Unreal, and Bevy don’t do it natively either. Animated parameters within one material handle the real-world use case. Consequence: NoSkyCompositepass. If we ever need sky crossfade, that’s a new pass with explicit cost.
2026-04-23 ·
SkyRendererandAtmosphereVolumeare separate componentsDecision:
SkyRenderer= singleton ambient backdrop (deep space or default gradient).AtmosphereVolume= volumetric shell per-planet with scattering, N coexisting in the world. Why: Architecture ofstellar_deliveryand Unreal’sSkyAtmosphere. Singleton sky and per-planet atmosphere have different lifetimes, coordinate frames, and shader budgets. Forcing one component to do both invents complexity. Consequence: Two paths to maintain, both simpler than one overloaded path.AtmosphereVolumeships in a future PR (#248).
Scene management
2026-04-24 ·
SceneManageragnostic of component types (PR #266 closes #259)Decision:
SceneManagerlives inkooch_ecsand knows nothing about Camera, Sky, or any specific component. The default scene bootstrap (Camera + Sky entities written to disk on project create) lives inkooch_editor_core::project::ensure_default_scene. Why: Same split asEphemeralComponents: mechanism in core, policy in editor. LetsSceneManagerbe reused by headless tools that have a different “default scene” idea. Consequence:kooch_ecscannot be the place to teach the engine “every project starts with a Camera and a Sky.” That decision is the editor’s.
2026-04-25 · Scene bootstrap runs at
Stage::First, NOTStage::Startup(PR #267 closes #260)Decision:
SceneBootstrapPlugin::load_boot_sceneruns atStage::First, which fires once-per-frame after allStage::Startupsystems complete. TheBootSceneresource is consumed on first call so it’s effectively a one-shot. Why: Race detected in manual testing — if userregister_componentsand SceneBootstrap both ran atStage::Startup, scene deserialization happened beforePlayer(custom component) registered →unknown component type: Playererror.Stage::Firstguarantees a clean handshake. Consequence: Replicable pattern for any future plugin that depends on user-registered state. First frame waits one stage tick for the scene to appear; imperceptible at 60 FPS.
2026-04-25 · Play uses
cargo run --manifest-path, no exe-detection (PR #267 closes #260)Decision:
EditorAction::Playrunscargo run --manifest-path <project>/Cargo.toml -- --scene <abs>. The oldis_project_binaryflag andcurrent_exe.starts_with(target)guard are gone. Why: Cargo handles incremental build, caching, and run as one primitive. Custom exe detection only worked for the half of project launches that ran the binary directly; not for in-processOpenProjectflows. The new approach works for both. Consequence: First Play after a code change costs acargo build(~0.1–30s). Editor stays responsive (cargo runs as child). Async-build modal with cancel is a future UX issue, not architecture.
2026-04-25 · Project template is play-mode-only (PR #267 closes #260)
Decision: Generated
main.rsis ~10 lines:App::new() + DefaultPlugins + register_components. The dual editor/play branching the old template carried is gone — the editor is its own binary, never embedded in user crates. Why: Cleaner mental model, cleaner code. The “editor inside the user binary” pattern was a leftover from before the editor binary existed; it confused exe-detection and confused users. Consequence: Existing user projects need migration (one-line change inmain.rs+ Cargo.toml cleanup). New projects are clean.
wgpu strategy
2026-04-23 · Stay on wgpu 29 for 24 months minimum (PR #239 closes #238)
Decision: Do not migrate to ash / vulkano / dx12-rs. No active migration trigger. Hybrid wgpu + ash only if RT pipelines become a requirement and upstream issue
#8560(Metal pipelines design) stays unresolved past April 2028. Why: Bevy ships Solari (path-traced GI) on wgpu in September 2025. If they don’t migrate prematurely, we don’t either. The audit indocs/research/wgpu-capabilities.mdlists 5 concrete migration triggers; none are active. Consequence: No raw Vulkan / Metal escape hatch in user code. Specific blocked features (mesh shaders cross-backend, FSR 2 viable, 3D texture arrays, GPU memory reporting) work around or wait.
2026-04-24 ·
PipelineCachewithfallback: true(PR #257 closes #251)Decision: Enable
wgpu::PipelineCachekeyed on(adapter.name, driver_info, engine_version). Save onDrop for GpuContext; SIGKILL is tolerated. Why: 100–500 ms cold-start saving per pipeline. TheunsafeofDevice::create_pipeline_cacheis covered byfallback: true— driver rejects an invalid blob without UB. Hash key invalidates on driver upgrades. Consequence:~/.cache/kooch/pipeline_cache/<hash>.binfiles accumulate (they’re tiny). Deleting them is harmless; engine regenerates on next run.
2026-04-24 ·
PowerProfileenum lives inkooch_core::power(PR #258 closes #253)Decision:
PowerProfile::{Plugged, Balanced, Battery, Debug}as aResourceinkooch_core::power. Auto-detect on Linux via sysfs and$SteamDeckenv var. Override viaKOOCH_POWER_PROFILE. Why: The Steam Deck / OneXFly target makes battery awareness non-negotiable. Renderers will gate quality defaults (DoF, SSR, TAA off in Battery) per-feature in future PRs. Consequence:kooch_corecarries the policy enum but renderers do not yet read it. Integration is per-feature PR work, intentional.
Inspector / editor UX
2026-04 ·
GlobalTransformtolerates shear, inspector warns (PR #217 closes #214)Decision:
GlobalTransformis a 4×4 matrix that can carry shear (non-uniform scale through a rotated parent), but the inspector does not attempt to decompose it. Instead it shows a warning icon and exposes alossy_scale()helper. Why: Decomposing shear is ambiguous (multiple TRS triplets reproduce the same matrix). Hiding the issue creates worse bugs downstream. Educating the user is honest. Consequence: Users authoring shear-causing parent chains see the warning. No automatic “fix” is offered.
2026-04-25 · Three-system editor architecture: Gizmos / Editor / UI Toolkit (research #276, doc
docs/research/editor-three-system-architecture.md)Decision: Editor evolves into three separate, pure-Rust, custom-built subsystems:
kooch_gizmos(visual gizmo API + visualizer registry, usable at runtime too) +kooch_gizmos_handles(interactive translate/rotate/scale, editor-only);kooch_editor_api(user editor extensions: inspectors, panels, actions, loaded via libloading from a usereditor/crate);kooch_ui(declarative HTML-like UI Toolkit:.kooch_uimarkup +.kooch_styleCSS subset + Rust behavior, retained-mode with fine-grained signals, coexists withegui). Why: Godot’s self-hosted monolith couples concerns; Unity’s separation (Gizmos / Handles / Editor scripts / UI Toolkit) lets each evolve independently and gives users one mental model per need. We follow Unity’s separation. External libraries —transform-gizmo, Slint, Dioxus — rejected: only cover narrow slices, none address user-extensibility for custom component visualizers, and we want the engine to be self-contained pure Rust with no FFI. Consequence: A multi-quarter commitment. Three implementation epics (one per subsystem) replace the original gizmo epic #198 as sub-epic of the Gizmos one. The currentkooch_render::gizmosmodule (PR #277) migrates intokooch_gizmosin phase 1. Thekooch_uitoolkit is the heaviest piece (multi-month) and runs in parallel with the others.
2026-07-25 · Keep
kooch_ecs; do not adoptbevy_ecs(decision #605)Decision:
kooch_ecsstays and improves in place.bevy_ecsis the reference to steal individual designs from, never a dependency. Why: #603 removed the GPU component storages that had justified a custom ECS, so the justification was re-derived from measurements rather than repeated. No technical blocker was found —bevy_ecsis genuinely standalone (65 crates, nobevy_app/bevy_render), the GPU-driven renderer touches the ECS throughQueryin four places, andbevy_reflectexpresses our custom field attributes. What decided it: 42 call sites reach into component storage directly against 3 that useQuery, which is work required in every path and whichkooch_ecscan already express;EntityAllocator::revivepreserves entity identity across Play/Stop, whichbevy_ecsrefuses by design while 177 sites outside the crate hold anEntityin a field; and 51 of the 80 affected files arekooch_editor_core, the one area where Bevy offers no upstream design to copy because it has no editor. Consequence: improvements are ordered by demonstrated pain, not by feature parity. Encapsulating the ECS behindQueryis the prerequisite for any future backend change — today the contact surface is 80 files. The schedule graph belongs inkooch_core, not the ECS: the ordering bugs it would fix live inapp.rs.
2026-07-25 · Entities are referenced by a persistent id, not a handle or an index (feat #607)
Decision: a component may hold an
Entityand have it survive a save. Identity is an opt-inPersistentId(EntityGuid); the wire form isEntityRef, which isLive(Entity)in memory andPersistent { scene, id }on disk. Ids are scene-local and remapped per instance.Parentbecomes an ordinary component andparent_indexis legacy-read-only. Why: reflection had no way to express “points at an entity”, so the scene format carried the parent link out of band. That worked for one component and could not scale: joints hold two entities, and an index into one document cannot address another scene at all. Assets had already solved the same problem by addressing through aGuid. Scene-local ids follow Unity (SceneLoadFlags.NewInstance) and Unreal (Level Instances), and are what allows one scene to be instantiated twice without both copies claiming the same identity. Consequence: saving a scene mutates the world, because whether an entity is referenced is only known once references are written —SceneDocument::from_ecstakes&mut Resources.Entitystill does not implementSerialize, so serialising a live reference is an error rather than a handle written to disk. A reference whose target is absent saves and loads as unset, which is the normal state for a reference into a non-resident cell under #566. Unblocks #560 and cross-scene references.
2026-07-25 · The world is the container; scenes are content loaded into it (feat #609)
Decision:
SceneManagerbecomes a registry of open scenes with one active, instead of a single current path whose load replaced the world. Scenes carry aGuid;SceneMemberrecords an entity’s authoring home and is derived on load rather than serialised. Saving writes only one scene’s entities; closing despawns only its own. Why: the model #566 settled on. One scene per world is “the entire world in one section”, which cannot express a space station and an asteroid field as separate content occupying the same volume, nor make “close the station” different from “walk away from it”. #607 supplied the prerequisite by making entity references survive a save. Consequence: there is always a scene, even before the first save, and entities with no membership are adopted by the active scene when it saves — otherwise anything spawned in the editor would belong to nothing and be written to no file. The reference remap table is keyed by(scene, id), never by id alone: ids are scene-local, so two open scenes both numbering an entity 1 is ordinary. Opening the same file twice is refused, because two copies would share every entity id. Scene transforms and instancing are deliberately deferred — they need a decision on whether the transform bakes at load, as Unreal’s Embedded Level Instances do.
2026-07-26 · A scene is the prefab; instancing and editing are different operations (epic #611)
Decision: prefabs are scenes instanced with their entity ids remapped per instance. No separate format. Built in two phases: runtime instancing first, the linked-with-overrides prefab system after. Why: #609 refuses to open one file twice, which is right for editing and wrong as a limit on instancing — and entity ids were made scene-local in #607 precisely so instances could remap them. Unity’s prefab is a serialised scene file, and Godot says so outright with
PackedScene; both store an instance as a reference to the source plus a list of differences rather than a copy, which is what keeps editing the source propagating to its instances. Consequence: two things must be settled in phase A because they touch already-merged types — whether a scene must have a single root (instancing as a unit with a transform needs one, and our documents are a flat list), and how an outside reference names this instance rather than the prefab, sinceEntityRef::Persistent { scene, id }is ambiguous once a scene is instanced twice. Phase B waits on one decision: whether overrides are per field, as Unity and Godot both do, or whether editing an instance promotes it to its own scene.