Platform

Asset pipeline

How Meep loads, caches, and transforms assets - the AssetManager API, the built-in loaders, the transformer chain, and turning a loaded glTF into entities.

Every image, model, sound, and data file passes through a single AssetManager. It deduplicates in-flight requests, queues work by priority, caps network concurrency, and runs each loaded asset through a chain of registered transformers before handing it to you. You register loaders for the types you need; the engine does the rest.

Getting the asset manager

EngineHarness.bootstrap creates the engine and returns it. The manager is exposed directly:

const engine = await EngineHarness.bootstrap({ configuration: (config) => {
    // register loaders here (see below)
}});

const am = engine.assetManager;   // AssetManager instance

Loaders are registered during the configuration callback - before bootstrap starts the engine - using config.addLoader(type, loader). That call queues a registerLoader call on the manager; by the time bootstrap resolves, all loaders are linked and ready.

Requesting assets

promise - async/await

import { GameAssetType } from "@woosh/meep-engine/src/engine/asset/GameAssetType.js";

const asset = await am.promise("models/chair.glb", GameAssetType.ModelGLTF);
const bundle = asset.create();   // a Shade SceneBundle

promise(path, type) returns a Promise<Asset>. If the asset is already loaded, the promise resolves on the next microtask. If it is in flight, the new request is merged with the existing pending entry and resolved when that load completes. The optional third argument takes { scope, skip_queue, progress }.

get - callback form

am.get({
    path: "textures/ground.png",
    type: GameAssetType.Image,
    callback: (asset) => useSampler(asset.create()),
    failure: (err) => console.error("load failed", err),
    progress: (loaded, total) => updateBar(loaded / total),
});

get accepts an options object. callback, failure, and progress are all optional (defaults: no-op, console.error, no-op). Passing skip_queue: true bypasses the priority wait queue and dispatches the load immediately - useful for assets that need to unblock rendering.

tryGet - synchronous, no side effects

const asset = am.tryGet("models/chair.glb", GameAssetType.ModelGLTF);
if (asset !== null) {
    placeModel(asset.create());
}

Returns the cached Asset if already loaded, null otherwise. Does not trigger a load.

Status checks

am.isLoaded(path, type)   // true if cached and ready
am.isPending(path, type)  // true if in flight
am.isFailed(path, type)   // true if the last attempt failed

The Asset object

A loaded asset wraps a factory function, not the resource itself:

const asset = await am.promise("textures/ground.png", GameAssetType.Image);
const a = asset.create();   // a fresh Sampler2D over the decoded pixels
const b = asset.create();   // another one

What create() returns is entirely the loader’s business, and the two answers you will meet are different in kind:

  • Data assets build a new wrapper each call. ImageRGBADataAsset.create() constructs a Sampler2D over the decoded pixel buffer every time.
  • Models do not. GLTFSceneBundleAssetLoader builds one SceneBundle per file and hands that same instance to every caller. This is deliberate: a bundle is the model as loaded, not a placement of it, and a level that places one model two hundred times would otherwise pay for two hundred copies of its geometry. The private copy a placement needs is made by instantiate_scene_bundle - see Loading a model into entities.

The asset also carries byteSize (RAM footprint in bytes) and a description ({ path, type }). byteSize is 1 for models, hard-coded: neither the meshlet geometry nor the decoded images are numbers the loader is handed, so the preloader counts a model as one unit rather than guessing at bytes.

Priority and concurrency

load_concurrency caps how many loaders run in parallel. It defaults to Infinity (no cap). Setting it - e.g. am.load_concurrency = 6 - keeps network slots available for high-priority work:

am.load_concurrency = 6;

Requests queue in a binary-heap wait queue ordered by priority. Each AssetRequest carries a priority number (default 1); higher numbers are dispatched first. When multiple requests share the same pending asset, the queue score is the maximum priority across those requests, so one high-priority caller elevates an already-queued asset. Passing skip_queue: true in get (or the skip_queue option in promise) force-dispatches the asset past the queue immediately.

Loading multiple assets together - AssetPreloader

AssetPreloader batches requests into priority levels and fires progress and completion signals:

import { AssetPreloader } from "@woosh/meep-engine/src/engine/asset/preloader/AssetPreloader.js";
import AssetLevel        from "@woosh/meep-engine/src/engine/asset/preloader/AssetLevel.js";

const preloader = new AssetPreloader();

preloader.add("models/hero.glb",     GameAssetType.ModelGLTF, AssetLevel.CRITICAL);
preloader.add("audio/ambient.mp3",   GameAssetType.Sound,     AssetLevel.NORMAL);
preloader.add("textures/splash.png", GameAssetType.Image,     AssetLevel.OPTIONAL);

