Rendering

Meshes & materials

Shade's geometry, material and texture types, the meshlet build that turns one into the other, and the ShadedGeometry component that gives a single primitive a row in the scene.

A drawable is three things: a MeshletGeometry (what to draw), a ShadeMaterial (how to shade it) and a Transform64 (where it is). ShadedGeometry is the ECS component that pairs the first two, and ShadedGeometrySystem gives every entity carrying it one row in Shade’s Scene.

There is no CPU-side machinery around them: no BVH to keep fed, no frustum-culling pass you can query, no raycast on the system, and no draw-method optimiser promoting repeated geometry to hardware instancing. Culling, occlusion and instancing all happen on the GPU from meshlet clusters, so the only thing for you to do is hand the renderer a shape and a material.

Geometry: two types, one of them a precompute

make_box_geometry, or a glTF primitiveGeometry - named attributes over typed arraysmeshlet_geometry_build_from_geometry - once, per shapeMeshletGeometry - clusters, encoded attributes, BVHShadedGeometry.from, plus a Transform64ShadedGeometrySystem gives it a row in the Scene

Geometry - the authoring form

import { Geometry }           from "@woosh/meep-engine/src/shade/renderer/geometry/Geometry.js";
import { Attribute }          from "@woosh/meep-engine/src/shade/renderer/geometry/Attribute.js";
import { StandardAttributes } from "@woosh/meep-engine/src/shade/renderer/geometry/StandardAttributes.js";

const geometry = new Geometry();

geometry.setAttribute(Attribute.from(positions, 3, StandardAttributes.Position));
geometry.index = Attribute.from(indices, 1, StandardAttributes.Index);

geometry.ensureNormals();   // computes them only if absent
geometry.ensureBounds();    // fills bounding_box / bounding_sphere

Attributes are held in a flat attributes: Attribute[] and addressed by name. The names are strings, and the ones the renderer knows come from StandardAttributes:

ConstantStringEncoding / use
Index"index"the index buffer, on geometry.index
Position"position"vertex positions
Normal"normal"vertex normals
Tangent"tangent"tangent basis, required by normal mapping
TextureCoordinates0"uv0"the main UV layout
TextureCoordinates1"uv1"the lightmap UV layout
Color"color"vertex colour, vec3<f32>
SkinningJoints"joints"vec4<u16>
SkinningWeights"weights"vec4<f16>

An Attribute is Attribute.from(array, itemSize, name); read one back with geometry.getAttribute(StandardAttributes.Position).data, which is the typed array itself.

The methods worth knowing:

MethodWhat it does
ensureBounds()computes bounding_box and bounding_sphere if the GeometryFlags.BoundsNeedUpdate flag is set. computeBoundingBox() / computeBoundingSphere() / computeBoundingSphereFromBox() force it
ensureNormals() / computeNormals()vertex normals. ensure* is a no-op when the attribute is already there
ensureTangents() / computeTangents()the tangent basis (the computeTangents algorithm from three.js). The graphics/geometry/MikkT/ tree ships but has no importer outside itself. Throws if index, position, normal or uv0 is missing
ensureIndex() / buildIndex()builds an index if the geometry has none
getVertexCount() / getIndexCount() / getPrimitiveCount()counts; getIndexCount() is three times the triangle count
clone() / copy(other) / equals(other) / hash()value semantics

geometry.version is bumped by geometry.needsUpdate = true. Nothing watches the arrays - writing into an attribute’s data is invisible until you say so. That only matters on the dynamic-mesh path below; static geometry is encoded into meshlets once and never consulted again.

MeshletGeometry - the GPU form

import { meshlet_geometry_build_from_geometry }
    from "@woosh/meep-engine/src/shade/renderer/geometry/meshlet_geometry_build_from_geometry.js";

const meshlets = meshlet_geometry_build_from_geometry(geometry);

MeshletGeometry is the shape as the renderer holds it: triangles clustered into meshlets (meshlets: MeshletBatch), attributes encoded and compressed, and a BVH over the clusters (bvh: ArrayBuffer) that GPU culling walks. It also carries primitive_count, bounding_box, bounding_sphere and getIndexCount().

The build is a precompute. meshlet_geometry_build_from_geometry computes bounds, builds and compresses the meshlet batch and constructs the BVH. Do it once when the shape is created or loaded, never per frame. Passing the same Geometry through it twice gives you two independent GPU residencies of the same triangles.

