# Pipeline compilation

A shader artifact is not GPU code. A metallib holds AIR and a D3D12 shader holds DXIL; the driver translates either into the GPU's instruction set when a pipeline is built from it. That translation is what a player waits on when a pipeline is built on the frame's critical path: behind the startup cover on first launch, or as a hitch when a setting or a new kind of content first needs a pipeline.

Hikari does not ship pre-translated GPU binaries, and packaging runs nothing. Like Unreal's PSO precaching and Unity's warm-up collections, it builds pipelines on the player's machine, in parallel, before they are needed, and relies on a persistent cache so the work happens once per machine:

| Backend | Parallel builds | Persistent cache |
|---|---|---|
| Metal | `MTL4Compiler` asynchronous builds, `shouldMaximizeConcurrentCompilation` | the OS per-app shader cache (`$(getconf DARWIN_USER_CACHE_DIR)/<bundle id>/com.apple.metal`) |
| D3D12 | the device's pipeline builder, a worker pool sized to the CPU | an `ID3D12PipelineLibrary` per adapter, `<writable>/pipelines/d3d12-<vendor>-<device>.plib` |

On an M-series Mac, the 69 engine compute kernels take about 15 s to build one after another from a cold cache, and about 4.5 s in parallel. The longest single kernel (ray-traced reflections, about 3.3 s) is the floor. With a warm cache the same set builds in under 10 ms.

## Asynchronous pipelines

Pipeline creation is asynchronous at the native layer (`native/macOS/src/MetalPipelineBuild.m`, `native/Windows/src/D3D12PipelineBuild.cpp`). A create call queues the build and returns a pipeline at once. The pipeline has a status: pending, ready, or failed.

- **Binding** a pipeline waits for its build if it is still running. On D3D12, a build no worker has started yet runs on the binding thread instead of waiting in line. Every wait is counted in the pipeline stats.
- **A failed build** binds as "nothing": the draws and dispatches that follow it are skipped, and the failure is logged once. The renderer also stops naming it (`renderer_pipelines.dropFailedPipelines`), so feature readiness, which checks pipeline handles, degrades that feature to off. Failures never degrade to a pass that binds nothing.
- **Releasing** a pipeline that is still building never blocks. The build holds its own reference and frees the pipeline when it ends.

Because every pipeline goes through this path, every creation site builds in parallel: engine passes, compute kernels, GPU culling, particles, skinning, opacity micromaps, and materials.

## Who waits, and when

| Pipelines | Built | Waited on |
|---|---|---|
| Startup set: engine passes and compute kernels the project's config enables, shadow casters, GPU culling | queued during device init | once, at the end of device init (`settlePipelines`), behind the startup cover |
| Scene materials | when the material loads (`material/pipeline_preparation.zig`) | never. Draws use a stand-in until the build is ready, and gated scene loads count pending material pipelines as not ready. |
| Particle program kernels | when the particle system is published to the renderer, before it is drawn (`Executor.precacheRegistered`) | never. Gated loads and the world-partition first ring count them as not ready (see below); an emitter that runs sooner joins the simulation once both kernels are ready. |
| Particle lighting | on first use, or by warm-up | never. Receivers fall back until it is ready. |
| Everything else a player can reach later | background warm-up, after startup | only if used before warm-up reaches it |

A pipeline the startup set cannot do without (an eager engine pass the config enables) fails device init if it cannot be built, as it did when pipelines were built one at a time.

## Scene readiness

