A pack is a shareable bundle of 3D assets (models, and optionally textures / audio / materials) that shows up in the Explorer's Packs section. Packs are local to your app — loading one never syncs it to peers; only an object you place from a pack replicates, like any import.
There are two kinds of pack:
- Default packs come from the pack repo's
index.json, fetched fromPACKS_BASE(src/lib/packs.js) — the jsDelivr CDN over theprototype-app/packs, pinned to a ref (@format-1— never a semver-looking name: jsDelivr resolves a version once and a retag of it is a no-op, core #230). If the CDN is unreachable, the app falls back to the MINIMAL starter bundled atstatic/library/libraryList.json(offline / fresh clones are never empty). - Remote / imported packs are self-describing repos or
.zipfiles using themanifest.jsonformat below — drag a.zipin with + Import pack.
Each row: {name, title, value | zip, attribution, copyright, license, source}.
value / attribution / zip may be repo-relative (resolved against
PACKS_BASE), app-origin (/library/..., bundled fallback) or absolute URLs.
source renders as a Source button in the pack Properties panel and an
"↗ Open source" row action (imported packs reuse manifest.homepage).
Upstream-fetch pattern: an item list may carry ABSOLUTE glTF-Binary /
screenshot URLs — the khronos pack ships only an index whose entries resolve to
raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets, so the model bytes
never live in our repo. Relative variants resolve against
<base>/<item>/glTF-Binary/<file> as before.
The default model packs use the model-list format (one item folder per glTF).
A default-list entry can instead point at a self-describing .zip with a
zip field — used for audio / SFX packs (or any mixed-kind pack), because the
.zip import path is kind-agnostic (kindOf stores audio as audio, textures as
texture, …). Such an entry shows an ⬇ Install pack action in the Explorer
Packs list (right-click the pack) that fetches the .zip and imports it locally:
Drop the .zip at static/library/starter-audio/pack.zip (a normal pack .zip:
manifest.json + assets/…mp3|ogg|wav). Prefer CC0 loops/one-shots (freesound
CC0, OpenGameArt CC0). Keep each file under the 5 MB share cap so it round-trips to
peers. Installed audio items appear in the Explorer library and can be assigned to
a Sound node (spatial) or the Scene music channel (global).
A model item in an item list (the default.json row beside variants) MAY list offline
LOD files:
{ "name": "WallStone", "label": "Wall — sandstone (2 × 3 m)",
"variants": { "glTF-Binary": "wall-stone.glb" },
"lods": [ { "file": "wall-stone.lod1.glb", "ratio": 0.5 },
{ "file": "wall-stone.lod2.glb", "ratio": 0.2 } ] }fileis relative to the item'sglTF-Binary/folder (no.., no absolute URLs);ratiois the level's triangle count as a share of LOD0 (informational — the app sorts levels finest first by it and shows it in the LOD panel).- Keep the node names and hierarchy of LOD0. The app matches each level mesh to the placed object's mesh BY NODE NAME (falling back to traverse order when both have the same mesh count) and draws the level's GEOMETRY with the object's OWN material — so a level file's materials and textures are not used at all (ship them shared or tiny), and an animated item keeps animating, because the mixer still drives the same nodes.
- Bake the same node transforms as LOD0 where you can; a level mesh placed differently is re-expressed in LOD0's frame on load, which costs a geometry copy per placement.
- Placing an item writes its group into the object (
userData.lod, refs as PACKS_BASE-relative paths), so it saves, replicates and undoes with the object. A piece placed BEFORE its pack gainedlodspicks them up from the pack row at runtime. - Levels are fetched LAZILY — only when an object is small enough on screen to need one —
and parsed once per file for every placement. Items without
lodskeep the automatic runtime LOD (meshoptimizer, for meshes of 3000+ triangles).
An item row in a default pack's item list (default.json) MAY carry a behavior: a
door you click open, a chest lid, a lever, a fan. The GLB holds the clips; the row says
how they are used:
{
"name": "WoodDoor", "label": "Wooden door", "screenshot": "screenshot/screenshot.webp",
"variants": { "glTF-Binary": "WoodDoor.glb" },
"behavior": {
"type": "door", // door | toggle | oneshot | loop
"clip": "open", // the clip a trigger plays (its time 0 is the REST pose)
"closeClip": "close", // optional (door/toggle): played to close; absent = `clip` backwards
"trigger": "click", // click | proximity | knock
"autoplay": false, // honoured ONLY for type "loop" (ambient: a fan, a flag, a flame)
"sound": "door", // optional: a game-sound name (door gate slide lever lid click …)
"collider": "follow" // optional; the default for a door
}
}The rule. Nothing plays on placement, on load, or in Edit, and nothing loops unless
it is a loop with autoplay: true. A peer in Edit always sees the rest pose. A trigger
works in Interact or Play: door/toggle alternate open ⇄ closed, oneshot plays once
per trigger, and a loop without autoplay starts and stops. The Animation panel previews
a clip in Edit, locally: nothing is sent or saved, and the item goes back to rest.
- Triggers.
clickis the desktop click, the Play tap, and the VR laser, trigger and poke.proximityopens when the player comes within 1.5 m and closes when they leave (a click still works).knockis a VR hand moving into the part at ≥ 0.6 m/s (on a desktop, a click). - Shared state. One
behaviormessage per trigger, stamped with the session clock. Every peer derives the pose from the stamp, so a door swings in step everywhere, and a late joiner receives the state with the door. The state is runtime only: a scene saved with a door open reopens shut. - Frame and moving parts. A door ALWAYS ships with its frame. The nodes the
behavior's clips animate are the MOVING parts (put the hinge pivot on the node's origin);
everything else is the static frame. With
collider: "follow"the frame becomes slab colliders with the opening cut out, and each moving part gets a box that follows it, so you can walk through an open door and are stopped by a shut one (while a simulation runs; that is when the walker collides at all). - Authoring the GLB. Use LINEAR TRS tracks on the moving nodes, with
clipat time 0 being the closed pose. A staticidleclip FIRST is harmless and keeps older app builds (which autoplayed the first clip) still.lodsfiles must keep the same node names, hierarchy and clips. The samebehaviorobject may also sit in the GLB's scene extras (scene.extras.behavior); the pack row wins over it. - The Explorer marks such items with a small ▶ "animated" badge that says what they do and what sets them off.
<pack>/
manifest.json # the pack + item metadata (schema below)
cover.webp (or .png) # pack cover image, ~512² ← .webp preferred, .png fallback
assets/
<item-id>/
model.glb # the model (or a .png/.jpg texture, .mp3/.ogg audio, …)
thumb.webp # ~512² preview ← .webp preferred, thumb.png fallback
item.json # OPTIONAL per-item license/author/source overrides
LICENSE # SPDX full license text (per-item if the pack is mixed)
CREDITS.md # human-readable attribution (authors, sources, links)
README.md
A separate small index (its own repo or a static index.json) can list many
packs so new ones appear without redeploying the app:
[{ "id": "khronos", "name": "Khronos Samples", "repo": "packs-khronos",
"tag": "v1.0.0", "cover": "cover.webp", "itemCount": 12, "sizeMB": 40,
"license": "CC-BY-4.0", "description": "…" }]{
"id": "khronos",
"name": "Khronos Samples",
"version": "1.0.0",
"description": "…",
"author": "Khronos Group",
"homepage": "https://github.com/KhronosGroup/glTF-Sample-Assets",
"license": "CC-BY-4.0", // SPDX id; a per-item value overrides it
"cover": "cover.webp", // resolver tries .webp then .png
"items": [
{
"id": "duck",
"kind": "object", // object | texture | audio | material
// (inferred from the file extension if omitted)
"file": "assets/duck/model.glb",
"thumb": "assets/duck/thumb.webp",
"name": "Duck",
"size": 123456,
"tris": 4212,
"license": "SPDX-or-inherit",
"author": "…",
"source": "https://…",
"tags": ["prop"]
}
]
}- Licenses are SPDX ids (
CC-BY-4.0,CC0-1.0,MIT,Apache-2.0, …). The app maps them to human labels in a pack's Properties → Attribution. A per-itemlicenseoverrides the pack license, so mixed-license packs (e.g. Khronos) are fine. - Thumbnails: ship a pre-rendered
thumb.webp(or.png) per item — it loads from the CDN before the model itself, so the grid fills in instantly. If neither is present the app renders one offscreen the first time the model loads. Keep them ~512² for crisp-but-small. - Screenshots vs thumbnails: default packs (libraryList) may instead ship a
screenshotpath per item (the app triesthumb.webp→thumb.png→screenshot). New packs should preferthumb.webp. - Generate thumbnails deterministically with a headless three.js script
(
scripts/gen-pack-thumbs.mjs, when added) so CI/pack authors reproduce them.
Aligns with the Khronos glTF-Sample-Assets model-index.json layout and the
Poly Haven / ambientCG per-asset-folder convention.