--- name: optimize-scene description: Optimize Decentraland scene performance. Scene limit formulas, object pooling, LOD patterns, texture optimization, system throttling, and asset preloading. Use when the user wants to optimize performance, fix lag, reduce load time, check limits, or reduce entity/triangle count. Do NOT use for deployment (see deploy-scene). --- # Optimizing Decentraland Scenes ## Scene Limits (Per Parcel Count) All limits below scale with parcel count `n`. Triangles, entities, and bodies scale linearly. Materials and textures scale logarithmically. The height limit does **not** scale — it is a flat 330 m for every scene (see below). | Resource | Formula | 1 parcel | 2 parcels | 3 parcels | 4 parcels | 6 parcels | 9 parcels | 16 parcels | 20 parcels | | ------------------ | --------------- | -------- | --------- | --------- | --------- | --------- | --------- | ---------- | ---------- | | **Triangles** | n x 10,000 | 10,000 | 20,000 | 30,000 | 40,000 | 60,000 | 90,000 | 160,000 | 200,000 | | **Entities** | n x 200 | 200 | 400 | 600 | 800 | 1,200 | 1,800 | 3,200 | 4,000 | | **Physics bodies** | n x 300 | 300 | 600 | 900 | 1,200 | 1,800 | 2,700 | 4,800 | 6,000 | | **Materials** | log2(n+1) x 20 | 20 | 31 | 40 | 46 | 56 | 66 | 81 | 87 | | **Textures** | log2(n+1) x 10 | 10 | 15 | 20 | 23 | 28 | 33 | 40 | 43 | **Height limit: 330 m for every scene, regardless of parcel count.** Entities whose world position exceeds it are hidden. ⚠️ Anything above ~200 m may suffer multiplayer sync issues — keep gameplay-relevant and networked content below that height. **Read the Materials row with care.** The client instantiates a material per rendered object, so this number tracks how many objects a scene renders rather than how many distinct materials it authors — a scene that correctly reuses one model many times will pass this cap while doing the right thing. Treat it as a memory signal, and judge frame-time risk by `shaderVariants` instead (see *Repeated Models* below). **File limits:** 15 MB per parcel, 300 MB max total, 200 files per parcel, 50 MB max per individual file. File limits count only what is actually uploaded on deploy. Make sure `.dclignore` (at the project root) excludes all working files — Blender/FBX sources, draft models, concept art, spreadsheets, markdown docs — since these are often the bulk of a project's size and are never needed at runtime. See the `.dclignore` section in the **deploy-scene** skill. Important: Except for the MB size limits, all other limits can be exceeded. It's generally not recommended to go over them because of performance impact, but if a user tests their scene and determines that it's good enough, it should be ok to publish. ## Mobile Scene Limits A **separate**, flat set of limits applies when the scene runs on the mobile client — they do **not** scale with parcel count. A **soft** limit warns in the mobile performance panel; a **hard** limit blocks the scene from loading. Verified against docs commit `8139a7f` (Oct 2026). | Metric | Soft | Hard | | --- | --- | --- | | Triangles | 1,000,000 | 1,200,000 | | Entities | 4,800 | 6,000 | | Meshes (bodies) | 2,400 | 3,000 | | Geometries | 1,000 | 2,000 | | Materials | 400 | 500 | | Textures | 400 | 500 | | Colliders | 1,200 | 1,500 | | **VFX emitters** | **8** | **10** | | **Live particles** | **25,000** | **50,000** | | **Light sources** | **8** | **16** | | **Shadow lights** | **2** | **4** | | Content size | 120 MB | 150 MB | | External content | 40 MB | 50 MB | | Memory (process RSS) | 1,638 MB | 2,048 MB | | Draw calls | 1,000 | 2,000 | | Performance (higher is better) | 90% | 85% | **Lights and particles count what is *authored*, not what the engine runs.** - Each mobile **graphics profile** activates only a budget of the **nearest** light sources — **0 on Very Low up to 8 on High** — and may disable shadows entirely. A scene that authors 20 lights still pays the full scene cost even though at most 8 ever emit. Author to the 8/16 limit; do not rely on the runtime budget to make an over-lit scene cheap. See **lighting-environment**. - Particles are additionally **clamped at runtime**: **5,000 per emitter, 50,000 per scene**. Anything authored above those caps is silently reduced, so exceeding them only wastes scene budget with no visual gain. See **particle-system**. Measure without a device: Creator Hub > Preview > **Show QR Code for Mobile**, then open the **Scene Limits Preview** (monitor-with-red-stats icon, top right) on the phone. Target **Performance > 90% on the High profile** on a mid-spec device. ## Entity Count Optimization ### Reuse Entities Use this pattern only for cases where the scene should be spawning and removing instances dynamically. ```typescript // BAD: Creating new entity each time function spawnBullet() { const bullet = engine.addEntity() // Creates entity every call // ... } // GOOD: Object pooling const bulletPool: Entity[] = [] function getBullet(): Entity { const existing = bulletPool.find((e) => !ActiveBullet.has(e)) if (existing) return existing const newBullet = engine.addEntity() bulletPool.push(newBullet) return newBullet } ``` ### Remove Unused Entities ```typescript const removed = engine.removeEntity(entity) // returns boolean // true: components purged, id released for reuse // false: entity is renderer-reserved (avatar range) — components untouched ``` ### Use Parenting Instead of independent transform values for each child, use entity hierarchy: ```typescript const parent = engine.addEntity() Transform.create(parent, { position: Vector3.create(8, 0, 8) }) // Children inherit parent transform const child1 = engine.addEntity() Transform.create(child1, { position: Vector3.create(0, 1, 0), parent }) const child2 = engine.addEntity() Transform.create(child2, { position: Vector3.create(1, 1, 0), parent }) ``` ## Repeated Models (Reuse vs Merge) For repeated content — lamp posts, chairs, trees, fences — **default to one entity per copy, all pointing at the same `.glb`.** The engine dedups by source: one download, one asset-bundle conversion, one copy of the meshes and textures in memory, no matter how many entities use it. The dedup is global for the session, so a neighboring scene using the same file gets it for free. ```typescript // GOOD: one file, many entities — engine shares the loaded asset for (const position of lampPostPositions) { const lampPost = engine.addEntity() Transform.create(lampPost, { position }) GltfContainer.create(lampPost, { src: 'assets/scene/lampPost.glb' }) } // BAD: near-identical files — 20 downloads, 20 copies in memory // lampPost_01.glb, lampPost_02.glb, ... lampPost_20.glb ``` **What reuse does NOT save: draw calls.** 20 lamp posts are 20 renderers either way — whether from 20 entities or from one `.glb` with 20 lamp posts modeled into it. There is no runtime batching or GPU instancing for scene content: the client streams and builds scenes at runtime, so it cannot group repeated objects the way an engine can with pre-baked content. Draw count is fixed at author time. | Approach | Downloads | Meshes + textures | Renderers | Materials | Culling | Movable/clickable | | --- | --- | --- | --- | --- | --- | --- | | N entities, 1 shared `.glb` | 1 | 1 copy | N | N × slots | Per object | Yes | | 1 `.glb`, N separate objects inside | 1 | 1 copy | N | N × slots | Per object | No | | 1 `.glb`, meshes joined in Blender | 1 | Geometry duplicated | 1 | 1 × slots | All-or-nothing | No | Only the third row reduces draw calls, and it costs file size, memory, and per-object culling. **Recommend it only when: many small props, always co-visible in one spot, never interactive, AND a measurement showed renderer count is the bottleneck.** Otherwise recommend reuse. Middle ground for scattered props: **one `.glb` per cluster** (a street block, a room's furniture) rather than one-per-prop or one-for-the-whole-scene. Cuts renderer count while keeping clusters small enough for culling to help. Three more consequences worth knowing: - **Reuse does NOT reduce material count either.** The client instantiates a material per renderer to write the scene's boundary clipping, so `materials` in the stats tracks rendered objects, not distinct models — measured: 14 entities on one shared `.glb` report 28 renderers and 28 materials, while `geometries` and `textures` stay at one copy. Those instances share the same textures and shader variants, so this is a small memory cost, not a frame-time one. **Never recommend material dedup as a frame-time fix without checking `shaderVariants` first.** - **Spawning many copies at once?** Preload with `AssetLoad` first (see below) so copies come from a resident asset instead of each awaiting the same download. It does not make the copies free: building them is ~90% of a burst's cost even when the asset is already resident, so a large burst can still cost a frame. - **One huge model is worse than many small ones for load smoothness.** Asset creation is spread across frames under a frame-time budget, but a single enormous model cannot be split — it lands in one frame and is far more likely to cause a visible hiccup. See the [gltf-reuse-vs-merge benchmark scene](https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/94,-10-gltf-reuse-vs-merge) for a concrete measurement: it spawns the same lamp post model via reuse (one `.glb`, many entities), via duplicate files, and via a merged `.glb`, comparing renderer/material counts, memory, and load behavior. It also demonstrates `AssetLoad` preloading and collision-mask tuning for burst spawning. ## Triangle Count Optimization ### Use Lower-Poly Models - Small props: 100-500 triangles - Medium objects: 500-1,500 triangles - Large buildings: 1,500-5,000 triangles - Hero pieces: Up to 10,000 triangles ### Use LOD (Level of Detail) Show simpler models at distance: ```typescript engine.addSystem(() => { // Check distance to player and swap models const playerPos = Transform.get(engine.PlayerEntity).position const objPos = Transform.get(myEntity).position const distance = Vector3.distance(playerPos, objPos) const gltf = GltfContainer.getMutable(myEntity) if (distance > 30) { gltf.src = 'models/building_lod2.glb' // Low poly } else if (distance > 15) { gltf.src = 'models/building_lod1.glb' // Medium poly } else { gltf.src = 'models/building_lod0.glb' // High poly } }) ``` ### Use Primitives Instead of Models For simple shapes, `MeshRenderer` is lighter than loading a .glb: ```typescript MeshRenderer.setBox(entity) // Very cheap MeshRenderer.setSphere(entity) // Cheap MeshRenderer.setPlane(entity) // Very cheap ``` ## Fix Heavy Models in Blender When the stats point at specific models — a prop with tens of thousands of triangles, a GLB carrying 2048+ textures, meshes full of faces the player never sees — fix the asset itself by driving Blender rather than compensating in SDK code. The full guide (headless CLI vs Blender MCP, setup, export rules) is in `{baseDir}/../add-3d-models/references/blender-authoring.md`, with ready-made `bpy` scripts in `{baseDir}/../add-3d-models/references/blender-patterns.md`. Typical rescue edits: - **Decimate modifier** to cut triangle count on imported or over-detailed models (a rescue tool for existing assets — new models should be modeled low-poly from the start). - **Resize textures** to power-of-two, 1024×1024 or less, and repack them into the GLB. - **Delete never-visible faces** (undersides, occluded backs) and enable back-face culling instead of doubling geometry. - **Merge per-color materials** into a single palette-texture material, and per-part textures into one atlas. - **Strip lights, cameras, and materials on `_collider` meshes** — dead weight the engine ignores or pays for twice. Audit before and after with the triangle-count and material-audit scripts in the patterns file, export back to the same path (the preview hot-reloads the file), and re-check the stats panel. For a quick non-Blender pass, `gltf-transform` (below) can compress and strip a GLB without touching its geometry choices. ## Texture Optimization - **Dimensions must be power-of-two**: 256, 512, 1024 - **Maximum is 1024x1024.** The asset-bundle-converter enforces `DESKTOP_MAX_TEXTURE_SIZE = 1024` (`AssetBundleConverter.cs` → `ReduceTextureSizeIfNeeded`): anything larger (e.g. 2048) is **downscaled to 1024 at conversion**, so authoring above 1024 wastes source size without visual benefit. - **Recommended sizes**: 512x512 for most objects, 1024x1024 for hero pieces - Use `.png` for UI/sprites with transparency - Use `.jpg` for photos and textures without transparency - Prefer compressed formats (WebP) over raw PNG where possible - Use texture atlases (combine multiple textures into one image) to reduce draw calls and material count - Share texture references across materials — do not duplicate texture files - Reuse materials across entities: ```typescript // GOOD: Define material once, apply to many Material.setPbrMaterial(entity1, { texture: Material.Texture.Common({ src: 'images/wall.jpg' }), }) Material.setPbrMaterial(entity2, { texture: Material.Texture.Common({ src: 'images/wall.jpg' }), }) // Same texture URL = shared in memory ``` ### Texture Size Guide by Use Case | Use Case | Recommended | Maximum | | ----------------------------- | ----------- | --------- | | Scene objects (walls, floors) | 1024x1024 | 1024x1024 | | Props and furniture | 512x512 | 1024x1024 | | UI elements / icons | 256x256 | 512x512 | | Skybox / environment maps | 1024x1024 | 1024x1024 | Textures do not need to be square — 512x1024 is valid as long as both dimensions are powers of two. ## Back-Face Culling Back-face culling skips rendering the inside face of any polygon the player will never see from behind. It's set in your 3D modeling tool (Blender, Maya, etc.) — **not** in SDK code. **Rule of thumb:** Enable back-face culling on all materials by default. Only disable it when a surface must be visible from both sides (e.g., a leaf plane on a tree, a thin wall). ## System Optimization ### Avoid Per-Frame Allocations ```typescript // BAD: Creates new Vector3 every frame engine.addSystem(() => { const target = Vector3.create(8, 1, 8) // Allocation! }) // GOOD: Reuse constants const TARGET = Vector3.create(8, 1, 8) engine.addSystem(() => { // Use TARGET }) ``` ### Throttle Expensive Operations ```typescript let lastCheck = 0 engine.addSystem((dt) => { lastCheck += dt if (lastCheck < 0.5) return // Only run every 0.5 seconds lastCheck = 0 // Expensive operation here }) ``` ### Remove Systems When Not Needed ```typescript const systemFn = (dt: number) => { /* ... */ } engine.addSystem(systemFn) // When no longer needed: engine.removeSystem(systemFn) ``` ## Asset Preloading (AssetLoad Component) Use `AssetLoad` to pre-load assets into memory ahead of time so they display instantly when needed. `PBAssetLoad` has a single field: `assets: string[]` — the list of asset paths to load. Commonly created on `engine.RootEntity`, but any entity works. ```typescript import { engine, AssetLoad, assetLoadLoadingStateSystem, LoadingState, } from '@dcl/sdk/ecs' // Queue assets to pre-load (any entity works — commonly a dedicated cube/root). // AssetLoad.getOrCreateMutable lets you also PUSH more paths later: // AssetLoad.getOrCreateMutable(entity, { assets: [...] }).assets.push(morePath) AssetLoad.create(entity, { assets: ['models/big.glb', 'sounds/win.mp3'] }) // React to loading state. The callback fires PER ASSET (once per path in the // list), receiving { asset, currentState } — NOT one batch-level event. assetLoadLoadingStateSystem.registerAssetLoadLoadingStateEntity( entity, (state: { asset: string; currentState: LoadingState }) => { if (state.currentState === LoadingState.FINISHED) { // state.asset finished loading and is now cached } }, ) // Stop listening: assetLoadLoadingStateSystem.removeAssetLoadLoadingStateEntity(entity) ``` `LoadingState` enum members: `LOADING`, `FINISHED`, `FINISHED_WITH_ERROR` (asset found but failed to load), `NOT_FOUND` (path does not exist), `UNKNOWN` (initial/default state). A missing/typo'd `src` resolves to `NOT_FOUND`, not a thrown error. Caveats: - The state callback is **per-asset**, keyed by the `asset` path string — dispatch on `state.asset` to update the right entity. There is no single "all finished" event; track completion yourself by counting per-asset `FINISHED`/error states. - `AssetLoad` only **adds** assets to memory. Removing a path from the `assets` list does **not** free memory — there is no unload via `AssetLoad`. - Preloading a path does **not** create/render anything — you still `getOrCreateMutable` the real component (`GltfContainer`, `AudioSource`, `VideoPlayer`, `Material` texture) on an entity to use it; the preload just makes that later use instant. - If an asset is used immediately at scene startup, there is **no need** for `AssetLoad`. Only pre-load assets NOT required at startup — things that appear later or on player interaction. ## Local Asset Bundle Preview Reproduce the server-side asset bundle conversion locally before publishing. This catches conversion issues (missing textures, broken models after compression) and makes the preview render with production-quality optimized models. - **Creator Hub:** **Play Options** (the dropdown arrow on the **Play** button) > hover **Desktop Client** > check **Compress Assets**. The toggle is hidden on Linux and for scenes whose `@dcl/sdk-commands` is too old for the flag. - **CLI:** `npm run start -- --asset-bundles` (forwarded into the Explorer deep link as `local-ab=true`; `--local-ab` is the deep-link param, not a CLI flag) The Desktop Explorer converts all `.gltf`/`.glb` models to asset bundles on your machine. The first run may take several minutes on large scenes; converted models are cached, so subsequent previews only reconvert new or modified assets. If an asset fails to convert, the preview falls back to the raw model. Only available with the Desktop Client (not Bevy Web). This is the local equivalent of the server-side conversion that runs after every publish (see the **deploy-scene** skill). Use it routinely before publishing, especially before live events. ## Loading Time Optimization - Use CDN URLs for large shared assets when possible ### Loading Areas for Large Scenes For scenes with many 3D models (e.g. a furnished multi-room building), avoid rendering everything at once. Use trigger areas to load and unload content as the player moves through the scene: ```typescript import { engine, Transform, GltfContainer, TriggerArea, triggerAreaEventsSystem, ColliderLayer } from '@dcl/sdk/ecs' import { Vector3 } from '@dcl/sdk/math' // Keep furniture hidden initially let furnitureLoaded = false // When player enters the building, spawn interior furniture const trigger = engine.addEntity() Transform.create(trigger, { position: Vector3.create(8, 1, 8), scale: Vector3.create(3, 3, 3) }) TriggerArea.setBox(trigger, ColliderLayer.CL_PLAYER) triggerAreaEventsSystem.onTriggerEnter(trigger, () => { if (!furnitureLoaded) loadInterior() furnitureLoaded = true }) triggerAreaEventsSystem.onTriggerExit(trigger, () => { if (furnitureLoaded) unloadInterior() furnitureLoaded = false }) ``` This pattern keeps the initial triangle and entity counts low and loads detail only when needed. ## Common Performance Pitfalls | Pitfall | Symptom | Fix | | ------------------------------------ | -------------------------------- | -------------------------------------------------------- | | Too many unique materials | Memory + texture budget, lost batching | Atlas textures, reuse models; check `shaderVariants` before blaming frame time | | Non-power-of-two textures | Memory bloat, visual artifacts | Resize all textures to 256/512/1024 (1024 max) | | Creating/destroying entities rapidly | Frame stutters | Use entity pooling | | Heavy computation every frame | Consistent low FPS | Add timer guards, reduce frequency | | Unused colliders on decorations | Physics body limit exceeded | Remove MeshCollider from non-interactive objects | | Large uncompressed textures | Slow loading, file size exceeded | Use WebP, reduce resolution, use atlases | | Working files uploaded on deploy | "Scene too large" deploy error | Add Blender/FBX sources, concept art, docs to `.dclignore` (see **deploy-scene**) | | Too many transparent materials | Extra draw calls, sorting issues | Minimize transparency, use alpha cutoff instead of blend | | Adding entities/components in a system without guards | Entity count explodes | Systems run every frame — always check before creating | | Unbounded entity queries | CPU spike | Filter with specific components, cache results | | All detail loaded at all distances | Triangle budget blown | Implement LOD system | | Near-identical `.glb` per copy (`chair_01.glb`, `chair_02.glb`...) | Slow load, memory bloat, texture limit blown | One shared `.glb` referenced by many entities — the engine dedups by source | | One monolithic `.glb` for the whole scene | Load hiccup on appearance, nothing culls | Split into per-cluster models (a street block, a room) | | No asset preloading | Pop-in during gameplay | Use AssetLoad to preload assets needed later (not startup assets) | ## Scene Statistics Monitoring ### In Preview Mode When running the scene locally with `npm run start`: - Press **P** to toggle the performance panel. - Monitor: FPS, draw calls, triangles, entities, materials, textures, memory. - Scene limits are shown alongside current usage with green/yellow/red indicators. ### What to Watch - **FPS below 30**: Something is too expensive. Check draw calls and system execution time. - **Triangle count approaching limit**: Enable LOD, reduce model detail, remove hidden faces. - **Entity count climbing**: Likely a leak — entities being created but never destroyed. Implement pooling. - **Draw calls above 300 (1 parcel)**: Too many material slots being rendered. Atlas and reduce transparency to cut slots per object, and reduce the number of rendered objects. Note that draw count tracks *rendered objects × their material slots* — reusing one model across many entities does not reduce it (see **Repeated Models** above), and neither does packing many separate objects into a single `.glb`. ## Recommended Optimization Tools | Tool | Purpose | | --------------------------- | --------------------------------------------------------- | | Blender Decimate modifier | Reduce triangle count on imported models (drive it headless or via MCP — see *Fix Heavy Models in Blender*) | | Blender Limited Dissolve | Remove unnecessary vertices from flat surfaces | | Squoosh (squoosh.app) | Convert images to WebP, resize to power-of-two | | TexturePacker | Create texture atlases from multiple images | | gltf-transform CLI | Compress GLB files with Draco, strip unused data | | glTF Validator | Check for export errors before importing into DCL | | Creator Hub Scene Inspector | Visual tool for entity counts, triangle counts, placement | | Preview Debug Panel (P key) | Live performance metrics during `npm run start` | ```bash # Optimize a GLB with Draco compression npx @gltf-transform/cli optimize input.glb output.glb --compress draco ``` ⚠️ **Draco does not decode on mobile, and the failure is silent and preview-only-visible.** The mobile app ships **no runtime Draco decoder**. A Draco-compressed `.gltf`/`.glb` loads on a phone **only from the scene's optimized assets**, where the asset server has already decompressed it. When a scene has no optimized assets — **local preview, Creator Hub mobile preview, QR preview** — the model **fails to load with no error at all**: no console message, no placeholder, just an absent mesh (docs commit `5f212e3`, Oct 2026; an error dialog is tracked in godot-explorer #2432). The trap is the asymmetry: a published scene can look fine on a phone while the same model is invisible in every mobile preview — and a creator debugging that preview has nothing to go on. **Export without Draco** if the scene targets mobile; run `optimize` with `--compress false` for a cross-platform build, and cut triangles/textures instead. [UNVERIFIED: whether the other `gltf-transform` compression modes (`meshopt`, `quantize`) load on mobile — the docs only call out Draco.] ## Example scenes Engine-team stress-test scenes (treat as ground truth for API shape): - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/0,2-cube-wave-32x32 — ~961 primitive cubes in ONE parcel (far over the 200-entity soft limit), all `Transform.position.y` mutated every frame via a single `engine.getEntitiesWith(MeshRenderer)` query. Demonstrates that soft limits can be exceeded and that a per-frame query over hundreds of entities is the intended pattern (no pooling needed for a fixed static set — cubes are created once in `main()`, not per frame). - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/73,-2-dbmonster — UI stress test: dozens of nested `