← Index · Prev · Next →

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

  1. Fetch and validate the manifest JSON.
  2. Load input-action assets.
  3. Append the .glb and prepare nodes (visibility, materials, GUID index).
  4. Build one Entity per manifest row and apply its components.
  5. Resolve cross-entity references and camera targets (second pass).
  6. 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)

  1. FetchAndValidateManifest (loader/manifest.ts) — clear errors for the two classic failures: HTTP 404, and the dev server returning index.html (HTML, not JSON). The parsed JSON then runs through ValidateManifest: schema version must equal SUPPORTED_SCHEMA_VERSION (kept in sync with the exporter's SCHEMA_VERSION by scripts/check-component-types.mjs), glb must be a non-empty path, and every entity needs an id, a name, and a components array whose rows carry a type. Prefetched manifests passed into Load validate too. Structural problems throw with the first failure named; a component type the runtime doesn't know only warns at apply time.

  2. Load inputInputManager.LoadAsset(manifest.scene.inputActions ?? DEFAULT_INPUT_ASSET, manifest.scene.defaultInputMap ?? "Player") so @inputMap fields and the scene-default behavior.input fallback can be injected before behaviors are built.

  3. Append the glbscene.useRightHandedSystem = true is set first so the loader skips the handedness-mirroring __root__ transform (the old collider-orientation bug; full story in Physics). When ShouldClusterBeforeGlbLoad says the manifest's enabled punctual lights already exceed the budget, the glb is imported via LoadAssetContainerAsync + AddContainerToSceneWithLightClustering instead of appendSceneAsync, 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 the ExtrasAsMetadata loader extension import — without it node.metadata stays empty and GUID matching silently fails.

  4. ApplyNodeVisibility (loader/nodeResolution.ts) — for each glTF node whose extras carry bjs_visible: false (Blender viewport-hidden), set node.isVisible = false and disable descendant lights/cameras. Meshes with bjs_cast_shadows: 0 (ray-visibility Shadow off) are handled later in SetupShadows (receive-only). Render-disabled objects never reach this step — they were omitted at export.

  5. ApplyNodeMaterials (subsystems/materials/) — when the manifest includes materials[], parse each Node Material Editor JSON from materials/ (cached per file + Blender material name) and replace mesh.material on 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 from texture.url (data:…;base64,…) or base64String in the JSON; relative paths resolve beside the JSON via urlRewriter. Manifest textures[] always override embedded JSON; inputs[] patch inspector parameters. Block ids resolve via editorData.map. Parses and binds overrides only — no shader compile yet. Runs after visibility, before entities.

  6. BuildIdIndex (loader/nodeResolution.ts) — walk every transform node + mesh, map metadata.gltf.extras.bjs_id → node.

  7. ProcessEntity (loader/entityBuilder.ts) per manifest entity:

    • resolve node (GUID first, name fallback), create Entity, register in level.entities, back-reference node.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: HideEntityNode when any COLLIDER has makeInvisible, one body via BuildPhysics (compound PhysicsShapeContainer when multiple colliders, else single-shape paths; shapes registered in context.physicsShapesByEntity), RegisterAttachment per 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 manifest parent GUID for panel nesting). The script handler (loader/scripts.tsInstantiateScripts) builds behaviors (RegisterAttachment per SCRIPT), applies @exposed values, and InjectInputMaps (entity refs deferred as PendingRefs). RegisterAttachment is 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 via BuildTypedCamera; FOLLOW/ARC/OFFSET target bindings queued by QueueCameraTargets — GEOSPATIAL resolves pose immediately).
  8. Second pass — now every entity exists: ResolveObjectReferences (entity-typed @exposed fields + list slots) and ResolveCameraTargets (FOLLOW lockedTarget / ARC re-pivot / OFFSET per-frame updater).

  9. FinalizeLevel (in code order) —

    • ClusterPunctualLightsIfNeeded (when enabled lights exceed the budget: cluster point/spot lamps or disable light UBOs — sets level.punctualLightingMode / level.clusteredLights; skipped when clustering already ran during glb import, see step 3);
    • SetupShadows (one generator per casting lamp; all meshes receive; casters respect bjs_cast_shadows and outlier-size heuristics; a caster/receiver summary logs only when the export's Debug Build flag is on);
    • await ApplySceneSettings → clear/ambient, async ApplyEnvironment + fog;
    • ApplyAtmosphere when the manifest includes scene.atmosphere (SUN lamp → @babylonjs/addons/atmosphere; sets level.atmosphere);
    • BuildNodeMaterials — single shader compile for every scene NodeMaterial (whenTexturesReadyAsync then build()). 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);
    • SettleTasks for audio/GUI/particle/MSDF text promises (Promise.allSettled so 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 (WireCollisionEventslevel.collisionEventHandles);
    • build constraints (BuildConstraintslevel.constraints + CONSTRAINT rows on owner entities);
    • build 3D GUI (BuildGui3DControlslevel.gui3DManager + GUI3D_* rows on owning entities);
    • build reflection probes (BuildReflectionProbesAssignProbeMaterialslevel.reflectionProbes + REFLECTION_PROBE rows on owner entities — after IBL and the NME compile so probe cubemaps override material.reflectionTexture last);
    • apply render layers (ApplyRenderLayersRENDERING_GROUP / LAYER_MASK on owned meshes with optional child propagation);
    • apply collision layers (ApplyCollisionLayersCOLLISION_LAYER + scene.collisionLayers matrix → Havok filter masks on registered physics shapes);
    • then level.Begin() (behaviors' OnStart, including any runtime camera creation);
    • then ApplyPostProcessing (default pipeline + SSAO on scene.activeCamera);
    • and if debugColliders and 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.