Dicey Table

Importing and Optimizing Models

The two-pane asset explorer: the Project pane open on its Models folder, showing the Source sub-folder and a project model, beside the read-only Library pane open on Presets › Models, showing built-in model tiles such as the poker chips with baked thumbnails

What the importer accepts#

Everything the importer produces converges on one self-contained binary .glb — that is the only format every later stage (bake/repair, the Model editor's inspection and optimize operations, the runtime loader, and the mod's GitHub repo, which stores one file per model) actually reads (apps/web/src/ui/editor/modelConvert.ts). Three kinds of upload get you there:

  1. A binary .glb — uploaded as-is. No conversion step.

  2. A .gltf JSON document plus its external .bin/texture resources, selected together — packed client-side into one .glb.

  3. A DCC/interchange model, converted in the browser through an assimp WebAssembly build before it ever reaches the normal upload path. The supported set, each verified end-to-end against a real sample (apps/web/src/ui/editor/modelImportFormats.ts):

    Extension Format
    .fbx Autodesk FBX
    .obj Wavefront OBJ (select its .mtl and textures alongside it)
    .dae COLLADA
    .stl Stereolithography
    .ply Stanford Polygon
    .3ds 3D Studio
    .3mf 3D Manufacturing Format
    .off Object File Format
    .lwo LightWave Object
    .x DirectX .x

    Select the model and any companion files (a .mtl library, referenced textures) together in one drop or one file-picker selection — a companion referenced through a subdirectory (mtllib sub/x.mtl) won't resolve, so flatten the folder first. A texture the model references but that isn't in your selection is substituted with a white placeholder rather than failing the whole import, and is named in the conversion result so you know what to re-import.

Only one convertible model may be selected at a time — the router refuses a batch with more than one (pickModelImportRoute), because companion resolution is by filename and two models sharing one selection would fight over the same textures.

Draco-compressed models#

A .glb that declares KHR_draco_mesh_compression is accepted and renders correctly on the table — the runtime has its own Draco decoder, initialized at startup (apps/web/src/playcanvas/loadDraco.ts; wired in TabletopRuntime.ts). What Draco costs you is the editor's own tooling: gltf-transform (the library the Model editor's Errors & stats panel and every Optimize operation are built on) needs a draco3d decoder that isn't a dependency here, so a Draco model always inspects as empty stats with a warning, and Optimize is unavailable (apps/web/src/ui/editor/modelInspect.ts). Decompress it outside the editor (for example npx @gltf-transform/cli copy model.glb model-out.glb) and re-import the result to get stats and Optimize back — the model itself was already fine on the table the whole time.

Reading the Errors & stats panel#

Opening a project model in its own tab (double-click it in the asset explorer) shows triangle and vertex counts, mesh/primitive/material/texture counts, and per-texture dimensions and byte size, plus two budgets that turn into warnings rather than hard errors:

  • 150,000 triangles (MODEL_TRIANGLE_BUDGET) — above this, "Simplify to N%" is the fix, but note that the ratio is a target the quality setting can override, and that a model made of many small parts may not decimate at all. See Item editors: Model.
  • 2048px on either side of an embedded texture (MODEL_TEXTURE_SIZE_LIMIT_PX) — "Re-encode textures" with a max size of 2048 shrinks it back down.

Missing vertex normals and a normal map with no tangents are also flagged, each pointing at a one-click repair ("Repair normals" / "Generate tangents"). None of this blocks upload — a model with every warning still spawns and plays; the panel exists so you notice before your peers download an 80MB deck box.

Optimizing without losing the original#

Every Optimize action (Clean up, Simplify, Re-encode textures to WebP, Repair normals, Generate tangents) stages a result — it shows a before/after comparison and waits for Apply or Discard, and never touches your file until you apply it. Before the very first Apply, the editor snapshots your untouched bytes to originals/<stem>.glb in the project tree, which is what Revert to original restores from later — including after an embedded-texture replace, which goes through the same path. See Item editors: Model for the full Apply/Discard/Revert mechanics.

Splitting a scene, and putting it back together#

