Skip to content
hikari
RenderingEditorAIToolchainDocumentation
Download

© 2026 Flying Rat Studio.
All rights reserved.

Explore the engineDocumentationContributorsDownloadLicenseBack to top
Documentation / Systems
Browse docs
Overview
Tutorials17
OverviewPlay, Edit, and scenesFirst UI documentFirst game projectFirst entityComponent properties and the inspectorFirst mesh and materialFirst physics body and triggerFirst character controllerFirst session servicesFirst UIFirst input actionFirst messagesFirst runtime spawnFirst motionFirst skeletal animationAssets in Play (soft refs and hot reload)Dynamic editor recompile
Guides25
OverviewWorking in the editorDevelopment guideGameplay APIActor and component lifecycleThreads, jobs, events, and routinesGameplay AI tasksGame AI: perception and movement playgroundBrain assetsBrain execution for swarm populationsLocalizationGame cursorsChoosing component storageBuild and packagingProject filePluginsHikari Plugin APIData-driven content, JSON, and pathsScripting with KawaShader authoringChoosing and scaling shadowsTime of dayUser interfaceUI layoutUI widgetsMigration from Unity / Unreal
Systems32
OverviewArchitectureApplication lifecycleFrontends and driversPlatforms and supportSession services and cross-scene stateScenes and gameplayActor communication (hi.actors)Game-facing refsSave / replication wire versionCoordinate space and camera conventionsRenderingRenderer architecture mapFrame governorPipeline compilationSkeletal animationGPU particlesVisual ZonesVolumetric mediaInputAudioPhysicsMotion KitTemporal KitUI documentsAssets and ShinraPrefabsAsset residencyAsset formats (Shinra pipeline)Document schemasUI and editorEditor asset hot reloadEditor Project Selector
Language reference3
OverviewAkari GPU ABIAkari language referenceKawa language reference
Engine overview
Start exploring
  • No matching sections. Try fewer words or another topic.
NavigateEnter Openesc Close
Overview
Tutorials17
OverviewPlay, Edit, and scenesFirst UI documentFirst game projectFirst entityComponent properties and the inspectorFirst mesh and materialFirst physics body and triggerFirst character controllerFirst session servicesFirst UIFirst input actionFirst messagesFirst runtime spawnFirst motionFirst skeletal animationAssets in Play (soft refs and hot reload)Dynamic editor recompile
Guides25
OverviewWorking in the editorDevelopment guideGameplay APIActor and component lifecycleThreads, jobs, events, and routinesGameplay AI tasksGame AI: perception and movement playgroundBrain assetsBrain execution for swarm populationsLocalizationGame cursorsChoosing component storageBuild and packagingProject filePluginsHikari Plugin APIData-driven content, JSON, and pathsScripting with KawaShader authoringChoosing and scaling shadowsTime of dayUser interfaceUI layoutUI widgetsMigration from Unity / Unreal
Systems32
OverviewArchitectureApplication lifecycleFrontends and driversPlatforms and supportSession services and cross-scene stateScenes and gameplayActor communication (hi.actors)Game-facing refsSave / replication wire versionCoordinate space and camera conventionsRenderingRenderer architecture mapFrame governorPipeline compilationSkeletal animationGPU particlesVisual ZonesVolumetric mediaInputAudioPhysicsMotion KitTemporal KitUI documentsAssets and ShinraPrefabsAsset residencyAsset formats (Shinra pipeline)Document schemasUI and editorEditor asset hot reloadEditor Project Selector
Language reference3
OverviewAkari GPU ABIAkari language referenceKawa language reference
Engine overview
Systems34 min read

Asset residency

On this page
On this pageMental modelWhat is tracked whereLayer 1 — AssetStore (shared CPU decode)Concurrency contractLayer 2 — MaterialCache (shared materials)Layer 3 — Entity-unique (not in AssetStore)Layer 4 — GPU (render thread)Layer 5 — Audio worker (decoded PCM)Who retains what (consumers)What happens at refs == 0Residency budget (keep-alive tier)GPU texture and mip streamingOpen-world integration seamGenerated / GPU geometryResidency tickets (readiness as an event)The residency pass (stage table)The cursorDebtThe stall watchThe budget knob — off by default, and whyDecode is off the game threadThe unit of work is a primitive, not an actorMaterials are the expensive halfOne stage, both consumersRetain ownershipTerminationScene transitions and streamingAdditive layersFull replace (overlap-safe)Stream cellsSingle-asset holds: Retained(T)DiagnosticsInvariants (tests / mental checklist)Quick code map Back to top

How shared assets stay in memory, who holds them, and what happens at free-at-zero.

Code: asset_store.zig, material_cache.zig, residency_scope.zig, scene loaders, audio/audio.zig, editor Residency panel.
Related: Assets and Shinra · Refs · Session services · Rendering · Renderer architecture

Contents: Mental model · Tracked layers · Consumers · Free at zero · Budget · Residency pass · Texture / mip streaming · Generated / GPU geometry · Scene / stream · Diagnostics · Invariants


Mental model

Shared decode lives in AssetStore (spin mutex on public entry points). Each use retains; each end releases. At refs == 0 and unpinned, the CPU payload is destroyed immediately, unless a keep-alive budget is configured (product default 512 MB; zero = debug “re-decode every acquire”).

text
Entity (unique: components, vertex buffer copies, scripts, physics bodies)
    │ despawn → free unique state always
    │
    ├─ MaterialCache ──retain textures──► AssetStore (CPU: Texture, Model, EngineMesh, AudioCue, Font, Blob)
    ├─ RenderPrimitive ─retain mesh─────►
    ├─ Skybox / probe ──retain texture──►
    └─ Audio worker ────retain cue──────►  (+ separate decoded PCM map, voice-safe)

Stream cell / preload ── ResidencyScope ─► same AssetStore counters
Scene replace bridge ── beginReplacePreload ─► same

The store does not know about scenes, layers, or stream cells. Only counts matter. Any number of concurrent consumers (layers, cells, editor previews) share one counter per path.

