trueno/docs/ARCHITECTURE.md

509 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Trueno Engine — Architecture Overview
**Version:** 0.6
**Language:** Jai
**Target platforms:** macOS (native), Linux (native), WebAssembly (via Emscripten)
---
## High-Level Structure
Trueno is a purpose-built 3D game engine for a beach volleyball game. The codebase is split into
three clear layers:
```
┌─────────────────────────────────────────────────┐
│ game/ │ Game logic (volleyball rules, players, state)
├─────────────────────────────────────────────────┤
│ src/ │ Engine systems
├─────────────────────────────────────────────────┤
│ sokol / stb_image / GetRect / Jaison / ... │ Third-party modules/
└─────────────────────────────────────────────────┘
```
The engine layer (`src/`) owns the main loop, rendering, audio, asset loading, and editor. The
game layer (`game/`) only implements game-specific logic and calls into the engine. There is no
formal interface between the two — they share a single Jai compilation unit — but the separation
is clean in practice.
---
## Entry Points and the Frame Loop
```
sokol_app
└─ init() -- one-shot startup
└─ frame() -- called every display frame
└─ event() -- input / window events
└─ cleanup() -- shutdown
```
`src/platform_specific/` contains two thin files: `main_native.jai` (desktop) and `main_web.jai`
(WASM). Both call into `common.jai` which sets up sokol and then calls the engine's `init()`,
`frame()`, `event()`, `cleanup()` in `src/main.jai`.
### Deferred Initialization
Engine startup is split into three phases to handle async asset loading:
```
frame() tick 1..N
├─ asset_manager_tick() ← drives sokol_fetch async I/O
├─ mandatory_loads_done()? ──no─→ return (blocks entire frame)
│ yes
├─ init_after_mandatory() ← (currently empty, reserved)
├─ show_loading_screen()? ──yes→ return (shows loading UI)
│ no
└─ init_after_core() ← init UI, audio, game, pipelines
```
The two boolean flags (`init_after_mandatory_done`, `init_after_core_done`) ensure each phase
runs exactly once after its prerequisite packs have loaded.
### Fixed Timestep Game Loop
```jai
delta_time_accumulator += delta_time;
while delta_time_accumulator > (1.0/60.0) {
game_tick(1.0/60.0);
delta_time_accumulator -= (1.0/60.0);
}
```
Game logic runs at a fixed 60 Hz regardless of render frame rate. Rendering always runs at the
display's native rate. The editor bypasses the accumulator entirely — when `in_editor_view` is
true, no game ticks run.
---
## Directory Map
```
beachgame/
├── first.jai Build metaprogram (compile-time build script)
├── build.sh / build_web.sh Shell wrappers around the Jai compiler
├── src/
│ ├── main.jai Engine core: init, frame loop, window info
│ ├── events.jai Sokol event dispatch → Input module
│ ├── load.jai Asset loading flags, init state
│ ├── time.jai get_time() / get_apollo_time() abstraction
│ ├── trile.jai Trile/Trixel/Material types, JSON serialization
│ ├── world.jai World/chunk data model, binary serialization
│ ├── ray.jai Ray-casting helpers
│ ├── utils.jai sign(), mix() math helpers
│ ├── profiling.jai Frame-level profiling points
│ ├── buffers.jai
│ ├── shapes.jai
│ │
│ ├── assets/
│ │ ├── asset_manager.jai Async fetch queue, pack/world/RDM loading
│ │ ├── loaders.jai PNG / WAV / string loaders from raw memory
│ │ └── rdm_loader.jai RDM chunk streaming enqueue/cancel
│ │
│ ├── audio/
│ │ ├── audio.jai Audio init/cleanup, Audio_Data type
│ │ ├── backend.jai Sokol audio stream callback bridge
│ │ ├── load.jai WAV loading helpers
│ │ └── mixer.jai Software float32 mixer (buses, tasks, mutex)
│ │
│ ├── rendering/
│ │ ├── rendering.jai Module root, window-resize handler
│ │ ├── core.jai init_rendering(), render() entry point
│ │ ├── tasks.jai Rendering_Task types and task→command conversion
│ │ ├── backend.jai Command bucket types, process_command_buckets()
│ │ ├── backend_sokol.jai All sokol draw call implementations
│ │ ├── backend_sokol_helpers.jai
│ │ ├── pipelines.jai Pipeline creation, all render target images
│ │ ├── camera.jai Camera struct, perspective/lookat math
│ │ ├── meshgen.jai Quad mesh generation from trile voxel data
│ │ ├── animation.jai Sprite animation player, Aseprite JSON structs
│ │ ├── helpers.jai create_world_rendering_tasks(), uniform helpers
│ │ ├── arbtri.jai Immediate-mode triangle batch renderer (UI)
│ │ ├── post_processing.jai Post-process config, LUT management
│ │ ├── sky.jai Sky rendering helpers
│ │ └── ssao.jai SSAO kernel/noise generation
│ │
│ ├── editor/
│ │ ├── editor.jai Editor root: three views, F3 toggle
│ │ ├── console.jai In-game console (F1), command dispatch
│ │ ├── level_editor.jai Level editing camera and trile placement tools
│ │ ├── trile_editor.jai Voxel painting editor
│ │ ├── tacoma.jai Path tracer (Tacoma) integration
│ │ ├── no_tacoma.jai Stub when Tacoma is unavailable
│ │ ├── rdm_disk.jai RDM bake result disk I/O
│ │ ├── picker.jai Mouse picker
│ │ ├── iprof.jai Iprof profiler integration
│ │ └── textureDebugger.jai Texture visualization tool
│ │
│ ├── input/
│ │ └── hotkeys.jai
│ │
│ ├── ui/
│ │ ├── ui.jai UI backend: GetRect integration, font, triangles
│ │ ├── component_themes.jai Custom UI themes
│ │ └── autoedit.jai Reflection-based automatic struct editors
│ │
│ ├── pseudophysics/
│ │ ├── core.jai
│ │ └── colliders.jai Rect-circle collision
│ │
│ ├── platform_specific/
│ │ ├── common.jai Sokol imports, sapp_run, context/temp storage
│ │ ├── main_native.jai Desktop entry point
│ │ ├── main_web.jai WASM entry point
│ │ ├── main.c C glue for Emscripten link
│ │ ├── runtime.js JS runtime helpers
│ │ └── shell.html WASM HTML shell
│ │
│ ├── meta/
│ │ ├── meta.jai Metaprogram: message handler entry point
│ │ ├── pack.jai Asset pack creation at build time
│ │ ├── shaderload.jai Adds compiled shaders to workspace
│ │ ├── lint.jai Compile-time snake_case linter
│ │ ├── console_commands.jai @Command auto-registration code generator
│ │ ├── ascii.jai ASCII art logo
│ │ └── hacks.jai Metaprogram workarounds
│ │
│ └── shaders/
│ ├── *.glsl GLSL shader sources (~12 shaders)
│ ├── jai/*.jai Compiled shader descriptors (sokol-shdc output)
│ └── compile_shaders*.sh
├── game/
│ ├── game.jai Game init, fixed-step tick, draw
│ ├── player.jai Player struct and input handling
│ └── state.jai Game state machine and scoring
├── modules/ Third-party / custom Jai modules
│ ├── sokol-jai/ Jai bindings for sokol (gfx, app, audio, fetch)
│ ├── stb_image/ stb_image bindings
│ ├── Jaison/ JSON parsing/serialization
│ ├── Simple_Package_Reader/ .pack file reader
│ ├── Input/ Input module with sokol bindings
│ ├── Tacoma/ Path tracer integration
│ └── Walloc.jai WASM allocator
├── resources/ Engine-level assets (fonts, textures, audio)
├── game/resources/ Game-level assets (worlds, sprites, audio)
└── packs/ Compiled .pack files (build output)
```
---
## Core Systems
### Asset Manager
All file I/O flows through a single sequential fetch queue backed by `sokol_fetch`. Only one file
is ever in-flight at a time.
```
load_pack() / load_world()
fetch_queue [..]Fetch_Request
▼ (asset_manager_tick, once per frame)
sfetch_send() ──async──► fetch_callback()
resolve by Fetch_Type
┌─────────┬──────────┬────────────┐
PACK WORLD RDM_ATLAS RDM_LOOKUP
│ │ │ │
Loaded_Pack World sg_make_image sg_make_image
in loadedPacks in current_world in chunk
```
Static pre-allocated I/O buffers are used for each fetch type (200 MB for packs/worlds, smaller
buffers for RDM data) so no runtime heap allocation occurs during file loading.
**Blocking modes:**
- `should_block_engine` — halt entire frame (used for the boot pack; engine cannot run without it)
- `should_block` — show loading screen (used for core/game packs)
### Rendering Pipeline
The rendering system uses a three-stage data flow:
```
Game/Editor code
│ add_rendering_task()
[Rendering_Task list] (per-frame, temp allocated)
│ tasks_to_commands()
[Command Buckets] setup / shadow / reflection / gbuffer / main / ui
│ backend_process_command_buckets()
Sokol GFX draw calls
```
**Render passes per frame (in order):**
| Pass | Output | Notes |
|--------------|--------------------------------|----------------------------------------|
| Setup | GPU position/uniform buffers | Uploads trile instance stream data |
| Shadow | 1000×1000 depth texture | Orthographic sun directional light; PCF soft edges (Gaussian-weighted sampling) |
| Reflection | Half-res RGBA32F texture | Planar water reflection: camera Y-flipped around water plane; handles dynamic objects that RDMs cannot |
| G-Buffer | Position + Normal RGBA16F | Used by SSAO and deferred effects |
| SSAO | Single-channel AO texture | Random sample kernel, 64 samples |
| SSAO Blur | Blurred AO texture | Separable blur |
| Main | RGBA32F HDR target | Forward render: sun direct + RDM indirect (specular + diffuse) + SSAO |
| Post chain | Bloom → DoF ping-pong | Between postprocess_a / postprocess_b |
| Final | Swapchain | Tonemap, LUT grade, vignette, grain |
**Trile rendering** uses GPU instancing. Each trile type's mesh is generated once (quad mesher,
`src/rendering/meshgen.jai`) and uploaded as a static vertex buffer. Per-frame instance
positions are streamed via `sg_append_buffer` into a dynamic buffer, with one draw call per
trile type per pass.
Mesh generation merges visible trixel faces into larger coplanar quads (greedy meshing), reducing
triangle count dramatically vs. rendering each trixel individually. The mesh triangles no longer
correspond 1:1 to trixels, so the shader resolves the trixel under a fragment by offsetting the
surface position slightly inward along the normal to land cleanly inside the trixel grid.
**Trixel material encoding:** each trixel stores 4 bytes — 3 bytes of RGB albedo and 1 byte
packed as: roughness (3 bits → 8 levels), metallic (2 bits → 4 levels), light emission (2 bits →
4 levels), plus 1 reserved bit. All material values map to the [0 … 1] range in the shader.
**RDM (Rhombic Dodecahedron Mapping)** is the pre-baked indirect lighting system for static
trile geometry. Each chunk optionally carries an `rdm_atlas` (RGBA32F 4096×4096) and an
`rdm_lookup` (RGBA32F 512×512) GPU texture. The trile shader uses these for indirect diffuse
and specular, falling back to flat ambient when unavailable.
The RDM concept: because all trile surface normals are axis-aligned, incoming light for a block
can be captured by storing exactly one hemisphere per face (6 total). Each hemisphere is encoded
with the **hemi-oct** projection (Cigolle et al. 2014) — the hemisphere is folded into a pyramid
and then flattened to a square. The six hemisphere squares are arranged in a 2×3 grid; together
they form a rhombic dodecahedron shape, giving the technique its name.
Each hemisphere stores both **radiance** (RGBA32F) and **depth-to-first-hit** in the same
texel, encoded via a 4-channel HDR image (3 colour channels + 1 depth channel). The depth
is used at runtime for parallax-corrected sampling: a ray is stepped outward from the surface
point to find where it would intersect the geometry visible in the stored hemisphere, correcting
the angular discrepancy between the hemisphere's centre-of-face origin and the actual shading
point.
**Multiple roughness levels:** the Tacoma path tracer pre-filters each RDM for 8 discrete
roughness values. Hemisphere resolution scales as 3·2^roughness × 2·2^roughness pixels, so
rougher (blurrier) RDMs are physically smaller. The maximum-roughness RDM (always baked)
doubles as the diffuse irradiance probe. Per-instance transform data passed alongside the GPU
instancing matrices encodes atlas UV offsets for roughness levels 17; the maximum-roughness
position is looked up separately from the lookup texture.
**Specular evaluation:** the shader selects the RDM for the fragment's roughness, finds the
reflected view direction, steps the reflection vector to find the correct sampling direction via
stored depth, samples the hemisphere, then combines the result with a pre-computed BRDF
split-sum lookup (indexed by roughness and NoV) to form the full split-sum specular.
**Diffuse evaluation:** the shader reads the maximum-roughness RDMs for the block and up to
three adjacent blocks (determined by which quadrant of the block face the fragment lies in),
samples each in the surface normal direction, and bilinearly blends the values. Missing
neighbours fall back to a static ambient constant.
**RDM streaming:** RDM data is loaded per chunk on demand via `src/assets/rdm_loader.jai`.
Each chunk's atlas and lookup textures are allocated as `sg_image` resources and destroyed
when the chunk is unloaded (`src/editor/rdm_disk.jai` handles disk I/O in the editor).
### World / Chunk System
The world is a sparse hash map of chunks. Each chunk is a 32×32×32 block of the world at
integer trile-space coordinates.
```
World
└─ chunks: Table(Chunk_Key{x,y,z}, Chunk)
└─ groups: [..]Chunk_Trile_Group
├─ trile_name: string
└─ instances: [..]Trile_Instance{x,y,z,orientation}
```
**Coordinate math:**
- World integer position → chunk key: floor division (`floor_div`)
- World integer position → local position (0..31): floor modulo (`floor_mod`)
- Both handle negative coordinates correctly (rounds toward -infinity)
**Binary world format:**
```
[magic u32][version u16][name_len u16][name bytes]
[World_Config_Binary]
[num_chunks u32]
[chunk table: per chunk → chunk_x,y,z s32 × 3, data_offset u32, data_size u32]
[chunk data blobs]
```
### Lighting System
Trueno uses a **hybrid lighting model**: real-time direct light from the sun combined with
pre-baked indirect and emissive light stored in per-chunk RDM textures.
#### Direct lighting (sun)
A single directional light ("sun") is evaluated per-fragment at runtime. Visibility is determined
with a shadow map rendered each frame from an orthographic projection centred on the camera
target. Percentage-closer filtering (PCF) with Gaussian-weighted sampling produces soft shadow
edges. Surfaces outside the shadow map area are assumed unshadowed (no cascades currently).
#### Indirect lighting (RDM)
The Rhombic Dodecahedron Mapping technique (described in the Rendering Pipeline section above)
provides pre-baked indirect and emissive light for static trile geometry. The bake captures both
diffuse and specular (including sharp mirror-like reflections) from the path tracer.
**Emissive voxels:** materials with non-zero emission contribute to indirect lighting through the
RDM bake rather than being evaluated as real-time lights. This means dynamic objects only
receive sun light; they do not receive indirect light from emissive triles.
**Split-sum specular:** the specular term combines the pre-filtered RDM sample with a
pre-integrated BRDF lookup texture (indexed by roughness and the angle between view direction
and surface normal), following the split-sum approximation.
#### Water reflections (planar)
The ocean/water surface uses planar reflections: the entire scene is rendered a second time
with the camera flipped across the water plane into a half-resolution RGBA32F target. This
handles dynamic objects (players, ball) that do not appear in the static RDMs, and produces
sharp mirror-like reflections correct for large flat surfaces. Post-processing can add ripple
distortion to the reflection texture.
#### SSAO
Screen-space ambient occlusion darkens cavities and contact areas. A geometry pass outputs
world-space position and normal (RGBA16F G-buffer), then the SSAO pass samples 64 random
offsets to estimate local occlusion. The result is blurred with a separable filter and applied to
the ambient/diffuse contribution.
#### Pre-bake pipeline
RDM data is generated offline by **Tacoma** (`modules/Tacoma/`, integrated via
`src/editor/tacoma.jai`). Tacoma is a GPU path tracer (C++/Vulkan) that:
1. Builds a BLAS per unique trile type and assembles a TLAS with one instance per trile
occurrence in the scene.
2. For each block, detects which of the 8 roughness levels are present in its trixels and
renders one RDM per level. The maximum-roughness RDM is always rendered (needed for
diffuse).
3. Saves per-RDM HDR images (3 colour + 1 depth channel) and packs them into a single atlas
with a companion lookup file.
The editor (`Level Studio`) triggers this bake; results are saved to disk via `src/editor/rdm_disk.jai`
and loaded at runtime via the chunk streaming system.
### Audio Mixer
The mixer runs on a background audio thread (native) or on the main thread (WASM). The sokol
audio callback calls `mixer_get_samples()` which iterates all active `Mixer_Play_Task` entries,
accumulates float samples into the output buffer, and removes finished tasks.
```
Mixer
├─ config: Mixer_Config (per-bus volume, master volume)
├─ tasks: [..]Mixer_Play_Task
│ └─ audio: *Audio_Data (pointer into pack memory)
│ bus: MUSIC | SOUND_EFFECT | DIALOGUE
│ mode: ONESHOT | REPEAT
│ curSample: s64
└─ mutex (native only; no-op on WASM)
```
Thread safety: `mixer_add_task` and `mixer_get_samples` both acquire the mutex. On WASM, sokol
audio is single-threaded so the mutex is compiled out.
### UI System
The UI is built on top of `GetRect_LeftHanded` (immediate-mode retained UI) with a custom
immediate-mode triangle renderer (`arbtri`) as its drawing backend. All UI geometry is batched
into a single vertex buffer and drawn in the `ui` command bucket at the end of each frame.
A viewport-relative unit system (`vw`/`vh`, each = 1/100 of window dimension) is used throughout
for responsive sizing.
`autoedit.jai` provides reflection-based automatic struct editors: struct fields annotated with
`@Color`, `@Slider,min,max,step` etc. automatically get UI widgets generated at compile time via
Jai's type info system.
### Editor
The in-game editor (F3) has three views:
- **Trile Studio** — voxel painting on a 16×16×16 grid with material selection
- **Level Studio** — orbiting camera, ray-based trile placement, Y-layer selection, optional
path-traced RDM bake via Tacoma integration
- **Material Studio** — lighting and world config parameters
The developer console (F1) dispatches commands registered at compile time. Functions annotated
`@Command` are processed by the metaprogram during compilation: argument-parsing front-ends
(`%__command_front`) are auto-generated and registered in `console_command_names` /
`console_command_procs` arrays — zero runtime overhead.
---
## Compile-Time Metaprogramming
`first.jai` is the build script, executed by the Jai compiler as a metaprogram. It:
1. Invokes `sokol-shdc` as a subprocess to compile GLSL shaders to backend-specific bytecode
and generate Jai descriptor structs
2. Packs all files under `resources/` and `game/resources/` (excluding `.aseprite` and `/worlds/`)
into binary `.pack` files
3. Adds the compiled shader descriptors to the compilation workspace
4. Runs the custom snake_case linter over all function declarations
5. Auto-generates `@Command` wrapper functions for the developer console
This means asset pipeline and code generation happen as part of compilation, with no separate
build step needed.
---
## Memory Model
| Lifetime | Mechanism |
|-----------------|-----------------------------------------------------|
| Per-frame temp | `reset_temporary_storage()` at end of each frame |
| Mesh generation | `Pool.Pool` reset after each trile mesh is uploaded |
| World lifetime | `Pool.Pool` per `Current_World`, reset on unload |
| Asset loading | Static pre-allocated I/O buffers (never heap) |
| GPU resources | Explicit `sg_destroy_image/buffer` on replacement |
| Leak detection | `MEM_DEBUG :: true` enables Jai's memory debugger |
---
## Platform Abstraction
Platform differences are handled with `#if OS == .WASM / .MACOS / .LINUX` throughout:
| Feature | Native | WASM |
|---------------------|----------------------|--------------------------|
| Audio thread mutex | Thread.Mutex | no-op |
| File I/O (editor) | File module | disabled |
| UV origin | Metal: flipped | WebGL: normal |
| Allocator | default Jai heap | Walloc |
| Profiler | Iprof available | disabled |
| Stack size | default | 1 GB (emcc flag) |
| WASM features | — | bulk-memory enabled |
---
## Third-Party Dependencies
| Library | Role |
|--------------------------|----------------------------------------------|
| sokol_gfx | Cross-platform graphics (Metal/GL/WebGL) |
| sokol_app | Window, input, event loop |
| sokol_audio | PCM audio stream callback |
| sokol_fetch | Async file / HTTP I/O |
| sokol_time | High-resolution timer |
| sokol_gl / sokol_fontstash | Immediate-mode lines, font rendering |
| stb_image | PNG decode |
| Jaison | JSON parse/serialize |
| GetRect_LeftHanded | Immediate-mode retained UI layout |
| Simple_Package_Reader | Binary .pack file reader |
| Walloc | WASM-compatible allocator |
| Tacoma | GPU path tracer (C++/Vulkan) for offline RDM baking; integrated via editor |
| Iprof | Optional frame profiler |