The optional second argument fills an existing instance in place: meshlet_geometry_build_from_geometry(geometry, out). That is how BoxGeometry and PlaneGeometry - the only two, both in geometry/BoxGeometry.js - are built: MeshletGeometry subclasses whose constructor runs the build.

The reverse direction exists too, and you need it for CPU-side triangle queries:

import { geometry_build_from_meshlet_geometry }
    from "@woosh/meep-engine/src/shade/renderer/geometry/geometry_build_from_meshlet_geometry.js";

const decoded = geometry_build_from_meshlet_geometry(meshlets);   // a Geometry again

Primitive generators

Seven, all in src/shade/renderer/geometry/primitives/, all named exports, all returning a Geometry that still needs a meshlet build.

FunctionSignature
make_box_geometry(width = 1, height = 1, depth = 1, widthSegments = 1, heightSegments = 1, depthSegments = 1)
make_plane_geometry(width = 1, height = 1, widthSegments = 1, heightSegments = 1)
make_polyhedron_geometry(vertices, indices, radius = 1, detail = 0)
make_octahedron_geometry(radius = 1, detail = 0)
make_torus_geometry(radius = 1, tube = 0.4, radialSegments = 8, tubularSegments = 6, arc = Math.PI * 2)
make_cylinder_geometry(radiusTop = 1, radiusBottom = 1, height = 1, radialSegments = 8, heightSegments = 1, openEnded = false, thetaStart = 0, thetaLength = Math.PI * 2)
make_torus_knot_geometry(radius = 1, tube = 0.4, tubularSegments = 64, radialSegments = 8, p = 2, q = 3)

There is no UV sphere. The sphere is a subdivided octahedron:

import { make_octahedron_geometry }
    from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_octahedron_geometry.js";

const sphere = make_octahedron_geometry(1, 3);   // radius 1, three subdivisions

make_polyhedron_geometry is the general form the octahedron is built on: give it a vertex list, an index list, a radius and a subdivision count and it projects every subdivided vertex onto the sphere of that radius.

ShadedGeometry

The component. Named export, at engine/graphics/ecs/mesh-v2/ShadedGeometry.js.

import { EngineHarness }     from "@woosh/meep-engine/src/engine/EngineHarness.js";
import Entity                from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform64 }       from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";

import { ShadedGeometry }       from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics3/ShadedGeometrySystem.js";

