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, by Lengyel’s per-triangle accumulation followed by Gram-Schmidt orthogonalisation. 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 the ready-to-render primitives are built: MeshletGeometry subclasses whose constructor runs a generator below and then the build, one class per file in src/shade/renderer/geometry/ - BoxGeometry, PlaneGeometry, CapsuleGeometry, ConeGeometry, CylinderGeometry, TorusGeometry and UVSphereGeometry, each taking its generator’s arguments. BoxGeometry.js also re-exports PlaneGeometry as a deprecated alias; import it from PlaneGeometry.js.

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

Ten, all in src/shade/renderer/geometry/primitives/, all named exports, all returning a Geometry that still needs a meshlet build. The sphere, capsule, cylinder and cone run their axis along Y; the plane lies in XY facing +Z, and the torus lies in XY with its hole along Z.

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_uv_sphere_geometry(radius = 1, widthSegments = 32, heightSegments = 16, phiStart = 0, phiLength = Math.PI * 2, thetaStart = 0, thetaLength = Math.PI)
make_capsule_geometry(radius = 1, height = 1, capSegments = 4, radialSegments = 8, heightSegments = 1)
make_cylinder_geometry(radiusTop = 1, radiusBottom = 1, height = 1, radialSegments = 8, heightSegments = 1, openEnded = false, thetaStart = 0, thetaLength = Math.PI * 2)
make_cone_geometry(radius = 1, height = 1, radialSegments = 8, heightSegments = 1, openEnded = false, thetaStart = 0, thetaLength = Math.PI * 2)
make_torus_geometry(radius = 1, tube = 0.4, radialSegments = 8, tubularSegments = 6, arc = Math.PI * 2)
make_torus_knot_geometry(radius = 1, tube = 0.4, tubularSegments = 64, radialSegments = 8, p = 2, q = 3)
make_polyhedron_geometry(vertices, indices, radius = 1, detail = 0)
make_octahedron_geometry(radius = 1, detail = 0)
  • make_uv_sphere_geometry is rings of latitude and meridians of longitude with equirectangular texture coordinates. The two angular ranges cut a partial sphere: phi turns about the axis and theta runs down from the north pole, so a thetaLength of Math.PI / 2 is a dome.
  • make_capsule_geometry describes the same shape as a CapsuleShape3D with the same two numbers: height is the straight section, and the whole body spans height + 2 * radius. A collider and its mesh therefore agree.
  • make_cone_geometry is make_cylinder_geometry with a top radius of zero, apex up. Each segment gets its own apex vertex, so the apex normals do not lean toward one edge.
  • make_octahedron_geometry is the other sphere: a subdivided octahedron, which spreads its triangles over the surface without the crowding a UV sphere has at its poles. make_polyhedron_geometry is the general form it 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.
import { make_uv_sphere_geometry }
    from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_uv_sphere_geometry.js";
import { make_octahedron_geometry }
    from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_octahedron_geometry.js";

const globe = make_uv_sphere_geometry(1, 48, 24);   // radius 1, 48 meridians, 24 rings
const ball  = make_octahedron_geometry(1, 3);       // radius 1, three subdivisions

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
materialShadeMaterialusually StandardShadeMaterial; terrain tiles use TerrainShadeMaterial with its own material template
depth_materialShadeMaterial|nullcarried and compared, read by nothing in 3.57.0
modeDrawModecarried, read by nothing in 3.57.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, by projecting the meshlet geometry’s own box through the entity’s world transform on the CPU. It deliberately does not read back the box the GPU derives: it answers where the primitive is under what the simulation knows, which is what a pick or a framing wants, and the GPU’s answer is a frame or two behind.
  • 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). One of its flags is live and the rest are not (3.57.0). Skinned is the live one: prefab_instantiate sets it, prefab_place and ShadedGeometrySystem read it, and MeshSkinSystem asserts it on every primitive carrying a MeshSkin. The only other flag any code branches on is DeferredBoundsUpdate, and the path it opens is broken rather than useful - see below. Of the remaining five, four reach ShadedGeometry.js only as its default flag set (CastShadow, ReceiveShadow, Visible) and its equality mask (those three plus DrawMethodLocked), and nothing branches on them; InView appears nowhere but its own definition.

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
Skinned64live. Set by prefab_instantiate on a primitive a skin deforms; read by ShadedGeometrySystem, which gives such a primitive its own per-instance vertex clone for its MeshSkin to deform. Set it yourself on a skinned mesh built in code

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 primitive’s row out of the scene. A meshes row has no visible field at all - DynamicMesh, which is no part of the scene database, is the only drawable that does.
  • Do not set DeferredBoundsUpdate. It 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, so #bounds_queue is always null. updateTransform takes the deferred branch on the flag and dereferences that null, so the first transform change after setting it throws TypeError: Cannot read properties of null (reading 'add') instead of updating bounds.

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 attaches the entity a meshes row naming the component’s geometry and material (over a per-instance vertex clone where the component carries the Skinned flag), and subscribes to three announcements on that entity:

AnnouncementWhat it does
TRANSFORM64_EVENT_CHANGEmarks the entity’s world row, so the GPU re-reads where it is
TRANSFORM_ATTACHMENT_EVENT_CHANGEmarks its local offset, which is what its row holds when its parent has one
SHADED_GEOMETRY_EVENT_CHANGErewrites the row from the component - a material swapped, most often. Announce it with shaded_geometry_announce_change(dataset, entity); a ShadedGeometry carries no signals of its own