preloader.on.progress.add(({ global }) =>
    updateLoadBar(global.progress)    // 0-1
);
preloader.on.succeeded.add(() => startGame());

preloader.load(am);

AssetLevel values - CRITICAL (0), HIGH (1), NORMAL (2), OPTIONAL (3) - control load order: lower numbers load first. succeeded fires only when every asset loaded without error; resolved fires regardless (receives a count of successes and failures). A preloader instance is single-use - create a new one for each batch.

Built-in loaders

Register the loaders you need during configuration. The type strings come from GameAssetType. Every loader class listed here is a named export.

Type constantString valueLoader classWhat create() yields
GameAssetType.ModelGLTF"model/gltf"GLTFSceneBundleAssetLoaderSceneBundle - shared, not cloned
GameAssetType.ModelGLTF_JSON"model/gltf+json"GLTFSceneBundleAssetLoadersame
GameAssetType.ModelUSD"model/vnd.usd"USDSceneBundleAssetLoaderSceneBundle - shared, not cloned; skins and clips empty
GameAssetType.Image"image"ImageRGBADataLoaderSampler2D (16-bit PNG preserved; JPEG and AVIF decode in-tree where there is no browser decoder)
GameAssetType.ImageBitmap"x-meep/image-bitmap"ImageBitmapAssetLoaderImageBitmap
GameAssetType.ArrayBuffer"arraybuffer"ArrayBufferLoaderraw ArrayBuffer
GameAssetType.JSON"json"JsonAssetLoaderparsed JS object
GameAssetType.Text"text"TextAssetLoaderraw string
GameAssetType.Sound"audio"SoundAssetLoaderdecoded AudioBuffer
GameAssetType.ImageSvg"image/svg"SVGAssetLoadercloned SVGElement
GameAssetType.JavaScript"text/javascript"JavascriptAssetLoadercompiled function
GameAssetType.Font"font/opentype"FontAssetLoaderFontAsset (opentype.js)

Loader modules live under src/engine/asset/loaders/, except the two image loaders (.../loaders/image/) and the font loader (.../loaders/font/). guessAssetType maps a path’s extension to one of these: .glb and .gltf to the two glTF types, .usd, .usda, .usdc and .usdz to ModelUSD, and .png, .jpg, .jpeg and .avif to Image.

Types in the enum that no loader produces. GameAssetType.Texture and GameAssetType.DeferredTexture exist as string constants, but nothing loads them: an image file is GameAssetType.Image, whose asset is a Sampler2D the renderer uploads itself. Asking for either type gets “no loader exists”. GameAssetType.AnimationGraph ("x-meep/animation-graph") is likewise unbound by default: AnimationGraphDefinitionAssetLoader (src/engine/graphics/ecs/animation/animator/graph/definition/serialization/AnimationGraphDefinitionAssetLoader.js) ships and works, but you register it yourself.

JsonAssetLoader is registered automatically by EngineHarness if you don’t add one yourself. ImageRGBADataLoader auto-registers an ArrayBufferLoader if none is present when it links.

import { GLTFSceneBundleAssetLoader } from "@woosh/meep-engine/src/engine/asset/loaders/GLTFSceneBundleAssetLoader.js";
import { USDSceneBundleAssetLoader }  from "@woosh/meep-engine/src/engine/asset/loaders/USDSceneBundleAssetLoader.js";
import { ImageBitmapAssetLoader }     from "@woosh/meep-engine/src/engine/asset/loaders/image/ImageBitmapAssetLoader.js";
import { ImageRGBADataLoader }        from "@woosh/meep-engine/src/engine/asset/loaders/image/ImageRGBADataLoader.js";
import { ArrayBufferLoader }          from "@woosh/meep-engine/src/engine/asset/loaders/ArrayBufferLoader.js";
import { GameAssetType }              from "@woosh/meep-engine/src/engine/asset/GameAssetType.js";

await EngineHarness.bootstrap({
    configuration: (config) => {
        const gltf = new GLTFSceneBundleAssetLoader();
        config.addLoader(GameAssetType.ModelGLTF,      gltf);
        config.addLoader(GameAssetType.ModelGLTF_JSON, gltf);
        config.addLoader(GameAssetType.ModelUSD,       new USDSceneBundleAssetLoader());

        config.addLoader(GameAssetType.ArrayBuffer, new ArrayBufferLoader());
        config.addLoader(GameAssetType.ImageBitmap, new ImageBitmapAssetLoader());
        config.addLoader(GameAssetType.Image,       new ImageRGBADataLoader());
    },
});

A single GLTFSceneBundleAssetLoader instance can be shared between ModelGLTF and ModelGLTF_JSON - it handles both binary and JSON glTF files.