Keys for typed decoded caches are extension-free stems. Typed blob and dependency-graph identities include the cooked suffix, so different payload kinds at the same stem stay distinct.


What is tracked where

Layer 1 — AssetStore (shared CPU decode)

KindPayloadAPI
TextureDecoded image (+ optional GPU handle)retainTexture / releaseTexture / hasTexture
ModelCooked model parseretainModel / releaseModel / hasModel
Engine meshDual payload: rigid indexed 32 B verts + u32 indices or skinned indexed 40 B verts + u32 indicesretainEngineMesh / releaseEngineMesh / hasEngineMesh
Audio cueCooked audio bytesretainAudioCue / releaseAudioCue / hasAudioCue
FontParsed TrueTyperetainFont / releaseFont / hasFont
BlobOpaque bytes (scripts, scene JSON, collision, materials, particles, animgraphs, …)retainBlob / retainBlobKind / releaseBlob / hasBlob / retainPinnedBlob
Shader artifactPlatform blob for a package roleretainShader / releaseShader; renderer uses scoped ShaderAssetSource.Lease

There are no getTexture / getBlob / similar aliases. Names that load always mean retain (or retain + pin). The has* probes return bool, never a borrowed pointer — anything that needs the payload must retain it.

Concurrency contract

The lock is held for map operations only — never across IO or decode. Two threads may therefore decode the same path concurrently; the loser destroys its copy and adopts the winner's (retainResident step 3). Duplicate decode costs one asset's work; holding the mutex across a pack read plus zstd inflate would stall every other thread — including the audio worker — for the full duration. Every residency kind shares one implementation (asset_store/residency.zig) so the three lock regions and the lost-race branch cannot drift apart per kind.

Each active decode registers a stack-owned receipt under the store mutex. Reload invalidates matching stem/family receipts, including assets not yet resident. An invalidated decode destroys its result and returns AssetNotReady; a stale failure cannot overwrite the new asset state. Receipts disappear when the decode returns.

The mutex spins briefly and then yields, so a preempted lock holder cannot make every waiter burn a core.

Each resident entry has:

FieldMeaning
refsNumber of explicit retains (materials, meshes, scopes, short-lived parses, …)
pinExplicit session-lifetime holds (pinPath / retainPinnedBlob). Survives refs == 0 until unpinned or invalidated by hot reload; reserved for intentional reuse, including engine shader packages and parsed material documents

Pack IO is separate: retainBundle / releaseBundle open/close pack catalogs. releaseBundle does not mass-evict decoded assets. Uncompressed pack payloads stay zero-copy through an atomic mapping lease held by the decoded entry; closing the catalog prevents new reads but unmaps only after the last decoded lease is destroyed. Compressed payloads remain independently owned after inflate. In editor/loose mode, large immutable cooked textures, models, and collision payloads use the same lease contract over individual read-only file mappings instead of anonymous whole-file copies. The mapping does not keep the source descriptor open and remains valid across Shinra's atomic replacement; clean pages are reclaimable by the OS. Decoded free is only via asset refs/pin.

Layer 2 — MaterialCache (shared materials)

ItemTracking
Material template / MIPath-keyed refcount (retain / release)
Textures on a materialStore retainTexture only for successfully bound slots (slot non-null ⟺ retain held); rebind retains new before releasing old
Layer / scene listsworld.material_paths + per-layer lists — one retain per unique key for that load

Materials are not immortal across scenes: unload releases that layer’s keys; last global release destroys the material entry and its texture retains.

Layer 3 — Entity-unique (not in AssetStore)

ItemLifetime
Components, scripts, user_dataDespawn / unload
VertexBuffer CPU mirror on primRebind / prim deinit
ProceduralMesh vertex store (SDK)Owner's deinit
Physics bodiesEntity release → deferred physics free
Script VM instancesEntity script destroy

Layer 4 — GPU (render thread)

ItemTracking
Geometry / material / pipeline handlesRenderResidency refcounts
Sampled texture GPU objectsCreated when used; freed via pending_texture_frees → release_texture when CPU texture last-ref frees
Vertex/index GPU buffersReleased when no primitive uses that geometry key

GPU free is frame-deferred (in-flight draws). That is independent of CPU refcounts, but CPU texture free is what enqueues texture GPU free. Deferred records are idempotent. If their queue cannot grow, the resource-table slot carries an allocation-free retirement marker and the normal frame sweep reclaims it later; allocation pressure never changes a deferred free into an unsafe immediate free.

register rolls back every retain if any step fails, including the final primitives.put — an OOM there must not strand a refcount on a material or pipeline.

rebindMapsForTexture consults a texture-to-primitive reverse index rather than scanning every resident primitive per completed upload. The index includes ready textures because mip transitions replace their GPU handles after initial publication; every user must move before the old handle retires. Live-cook material mutations refresh the indexed texture snapshot even when the Material pointer stays unchanged. If the index cannot be maintained, overflow forces an exhaustive scan and rebuild — degradation is performance, never correctness.

Layer 5 — Audio worker (decoded PCM)

ItemTracking
Cooked cue in storeretainAudioCue while a decoded entry exists
Decoded stereo PCMAudioSystem.decoded map; held while any voice still samples that buffer
FreeWorker sweepIdleDecoded when mixer marks sweep needed (stop / stop_all / one-shot end): no voice → free PCM + releaseAudioCue

Device mix thread may null a finished one-shot’s sample pointer and set a sweep flag; the worker reclaims the PCM (never free from the device callback).


Who retains what (consumers)

