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. What a placement needs is spawned from a compiled Prefab - 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 nodes are the file’s nodes 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 resolves to a SceneBundle; 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 nodes, its skins, its clips and its lights. Getting one into the world is MeshSystem’s job, and there is one path for it, whatever the file holds.

SGMesh names a urlload_model_scene_bundleSceneBundle, shared by every placementprefab_compile, once per urlprefab_instantiate, once per placementone entity per node, ShadedGeometry where it draws

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 { MeshSkinSystem }          from "@woosh/meep-engine/src/engine/graphics3/MeshSkinSystem.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 MeshSkinSystem(engine.graphics, scene));   // deforms skinned primitives
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.

One path, whatever the file holds

MeshSystem compiles each url into a Prefab the first time it is mentioned (prefab_compile, src/engine/graphics3/prefab/) and spawns one instance per placement (prefab_instantiate). Every node of the file becomes an entity under the entity that placed the model, carrying a Transform64, a Name, a TransformAttachment naming its parent, and a ShadedGeometry where the node draws. ShadedGeometrySystem gives each of those primitives its meshes row. A skinned primitive also carries a MeshSkin, which MeshSkinSystem deforms, and the model’s clips go on the placing entity as an AnimationClips, which the animation systems play. MeshSystem puts nothing on the GPU itself.

What is read-only is shared, not copied - geometry, materials, inverse-bind matrices and the animation curves themselves all belong to the prefab, so two entities placing the same model name the same MeshletGeometry and the same ShadeMaterial. What is per-placement is the addressing: a placement’s MeshSkins name its own joint entities and its AnimationClips target its own entities, which is exactly what lets two entities show one animated model without the second driving the first one’s joints.

A PrefabInstance carries entities (the entity of each node, by node index), mesh_entities (the ones that draw), and entity_of(name) - the entity of the 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 placement into a scene that has no systems is one call:

import { prefab_compile } from "@woosh/meep-engine/src/engine/graphics3/prefab/prefab_compile.js";
import { prefab_place }   from "@woosh/meep-engine/src/engine/graphics3/prefab/prefab_place.js";
import { Transform64 }    from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";

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

const { root, instance } = prefab_place(
    engine.graphics.scene_context(scene),
    scene.dataset,
    prefab,
    new Transform64(),
);

prefab_place does by hand what the three systems do between them: a root entity carrying the placement, the prefab spawned under it, a transforms row for every entity of the instance, a meshes row for every primitive, and each skinned primitive’s skin registered with the animation manager. prefab_unplace takes it all back.

MeshSystem also answers questions about what it placed: instance_of(entity), mesh_entities_of(entity), 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