A scene layer reaches `.gpu` only when everything it will draw can draw without a stall ([Session services — readiness stages](/docs/systems/session-services.md#readiness-stages-show-the-scene-gate)). For pipelines that means:

- **Materials:** each material's pending-pipeline count, held until the native build reports ready.
- **Particle systems:** the program's specialized kernels and the sprite uploads. Both start when the component publishes its program (`scene/components/particle.zig` `publishProgram`), not on first draw, because a gated layer is not drawn until it is ready. The component holds a `ProgramReadiness` (`graphics/particles/program_readiness.zig`) that the render residency resolves once the executor has built every kernel of the program, and the layer counts it while pending. An unbuilt program also counts as pending pipeline work, so an idle editor viewport keeps encoding until it resolves.
- **Failures resolve.** A failed build, a refused registration or a program released before it built answers `failed`: the effect degrades, the gate opens.

World partition uses the same bar. Cells load `.immediate`, and `World.streamWarmingUp` stays true until the first ring is resident and every loaded layer is at `.gpu` ([Scenes and gameplay — World partition](/docs/systems/scenes-and-gameplay.md#world-partition-authoring)).

## Deferring what the config does not use

Engine passes and compute kernels carry a feature gate (`engine_pso.Gate`; `engine_pso.computeGate` for compute). Device init builds only what the current config opens, and only on a device that supports it: a ray-traced gate stays closed on a GPU that cannot trace. A ray-traced feature that is switched off does not cost startup its multi-second kernels.

The config device init reads is the one the player launches with, not the project default:

1. the project's `configs/render.json`;
2. the player's saved settings: the sparse `configs/render.json` in the user layer, written by `hi.config` ([Project file — environments](/docs/guides/project-file.md#config-ownership)), composed before the renderer is created;
3. graphics settings the game applies while it launches (`hi.render().update` from `onLaunch` or a world-attach hook). Device init takes these from the world's command queue before it builds anything (`graphics_ops.initializeRenderDevice`).

So a game whose player turned ray tracing on builds its ray-traced passes behind the startup cover, and a game whose player turned it off never builds them at startup.

When a config change opens a gate, the pipelines it needs are created at once (`ensureOptionalPipelines`, and the next warm-up tick for compute kernels). Frame features resolve against pipeline availability (`featureAvailability`), so a feature whose pipelines do not exist yet is simply off until they do.

## Warm-up

`graphics/renderer/pipeline_warmup.zig` runs once per frame on the render thread. It starts as soon as the startup scene's material pipelines are built (or after about five seconds if content keeps streaming). It then adds work in steps, likeliest first:

1. subsystems a scene reaches without a settings change: particle simulation, raster, shadows and lighting, skinning writeback, and the opacity-micromap bake on devices that trace;
2. the features on the other side of the ray-tracing switch. From a config that traces, that is ray tracing off. From one that does not, it is ray tracing on with every enabled feature at its ray-traced mode: screen-space GI becomes ray-traced GI, raster shadows become ray-traced shadows. A feature the config leaves off is not in this step;
3. every other feature this device supports and the config keeps closed.

Each feature is one step: its compute kernels and its engine passes together, so a feature becomes usable as a unit instead of waiting for every other feature's kernels. A step is added only while few builds are in flight, so a scene's material pipelines never queue behind warm-up and a running game keeps its CPU.

The order decides what is ready when a player opens the options menu early, not how long the whole set takes. The Metal compiler runs about 18 builds at once, so submitting the slowest kernels first does not shorten a batch: on an M-series Mac all 69 engine kernels take 4.5 s cold in directory order, longest first, or shortest first.

Warm-up builds through the same code that builds on demand, so what warms is exactly what runs. When everything has built, D3D12 persists its pipeline library, and the log reports how many pipelines warm-up built and how long it took.

Warm-up skips the HDR display set of an output mode that is not active. Switching HDR builds that set in parallel and waits for it before the swapchain flips.

## Shader stripping

Warm-up and the startup set only build engine pipelines, and engine renderer shaders are runtime roots of [shader stripping](/docs/guides/shaders.md#shipping-shader-packages): a runtime-selectable feature always ships its shaders. Content pipelines (materials, particle programs) are built only from content that loads, and content can only reference shaders the product ships. Nothing builds a pipeline from a stripped package.

## D3D12 pipeline library

Every D3D12 build first asks the library (`native/Windows/src/D3D12PipelineCache.cpp`). Entries are named by a 128-bit hash of the root-signature identity, the shader bytecode, and every state input. The runtime re-validates the desc on load, so a stale entry costs a compile, never a wrong pipeline. A driver update invalidates the whole library, and it is rebuilt that launch. The library is written when warm-up finishes and again at device release. Bump `kTranslationVersion` whenever the hikari desc → D3D12 desc translation in `D3D12Pipeline.cpp` changes.

## Verifying

- A standalone game logs one line when the startup cover lifts:

  ```text
  pipelines: 214 built (0 from cache, 0 failed, 0 still building); 0 binds waited 0.0 ms
  ```

  Bind waits after startup mean something was created too late for warm-up to hide it. The startup log's `pipeline_creation` stage is the time device init waited for the startup set.
- `pipeline warm-up: N pipelines in X ms` marks the end of warm-up.
- To reproduce a player's first launch on macOS, move the per-app OS cache (`$(getconf DARWIN_USER_CACHE_DIR)/<bundle id>/com.apple.metal`) aside and launch. On Windows, delete `<writable>/pipelines`. Both drivers also keep their own caches, so a truly cold launch may also need the driver cache cleared.

## Ray-tracing build scratch

Metal acceleration-structure submission contexts own their allocator and scratch until completion. Scratch starts at the aligned hardware requirement and grows geometrically with batch demand, up to a 128 MiB arena target (or a larger single-build requirement). Growth retains any buffer already referenced by recorded builds until their submission completes. Independent ranges remain disjoint; wrapping the arena inserts the acceleration-stage barrier before reuse. Completed contexts cache their earned capacity, so tiny batches no longer reserve a 128 MiB minimum per context.