A model that contains a whole authored scene — a building, a room, a kitbash — can be split into parts (the tile's split button, or right-click → Split into parts…). Each top-level part becomes its own model, and the original is kept. For models/castle.glb the split writes:

models/castle/castle-tower.glb          one model per checked part
models/castle/castle-gate.glb
models/castle/castle.assembly.json      the parts, put back together

The parts appear in a castle sub-folder of Models, beside the assembly — listed as castle (assembly). An assembly is placed exactly like a model: drag it onto the table, or pick it as room scenery. It renders every part at the position it had in the source file, so the split model still reads as one thing.

Place the assembly in the dialog decides what happens straight away:

  • As environment scenery (the default) — one environment scenery placement at the origin, which reproduces the source exactly.
  • On the table — one table entity, like placing any model.
  • Don't place it — just write the files.

The position of each part is not a guess. The editor measures each part's world bounds in the source file and records its X/Z centre and its lowest point — the exact inverse of how the runtime rests an imported model (X/Z centre to the origin, minimum Y to the base). Because it is derived from that rule rather than tuned to match it, the two cannot drift apart. A part with no renderable geometry starts at the assembly origin, and the status line says so.

Each part is written on its own origin. A part's geometry is recentred so the piece sits at the bottom centre of its own file — X/Z centred, resting on Y = 0 — rather than inheriting the coordinates it happened to have in the source scene. Without this a chess king extracted from a board would carry the board's origin: opened on its own, the model would render a scene-width away from its origin marker, its bounds box and its collider. Recentring does not affect reassembly, because the placement above is measured on the source file before any of it happens. It is also the point the runtime rests a model on, so each part is written already at rest, with originAuthored set to say so.

If you have older split parts with the problem, open each one and use Recenter origin in the Model editor's ORIGIN panel.

Splitting again never overwrites anything: parts that already exist get a numbered name, and if castle.assembly.json exists the new split writes castle-2.assembly.json beside it, so an assembly you have adjusted is never replaced. The parts keep the source model's materials and inherit its credit in the Credits tab.

Adjusting an assembly#

Double-click an assembly to open the Assembly tab. Each part has:

  • Model — replace the part's model with another project model; its position, rotation and scale are kept.
  • Position, Rotation (°) and Scale — typed, or nudged with the x−/x+ buttons (0.01 ft, 1°, 0.01; hold Shift for ten times that). Rotation and scale pivot about the part's base centre, so resizing a tower does not swing it across the scene. Uniform scale keeps the three axes equal.
  • Reset puts one part back to its saved values; Remove takes it out of the assembly (its model file stays in the project).

Add part puts another project model into the assembly at the origin. Save checks the assembly first and shows any problem on the part it concerns — an assembly cannot contain another assembly, and scale must be above zero. Wherever the assembly is placed, it reloads after you save. Save Draft and Publish also save an open Assembly tab with unsaved changes.

Two reasons to split a scene rather than import it whole:

  • Publishing. One enormous GLB can exceed the size a single upload allows. Split parts upload independently.
  • Control. Parts can be hidden, replaced, re-materialled or removed individually, and only the parts you check are extracted at all.

The option only appears when the parts have measurable positions, and a part with no renderable geometry is left for you to place by hand rather than dropped at a wrong guess.

Giving a model a collider#

A model arrives with no authored collider, and the runtime fits a box to its measured bounds. That is right for most pieces and visibly wrong for some — an arch a piece should drop through, a bowl, an L-shaped tile. Authoring one is the Model tab's COLLISION panel, and the choice comes down to three routes:

The model is Use Why
Roughly a box, ball, disc or capsule A primitive entry, Fit to bounds then adjust Cheapest to simulate, and exact numbers you can read.
Convex, and its silhouette matters Convex hull An accurate shell, and valid for a moving piece.
Concave, and it never moves on its own Triangle mesh with a static body type The only shape that keeps a hole a hole.
Concave, and it does move Several primitive entries — up to eight, each with its own offset, rotation and scale A triangle mesh cannot be dynamic; a hull fills the concavity in.

Baking and decimating a collider#

A mesh-derived collider takes its geometry from the render mesh live while its Collider model slot is empty, and from whatever .glb you put in that slot otherwise — a bake fills it with <stem>.collider.glb written beside the model, or you can pick or drag in any other project .glb, including one you baked for a different model.

Bake when the render mesh is too heavy to collide against. The collider budget is 2,000 triangles — a completely separate, far smaller budget from the 150,000-triangle render budget above — because the hull builder feeds every vertex of the source into the shape with no reduction at all, so a 40,000-vertex model becomes a 40,000-point hull that costs real time on every contact. Errors & stats carries a Collider tris row, and over budget the panel offers a one-click Simplify to fit that decimates and bakes in one step.

Three things to know:

  • Baking never touches the render model. It writes a separate sibling file, so your GLB stays byte-identical and Revert to original keeps working. This is the opposite of Optimize, which rewrites the model in place.
  • Emptying the Collider model slot unpoints the entry but does not delete the file. Delete the orphaned .collider.glb yourself, or it publishes to GitHub with everything else.
  • Baking is a project-model activity. A built-in preset's bytes ship with the app, so an admin can change a preset's collider settings in production but cannot bake one there.

Full control-by-control detail is on Item editors: Model.

Common mistakes#

  • Expecting FBX/OBJ/STL to fail. They don't — they convert automatically through the in-browser assimp pipeline described above. What does fail cleanly is a format assimp can read but that isn't on the verified list (an untested extension silently producing an empty mesh was judged worse than refusing it) — re-export as .glb or one of the listed formats.
  • Assuming a Draco warning means the model is broken. It renders fine on the table; only the editor's own inspect/optimize tooling can't see inside it until you decompress it.
  • Selecting only the model file for an OBJ/FBX with external textures. Select the .mtl and every referenced image alongside the model, or they come through as white placeholders.
  • Ignoring the triangle/texture warnings because upload succeeded. Nothing blocks you, but every peer in a multiplayer room downloads and renders this model — a budget warning today is a slow table for everyone tomorrow.
  • Assuming the render triangle budget is the collider budget. They are different numbers by two orders of magnitude — 150,000 for what you see, 2,000 for what you collide with — and a model comfortably inside the first can be badly over the second.
  • Choosing Triangle mesh on a piece that moves. It is silently downgraded to a convex hull, which fills in the very concavity you picked it for. The panel warns; read the warning.

See also#