Nothing is polled. 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 transparency mode, draw mode and sidedness. Front and Back share a one-sided pipeline; Double uses a separate bucket. 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.

Front draws the surface’s front, Back draws its back, and Double draws both. The vertex shader reverses triangle winding for Back, so both one-sided modes use back-face culling; Double disables culling. A mirrored world transform also reverses winding, and the two reversals cancel when combined. This lets instances with negative scale share geometry and materials without changing which side is visible. Normals account for the material’s requested side and the transform’s handedness. glTF’s doubleSided sets ShadeDrawSide.Double on load.

Shadow casters reverse the cull policy, drawing the opposite side of a one-sided surface to reduce self-shadow acne. Double remains unculled in the shadow pass.

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";
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_gltf("data/models/crate.glb", { assetManager });

prefab_place(
    renderer.scenes.obtain(scene),   // the scene's GPU context - its rows are where the model goes
    scene.dataset,                   // everything in a scene stands at an entity of this
    prefab_compile(bundle),          // the model, compiled once
    new Transform64(),               // where the root goes
);

load_gltf(url, { assetManager, scope, fileMap, texture_support }) reads .gltf and .glb and resolves to a SceneBundle - the one file’s contents as flat arrays:

FieldTypeFor
nodesSceneNode[]the file’s nodes in pre-order - name, parent index, local TRS, and a geometry + material where the node draws
skinsSkin[]joints and meshes as indices into nodes
clipsShadeAnimationClip[]each channel’s target an index into nodes
lights{light, node}[]punctual lights, anchored by node index

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 - records, with no transform state anyone could pose. prefab_compile(bundle) turns it into a Prefab once, and every placement is spawned from that with its own entities, which is what makes two of one model - animated or not - possible.

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 - and resolves to a SceneBundle with empty skins and clips, because UsdSkel is not read; USDSceneBundleAssetLoader registers it under GameAssetType.ModelUSD. 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 { MeshSkinSystem }       from "@woosh/meep-engine/src/engine/graphics3/MeshSkinSystem.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)
        ));

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

        // and a skinned primitive carries a MeshSkin, which this deforms
        config.addSystem(new MeshSkinSystem(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 model becomes entities. MeshSystem compiles each url into a Prefab once and spawns one instance per placement: every node of the file becomes an entity under the one carrying SGMesh, named, parented by ParentEntity and posed by TransformAttachment, carrying a ShadedGeometry where it draws. Geometry, materials, inverse-bind matrices and animation curves are shared by every placement, not copied. That is why ShadedGeometrySystem has to be registered as well - it is what gives each of those primitives its meshes row. There is one path here rather than two: a skinned primitive also carries a MeshSkin, which MeshSkinSystem registers and binds, and the model’s clips are an AnimationClips on the entity carrying SGMesh, which the animation systems play. MeshSystem itself puts nothing on the GPU - every one of those components is read by its own system exactly as if code had built it. See Skeletons & skinning.

SGMesh also exposes url, getBoundingBox(aabb3) and getUntransformedBoundingBox(aabb3), and the castShadow / receiveShadow flags. MeshSystem adds scene, instance_of(entity), mesh_entities_of(entity) and compute_world_bounds(target, entity).

A second placement of the same model

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

const prefab = prefab_compile(bundle);          // once per model

const instance = prefab_instantiate(prefab, dataset, root);   // once per placement

prefab_compile(bundle) reads the bundle and never writes it, producing a Prefab: a node count, a parent index per node, names, rest poses, composed rest worlds, primitives, and the skins and clips re-addressed to node indices. Geometry, materials, inverse-bind matrices and curves are referenced, not copied, which is what makes a prefab cheap to spawn.

prefab_instantiate(prefab, dataset, root) spawns one under an entity you already have, and answers a PrefabInstance:

MemberMeaning
entitiesInt32Array - the entity of each node, by node index. entities[0] is root
mesh_entitiesInt32Array - the entities that draw, one per primitive
entity_of(name)the entity of the node the asset calls name, or -1 - this is where a socket, an effect anchor or a projectile spawn is looked up

A skinned primitive’s entity carries a MeshSkin with its joints as entities of this instance, and the root carries an AnimationClips with every channel targeting an entity of this instance - see Skeletons & skinning.

prefab_place is the same thing for a scene with no systems: it makes the root entity, spawns the prefab under it, acquires a row for every entity, attaches a meshes row per primitive and registers each skinned primitive’s skin - what MeshSystem, ShadedGeometrySystem and MeshSkinSystem do between them in a game. It returns { root, instance, skins, drawn }, where skins is one { entity, skin_id } per registered primitive, or null when nothing was registered (register_animation: false). It registers no clips; whatever plays them does.

The GPU is authoritative for a driven joint’s local offset. An entity whose TransformAttachment a clip writes carries a GPUStateAuthority with the TransformAttachment bit granted (gpu_authority_declare / gpu_authority_grant, engine/ecs/gpu/), because the pose accumulator resolves that offset on the GPU and a CPU that also wrote it would race the animation. The bit stays granted whether or not a clip is currently playing, so a CPU edit to such an offset is not uploaded. Move the instance through its root entity, and drive its joints through the animation systems.

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.