A glTF is not one file, and the loader does not fetch its parts itself. It asks the manager back for them under the same request scope, so they are cached, counted, and cancelled like everything else the game loads. Concretely: the container and every .bin buffer come back as GameAssetType.ArrayBuffer, and every image comes back as GameAssetType.ImageBitmap. Register ImageBitmapAssetLoader or you get geometry with no textures.

A KHR_texture_basisu image (KTX2, ETC1S or UASTC) is read and transcoded to a block format the device samples, and EXT_meshopt_compression / KHR_meshopt_compression bufferViews are decoded on load; Draco is not. See Meshes & materials.

USD

USDSceneBundleAssetLoader (src/engine/asset/loaders/USDSceneBundleAssetLoader.js, named) is the USD counterpart of the glTF loader, registered under GameAssetType.ModelUSD. It asks the manager for the file’s bytes as an ArrayBuffer under the same scope, hands them to load_usd, and yields a SceneBundle whose scenes are the file’s roots and whose skins and clips are empty - UsdSkel and time-sampled attributes are not read. One USD file is one download: a .usdz carries its textures inside the archive, and external layer references are not resolved.

load_usd(buffer, { fileName, extensions }) (src/shade/renderer/loader/usd/load_usd.js, named) is the parser underneath. It reads USDA and USDZ - detected by magic bytes, with the file name as a fallback - and returns Node3D[]; USDC is detected and throws UsdUnsupportedError, and malformed data throws UsdParseError. load_model_scene_bundle(assetManager, url) accepts both model formats, so with the loader registered an SGMesh may name a .usdz.

Loading a model into entities

A SceneBundle is Shade’s representation of one loaded file: its scene roots, its skins, and its clips. Getting one into the world is MeshSystem’s job, and what it does depends on what the file contains.

SGMesh names a urlload_model_scene_bundleSceneBundle, shared by every placementfile has skins or clips?shade_bundle_to_entity_compositionchild entities carrying ShadedGeometryinstantiate_scene_bundleSceneBundleInstance roots added to the Shade scenenoyes

The wiring:

import { EngineHarness }           from "@woosh/meep-engine/src/engine/EngineHarness.js";
import { load_model_scene_bundle } from "@woosh/meep-engine/src/engine/asset/load_model_scene_bundle.js";
import { MeshSystem }              from "@woosh/meep-engine/src/engine/graphics3/MeshSystem.js";
import { ShadedGeometrySystem }    from "@woosh/meep-engine/src/engine/graphics3/ShadedGeometrySystem.js";
import { ParentEntitySystem }      from "@woosh/meep-engine/src/engine/ecs/parent/ParentEntitySystem.js";
import { SGMesh }                  from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/aggregate/SGMesh.js";
import { Transform64 }             from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { Entity }                  from "@woosh/meep-engine/src/engine/ecs/Entity.js";

const em    = engine.entityManager;
const scene = EngineHarness.shadeScene(engine);   // the one Shade Scene per engine

await em.addSystem(new MeshSystem(
    engine.graphics,
    scene,
    (url) => load_model_scene_bundle(engine.assetManager, url)
));
await em.addSystem(new ShadedGeometrySystem(engine.graphics, scene));
await em.addSystem(new ParentEntitySystem());

new Entity()
    .add(new Transform64())
    .add(SGMesh.fromURL("data/models/chair.glb"))
    .build(em.dataset);

MeshSystem(graphics, scene, load) takes its loader as a constructor argument rather than importing one, so the system can be driven in a test with no network and no device. load_model_scene_bundle(assetManager, url) is the loader you want in an app: it guesses the asset type from the URL on purpose, because the manager keys its cache on (path, type) and a caller naming the type by hand is a caller whose request misses the entry the preloader warmed. Its dependencies are [SGMesh, Transform64].

Give MeshSystem, ShadedGeometrySystem, and every other scene-taking system the same Scene instance, or content goes missing.

The two branches

No skins and no clips - the bundle is expanded into the dataset. shade_bundle_to_entity_composition(bundle) (src/engine/graphics3/shade_node_to_entity_composition.js) turns the file’s node tree into an EntityNode composition: one child entity per node, carrying a Transform64, a Name, and a ShadedGeometry where the node draws, parented under the entity that placed the model. ShadedGeometrySystem gives each of those a row in the scene. Geometry and material are shared, not copied - two entities placing the same model name the same MeshletGeometry and the same ShadeMaterial, which is why a static model needs no copy of the bundle at all.

Skins or clips present - the bundle is instantiated. instantiate_scene_bundle(bundle) (src/engine/graphics3/instantiate_scene_bundle.js) returns a SceneBundleInstance with its own roots, its own skins bound to those roots’ joints, and its own clips retargeted onto them; the roots go into the Shade Scene directly and the skins and clips are registered with the GPU animation manager. Two entities cannot share this: the bundle’s skins name Node3Ds and its clip channels target Node3Ds, so the second placement would drive the first one’s joints. What the copy still shares is everything read-only - geometry, materials, inverse-bind matrices, and the animation curves themselves.

