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: Reload document snapshot<br/>only if reconstruction is needed
E->>R: ResumeDaemon-to-editor event queue
assets/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.drainInto. - 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. - For each per-path event it:
- computes
affectedEntityCountby matching authored actorscript,model,mesh, andmaterialfields; - 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.
- computes
- When a scene reload is required and the boot game-module compile has finished, it serializes
SceneDocumentto UTF-8. - It calls
session.hotReloadAssets(applied, scene).
Runtime hot reload
runtime_session/scene_ops/hot_reload.zig:hotReloadAssetsis edit-mode-only.- 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, reflection-probe cubemaps, 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) reloads the scene from UTF-8 with
loadSceneFromUtf8(session, scene, .preserve).
Render-thread synchronization
graphics/renderer/render_thread.zigimplementspauseAtFrameBoundary/resumeFromFrameBoundaryusingpause_requestedandpausedflags protected by the existingmutex/cond.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 andaffectedEntityCount > 0(authoredscript/model/mesh/material, or a material that references a changed shader). TheSceneDocumentis serialized to UTF-8 and reloaded throughruntime_session/scene_ops/load.zig:loadSceneFromUtf8withEditViewSeed.preserve. loadSceneFromUtf8→instantiateScenestarts a replaceSceneLoadJoband drains it (live-A swap when the world is occupied;world_preclearedwhen empty).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 increment
affectedEntityCount, so they do not by themselves serialize the document.
First cook is not a change
A cold start cooks the whole project while the scene is already open. Every .rebuilt event for a model with live references would ask for a whole-scene re-instantiate; those requests coalesce while the daemon is busy (scene_reload_deferred) into one rebuild as the cooker goes quiet, and that rebuild would tear down and rebuild an identical scene (visible as the scene appearing piece by piece twice). It is redundant because actors waiting on an uncooked model are soft-pending and the residency wake binds the model in place when it lands (render.pending_model → bindModelParts), so firstCookBindsInPlace suppresses the reload request for a model going .pending → .rebuilt.
The narrowing is deliberately tight, because "binds in place" is a property of the consumer rather than of the event:
| Case | Reload? | Why |
|---|---|---|
mesh, .pending → .rebuilt | no | actors are soft-pending; the wake binds it |
mesh, .ready → .rebuilt | yes | genuine change, and nothing is soft-pending to rebind |
mesh, .failed → .rebuilt | yes | the failure already resolved to an error mesh and cleared the pending flag |
| script / prefab, any | yes | no in-place late-bind path exists |
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.