Adding 3D Models to Decentraland Scenes
Where models go: main-entities.ts
A 3D model placed at author time is a static visible entity. Declare it in main-entities.ts, not via engine.addEntity() in src/index.ts. The build compiles main-entities.ts into main.crdt, the engine preloads it before main() runs, and the editor can drag/rotate the model interactively.
// main-entities.ts
import type { Scene } from '@dcl/sdk/scene-types'
export const scene = {
my_model: {
components: {
Transform: { position: { x: 8, y: 0, z: 8 } },
GltfContainer: {
src: 'models/myModel.glb',
visibleMeshesCollisionMask: 3 // CL_PHYSICS | CL_POINTER
}
}
}
} satisfies Scene
Always set
visibleMeshesCollisionMaskonGltfContainer. Catalog models don't include separate collider meshes — using the visible mesh as the collider ensures the model is solid and clickable. Use the integer value (ColliderLayer.CL_PHYSICS = 1,CL_POINTER = 2, both =3) insidemain-entities.tssince enums aren't allowed in the literal.
When to use engine.addEntity() in src/index.ts instead: only when the model is spawned dynamically (procedurally placed in a loop, dropped on an event, gated by NFT ownership, etc.). For static props, always use main-entities.ts.
File Organization
Place model files in a models/ directory at the project root:
project/
├── models/
│ ├── building.glb
│ ├── tree.glb
│ └── furniture/
│ ├── chair.glb
│ └── table.glb
├── src/
│ └── index.ts
└── scene.json
Colliders
Using Model's Built-in Colliders
// main-entities.ts — declared inline with GltfContainer
building: {
components: {
Transform: { position: { x: 8, y: 0, z: 8 } },
GltfContainer: {
src: 'models/building.glb',
visibleMeshesCollisionMask: 3, // CL_PHYSICS | CL_POINTER
invisibleMeshesCollisionMask: 1 // CL_PHYSICS
}
}
}
Adding Simple Colliders
For basic shapes (no GLTF), add MeshCollider:
// main-entities.ts
invisible_wall: {
components: {
Transform: { position: { x: 0, y: 1, z: 8 }, scale: { x: 0.1, y: 2, z: 16 } },
MeshCollider: { mesh: { $case: 'box', box: { uvs: [] } } }
}
}
Other shapes: { $case: 'sphere', sphere: {} }, { $case: 'plane', plane: { uvs: [] } }, { $case: 'cylinder', cylinder: {} }.
⚠️ Important: Never Pass undefined in Transform Fields
The SDK serializer crashes if any Transform field (position, rotation, scale) is present but undefined. Omit the key entirely instead — both in main-entities.ts literals and in any runtime helpers. In main-entities.ts this is natural (you just don't write the field).
Common Model Operations
Scaling
// main-entities.ts
big_statue: {
components: {
Transform: { position: { x: 8, y: 0, z: 8 }, scale: { x: 2, y: 2, z: 2 } },
GltfContainer: { src: 'models/statue.glb' }
}
}
Rotation (Euler angles converted to a quaternion at author time)
Quaternions are { x, y, z, w }. For Quaternion.fromEulerDegrees(0, 90, 0) the equivalent literal is { x: 0, y: 0.7071, z: 0, w: 0.7071 }. If you need exact-degree rotations and don't want to compute by hand, set rotation to identity in main-entities.ts and rotate at runtime in src/index.ts using Transform.getMutable(entity).rotation = Quaternion.fromEulerDegrees(0, 90, 0).
Parenting
Reference the parent by name (a string key from the same scene object). The build resolves names to entity IDs in a second pass.
// main-entities.ts
character: {
components: {
Transform: { position: { x: 8, y: 0, z: 8 } },
GltfContainer: { src: 'models/character.glb' }
}
},
hat: {
components: {
Transform: {
position: { x: 0, y: 2, z: 0 }, // 2m above parent's origin
parent: 'character'
},
GltfContainer: { src: 'models/hat.glb' }
}
}
Free 3D Models — OpenDCL Catalog (5,700+ models)
The catalog file is at {baseDir}/references/model-catalog.md. Each line has this format:
slug | dims | tris | size | category/sub | description [tags] [anim: clips] | curl command | preview: thumbnail_url
How to search
Search with one keyword at a time — try the most specific word first:
grep -i "zombie" {baseDir}/references/model-catalog.md
If no results, try synonyms, broader terms, or related words:
- "sofa" → "couch" → "seat" → "furniture"
- "car" → "vehicle" → "truck" → "van"
- "wall" → "fence" → "barrier" → "structure"
Browse all categories to discover what's available:
grep "^##" {baseDir}/references/model-catalog.md
Search within a specific category:
grep "^##\|chair" {baseDir}/references/model-catalog.md
How to use models
- Search the catalog with different keywords until you find matching models
- Review the results — check dimensions, triangle count, animations, and description
- Download the model with the curl command into
models/ - Reference in code with
GltfContainer.create(entity, { src: 'models/{slug}.glb' }) - If the model has animations (listed in
[anim: ...]), use theAnimatorcomponent to play them - After placing the model, you can fetch its preview thumbnail (
preview:URL) to see what it looks like
Example workflow
# Search for zombie models
grep -i "zombie" {baseDir}/references/model-catalog.md
# Found: zombie-purple | 2.8×2.9×0.5m | 1472 tri | 271KB | character/zombie | ...
# [anim: Tpose, ZombieAttack, ZombieUP, ZombieWalk]
# preview: https://models.dclregenesislabs.xyz/blobs/bafkrei...
# Download the model
curl -o models/zombie-purple.glb "https://models.dclregenesislabs.xyz/blobs/bafybeiffc..."
// main-entities.ts — declare the entity with all its initial state
import type { Scene } from '@dcl/sdk/scene-types'
export const scene = {
zombie: {
components: {
Transform: { position: { x: 8, y: 0, z: 8 } },
GltfContainer: { src: 'models/zombie-purple.glb' },
Animator: {
states: [
{ clip: 'ZombieWalk', playing: true, loop: true },
{ clip: 'ZombieAttack', playing: false, loop: false }
]
}
}
}
} satisfies Scene
To switch animations at runtime (e.g., trigger attack on click), use src/index.ts:
import { engine, Animator } from '@dcl/sdk/ecs'
export function main() {
const zombie = engine.getEntityOrNullByName('zombie')
if (zombie) Animator.playSingleAnimation(zombie, 'ZombieAttack')
}
Important:
GltfContaineronly works with local files. Never use external URLs for the modelsrcfield. Always download models intomodels/first. Nevercdinto the models directory. Always run curl from the project root withcurl -o models/slug.glb "URL". Do NOT usecd models && curl -o slug.glb.
Checking Model Load State
Load-state polling is runtime behavior — put it in src/index.ts and reference the entity by name:
import { engine, GltfContainerLoadingState, LoadingState } from '@dcl/sdk/ecs'
export function main() {
engine.addSystem(() => {
const model = engine.getEntityOrNullByName('zombie')
if (!model) return
const state = GltfContainerLoadingState.getOrNull(model)
if (state?.currentState === LoadingState.FINISHED) {
console.log('Model loaded successfully')
} else if (state?.currentState === LoadingState.FINISHED_WITH_ERROR) {
console.log('Model failed to load')
}
})
}
Troubleshooting
| Problem | Cause | Solution |
|---------|-------|----------|
| Model not visible | Wrong file path | Verify the file exists at the exact path relative to project root (e.g., models/myModel.glb) |
| Model not visible | Position outside scene boundaries | Check Transform position is within 0-16 per parcel. Center of 1-parcel scene is (8, 0, 8) |
| Model not visible | Scale is 0 or very small | Check Transform.scale — default is (1,1,1). Try larger values if model was exported very small |
| Model not visible | Behind the camera | Move the avatar or rotate to look in the model's direction |
| Model loads but looks wrong | Y-up vs Z-up mismatch | Decentraland uses Y-up. Re-export from Blender with "Y Up" checked |
| "FINISHED_WITH_ERROR" load state | Corrupted or unsupported .glb | Re-export the model. Use .glb (binary GLTF) format. Ensure no unsupported extensions |
| Clicking model does nothing | Missing collider | Add visibleMeshesCollisionMask: ColliderLayer.CL_POINTER to GltfContainer or add MeshCollider |
Need to optimize models for scene limits? See the optimize-scene skill for triangle budgets and LOD patterns. Need animations from your model? See the animations-tweens skill for playing GLTF animation clips with Animator.
Model Best Practices
- Keep models under 50MB per file for good loading times
- Use
.glbformat (binary GLTF) — smaller than.gltf - Optimize triangle count: aim for under 1,500 triangles per model for small props
- Use texture atlases when possible to reduce draw calls
- Models with embedded animations can be played with the
Animatorcomponent - Test model orientation — Decentraland uses Y-up coordinate system
- Materials in models should use PBR (physically-based rendering) for best results
Scan to join WeChat group