SceneBundleInstance carries roots, skins, clips, and nodes: Map<string, Node3D> - every node by the name the asset gave it, which is where a socket, an effect anchor, or a projectile spawn is looked up.

Placing a bundle yourself

You do not have to go through SGMesh and MeshSystem. Given a bundle, a second placement is one call:

import { instantiate_scene_bundle } from "@woosh/meep-engine/src/engine/graphics3/instantiate_scene_bundle.js";

const bundle   = await load_model_scene_bundle(engine.assetManager, "data/models/chair.glb");
const instance = instantiate_scene_bundle(bundle);

for (const root of instance.roots) {
    scene.add(root);
}

MeshSystem also answers questions about what it placed: instance_of(entity), animation_of(entity), traverse_meshes(entity, visitor), and compute_world_bounds(target, entity).

Registering a custom loader

Extend AssetLoader and override load. The link lifecycle method gives you access to the manager and the engine context:

import { AssetLoader } from "@woosh/meep-engine/src/engine/asset/loaders/AssetLoader.js";
import { Asset }       from "@woosh/meep-engine/src/engine/asset/Asset.js";

class YamlAssetLoader extends AssetLoader {
    load(scope, path, success, failure, progress) {
        fetch(path)
            .then(r => r.text())
            .then(text => {
                const parsed = parseYaml(text);
                success(new Asset(() => parsed));
            })
            .catch(failure);
    }
}

// during configuration:
config.addLoader("application/yaml", new YamlAssetLoader());

load receives scope (AssetRequestScope), the resolved path (including rootPath prefix), and three callbacks. It can return a Promise - the manager catches rejections and routes them to failure.

A loader that needs another file should ask the manager for it, passing the scope through - this.assetManager.promise(path, type, { scope, progress }). That is how the glTF and image-bitmap loaders get their bytes, and it is what makes a shared dependency load once.

The transformer chain

Transformers run after a loader resolves, before the asset is cached. They are applied in registration order and can be async:

import { AssetTransformer } from "@woosh/meep-engine/src/engine/asset/AssetTransformer.js";
import { Asset }            from "@woosh/meep-engine/src/engine/asset/Asset.js";

class TrimWhitespaceTransformer extends AssetTransformer {
    async transform(asset, description) {
        const text = asset.create().trim();
        return new Asset(() => text, text.length);
    }
}

am.registerTransformer(GameAssetType.Text, new TrimWhitespaceTransformer());

transform(source, asset_description) returns the asset to cache - the same one, mutated, or a replacement. Transformers do not apply retroactively: assets already in the cache are not re-processed, and registerTransformer logs a warning naming every already-loaded asset of that type it just skipped. Remove a transformer with am.unregisterTransformer(type, transformer), which returns true if it was found.

Path prefix

am.rootPath is prepended to every path before it is handed to a loader. Set it once to relocate all asset fetches to a CDN or a subdirectory:

am.rootPath = "https://cdn.example.com/assets/";

CORS and credentials

am.crossOriginConfig is a CrossOriginConfig object whose kind is one of two CrossOriginKind values:

ValueEffect on the fetch
CrossOriginKind.Anonymous (default)credentials: 'same-origin'
CrossOriginKind.UseCredentialscredentials: 'include' - cookies and auth headers are sent
import { CrossOriginKind } from "@woosh/meep-engine/src/engine/asset/CORS/CrossOriginKind.js";

am.crossOriginConfig.kind = CrossOriginKind.UseCredentials;

Set this before loaders are linked (i.e., before bootstrap resolves).

The config is read in exactly one place: ArrayBufferLoader, when it builds its fetch. That is not the limitation it sounds like - the glTF, image, and image-bitmap loaders all pull their bytes through ArrayBufferLoader, so the setting reaches every model and every texture through that one door. The loaders that fetch on their own - SVGAssetLoader, SoundAssetLoader, FontAssetLoader (which hands the URL to opentype.js), and the XHR-based JsonAssetLoader / TextAssetLoader - do not consult it.

Manually inserting and aliasing assets

insert(path, type, asset) places a fully-resolved Asset into the cache without going through a loader or transformer. Any pending requests for that path/type are resolved immediately. insertAsync is the promise form for assets you load yourself.

assignAlias(alias, path, type) names an asset description with a string alias. Resolve the alias later with resolveAlias(alias) or load by alias with promiseByAlias(alias). Useful for remapping asset paths at runtime.

Dumping the loaded asset list

console.log(am.dumpLoadedAssetList());   // JSON array of all cached assets