ConsumerHoldsReleases when
Layer material_pathsMaterials (→ textures)Layer unload / full unload
MaterialCache entryTextures in descMaterial refcount → 0
Bound RenderPrimitiveEngine mesh pathRebind, error mesh, procedural setMesh, entity despawn
SkyboxCubemap textureClear / replace path / world destroy
Audio play pathCue + decoded PCMNo voice uses samples
Short parse (model_doc, scene, collision, input_actions, script bytecode)Blob for the calldefer releaseBlob after parse / loadBytecode
Material document parse (loadDesc)Scoped blob retain plus a store pinRetain released after parsing; pin reused until hot reload or store teardown
Engine shadersScoped blob retain plus a store pin for package reuseAcquire releases its retain after native copy/compile; hot reload clears the pin, and store teardown reclaims remaining pins
content://BlobScoped host retain while the SDK copies into caller-owned memory; released before Content.read returns
Kawa Content.readBlob brieflyAfter copy into Kawa string
beginReplacePreloadMaterials + store scope (meshes + visual-zone grading textures) for incoming sceneAfter replace load completes (SceneResidencyPreload.deinit)
ResidencyScopeWhatever the scope retained (also used inside scene preload for store assets)releaseAll / deinit (stream cell exit / preload bridge drop)
Editor inspector / texture dropTemporary retainEnd of inspect / drop prepare

What happens at refs == 0

ResourceUnpinned refs → 0Pinned
Texture (CPU)Destroy immediately, or park when keep-alive is configured; GPU handle always frees deferredStays until last unpinPath
ModelDestroy immediately, or park when keep-alive is configuredStays while pinned
Engine mesh soupFree immediately, or park when keep-alive is configuredStays while pinned
Audio cue (store)Destroy immediately, or park when keep-alive is configured (after decoded PCM drops its retain)Stays while pinned
FontDestroy immediately, or park when keep-alive is configuredStays while pinned
BlobFree immediately, or park when keep-alive is configuredStays only for an explicit long-lived pin
MaterialCache entryImmediate destroy material + shader (if owned) + release texturesN/A (no pin map; use store pin for textures)
Decoded audio PCMImmediate free when no voice samples it (worker sweep)N/A
GPU geometry / pipelinesWhen RenderResidency count hits 0 → deferred freeN/A
GPU textureAfter CPU texture free enqueues handle → render thread deferred freeN/A

There is no mark-and-sweep GC and no “wait N seconds then free” for real memory. The editor “Recently freed” list is a diagnostic ring only (last 48 frees).

Store-side ownership-transfer queues intentionally panic if they cannot record a texture free or in-flight upload. Renderer deferred-free tables mark the slot orphaned when their queue is out of memory, so a bookkeeping failure never becomes an unsafe immediate GPU free.

Special paths

  • Hot-reload force-evict (family + stem): forceEvictFamily in scene_ops — (1) abandon world holders for that family only (texture → skybox/LUTs/material map slots; model → bound_mesh + animation skeleton/clip binds; blob → mark animation-graph actors stale), (2) evictFamilyForReload(stem, family). Abandon releases every hold rather than nulling it — eviction defers to the last borrower, so a hold dropped without its release strands the entry reload_pending. Material slots must still be released before evictFamilyForReload, or a following refreshTextureAsset retain-new + release-old on the same path destroys the fresh decode. Same stem + different kind is valid: a .shintexture event must not wipe a model/audio/font/blob at that stem. Model companions (.shinmodel / .shinmodeldoc / .shincollision) share the model family and one wipe. ReloadFamily is texture / model / audio / font / blob. Batch-deduped per (family, stem). Never call store force-evict without abandon first. Pin clear applies to model/font/blob families. Blobs with active scoped readers become reload_pending: new acquisitions stop, the old slice remains valid, and the last scoped release destroys it before a fresh version may load.
  • Last-ref destroy (destroy*Entry): debug asserts refs == 0. Force-evict never bypasses live refs: a still-borrowed entry is marked reload_pending and destroyed by its last release; it never invalidates a live blob lease.
  • AssetStore.deinit / session teardown: logLeaksIfAny warns on unpinned leftovers, then clears everything.

Residency budget (keep-alive tier)

Code: assets/asset_store/budget.zig.

Without a budget there is nothing to evict: an asset is either referenced (must stay) or at refs == 0 (already gone). A budget only means something once unreferenced assets are allowed to linger.

StateMeaning
hotrefs > 0. Never parked, never evicted.
coldrefs == 0, still decoded, counted against the budget, evictable oldest-first.
pinnedOutranks the budget entirely. Never parked, never evicted.
zig
store.setBudget(.{ .keep_alive_bytes = 512 * 1024 * 1024 });
store.trimToBudget();              // also runs automatically on release
const stats = store.budgetStats(); // cold_bytes / cold_entries / evicted / resurrected
store.setBudget(.{});              // hard flush of all unreferenced residency

Standalone sessions configure the same budget in configs/game.json; JSON wins over the game module default. The texture budget already defaults to 768 MB, so the texture entry is only needed to override it:

json
{
  "residency": {
    "keep_alive_mb": 512,
    "gpu_geometry_mb": 1024,
    "geometry_submission_stream_mb": 144,
    "ray_tracing_mb": 512,
    "texture": { "gpu_budget_mb": 768 }
  }
}
FieldLayerZero means
keep_alive_mbCPU decoded assetsImmediate destroy at refs == 0
gpu_geometry_mbLive GPU mesh geometry (shared keys + dynamic rings)Unlimited; new uploads always accepted
geometry_submission_stream_mbSoft camera triangle-stream target, per stream per frame/surface slotNo budget-driven LOD reduction
ray_tracing_mbSoft BLAS/TLAS, scratch and compaction-source targetOnly normal age-based BLAS cache eviction
texture.gpu_budget_mbGPU bytes for ordinary 2D material-map mip chainsMip streaming disabled; full authored chains

Texture mip streaming is enabled by default with a conservative 768 MB budget. The remaining policy defaults are a four-mip tail, zero LOD bias, 30 frames of eviction grace, and at most 16 replacement requests per published frame.

gpu_geometry_mb is a hard ceiling on new mesh uploads. Joining an already resident geometry key costs nothing. Growing a live dynamic mesh is always allowed. New keys that would exceed the ceiling fail the create. Tracked as RenderResidency.geometry_live_bytes (exact).

Camera submission pressure reduces individual objects by projected size while preserving complete geometry. RT opaque receivers share the exact selected LOD with raster. Submission allocation accounting includes both early/late streams and all frame/surface slots; the per-stream target is not a device-wide ceiling. RT pressure evicts unused BLAS early but retains active and in-flight structures. These startup settings require a renderer restart. See geometry capacity for accounting and diagnostics.

