The editor implements live visual asset swapping through a thread-safe, daemon-to-edit event queue and a render-thread-paused re-instantiation path. This page describes the current flow and the invariants that keep it safe.
The queue separates background cooking from world mutation. GPU-visible changes happen only inside the frame-boundary pause:
Diagram source
sequenceDiagram
participant D as Shinra
participant E as Editor / session
participant R as Renderer
D->>E: Queue asset events
Note over E: Drain in Edit only<br/>Play keeps events queued
E->>R: Pause at frame boundary
R-->>E: Paused
Note over E: Release holds → evict<br/>Rebind → flush GPU releases
Note over E: Queue scene replacement<br/>only if reconstruction is needed
E->>R: ResumeDaemon-to-editor event queue
editor/asset_pipeline/daemon.zigruns Shinra in watch mode and emitsEventvalues:pending,available,rebuilt,failed,removed,catalog,disconnect.editor/editor_app/app.zigowns theasset_pipeline_daemonand receives events throughonAssetPipelineEvent, which pushes them intoEditorApp.asset_events.editor/editor_app/asset_events.zigdefines theQueue: path-coalesced, capacity-bounded (max_paths = 8192), and generation-aware. It keeps the last terminal state per path and ignores stale generations.cataloganddisconnectare flags, not per-path events.- Pipeline callbacks wake the durable main-thread bus so backgrounded editors can drain events. During Play, reload work is deferred: the durable pump parks the non-empty queue without re-waking, and the editor-state coordinator releases it when the mode returns to Edit.
Editor tick processing
editor/editor_app/hot_reload.zig:processPendingruns each edit tick wheneditor_state.allows(.asset_reload).- It drains
asset_eventsintoasset_event_drainusingQueue.drainBoundedInto, up to 48 distinct paths per tick without splitting a stem. - If events were dropped, it requests a rebuild-all from the daemon once per overflow burst (debounced until the daemon has gone busy→idle, or a
catalog/disconnectarrives). Stacking rebuild-all while the queue is still overflowing re-floods Shinra. asset_reload_refs.collectcounts affected actors for the entire batch in one document walk. It indexes event stems, matches script, geometry, surface-material and fog-material references, and resolves shader dependencies once per distinct surface material. Primary and per-slot materials share the same memo. An actor counts once per event even when several fields reference it.- For each per-path event it:
- classifies the path as texture / material / shader / mesh / script;
- decides whether a full scene reload is needed (
requiresSceneReloadis false only for texture / material / shader); - invalidates the editor thumbnail cache for that path;
- tracks
.shinparticlerebuilds for preview rebind; - builds a
Session.AssetChangelist.
- Scene rebuild requests coalesce until the cook and any existing scene load settle. They remain deferred during Play and boot module compilation. The editor rechecks consumers before serializing the authored
SceneDocument; a later scene load can supersede a deferred request. - It calls
session.hotReloadAssets(applied, scene).
Runtime hot reload
- The editor drains reload events in Edit.
runtime_session/scene_ops/hot_reload.zig:hotReloadAssetsupdates owned-world holders and live-world render queues. - It pauses the render thread with
session.render_thread.pauseAtFrameBoundary()and resumes it in adeferwithresumeFromFrameBoundary(). - For each change:
- it deduplicates by
asset_deps.ReloadFamily+ stem (texture/model/audio/font/blob); - stops audio streamers before eviction for
.audiofamilies; - calls
forceEvictFamily(world, store, stem, family), which abandons world holders (skybox, visual-zone LUTs, bound meshes, animation skeleton/clip binds, material texture slots; marks animation-graph actors stale) then callsstore.evictFamilyForReload(stem, family). Abandoning releases each hold rather than nulling it: eviction defers to the last borrower, so a hold dropped without its release strands the entryreload_pendingand the reload never arrives; - if the store returns a GPU texture handle, enqueues
RenderCommand.release_textureonworld.render_command_queue; - updates the
AssetStorestate topending,failed, orready; - refreshes texture bindings in
world.materialsfor texture assets; - reloads materials/shaders via
world.materials.reloadForAssetChangeand patches primitive references; - invalidates compiled Kawa scripts for
.kawa/.kawabcchanges; - re-arms looping audio beds for
.audiochanges.
- it deduplicates by
- After all changes it drains pending texture frees, flushes the render command queue, and (if a scene snapshot was provided) queues
requestSceneReplaceFromUtf8(session, session.scene_path, scene, .preserve).
Render-thread synchronization
graphics/renderer/render_thread.zigimplementspauseAtFrameBoundary/resumeFromFrameBoundaryusingpause_requestedandpausedflags protected by the existingmutex/cond. Pauses nest:pause_depthcounts host-side holders and only the outermost resume lets the thread run.graphics/renderer/render_command_queue.zighas arelease_texture: RenderHandlecommand variant.graphics/renderer/renderer_shared/commands.zig:applyRenderCommandsdrainsrelease_textureviaresources.deferRemoveTexture(handle), which ages the GPU resource before freeing.
Scene reload behavior
- Full scene re-instantiation runs only when
requiresSceneReloadis true and the batch’s affected-actor count is positive (authoredscript/model/mesh/material, or a material that references a changed shader). TheSceneDocumentis serialized to UTF-8 and queued throughruntime_session/scene_ops/load.zig:requestSceneReplaceFromUtf8withEditViewSeed.preserve. requestSceneReplaceFromUtf8queues a budgeted replaceSceneLoadJob; the outgoing scene remains visible until the replacement commits.World.unloadScene/unloadSceneForSwapdo not clearrender_command_queue, sorelease_primitivecommands emitted during entity teardown are preserved and drained before the new scene renders.- Texture, material, and shader changes rebind in place. Prefab / particle / animgraph / audio / font evict and rebind holders but do not contribute authored geometry/script/material references, so they do not by themselves serialize the document.
First cook is not a change
Content hashes are tracked by full logical path, so sibling artifacts cannot inherit one another’s change state. First arrivals and byte-identical rebuilds do not request a scene replacement. Changed bytes request one only when live consumers still hold the old content. Stub worlds and worlds still loading use the existing load and residency wake.
Without a content hash, firstCookBindsInPlace uses the consumer’s pending intent
and store state:
| Case | Reload? | Why |
|---|---|---|
mesh/script, .pending → .rebuilt | no | pending records bind the arrival in place |
| mesh with pending or matching bound parts | no | consumer state overrides shared-stem store state |
mesh/script, previously .ready or .failed | yes | no pending record will rebind changed content |
| prefab | yes | expansion happens at spawn without retained pending intent |
Authoring JSON mirrors (*.material.json, *.model.json, …) publish as
available, not rebuilt: the cooked binary is the runtime change, and treating
the JSON copy as a change would evict and re-parse the same material twice per cook.
Fog-volume materials parse through loadDesc, which pins the blob. Five
lanterns sharing lantern_glow decode once. They cannot MaterialCache.retain
— that path rejects non-surface domains and would install a pink stand-in.
Engine shader acquire pins the blob after the first decode, so PSOs sharing one metallib do not re-read it on every acquire/release.
Cook progress logs one line per source (relative path, duration). Extracted companions with no actor field still hot-reload; they log at debug, with an info summary when a batch has eight or more.
Invariants for maintainers
- Route asset change notifications through the path-coalesced queue; do not call
hotReloadAssetsdirectly from daemon threads. - A durable pump may re-wake only for work that is runnable now. Play-deferred asset events stay queued until the Edit transition publishes
work_ready; treating a merely non-empty queue as runnable can starve the native message loop. - Pause the render thread at a frame boundary before evicting or reloading assets; resume only after flushing the render command queue.
- Preserve the render command queue across scene unload/load so GPU release commands are not lost.
- Force-evict by
ReloadFamilyonly; a same stem with a different kind (for example, texture vs model) must not be wiped by a single event. - Abandon world holders before
AssetStoreeviction to avoid use-after-free on same-stem rebind.