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. What a placement needs is spawned from a compiledPrefab- 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 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.
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:
| 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.