Policy stays out of the store. A streamer sets the budget and calls trimToBudget; it never touches the maps. Release stays O(1) when under budget. If parking cannot be recorded (allocation failure) the entry is destroyed — a bookkeeping failure must never become a memory leak. Eviction advances a queue cursor and compacts only after enough records have been consumed or invalidated; steady eviction does not shift the remaining queue on every release.

Async mip completion can change a parked texture's CPU size. Publishing the new suffix updates cold_bytes under the store lock and immediately trims the budget. The worker hold keeps an evicted texture alive until publication finishes.

Owned buffers charge allocation size. A leased zero-copy pack or loose-file slice is charged by logical payload size so a cache of mappings cannot bypass the ceiling. Every destroy route settles through noteEntryDestroyed. Parked entries report as cold_entries in LeakReport, not as leaks.

For chunk scopes, call ResidencyScope.reserve(entry_count, path_bytes) from the chunk manifest before the first enter. The scope uses two retained-capacity linear vectors so steady-state enter/exit has no per-path heap allocation.


GPU texture and mip streaming

Whole-texture streaming and mip streaming are two layers of the same residency story, but they do not share a lifetime:

  1. A scene, material, preload, or future world cell retains a texture asset through AssetStore. This controls metadata and selected CPU-payload lifetime and whether any GPU texture may exist.
  2. Visible material geometry submits projected screen coverage. The estimate uses the sphere's near camera extent, so a large floor or wall that reaches the camera still requests detail even when its bounds centre is far away. Demands for a shared texture combine by highest required detail and priority.
  3. The store arbitrates those demands against one session-wide GPU texture budget, with a guaranteed small-mip tail, a demotion grace period, and a per-frame transition ceiling.
  4. A dispatch worker range-reads the selected cooked mip suffix. Loose assets use positional file reads; raw texture entries in packs use a leased mmap slice.
  5. The render thread uploads that suffix as a smaller physical texture. Completion atomically swaps the published handle; the old image remains sample-safe until then and is retired through the normal frame-deferred free path. Once safe, the previous CPU suffix is released too.

The implementation deliberately does not require sparse/tiled-resource support: Metal and D3D12 use the same replacement contract. A physical mip 0 can therefore represent authored mip N; its dimensions and uploaded chain are shifted together, so ordinary normalized-UV sampling remains correct. During a replacement both images can exist briefly. The configured budget and Residency-panel reading are steady-state bytes; the transition count plus the renderer's measured create budget bound temporary overlap and upload work.

Only ordinary 2D material maps participate today. Cubemaps and consumers without a projected-size signal (UI, sky/probes, LUTs, decals, and tools) request the full chain. Editor material, model, and animation previews are tool consumers, so their material maps also request full detail. If a map is shared with one of those consumers, the full-resolution request wins for that CPU residency lifetime. This avoids using a material-only heuristic to degrade a different contract. Material and particle previews submit full-detail requests through AssetStore.requestTextureGpuUpload, which wakes CPU mip reads even while the product World skips static publication. GPU upload completion then triggers the preview's material rebind and redraw.

Coverage demand includes all nine material-map slots, including anisotropy and clearcoat. Retained opaque and transparent draw rows refresh their streaming watches on material registration or texture-binding changes, so textures that bind after scene publication still request detail in Edit mode. A dedicated cache signal coalesces those changes; parameter edits and assignments of the same texture keep the watches intact. This refresh preserves draw structure and removes watches when their last streamed map is unbound; geometry LOD watches remain active independently.

The fixed header and complete mip directory remain CPU-resident, but pixel payload does not: initial decode reads only metadata plus the configured tail, and each transition replaces that payload with the demanded suffix. This keeps the demand, asset ownership, and GPU replacement model shared while bounding both decoded CPU payload and GPU residency. Shinra stores .shintexture entries uncompressed even when a bundle's default is Zstd, because the texture payload is already GPU-block-compressed and must remain range-addressable; other entries still use the requested bundle compression.

Open-world integration seam

World streaming should not build a second texture cache. A cell owns its normal ResidencyScope for asset lifetime and submits conservative, low-priority mip demands to the same texture demand accumulator while preloading. Once visible, renderer coverage naturally wins. Cell exit releases the asset scope; if another cell or material shares the texture its refcount and strongest demand keep it alive. The grace window absorbs camera and cell-boundary oscillation.

When a high-detail demand arrives while the budget is full, the store can first demote an older, non-demanded texture in the same pump. If the complete request does not fit, it admits the best finer suffix that does fit. Further upgrades require more headroom. This prevents a nearby surface from remaining at its guaranteed tail merely because one large upgrade could not fit in one budget step.

The coverage estimate remains conservative; it does not measure shader UV derivatives. A visible working set or full-resolution pins larger than the budget can still leave nearby surfaces below their requested detail. Grace-protected reclamation retries publish fresh demand even with a stationary camera; a fully budget-blocked request alone does not force continuous idle publication.

The texture_streaming warning scope reports budget-limited requests (including partial upgrades), missing detail bytes, accounted/budgeted bytes, pending transitions, and an example cooked texture path with requested/affordable mip levels. Mip 0 means full detail. Warnings are limited to once every five seconds per store while arbitration runs, including intermittent pressure. A request deferred solely by the per-publish transition limit is not counted as budget limited. These are configured texture-budget diagnostics, not device-wide VRAM pressure or allocation-failure measurements.

Once a range-streamed texture reaches its uploaded CPU mip base, the next store pump releases its CPU mip backing under the store lock. It reserves the upload slot while clearing slices and updates cold-cache accounting before publishing statistics. Immutable mip layouts and the source lease remain for later range reads. GPU completion wakes this reclamation even when world publication is idle. Non-streamed textures retain their original CPU payload contract.

The editor Residency → GPU page reports current/budgeted GPU texture bytes, current CPU mip-payload bytes, resident and full-detail counts, pending transitions, and remaining budget overflow. Overflow can be temporary while transitions are pending or capped; persistent overflow can indicate the budget is smaller than the guaranteed tails and full-resolution pins. Streaming stats also expose the evaluated budget-limited request count and missing detail bytes.