import { make_box_geometry }                    from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_box_geometry.js";
import { meshlet_geometry_build_from_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/meshlet_geometry_build_from_geometry.js";
import { StandardShadeMaterial }                from "@woosh/meep-engine/src/shade/renderer/material/StandardShadeMaterial.js";

import { Color } from "@woosh/meep-engine/src/core/color/Color.js";

const engine = await EngineHarness.bootstrap({
    configuration: (config, engine) => {
        config.addSystem(
            new ShadedGeometrySystem(engine.graphics, EngineHarness.shadeScene(engine))
        );
    },
});

await EngineHarness.buildBasics({ engine, enableTerrain: false, enableWater: false });

const ecd = engine.entityManager.dataset;

// once per shape, not per entity
const geometry = meshlet_geometry_build_from_geometry(make_box_geometry(1, 1, 1));

const material = new StandardShadeMaterial();
material.diffuse_color.copy(Color.from_sRGB_to_linear(Color.parse("#4ef0a8")));

const t = new Transform64();
t.setTranslation(0, 0, 0);

new Entity()
    .add(t)
    .add(ShadedGeometry.from(geometry, material))
    .build(ecd);

ShadedGeometry.from(geometry, material, draw_mode?) is the whole construction path. It does not compute a bounding box - the box a mesh is culled against comes off the MeshletGeometry, which has one built into it.

Fields

FieldTypeNotes
geometryMeshletGeometrywhat to draw. The JSDoc types it MeshletGeometry|Geometry for the sake of the component’s older CPU bounds path, but Shade’s GPUGeometryManager asserts isMeshletGeometry when the mesh reaches the GPU - build meshlets
materialShadeMaterialin practice a StandardShadeMaterial
nodeMesh|nulltransient. The scene row the system put this primitive in, written on link and cleared on unlink. Read it when you need the world bounds or the node id; do not assign it
depth_materialShadeMaterial|nullcarried and compared, read by nothing in 3.21.0
modeDrawModecarried, read by nothing in 3.21.0. The only writer in the engine passes DrawMode.Triangles
flagsnumbersee below

ShadedGeometry.serializable is false - the component does not round-trip through the save system. Models that do are placed with SGMesh instead.

Two methods are still live:

  • getBoundingBox(destination) writes the world-space AABB into an AABB3. When the component has a node it reads Shade’s own per-row box, which updateMatrices refreshes; this is what entity_node_compute_bounding_box walks a model tree with.
  • query_raycast_nearest(contact, ray, transform_matrix4) raycasts this primitive’s own triangles on the CPU, writing a SurfacePoint3. It requires a decoded Geometry - meshlet pages hold quantised triangles - so run geometry_build_from_meshlet_geometry first and keep the result. For picking a whole scene, use PickingSystem instead.

Flags

ShadedGeometry.flags is a bitmask with the helpers setFlag(f), clearFlag(f), writeFlag(f, bool) and getFlag(f), and nothing in the render path reads it (3.21.0): ShadedGeometryFlags is imported by ShadedGeometry.js and by no other module in the package.

FlagValueWhat it names
InView1a CPU frustum-cull result
CastShadow2shadow-map participation
ReceiveShadow4shadow-map participation
DrawMethodLocked8a pinned draw method
Visible16submission
DeferredBoundsUpdate32collapse repeated bounds updates between reads

What that means in practice:

  • Shadow participation is not per-mesh. Everything in the scene casts and receives; the switch is renderer.feature_shadows_enabled. See Lights & shadows.
  • To hide one, remove the component or the entity. unlink takes the node out of the scene. Shade’s Mesh has no visible field at all - DynamicMesh is the only node type that does.
  • DeferredBoundsUpdate documents a trade against the component’s CPU bounds queue, but that queue is not wired up: there is no DeferredBoundsQueue in the package and nothing calls bindBoundsQueue. Setting the flag changes nothing.

ShadedGeometrySystem

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

em.addSystem(new ShadedGeometrySystem(graphics, scene));   // (GraphicsEngine, Scene)

The constructor is (graphics, scene). ShadedGeometrySystem3 is a deprecated alias of the same class; use the unsuffixed name.

Its dependencies are [ShadedGeometry, Transform64]. On link it builds one Shade Mesh, copies geometry and material straight across, takes the entity’s Name for the node name if it has one, listens for TRANSFORM64_EVENT_CHANGE on the entity, and adds the node to the scene. Each announcement copies the whole transform onto the node and calls node.updateMatrices(), which is also what refreshes the world bounds the GPU culls against. An entity that does not move costs nothing - and one that moves without announcing it does not move on screen.

It does not call graphics.set_scene(). Only LightSystem, MeshSystem and ParticipatingMediaSystem do. An application that registers ShadedGeometrySystem on its own has to tell the facade what to draw:

graphics.set_scene(scene);

Pass EngineHarness.shadeScene(engine) as the scene, as the sample above does, and the harness’s lights and terrain land in the same one - a second Scene is a second, unlit, undrawn world.

Materials

There is exactly one shipping PBR material class. ShadeMaterial is the base and carries what the rasterizer buckets on:

import { ShadeMaterial }     from "@woosh/meep-engine/src/shade/renderer/material/ShadeMaterial.js";
import { TransparencyMode }  from "@woosh/meep-engine/src/shade/renderer/material/TransparencyMode.js";
import { ShadeDrawMode }     from "@woosh/meep-engine/src/shade/renderer/material/ShadeDrawMode.js";
import { ShadeDrawSide }     from "@woosh/meep-engine/src/shade/renderer/material/ShadeDrawSide.js";
FieldDefaultMeaning
name""debugging label
transparency_modeTransparencyMode.Opaquewhich rasterization path the surface takes
draw_modeShadeDrawMode.Trianglesprimitive topology
draw_sideShadeDrawSide.Frontwhich faces are drawn

plus a read-only id, a textures getter, and copy / clone / equals / hash. copy deliberately does not copy id.

StandardShadeMaterial

import { StandardShadeMaterial }
    from "@woosh/meep-engine/src/shade/renderer/material/StandardShadeMaterial.js";

const material = new StandardShadeMaterial();

material.texture_albedo   = albedo;      // a ShadeTexture
material.roughness_factor = 0.4;
material.metallic_factor  = 1;
FieldTypeDefaultMeaning
texture_albedoShadeTextureundefinedbase colour; alpha carries transparency
diffuse_colorColor1, 1, 1, 1multiplied with the albedo. Linear, see below
texture_normalShadeTextureundefinedtangent-space normals
texture_ormShadeTextureundefinedglTF packing: R occlusion, G roughness, B metalness
texture_emissiveShadeTextureundefinedemission
emissive_factorColor0, 0, 0multiplied with the emissive texture
roughness_factornumber1multiplied with the ORM green channel
metallic_factornumber0multiplied with the ORM blue channel
transmission_factornumber0how much of the dielectric base becomes transmissive rather than diffuse. 1 is clear glass
ior_factornumber1.5index of refraction for the dielectric Fresnel layer. 1.5 gives F0 = 0.04; 1.33 water, 2.4 diamond. Ignored for metals
ambient_factorsLinearModifier1, 1multiplier and offset over the indirect contribution
vt_stackVirtualTextureStack | undefinedundefinedopt-in virtual texturing. When set, albedo, normal and ORM come from streamed pages and the three regular textures are ignored; emissive stays regular. Opaque materials only - see Virtual texturing

textures returns the four regular textures that are actually set, which is what the renderer de-duplicates uploads from.

Transparency, topology, sides

TransparencyMode = { Opaque: 0, AlphaTested: 1, Transparent: 2 }
ShadeDrawMode    = { Points: 0, Lines: 1, Triangles: 2 }
ShadeDrawSide    = { Front: 0, Double: 1, Back: 2 }

Materials are bucketed by the triple (transparency_mode, draw_mode, draw_side), and each bucket becomes its own pipeline. The transparency mode picks the pass: Opaque goes through the opaque rasterization passes, AlphaTested through a pass of its own that can discard, Transparent through the order-independent one at AfterTransparency.

Double-sided drawing works. construct_primitive_state maps ShadeDrawSide to a WebGPU cull mode - Front culls back faces, Back culls front faces, Double culls nothing - and a back face that is drawn is shaded with a flipped normal. Shadow casters run the same table reversed, so a one-sided surface writes its back face into the shadow map and does not self-shadow. glTF’s doubleSided sets ShadeDrawSide.Double on load.

ShadeDrawMode is the one to be careful with: the mesh rasterizer’s topology table has an entry for Triangles only, and an unmapped value falls back to triangle-list. Setting Points or Lines on a mesh material does not give you a point or line pipeline. Lines and points come from the dynamic-mesh path.

Textures

Two objects, and the split is load-bearing: ShadeImage is the pixels, ShadeTexture is how to sample them. Two materials sampling one image differently are two textures over one image, and the image is uploaded once. Both answer equals and hash, which is what the de-duplication keys on.

import { ShadeImage }        from "@woosh/meep-engine/src/shade/renderer/texture/source/ShadeImage.js";
import { ShadeTexture }      from "@woosh/meep-engine/src/shade/renderer/texture/ShadeTexture.js";
import { TextureWrapType }   from "@woosh/meep-engine/src/shade/renderer/texture/TextureWrapType.js";
import { TextureFilterType } from "@woosh/meep-engine/src/shade/renderer/texture/TextureFilterType.js";
import { ColorSpace }        from "@woosh/meep-engine/src/shade/renderer/texture/ColorSpace.js";

const image = ShadeImage.fromImageBitmap(bitmap);

image.color_space = ColorSpace.SRGB;   // an albedo or emissive source; see below

const texture = ShadeTexture.from(image);

texture.wrapS     = TextureWrapType.Repeat;
texture.wrapT     = TextureWrapType.Repeat;
texture.magFilter = TextureFilterType.Linear;

ShadeImage is built with fromImageBitmap(bitmap), fromSampler2D(sampler) or fromArrayBuffer(buffer, channel_count, data_type, width, height, depth), and exposes id, source, width, height, depth, channel_count, data_type, normalized and color_space.

ShadeTexture.from(image) wraps one. Its fields are label, minFilter, magFilter, mipmapFilter, mipmapGenerationFilter, wrapS / wrapT / wrapR, dimensions and flags. The only flag is ShadeTextureFlags.GenerateMipMaps, and it is on by default.

EnumValues
TextureWrapTypeClampToEdge: 0, Repeat: 1, MirroredRepeat: 2
TextureFilterTypeNearest: 0, Linear: 1, Mitchell: 2, LinearNormal: 3, MagicKernelSharp: 4, CatmullRom: 5, Wronski2021: 6
ColorSpaceNone: 0, SRGB: 1, LinearSRGB: 2

LinearNormal is Linear with the result renormalised, which is what the glTF loader picks as the mip generation filter for normal maps; MagicKernelSharp is what it picks for colour maps.

ShadeImage.color_space defaults to LinearSRGB. The image’s colour space is what picks the GPU texture format, so an sRGB-encoded PNG used as albedo or emissive must say so - otherwise it uploads as rgba8unorm and is sampled without the decode, and the surface reads far too bright. load_gltf sets ColorSpace.SRGB on base-colour and emissive images for you; an image you build by hand it does not.

Compressed textures: KTX2 only. A glTF KHR_texture_basisu image - a KTX2 container carrying ETC1S (BasisLZ) or UASTC - is read by ktx2_read and transcoded on the CPU into a block format the device samples: BC7, BC1, ASTC or ETC2 when the adapter offers that family, RGBA8 otherwise. Pass texture_support: gpu_texture_compression_support(device) (src/shade/renderer/texture/format/gpu_texture_compression_support.js) to load_gltf so the target is what the device can hold; omitted, the loader targets the best format the transcoder writes, which assumes desktop-class hardware. GLTFSceneBundleAssetLoader and load_model_scene_bundle pass nothing, so a model placed through SGMesh gets that assumption. A block-compressed texture brings its own mip chain and gets no generated one. A texture’s image is chosen in the order KHR_texture_basisu, EXT_texture_webp, source, and only the chosen image is fetched - an image named through an extension the loader does not read is never requested.

Compressed geometry: meshopt. A bufferView compressed with EXT_meshopt_compression, or its draft-era name KHR_meshopt_compression, is decoded on load by the engine’s own decoder (src/core/binary/meshopt/, pure JavaScript): all three modes - attributes, triangles, indices - and the five filters - none, octahedral, quaternion, exponential, color - with the fallback placeholder buffer left unfetched. Nothing is registered for it; GLTF_BUFFER_VIEW_EXTENSIONS (src/format/scene/gltf/ext/) is the set every load honours, and TinyGltf#bufferViewExtensions is where a caller substitutes a different one. KHR_draco_mesh_compression is the one geometry extension the loader cannot decode: it re-encodes a primitive rather than a bufferView.

The sRGB trap

Color is deliberately space-agnostic: four floats, no space tag. The two halves of the package disagree by default, and this is where it bites.

  • Color.parse("#4ef0a8"), Color#toHex and Color#toCssRGBAString speak encoded sRGB, because that is what a hex literal and a CSS colour are.
  • Shade wants linear. diffuse_color and emissive_factor are written straight into the material’s GPU struct with no transfer function applied.

So this is wrong, quietly, and gives you a washed-out surface:

material.diffuse_color.copy(Color.parse("#4ef0a8"));           // wrong

and this is right:

import { Color } from "@woosh/meep-engine/src/core/color/Color.js";

material.diffuse_color.copy(Color.from_sRGB_to_linear(Color.parse("#4ef0a8")));

Color.from_linear_to_sRGB(input, output?) is the other direction, for putting an engine colour back into a CSS string. Both carry alpha across untouched. glTF’s own baseColorFactor is linear by specification and is copied in as-is, so a loaded model needs no conversion.

Loading a model

The Shade path

import { load_gltf } from "@woosh/meep-engine/src/shade/renderer/loader/gltf/load_gltf.js";

const bundle = await load_gltf("data/models/crate.glb", { assetManager });

scene.add(bundle.scenes);

load_gltf(url, { assetManager, scope, fileMap, texture_support }) reads .gltf and .glb and resolves to a SceneBundle - three arrays out of the one file:

FieldTypeFor
scenesNode3D[]the scene roots, ready for scene.add
skinsSkin[]GPUAnimationManager.register_skin
clipsShadeAnimationClip[]GPUAnimationManager.register_clip

assetManager is required, not optional: every sub-request the parse makes - the .bin buffers, the images - goes back through the manager, so they are cached, counted and cancellable like everything else the application loads. Pass a scope to tie a whole load to one cancellable unit, and a fileMap (Map<string, File|Blob|ArrayBuffer>) when the files came from a drop rather than the network.

A SceneBundle is the model as loaded, not a placement of it. Adding one bundle’s roots twice shares nodes, which is fine for static geometry and wrong for anything animated.

USD is the other model format. load_usd(buffer, { fileName }) (src/shade/renderer/loader/usd/load_usd.js) parses .usda and .usdz - .usdc is detected and not read - into scene roots; USDSceneBundleAssetLoader, registered under GameAssetType.ModelUSD, wraps them in a SceneBundle with empty skins and clips, because UsdSkel is not read. load_model_scene_bundle accepts either format, so with the loader registered an SGMesh may name a .usdz as readily as a .glb.

The ECS path: SGMesh and MeshSystem

Inside the engine, a model is placed with the SGMesh component and drawn by MeshSystem.

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

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

const engine = await EngineHarness.bootstrap({
    configuration: (config, engine) => {
        const gltf = new GLTFSceneBundleAssetLoader();

        config.addLoader(GameAssetType.ModelGLTF,      gltf);   // .glb
        config.addLoader(GameAssetType.ModelGLTF_JSON, gltf);   // .gltf
        config.addLoader(GameAssetType.ArrayBuffer,    new ArrayBufferLoader());
        config.addLoader(GameAssetType.ImageBitmap,    new ImageBitmapAssetLoader());

        const scene = EngineHarness.shadeScene(engine);

        config.addSystem(new MeshSystem(
            engine.graphics,
            scene,
            url => load_model_scene_bundle(engine.assetManager, url)
        ));

        // static models expand into entities carrying ShadedGeometry, so this is required too
        config.addSystem(new ShadedGeometrySystem(engine.graphics, scene));
    },
});

new Entity()
    .add(new Transform64())
    .add(SGMesh.fromURL("data/models/crate.glb"))
    .build(engine.entityManager.dataset);

Three things about that:

  • MeshSystem(graphics, scene, load) takes the loader as a function so it can be driven in a spec without a network or a device. load_model_scene_bundle(assetManager, url) is the stock implementation; it guesses the asset type from the extension so a preload and a placement hit the same cache entry. ImageBitmapAssetLoader is easy to forget - without it a model loads with no textures.
  • MeshSystem calls graphics.set_scene(scene) in startup. ShadedGeometrySystem does not, so when both are registered the scene wiring is already done.
  • A static model becomes entities. If the bundle has no skins and no clips, MeshSystem expands it through shade_bundle_to_entity_composition: every node of the file becomes an entity, parented with EntityNode, named, placed, and carrying a ShadedGeometry where it draws. Geometry and material are shared, not copied. That is why ShadedGeometrySystem has to be registered as well - it is what gives each of those primitives a row. A skinned or animated model keeps the instance path instead, because its joints are the renderer’s to pose.

SGMesh also exposes url, a materialOverride setter that walks the expanded tree and replaces every child’s material (once set it cannot be cleared - make a new SGMesh), getBoundingBox(aabb3), and node, the EntityNode root the system built. MeshSystem adds scene, traverse_meshes(entity, visitor), instance_of(entity), animation_of(entity) and compute_world_bounds(target, entity).

A second placement of the same model

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

const instance = instantiate_scene_bundle(bundle);

scene.add(instance.roots);

instantiate_scene_bundle(bundle) returns a SceneBundleInstance: its own roots, its own skins bound to that tree’s joints, its own clips retargeted onto it, and nodes, a Map<string, Node3D> from authored name to node for sockets and attach points (a duplicated name keeps the first node reached). Geometry, materials, inverse-bind matrices and the animation curves themselves are still shared - the copy is per node, not per byte. Every node a clip drives is set to TransformAuthority.GPU. Nothing in the source bundle is modified.

GPU authority controls a node’s local pose. MeshSystem registers an instance’s skins; clip playback is registered separately by the animation systems. A clip-targeted node keeps GPU authority even when no clip is playing, so later CPU edits to that node’s local pose are not uploaded. Parent motion still propagates through the GPU hierarchy every frame, including frames with no bound clips. Move the instance through its owning entity, and use the animation systems to drive its clip-targeted nodes.

This is what makes two of one animated model possible: the bundle’s skins name Node3Ds and its clip channels target Node3Ds, so a second entity added from the same bundle would drive the first one’s joints.

Geometry that changes every frame

A trail, a ribbon, a tube along a path, a debug line - content whose vertices are rewritten every frame cannot pay for a meshlet build, and rebuilding meshlets per frame to force it down this page’s path is explicitly not the answer. It goes down a separate one: a DynamicMesh holds a plain Geometry, lives in a DynamicMeshBatch of yours rather than in the Scene, gets no scene-database row, and is drawn by a GPUDynamicMeshRenderer inside a render extension. One fixed vertex layout, one pipeline, geometry.needsUpdate = true when you have rewritten the arrays. It is documented in full under Effects.

Where to go next

  • Rendering overview - the Shade pipeline, the GraphicsEngine facade and the six frame phases.
  • Lights & shadows - the Light component, LightSystem, and the shadow settings (there are no per-mesh shadow flags).
  • Picking - PickingSystem, the async batched ray query.
  • Virtual texturing - StandardShadeMaterial.vt_stack and streamed material pages.
  • Effects - decals, highlights and the dynamic-mesh path.