streaming-gltf
v1.0.19
Published
Streaming progressive glTF LOD renderer (BatchedMesh/InstancedMesh tiers, network-lazy GPU-eager LOD streaming, on-GPU position lerping) plus the local bake/convert + streaming-download pipeline (tools/bake-*.mjs).
Readme
Progressive glTF LOD renderer
A self-contained three.js renderer for large scenes of distinct glTF/GLB models with progressive LOD streaming, plus the local pipeline that converts source models into the progressive format it consumes.
Live demo
https://anentrypoint.github.io/streaming-gltf/ — the stress demo, deployed
from examples/local-progressive/ by .github/workflows/deploy-pages.yml. It
ships code only: three loads from a CDN (importmap) and the baked models are
streamed cross-origin from the assets host
(https://anentrypoint.github.io/assets/, derived from its
manifest.baked.json). Override the asset source with ?assets=<baseUrl>, or
use ?assets=local with the dev server (npm run demo:local).
SDK usage
streaming-gltf is an importable ES module. three and @pixiv/three-vrm are
peer dependencies — provide them yourself (e.g. via an importmap pointing at
a CDN build, or your bundler); they are not bundled.
import { ModelPool } from 'streaming-gltf';
// or: import { BatchedFarTier } from 'streaming-gltf/batched-far-tier';
const pool = new ModelPool({ scene, renderer, camera });
const entity = pool.spawn(url, { position: [x, 0, z] });
// per frame, after advancing the camera:
pool.update();
// sparse position targets — the GPU interpolates each frame (far tier),
// so moving entities cost ~no per-frame CPU matrix writes:
pool.setTarget(entity, x, y, z, durationMs);VRM support
VRM avatars load through @pixiv/three-vrm v3 (a peer dependency). When a baked
GLB carries the VRMC_vrm extension, the GLTFLoader is registered with
VRMLoaderPlugin and the parsed gltf.userData.vrm runtime is driven each frame
by pool.update() — humanoid bones, spring bones, expressions, and look-at all
animate. The HUD in the example reports the detected humanoid bone count.
What works:
- Full humanoid / spring-bone / expression / look-at runtime on a VRM avatar.
- Progressive mesh + texture LOD on the avatar's primitives, exactly as for any
other model. Sibling LOD chunks are loaded without the VRM plugin
(
includeVrm: false), so MToon material setup runs once on the root only. - MToon materials are LOD-swapped safely: texture-LOD application matches strictly by texture name against the material's existing slots, so it never stamps a foreign bitmap into an MToon slot.
Multi-driver — every instance animates independently. @pixiv/three-vrm v3
exposes no skeleton-rebind clone (VRM.prototype is [constructor, update] only;
there is no vrm.clone() / VRMUtils.clone), and its humanoid/spring-bone/
expression managers bind the bones of the scene they were parsed against. Rather
than share one runtime, the pool retains the asset's raw GLB bytes and re-parses
an independent VRM per driven entity — each gets its own scene, its own humanoid/
spring-bone/expression managers, and is driven independently by vrm.update(dt).
N pooled instances of one VRM all animate (distinct spring physics, distinct
expressions); none freeze in bind pose. Per-instance parses are concurrency-bounded
(heavy work) and queue when the limit is reached. This mirrors how a multiplayer
host parses one VRM per player.
entity.dispose() runs VRMUtils.deepDispose() on that entity's own VRM scene,
freeing its spring/collider/expression GPU resources without touching siblings.
pool.dispose() tears the pool down (every entity, then every asset).
Geometry decoding (Draco + meshopt)
Baked GLBs are compressed with both meshopt and Draco
(KHR_draco_mesh_compression). meshopt decodes via three's
MeshoptDecoder. Draco decodes via draco.js,
mrdoob's pure-JavaScript port of the Draco decoder, vendored at
examples/local-progressive/draco-loader.js as a drop-in for three's own
DRACOLoader.
This replaces the WASM Draco decoder. The win is deployment simplicity: no
.wasm fetch and no runtime CDN — the previous setup pulled the decoder from
https://www.gstatic.com/draco/versioned/decoders/... on first decode, a
third-party runtime dependency with cross-origin/CSP exposure. draco.js is one
~24 KB-gzipped ES module that ships with the page. It is decode-only (the
bake-time encoder in tools/bake-progressive.mjs still uses the draco3dgltf
WASM module, which is a dev-time Node dependency, not shipped to the browser),
and implements the EdgeBreaker triangle-mesh path that glTF/Draco content uses.
WASM is faster in absolute terms (~1.4-1.6x on large meshes) but the decode is
byte-for-byte equivalent.
The LOD web worker (lod-worker.js) loads the same vendored module, rewriting
its bare three import to the esm.sh URL the worker already uses, so
Draco+meshopt sibling LODs decode off-thread too. Decoder logic is a port of
Google Draco (Apache-2.0); the loader API mirrors three.js's DRACOLoader (MIT).
Textures (single GPU-compressed KTX2)
Each texture is baked to one mipmapped KTX2 (Basis Universal) file
(KHR_texture_basisu) -- no per-size webp ladder. Color maps use ETC1S
(sRGB, smallest), normal/ORM maps use UASTC (linear; ETC1S wrecks normals).
At runtime a KTX2Loader (transcoder vendored at
examples/local-progressive/basis/, served locally) transcodes it to a
GPU-compressed texture for the device (BC7/BCn on desktop, ASTC/ETC2 on
mobile) -- witnessed isCompressedTexture with a 10-level mip chain. The GPU mip
chain handles distance LOD, so texture streaming is eliminated: progressive
streaming is now geometry/vertex-only. Far entities render vertex-colored
geometry (baked to match the texture); the single KTX2 arrives with the root GLB
for the near/textured view -- vertices first, then texture.
Downscaled to BAKE_TEX_SIZE (default 1024; set e.g. 512 to compress harder)
before encoding. KHR_texture_basisu is a standard Khronos extension, so any
loader with a KTX2 transcoder (three, Babylon, model-viewer) opens it; it is
declared required (KTX2 carries no fallback image), but the untextured
geometry+vertex-color far path covers loaders without it. Encoded with
ktx2-encoder (Basis); the transcoder is three.js's vendored Binomial Basis
wasm (Apache-2.0).
Layout
examples/local-progressive/— the renderer (latest). Entry:stress.html→stress.js→model-pool.js(+draw-call-batching.js,batched-far-tier.js,material-pool.js,deferred-load-queue.js,lod-unload-manager.js,frustum-cache.js,multi-draw-optimizer.js/multi-draw-utils.js,vertex-compression.js,draw-call-sorter.js,buffer-pool.js,lod-worker.js,octahedral-impostor.js/octahedral-impostor-tier.js).serve.mjsis the dev server;measure-fps.mjsthe steady-state FPS harness;impostor-smoke.mjs/impostor-pool-smoke.mjsare the impostor test harnesses.tools/— the conversion + download pipeline:bake-progressive.mjs— convert one source GLB into a progressive GLB (meshopt decimation + sharp texture resizing + aEP_progressive_lodextension referencing sibling LOD files).bake-all.mjs— batch-bake every model under a source dir.bake-streaming.mjs— download + bake for the streaming workflow.
models/— source models fed to the bake tools.
Usage
Install deps once:
npm installConvert source models into the progressive format the renderer loads
(examples/local-progressive/output_<name>/):
npm run bake:local -- models/<model>.glb examples/local-progressive/output_<name>
# or batch every model under a directory:
npm run bake:all -- modelsRun the renderer (serves the stress demo at /):
npm run demo:local
# open http://127.0.0.1:5180/Measure steady-state FPS (hardware GPU via system Chrome):
CHANNEL=chrome npm run measure -- 500glTF extension: EP_progressive_lod
The bake pipeline emits a glTF extension, EP_progressive_lod, that
declares the progressive LOD ladder (per-mesh and per-texture) the renderer
consumes. The base glTF always carries the coarsest LOD, and the extension is
declared in extensionsUsed — never extensionsRequired — so a viewer that
does not implement it simply renders that coarse base and ignores the rest. Two
storage modes are covered by one extension, discriminated by a storage field:
sibling-file (higher LODs in sibling files, bake-progressive.mjs) and
single-glb-range (all LODs packed as bufferView byte ranges in one GLB,
range-fetched on demand, bake-streaming.mjs).
Progressive (".plod-style") load on a regular glTF
single-glb-range is baked coarse-first: the base LOD (coarsest geometry +
smallest textures) is packed immediately after the JSON chunk, and the default
primitives/images reference it. So the one model.streaming.glb is a valid
glTF any loader opens (it renders the coarse base; EP_progressive_lod is
extensionsUsed, never required), AND a byte-prefix is the renderable base — a
client can HTTP-Range-fetch the prefix to show coarse, then range-fetch finer
LODs as they arrive, the progressive behaviour of a custom .plod stream but
on a standard file. examples/local-progressive/plod-gltf-stream.js is the
range consumer (loadStreamingHeader -> streamMeshLods coarse->fine); the dev
server (serve.mjs) supports HTTP Range. Witnessed: the coarse base builds from
~58 KB of a 362 KB file (6 range requests) without downloading the whole model.
Size note: single-glb-range packs LOD attributes uncompressed (so each
bufferView is a clean range to fetch), which is larger than the draco/meshopt
sibling-file format. When download size matters more than single-file
range-streaming, prefer sibling-file (the default), which also loads
coarse-first and progressively. single-glb-range could adopt per-bufferView
EXT_meshopt_compression to remove that size delta.
- Spec + JSON Schema:
extensions/EP_progressive_lod/ - Conformance check:
node tools/validate-extension.mjs <model.glb>
Registration status: the EP vendor prefix is not yet registered with
the Khronos glTF extension registry; the name is provisional until a
registration PR to KhronosGroup/glTF
lands. Assets baked before the rename (payload under extras.LOCAL_progressive)
are still read by the runtime via a compatibility fallback.
Octahedral impostors (final LOD)
Opt-in (ModelPool option useImpostorFinalLod, or the demo's ?impostor=1).
Past the vertex-color BatchedMesh far tier, a model below impostorPx (default
14) screen pixels collapses to a single camera-facing billboard sampling a
per-asset octahedral atlas — the model captured from a grid of directions and
octahedral-encoded. Every distinct asset's atlas is a layer of one WebGL2
sampler2DArray, so the entire far population draws in one InstancedMesh
draw call (octahedral-impostor-tier.js).
The atlas is rendered on the fly in-browser the first time an asset reaches
impostor distance — no bake step, no extra download. To avoid frame stalls, the
bake is incremental: impostorCellBudget octahedral cells (default 4) are
rendered per frame and the atlas accumulates over a few frames; the array-texture
is eager-allocated at pool construction so its VRAM allocation is off the swap
path. The budget is headroom-gated (like the LOD warm loader) — doubled when
FPS sits above target so coverage converges faster, halved under target so baking
never deepens a frame deficit. ?impostorBlend=1 enables an alpha-weighted
bilinear cross-fade of the nearest captured views to soften cell-to-cell popping
as the camera orbits.
Each cell carries a small transparent gutter (impostorPadding, default 1.05:
the model is captured at radius × padding) so a LinearFilter tap near a
billboard edge lands on alpha-0 texels instead of bleeding the neighbouring view —
without it, packed-edge-to-edge cells produce faint cross-view ghosting.
Lit impostor variant (?impostorEz=1)
An alternative impostor tier (octahedral-impostor-ez-tier.js) renders lit
impostors: the on-the-fly atlas is a 2-target render (albedo + packed
normal/depth), so the billboard receives scene lighting (a real
MeshStandardMaterial) and blends the three nearest captured views with
per-sprite plane-projected UVs. It bakes a 1024 atlas (handles ~1M-triangle
source models) incrementally, capped by impostorMaxAssets. Opt in with
?impostorEz=1 (or useImpostorEz); the default tier above stays the
single-draw performant path. The impostor sampling/baking code is localized from
@three.ez/octahedron-imposter
(octahedral-impostor-ez.js).
Notes
- The renderer is draw-call-bound at scale; the FAR tier collapses many distinct
models into a single
THREE.BatchedMeshdraw and the FPS controller adjusts LOD distance (not a global ceiling) to hold the target frame rate. - Baked
output_*/assets are git-ignored (regenerate with the bake tools).
Credits
- @three.ez/octahedron-imposter
by Andrea Gargaro (MIT) — the lit octahedral impostor (atlas capture + sampling
shaders) is localized into
examples/local-progressive/octahedral-impostor-ez.js(TS ported to JS, GLSL inlined, full-octahedron encode/decode completed). The related@three.ez/batched-mesh-extensionswas evaluated but not vendored: its draw/cull wins are non-binding here (the far tier already draws in one call). - draco.js by mrdoob (Apache-2.0) —
vendored pure-JS Draco decoder (
draco-loader.js).