Generated / GPU geometry

Generated meshes (chunks, trails, splines) never enter AssetStore. They live on the primitive VertexBuffer and are shared on the GPU by geometry key, refcounted in RenderResidency.geometry_refs.

KeyingWhen
Content hash (default)Authored / one-off meshes — identical meshes share one GPU buffer
Identity (geometry_id)Generated geometry rebuilt often — O(1), assumed unique

SDK: ProceduralMesh (sdk/src/procedural_mesh.zig) for render-only generated meshes; RuntimeGeometry when the same shape also needs a triangle collider. Identity does not stick to the actor — any vertex-data replacement clears geometry_id. Dynamic non-indexed meshes can rewrite in place (update_primitive_geometry); indexed / static updates retire and re-create.

Apply of create commands is budgeted on the render thread (RenderCommandQueue.takeBudgeted) by measured cost, not by count: a create that joins a resident geometry key costs microseconds while the first primitive of a new material compiles a pipeline (milliseconds). noteApplyCost folds each frame's measured create time into an EMA and derives the next frame's count against the residency urgency's targetNs, floored at one and ceilinged by ApplyBudget.create_ceiling. What counts is createsGpuObjects, not the command name: applyUpdatePrimitiveMaterial compiles pipelines exactly as a create does and is budgeted the same way.

Asset binding keeps metadata per primitive and retains shared decoded geometry in the store until successful render completion. The first admitted instance stages the vertices, indices and meshlets; other instances join the pending or resident geometry key without copying the payload. The pending identity survives queue draining until the renderer completes or abandons all queued users. Completion receipts include the primitive request serial, so an obsolete attempt cannot release a newer binding. Missing geometry re-enters the ordinary rebind path. Backend retries retain the shared decode. Geometry-budget refusals release the upload-only retain and wait for capacity or content changes; queued followers inherit the same refusal. Results are indexed once and handled in one scene walk. Allow CPU Access keeps the store retain for the full binding lifetime.

Queue-owned CPU geometry is also admitted by bytes, including meshlet topology and triangle-count metadata: retry-capable world and swarm publishers use a 256 MiB window, restore ownership on UploadBackpressure, and remain dirty until the render thread drains enough payload to admit them. Dynamic vertex/descriptor updates use the same byte window and preserve dirty data when refused. Draining retains a head cursor and compacts geometrically; cached create and urgency counts avoid rescanning the backlog. One oversized mesh may enter an empty queue so valid content cannot deadlock. One-shot editor previews use the unbounded create API; they are still measured, but flow control can never silently turn a preview or tool action into a permanently missing resource.

The game-thread deferred texture pump (pumpDeferredTextureResidencyLocked) runs only when texture.residency_epoch moved and watchers exist.

On D3D12, completion of the texture upload fence releases both the staging resource and its upload command list/allocator; the sampled texture does not keep those temporary objects for its resident lifetime.

Draw / upload details: Rendering, Renderer architecture.


Residency tickets (readiness as an event)

An asset becoming resident is an event delivered to the things waiting for it, not a state change every subsystem discovers by rescanning itself (a scalar generation counter made every consumer re-walk everything it owned on every cook edge).

