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 aSampler2Dover the decoded pixel buffer every time. - Models do not.
GLTFSceneBundleAssetLoaderbuilds oneSceneBundleper 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 byinstantiate_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 constant | String value | Loader class | What create() yields |
|---|---|---|---|
GameAssetType.ModelGLTF | "model/gltf" | GLTFSceneBundleAssetLoader | SceneBundle - shared, not cloned |
GameAssetType.ModelGLTF_JSON | "model/gltf+json" | GLTFSceneBundleAssetLoader | same |
GameAssetType.ModelUSD | "model/vnd.usd" | USDSceneBundleAssetLoader | SceneBundle - shared, not cloned; skins and clips empty |
GameAssetType.Image | "image" | ImageRGBADataLoader | Sampler2D (16-bit PNG preserved; JPEG and AVIF decode in-tree where there is no browser decoder) |
GameAssetType.ImageBitmap | "x-meep/image-bitmap" | ImageBitmapAssetLoader | ImageBitmap |
GameAssetType.ArrayBuffer | "arraybuffer" | ArrayBufferLoader | raw ArrayBuffer |
GameAssetType.JSON | "json" | JsonAssetLoader | parsed JS object |
GameAssetType.Text | "text" | TextAssetLoader | raw string |
GameAssetType.Sound | "audio" | SoundAssetLoader | decoded AudioBuffer |
GameAssetType.ImageSvg | "image/svg" | SVGAssetLoader | cloned SVGElement |
GameAssetType.JavaScript | "text/javascript" | JavascriptAssetLoader | compiled function |
GameAssetType.Font | "font/opentype" | FontAssetLoader | FontAsset (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.
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:
| Value | Effect on the fetch |
|---|---|
CrossOriginKind.Anonymous (default) | credentials: 'same-origin' |
CrossOriginKind.UseCredentials | credentials: '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
Related
- Meshes & materials - what
ShadedGeometrycarries once a bundle has been expanded. - Skeletons & skinning - the skins and clips a bundle brings with it.
- Saving & loading -
SGMeshpersists as a URL, not as geometry.