04 — The Load Pipeline
Load vs run: everything in this chapter happens once inside LevelLoader.Load() until it returns. Per-frame behavior hooks (OnUpdate, input polling) start only after the app calls engine.runRenderLoop(() => scene.render()) — see 02 — Runtime Basics.
At a glance
- Fetch and validate the manifest JSON.
- Load input-action assets.
- Append the
.glband prepare nodes (visibility, materials, GUID index). - Build one
Entityper manifest row and apply its components. - Resolve cross-entity references and camera targets (second pass).
- Finalize scene settings, shadows, async assets, constraints, then call
level.Begin()(OnStart).
Interactive step-through with source: trace-load. Subsystem diagram: load-pipeline.html.
LevelLoader.Load(manifestUrl) (packages/engine/src/core/LevelLoader.ts) is the orchestrator; each stage lives in core/loader/ (manifest.ts, nodeResolution.ts, entityBuilder.ts, componentRegistry.ts, scripts.ts, sceneSettings.ts, context.ts).
Steps (in order)
FetchAndValidateManifest(loader/manifest.ts) — clear errors for the two classic failures: HTTP 404, and the dev server returningindex.html(HTML, not JSON). The parsed JSON then runs throughValidateManifest: schemaversionmust equalSUPPORTED_SCHEMA_VERSION(kept in sync with the exporter'sSCHEMA_VERSIONbyscripts/check-component-types.mjs),glbmust be a non-empty path, and every entity needs anid, aname, and a components array whose rows carry atype. Prefetched manifests passed intoLoadvalidate too. Structural problems throw with the first failure named; a component type the runtime doesn't know only warns at apply time.Load input —
InputManager.LoadAsset(manifest.scene.inputActions ?? DEFAULT_INPUT_ASSET, manifest.scene.defaultInputMap ?? "Player")so@inputMapfields and the scene-defaultbehavior.inputfallback can be injected before behaviors are built.Append the glb —
scene.useRightHandedSystem = trueis set first so the loader skips the handedness-mirroring__root__transform (the old collider-orientation bug; full story in Physics). WhenShouldClusterBeforeGlbLoadsays the manifest's enabled punctual lights already exceed the budget, the glb is imported viaLoadAssetContainerAsync+AddContainerToSceneWithLightClusteringinstead ofappendSceneAsync, so PBR never compiles against an over-budget forward light count.NeutralizeGltfRoot(loader/nodeResolution.ts) remains as a guard that warns if a mirrored root ever reappears. Requires theExtrasAsMetadataloader extension import — without itnode.metadatastays empty and GUID matching silently fails.ApplyNodeVisibility(loader/nodeResolution.ts) — for each glTF node whose extras carrybjs_visible: false(Blender viewport-hidden), setnode.isVisible = falseand disable descendant lights/cameras. Meshes withbjs_cast_shadows: 0(ray-visibility Shadow off) are handled later inSetupShadows(receive-only). Render-disabled objects never reach this step — they were omitted at export.ApplyNodeMaterials(subsystems/materials/) — when the manifest includesmaterials[], parse each Node Material Editor JSON frommaterials/(cached perfile+ Blender materialname) and replacemesh.materialon every mesh whose glTF material name matches. Two phases: every unique override the scene actually uses fetches and parses in parallel (Promise.all; a failed material warns once and its meshes keep their glTF PBR material), then meshes are assigned synchronously from the cache — load time scales with unique materials, not mesh count. Embedded textures load fromtexture.url(data:…;base64,…) orbase64Stringin the JSON; relative paths resolve beside the JSON viaurlRewriter. Manifesttextures[]always override embedded JSON;inputs[]patch inspector parameters. Block ids resolve viaeditorData.map. Parses and binds overrides only — no shader compile yet. Runs after visibility, before entities.BuildIdIndex(loader/nodeResolution.ts) — walk every transform node + mesh, mapmetadata.gltf.extras.bjs_id→ node.ProcessEntity(loader/entityBuilder.ts) per manifest entity:- resolve node (GUID first, name fallback), create
Entity, register inlevel.entities, back-referencenode.metadata.bjsEntity = entity; - if manifest
visible: false(viewport-hidden),HideEntityNode; ApplyEntityComponents(loader/componentRegistry.ts) — the component registry: components group by handler, then each claimed handler runs once in table order with all of its components (unknown types warn — usually an add-on newer than the engine). The physics handler owns COLLIDER + RIGIDBODY together:HideEntityNodewhen any COLLIDER hasmakeInvisible, one body viaBuildPhysics(compoundPhysicsShapeContainerwhen multiple colliders, else single-shape paths; shapes registered incontext.physicsShapesByEntity),RegisterAttachmentper collider/rigidbody row, event-message registrations queued. Other handlers register TAG / RENDERING_GROUP / LAYER_MASK / COLLISION_LAYER attachments, queue AUDIO/GUI/PARTICLE/MSDF_TEXT async tasks, CONSTRAINT / REFLECTION_PROBE / GUI3D registrations (GUI3D with the manifestparentGUID for panel nesting). The script handler (loader/scripts.ts→InstantiateScripts) builds behaviors (RegisterAttachmentper SCRIPT), applies@exposedvalues, andInjectInputMaps(entity refs deferred asPendingRefs).RegisterAttachmentis the single write path — it appends the attachment row and mirrors the matching convenience field (entity.tag,entity.body,entity.behaviors,entity.sounds, …);ProcessLightForEntity/ProcessCameraForEntity(typed camera override viaBuildTypedCamera; FOLLOW/ARC/OFFSET target bindings queued byQueueCameraTargets— GEOSPATIAL resolves pose immediately).
- resolve node (GUID first, name fallback), create
Second pass — now every entity exists:
ResolveObjectReferences(entity-typed@exposedfields + list slots) andResolveCameraTargets(FOLLOW lockedTarget / ARC re-pivot / OFFSET per-frame updater).FinalizeLevel(in code order) —ClusterPunctualLightsIfNeeded(when enabled lights exceed the budget: cluster point/spot lamps or disable light UBOs — setslevel.punctualLightingMode/level.clusteredLights; skipped when clustering already ran during glb import, see step 3);SetupShadows(one generator per casting lamp; all meshes receive; casters respectbjs_cast_shadowsand outlier-size heuristics; a caster/receiver summary logs only when the export's Debug Build flag is on);await ApplySceneSettings→ clear/ambient, asyncApplyEnvironment+ fog;ApplyAtmospherewhen the manifest includesscene.atmosphere(SUN lamp →@babylonjs/addons/atmosphere; setslevel.atmosphere);BuildNodeMaterials— single shader compile for every sceneNodeMaterial(whenTexturesReadyAsyncthenbuild()). Runs after environment IBL exists when the manifest declares one, and after manifest texture/input/gradient overrides are bound;ApplyAutoPlayAnimations(stop the glTF loader's auto-started groups, start the chosen clips);SettleTasksfor audio/GUI/particle/MSDF text promises (Promise.allSettledso one bad file logs instead of rejecting the load);WireParticleEmitterTracking(empty-node particle emitters →level.particleEmitterManager);WireMsdfTextRendering(when any MSDF labels exist);- wire collision/trigger events (
WireCollisionEvents→level.collisionEventHandles); - build constraints (
BuildConstraints→level.constraints+ CONSTRAINT rows on owner entities); - build 3D GUI (
BuildGui3DControls→level.gui3DManager+ GUI3D_* rows on owning entities); - build reflection probes (
BuildReflectionProbes→AssignProbeMaterials→level.reflectionProbes+ REFLECTION_PROBE rows on owner entities — after IBL and the NME compile so probe cubemaps overridematerial.reflectionTexturelast); - apply render layers (
ApplyRenderLayers—RENDERING_GROUP/LAYER_MASKon owned meshes with optional child propagation); - apply collision layers (
ApplyCollisionLayers—COLLISION_LAYER+scene.collisionLayersmatrix → Havok filter masks on registered physics shapes); - then
level.Begin()(behaviors'OnStart, including any runtime camera creation); - then
ApplyPostProcessing(default pipeline + SSAO onscene.activeCamera); - and if
debugCollidersand the export's Debug Build flag allow, show collider wireframes.
See 11 — UI for the 2D GUI, particle, and 3D GUI pipelines.
EnableHavokPhysics(scene) must run before Load — physics bodies are built during ProcessEntity / ApplyEntityComponents.
After load: the Level container
Level (core/Level.ts) is the runtime container: entities map, ById/ByTag, activeCamera, shadowGenerators, punctualLightingMode, clusteredLights, constraints, post, atmosphere, particleEmitterManager, msdfTextManager, gui3DManager, collisionEventHandles, reflectionProbes, debugEnabled, ShowColliders, AddUpdater.
Begin attaches input (12 — Input), runs every OnStart, then subscribes scene.onBeforeRenderObservable to drive RunFrame each render: InputManager.Process first, all OnUpdate(deltaSeconds), registered updaters, then InputManager.EndFrame last (so WasPressed edges last one full frame). Traces: trace-runtime-loop · trace-lifecycle.
Dispose detaches input, removes observers, disposes constraints and sounds, clears the scene's MSDF font cache, clears entity.attachments, runs OnDestroy on every behavior.