text
open → require × N → seal → (mailbox edge) → close
  • The waiter key is (stem, kind). Keying on the stem alone opens a gate early: a material landing at a model's stem would satisfy the model's waiter. Highest-risk detail in the whole design.
  • seal is not optional. An unsealed ticket never satisfies — it stays open for requirements that may still arrive.
  • No consumer code runs on decode threads. A decode completing writes to a mailbox; the session tick drains it. drainResidencyWakes returns the (stem, kind) list that woke each ticket, so a consumer binds what landed instead of re-examining everything it owns.
  • Handles are revalidated at wake. Slot + generation; a stale waiter drops silently. Never store a dense index in a ticket or a waiter.
  • WakeMode is the presentation policy in disguise: .progress wakes per asset (incremental binding, the editor's pop-in), .complete wakes once when the set is done (gates).
  • A failed asset satisfies, degraded. failed > 0 opens the gate and reports; it never hangs a level on a bad cook.
  • Hot-reload force-evict must cancel and reopen. A retired stem stops answering acquires (reload_pending → AssetNotReady) until the new cook lands, so a ticket holding an evicted stem has to re-arm rather than report satisfied.

Owners: a SceneLoadJob owns one ticket for everything its document names; the world owns one for its own assets (skybox, zones, decals, particles); hi.assets() hands game code its own. Live tickets and their unresolved requirements are visible in the editor's Residency panel.

The residency pass (stage table)

Code: scene/world/world_residency.zig, scene/residency_budget.zig, scene/residency_filter.zig.

Every readiness edge (store attach, bundle retain, hot reload, scene load, ticket wake, budgeted resume) runs the same pass over seven consumers. Each consumer is one row in stages, a std.EnumArray over StageId, so a new consumer that forgets its row is a compile error rather than a consumer that silently never runs, and the min-progress rule, kind gate, prefetch handover and debt flag are applied by the driver, not re-spelled per consumer.

StageWakes onDecodes
skyboxtexture
visual_zonetexture
decaltexture
particleparticle, texture
material_upgradetexture, materialyes
fog_volumematerial
geometrymodel, model_doc, animation_graph, scriptyes

The row declares what can make this consumer productive and whether it decodes on the game thread. The driver — not the row — applies the budget, the filter and the warm-up gate.

The cursor

World.residency_stage_cursor is where the next slice enters the table. A pass that runs out of time parks the cursor on the stage it stopped in; the next pass starts there and rotates on. One turn of the table visits every stage exactly once from any cursor, so no stage can starve behind another, whatever the ones ahead of it cost. That property is a unit test on stageAt alone — it needs no world and no assets.

Rotation is legal because the stages have no ordering dependency. A primitive that takes a stand-in material before material_upgrade runs is patched by replaceMaterialReferences when it does; that is the same path a mid-cook upgrade already used.

Debt

World.residency_work_pending means some consumer, not named by any wake, is still owed a look. It is written in exactly one place — the pass epilogue — from what the pass actually managed to cover, plus two external arms between passes: a runtime spawn whose mesh could not bind, and a scene script recorded before its bytecode landed.

  • Only a pass that covered every consumer and reached the end of every stage can discharge it. A filtered wake examines the assets that wake named and nothing else, so reaching its end proves nothing about debt left by something else — it carries the standing debt forward.
  • A wake pass with slice left over promotes itself to a full pass and pays that debt on the same budget, rather than leaving it for a frame with no wake in it. During a cold cook wakes are continuous, so that frame may not arrive for seconds.
  • The session tick spends one slice per frame (residencyPassRanThisFrame). The resume in tickPublish is deliberately not an else of the wake branch: a satisfied ticket is not necessarily this world's — during a scene load the job pool holds its own — so gating the resume on "no wake this frame" meant that during a cook the debt never drained at all.

The stall watch

A residency loop that neither progresses nor terminates is invisible by construction: the world simply never finishes loading, with nothing in the log. noteStall counts consecutive passes that owed work, committed nothing, dispatched nothing, waited on no decode batch and held no open store ticket — each exclusion is a legitimate reason to make no progress, and what is left is not. After ~4 s of such frames it warns once, naming the stage the cursor is parked on, then repeats sparsely.

The budget knob — off by default, and why

game.world.residency_bind_budget_ms (default 0, editor: Project Settings → World → Scene streaming) sets World.residency_bind_budget_ns. 0 means no ceiling: a pass binds everything that has become bindable.

Off by default because the work a ceiling would bound is already cheap: residency_prefetch moved decodes to the worker pool, so what reaches the bind pass is normally a store hit and metadata binding, and a fixed slice cannot tell that apart from an inline decode. Geometry staging is separately admitted at render publication; a 2 ms ceiling would spread that over seconds of visibly half-built scene. The rotation, min-progress rule, debt ledger and stall watch exist to make a non-zero value safe, not mandatory. Set a ceiling if you measure a residency hitch; 2 ms is the value to try first.

Decode is off the game thread

A bind must never decode. Every retain* on the store decodes on the calling thread the first time, and the frame budget cannot slice one — residency_bind_budget_ns is checked between units of work, never inside one, because a half-bound asset is not a resumable state. One Sponza-sized mesh runs to completion whatever the budget says.

The unit of work is a primitive, not an actor

A model reference expands into one primitive per mesh part on a single RenderComponent, so a budget checked between actors cannot split a four-hundred-part model: the whole thing lands in one frame whatever the value. The budget is checked between primitives.

A part-bound actor is resumable, which is what makes the finer check legal where stopping inside a single applyBundleMesh would not be: each primitive keeps its own pending_mesh until it binds, and refreshSoftPending recomputes the flag from whatever is left. Note that the partial path must still run refreshSoftPending / notifyRenderDependencyDirty — leaving the component stale hides the parts that did bind until some later pass happens to touch the same actor.

A pass always completes at least one unit of work, and that rule lives in Budget, not in its callers: exhausted() returns false until something has been committed, so a bare if (budget.exhausted()) is the correct spelling everywhere. commit counts work, never attempts: a walk over four hundred still-pending primitives does nothing, and a forgotten commit costs throttling, never termination.

bindModelParts counts too. It is the first bind attempt for every part of a model — and for a warm cache the only one, because a part that binds there never reaches the retry path at all. Budgeting only the retry left the whole model landing in one frame again by a different route, so the same slice is threaded down from both callers: pumpSceneInstantiate (the spawn budget) and rebindSoftPending (the residency budget). The primitive table is still built in full — a half-sized array is not a state anything downstream can read — only the mesh binds are sliced, and parts left over are marked pending exactly as a cold part would be. null is for callers with no frame to protect (one-off spawnActor, prefab expansion, tools).

Two paths reach a bind, and both dispatch to the worker pool before it:

PathDispatcher
Gated asset needed before scene presentationpreload.dispatchStoreWork
Immediate scene asset or asset arriving after spawnresidency_prefetch.zig

immediate presentation deliberately stops preload after structural planning and stable material-stand-in creation. Actors can then instantiate with soft-pending mesh/texture bindings and upgradeable materials; finalize wakes the world residency pass, whose bounded 64-item batches reserve 75% of each batch for geometry and use the remainder for material/texture work. Unused shading capacity flows back to geometry. Gated, deadline, and manual presentation retain material parsing and the complete pre-presentation store warm-up because their contract is to withhold the layer.

Worker completion is also an ownership boundary. Every mesh/texture worker publishes an exact per-job retain receipt; adoption never infers ownership from global cache presence. If the residency scope cannot record a successful retain, the consumer releases it immediately rather than stranding a reference.

For presentation readiness, store .ready means only that cooked bytes exist. A mesh still carrying soft_pending, or a material path still backed by an upgradeable stand-in, continues to block a strict gate until the consumer bind has completed. Missing/failed assets remain terminal and degrade to defaults; deadline is the authored escape hatch from waiting indefinitely.

Materials are the expensive half

A cold scene spends most of its stall in the material block, because MaterialCache.upgradeErrorMaterials clears up to error_upgrade_batch (8) stand-ins per pass and each one re-reads a material document and retains up to five textures, and rebindDeferredTextures fills every missing slot on every live material. Warming everything a pass is about to touch, not just meshes, is what removes the hitch.

One stage, both consumers

The warm-up gate belongs to the pass, not to either consumer — whichever ran first would otherwise eat the stall alone. A stage declares needs_warm; the driver adopts the batch lazily at the first stage that asks, and a stage that cannot be warmed yet is skipped (and its work recorded as debt) rather than aborting the whole pass. Stages that decode nothing — skyboxes, particles — no longer lose a frame to a batch they were never going to read.

text
for each stage, from the cursor:
    filter names none of its kinds?     skip
    needs_warm and batch still decoding? skip, record debt
    budget exhausted?                    park the cursor here, stop
    run → done | more (batched, carry on) | yielded (park here, stop)
releaseAll                               // pass-level defer: covers every exit
submitColdDecodes → warmed something? debt

submitColdDecodes collects three ref kinds, materials first — a stand-in clearing is worth more than a mesh binding, because the mesh shows up untextured either way while the material is what makes the scene stop being pink:

Ref kindCollected whenWorker does
.materialentry is_error, document .readyparse the doc off-thread, retain its textures
.texturelive material has an unbound slotretainTexture
.engine_meshprimitive soft-pending, mesh .readyretainEngineMesh

.material is the one that cannot be named in advance: the game thread does not know which textures a still-unparsed document references. The worker parses it, retains what it finds, and reports those paths back so adopt can own exactly those. The desc itself is thrown away — upgradeErrorEntry builds the real one under the cache lock, and duplicating it here would mean two owners of the same material state.

Retain ownership

Both paths use the same sequence: worker decodes (refcount 1, unowned) → adopt into a ResidencyScope → the bind takes its own retain (2) → releaseAll (1, owned by the primitive or the material). Nothing bound it? The release drops it to zero and it is evictable — no leak, and no pin on an asset the world turned out not to need. A retain the worker cannot report is released by the worker rather than stranded.

The preload retain bridges decoding and binding; it is not a permanent pin:

Diagram
Diagram source
sequenceDiagram
    participant W as Worker
    participant A as AssetStore
    participant S as ResidencyScope
    participant C as Consumer
    W->>A: Decode and retain
    A-->>W: Payload + retain
    W->>S: Adopt exact retain receipt
    opt Successful bind
        C->>A: Take its own retain
        Note over A,C: Two retains · scope + consumer
    end
    S->>A: releaseAll · drop preload retain
    alt Consumer retained
        Note over A,C: Live until consumer releases
    else No consumer
        Note over A: refs = 0<br/>Keep-alive or destruction
    end

Termination

Only cooked assets are dispatched. One that has not cooked yet is still .pending in the store and its ticket wake is what brings the pass back; dispatching it would burn a worker on a guaranteed failed open, which is the polling this design removed.

MaterialCache.Entry.warm_dispatched closes the other end. A stand-in whose document is cooked and parseable upgrades on the very next game-thread pass, so the flag only ever stays set for one that is present and unparseable — and re-warming that forever is the same polling by another name. It is marked from what was actually submitted, never from what was collected, so a ref the batch had no room for stays eligible; a hot reload installing a fresh stand-in clears it.

Profiling: mesh_bind.decode should be ~0 in steady state. If it is not, a bind is taking the inline path it should not. mesh_bind.stage measures the first admitted uploader's copy per geometry key; repeated instances bind metadata without staging. editor.scene_document.open / .decode cover the authoring document, while scene.document_load, scene.preload.plan, scene.preload.materials, scene.preload.store, and the scene.instantiate_* zones split runtime scene-open latency; residency.collect_cold / residency.prefetch_submit show the post-presentation warm path.

Scene transitions and streaming

Additive layers

Load layer B while A is live → both hold materials/meshes. Unload A → only A’s retains drop. Shared paths stay if B still holds them.

Full replace (overlap-safe)

Session replace is a SceneLoadJob (tick-pumped or drained by instantiateScene):

text
budgeted prepare of B     // A keeps simulating; gated policies also warm bulk assets
budgeted spawn of B       // live-A: A still renders; B lives in the open batch
unloadSceneForSwap(A)     // same frame as finalize; stores keep B's rows
finalize + commitJobPacks // B's packs move onto world.scene_retained_bundles
preload.deinit()          // drop bridge; steady-state = B only

For immediate, finalize also hands cold mesh/texture work to the live world's residency prefetch; the scene job is free to retire, cancel, or make room for a later scene operation without owning that background work.

world_precleared (empty world — launch, module reload, or construction of the disposable Play world) skips the live-A swap and its onSceneWillUnload. Editor Stop does not load a scene: it switches back to the retained editor world, then retires Play invisibly. If preload begin fails, the job instantiates without a residency bridge (shared assets may free and re-decode). World loadScene is the unit-test drain of the same spawn primitive.

Stream cells

zig
var scope = ResidencyScope.init(store);
// onEnterRange:
_ = try scope.retainEngineMesh(...);
_ = try scope.retainTexture(...);
// onExitRange:
scope.releaseAll(); // or scope.deinit()

Cell tables own path lists; the store only sees counts. Hysteresis lives in the streamer.

Scene-layer cells go through the scene-op pool instead — up to four load concurrently, dispatched nearest-first, with one shared frame budget. See Session services for the op queue, presentation policy and cancellation rules; hi.assets() there is the same primitive for sets no scene describes.

Single-asset holds: Retained(T)

ResidencyScope is for bulk lifetimes. For one asset held by one owner, prefer assets/retained.zig — a move-only token binding store + key + payload:

zig
var albedo = try RetainedTexture.retain(store, "textures/hero_albedo");
defer albedo.deinit();          // idempotent; safe alongside an early release
upload(albedo.get());

try albedo.reset(store, other); // retains new *before* releasing old
const owned = albedo.move();    // transfer; copying would double-release

Diagnostics

Pack counts describe IO ownership, including source-pack retains held by streamed textures for later mip reads; every resident asset and every read retains, so only open/close transitions are logged. Packed and product-loose packs log pack loaded / pack opened and pack unloaded at info. Advisory editor packs have no bundle mapping and log pack opened … (advisory) / pack closed … (advisory) at debug. Live counts are in listOpenPacks (the Packs panel). Counts can remain nonzero during renderer shutdown until the remaining World and store owners are destroyed; a pack still retained when the asset IO itself is torn down is a real leak and warns still retained at asset IO teardown.

In the editor, each cook batch asks the store to re-read packs.shinplan.json and assets.shingraph. Identical bytes are a no-op: the catalog, the graph snapshot and the bundle epoch stay as they are, so .pending entries are not re-probed by batches that left the partition alone.

Diagnostics are opt-in at product build time: --profiler-residency=cpu, --profiler-residency=gpu, or --profiler-residency=cpu+gpu (bare --profiler-residency enables both). Omitting the flag is --profiler-residency=off. This does not disable AssetStore/render ownership, reference counting, the keep-alive budget, eviction, hot-reload generations, or fence-aged GPU destruction.

ToolRole
checkLeaks() → LeakReportentry_count, total_refs, pinned_paths, unpinned_entries, per-kind counts
logLeaksIfAny(context)Warn + sample paths on teardown
appendLiveSnapshot / copyRecentFrees / copyTicketEntriesLive path/kind/refs/pin; last-48 free ring; every ticket + what it waits on (--profiler-residency=cpu)
geometry_residency.zigCPU mesh used vs held, static vs dynamic, staging free lists
RenderResidency.geometryStatsGPU hashed / identity / instanced / dynamic-ring bytes
gpu_residency.zigSynchronized GPU snapshot (textures, buffers, programs, PSOs, AS)
Editor Residency tabRight dock (contrib id residency). Overview map of CPU / GPU / Loading / History; drill-in lists refresh ~5 Hz when selected

CPU snapshots are bounded owned copies taken under the store lock. GPU telemetry reads authoritative render owners (resource tables / transient pool / compute registry / RT cache) — never a second per-allocation map. The render thread publishes one fixed-capacity snapshot; the editor copies it under a short mutex. Counts are exact; some byte totals are estimates. Owner hints are best-effort scans, not a per-retain owner table.

The tab opens on an overview (CPU assets, GPU, in-flight loads, recently freed). Kind chips jump into that class; back or Escape returns to the map. Kind pages scroll the full matching list with no display caps (the recently-freed ring of 48 is the store's own size, not a budget). Every drill-in has a name filter; chips count what the filter left, and a section hiding rows reads 12 of 843. Each page distinguishes not compiled (--profiler-residency off) from not bound (no project or renderer yet). A refresh copies only the row lists the open page draws and keeps their capacity, so 5 Hz polling stops allocating after the high-water mark.


Invariants (tests / mental checklist)

  1. After full unload, scoped content:// reads leave no live retain. Engine shader packages and parsed material documents may remain intentionally pinned for reuse; hot reload invalidates those pins and store teardown reclaims them.
  2. Two actors, same mesh: despawn one → mesh stays; despawn both → mesh free.
  3. Material release frees unique textures; shared albedo used by two materials survives the first release.
  4. Additive A+B shared texture: unload A → stays; unload B → gone.
  5. Replace with shared assets: preload keeps shared resident across unload (no free+reload of shared paths when preload succeeds).
  6. Soft pending mesh: no mesh retain until bind succeeds.
  7. Bundle IO release with live decoded refs does not destroy those entries.
  8. register failing at any allocation leaves material_refs / pipeline_refs / geometry_refs empty.
  9. A completed texture upload binds the same maps whether the pending index or the overflow scan runs.
  10. Retained deinit is idempotent; a moved-from token releases nothing.
  11. states stays bounded by resident assets — destroy paths remove the key rather than storing .pending.
  12. A (stem, kind) waiter is satisfied only by that kind — a material landing at a model's stem must not satisfy the model waiter.
  13. A ticket that outlives its owner decrements into a reused slot: cancel on abort, close once the edge is consumed.
  14. Nothing reacts to state_generation any more. It survives as a diagnostic counter only; re-introducing a consumer re-introduces the rescan storm.
  15. A probed-absent asset is not re-probed until a pack opens or closes (probe_epoch vs bundle_epoch). Absence is provisional, not cached forever.
  16. One turn of the residency stage table visits every stage exactly once from any cursor. A stage cannot starve behind another however expensive the ones ahead of it are.
  17. residency_work_pending is never cleared while a consumer is still waiting. Only a pass that covered every stage and finished it may discharge it; a filtered wake carries it forward or pays it.
  18. A budget slice always completes at least one unit of work. Budget.exhausted() reports false until something is committed, and commit counts work rather than attempts.

Quick code map

ConcernFile
Store entries and public surfaceassets/asset_store/store.zig, assets/asset_store/types.zig
Keep-alive tier, budget, evictionassets/asset_store/budget.zig
Shared retain/release coreassets/asset_store/residency.zig
Materials + texture edgesassets/material_cache.zig
Stream-cell bulk retainassets/residency_scope.zig
Single-asset RAII holdassets/retained.zig
Procedural geometry + identity keyingsdk/src/procedural_mesh.zig, graphics/vertex_buffer.zig
Mesh bind / probe / scene preloadscene/scene_loader.zig, render_component.zig
Residency pass, stage table, debtscene/world/world_residency.zig
Frame slice + min-progress rulescene/residency_budget.zig
What a wake deliveredscene/residency_filter.zig
Scene replace wiringruntime_session/scene_ops.zig
Audio PCM + cueaudio/audio.zig, audio/mixer.zig
Scoped shader bytesruntime_session/assets.zig, platform shader/material loaders
GPU ownership + editor telemetrygraphics/residency/
Editor paneleditor/panels/residency_panel.zig + residency/{model,nav,overview,…}, contrib id residency
PreviousPrefabsNext Asset formats (Shinra pipeline)

Documentation follows the current engine checkout.

Snapshot 86f12d68Source docs/systems/asset-residency.md
On this pageMental modelWhat is tracked whereLayer 1 — AssetStore (shared CPU decode)Concurrency contractLayer 2 — MaterialCache (shared materials)Layer 3 — Entity-unique (not in AssetStore)Layer 4 — GPU (render thread)Layer 5 — Audio worker (decoded PCM)Who retains what (consumers)What happens at refs == 0Residency budget (keep-alive tier)GPU texture and mip streamingOpen-world integration seamGenerated / GPU geometryResidency tickets (readiness as an event)The residency pass (stage table)The cursorDebtThe stall watchThe budget knob — off by default, and whyDecode is off the game threadThe unit of work is a primitive, not an actorMaterials are the expensive halfOne stage, both consumersRetain ownershipTerminationScene transitions and streamingAdditive layersFull replace (overlap-safe)Stream cellsSingle-asset holds: Retained(T)DiagnosticsInvariants (tests / mental checklist)Quick code map Back to top