optimize-scene
Skill4 starsChanged 9 days agoApache-2.0View on GitHub
Optimize Decentraland scene performance and fit scene limits — per-parcel and mobile limit tables, draw calls and materials, reuse vs merge of models, object pooling, LOD, textures, system throttling, asset preloading. Use when the user wants to fix lag or load time, check or reduce triangle/entity/material counts, or fit a scene to mobile.
In this repository37 other files it gives its agents.
- add-3d-modelsSkill
- add-interactivitySkill
- advanced-inputSkill
- advanced-renderingSkill
- animations-tweensSkill
- audio-analysisSkill
- audio-videoSkill
- authoritative-serverSkill
- avatar-animationsSkill
- build-uiSkill
- camera-controlSkill
- compositesSkill
- create-sceneSkill
- creator-hub-mcpSkill
- dcl-gddSkill
- dcl-prototypeSkill
- deploy-sceneSkill
- deploy-worldsSkill
- fontsSkill
- lighting-environmentSkill
- migrate-sdk6-to-sdk7Skill
- multiplayer-syncSkill
- nft-blockchainSkill
- npcsSkill
- particle-systemSkill
- player-avatarSkill
- player-physicsSkill
- promote-sceneSkill
- report-sdk-issueSkill
- scene-feedbackSkill
- scene-retroSkill
- scene-runtimeSkill
- script-componentsSkill
- sdk-scenesSkill
- smart-wearablesSkill
- unity-explorer-mcpSkill
- wearablesSkill
The file
We split this skill into the rules it gives an agent
10 rules. About 0 are common, 6 are distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈6
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 12
All limits below scale with parcel count n.
- Distinctive: line 66
Use this pattern only for cases where the scene should be spawning and removing instances dynamically.
- Distinctive: line 96
Instead of independent transform values for each child, use entity hierarchy:
- Distinctive: line 134
Only the third row reduces draw calls, and it costs file size, memory, and per-object culling.
- Very specific: line 205
Use .png for UI/sprites with transparency
- Very specific: line 206
Use .jpg for photos and textures without transparency
- Distinctive: line 207
Prefer compressed formats (WebP) over raw PNG where possible
- Distinctive: line 208
Use texture atlases (combine multiple textures into one image) to cut material slots per object and texture count
- Distinctive: line 286
Use CDN URLs for large shared assets when possible
- Very specific: line 335
When running the scene locally with npm run start:
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Optimizing Decentraland Scenes
For a deployed authoritative multiplayer scene's server performance, use the authoritative-server skill, which must also be installed, and read its references/scene-insights.md: inspect execution-scoped CPU, heap and workload metrics before changing code. This skill's rendering budgets do not diagnose server CPU or memory. For client FPS/rendering, continue below and use Explorer performance diagnostics.
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 as a memory signal, not a count of authored materials (see Repeated Models).
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.
| 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 Play Options > 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.
// 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
const removed = engine.removeEntity(entity) // returns boolean
// true: components purged, id released for reuse
// false: entity is renderer-reserved (avatar range) — components untouchedUse Parenting
Instead of independent transform values for each child, use entity hierarchy:
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.
// 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/Models/lampPost.glb' })
}
// BAD: near-identical files — 20 downloads, 20 copies in memory
// lampPost_01.glb, lampPost_02.glb, ... lampPost_20.glbWhat 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. Scene content gets no static batching, dynamic batching or GPU instancing: the client streams and builds scenes at runtime, so it cannot group repeated objects the way an engine can with pre-baked content. (The SRP Batcher is on; it makes each draw's setup cheaper and groups draws by shader variant, but does not merge them.) 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
materialsin the stats tracks rendered objects, not distinct models — measured: 14 entities on one shared.glbreport 28 renderers and 28 materials, whilegeometriesandtexturesstay 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 checkingshaderVariantsfirst. - Spawning many copies at once? Preload with
AssetLoadfirst (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 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:
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 = 'assets/Models/building_lod2.glb' // Low poly
} else if (distance > 15) {
gltf.src = 'assets/Models/building_lod1.glb' // Medium poly
} else {
gltf.src = 'assets/Models/building_lod0.glb' // High poly
}
})Use Primitives Instead of Models
For simple shapes, MeshRenderer is lighter than loading a .glb:
MeshRenderer.setBox(entity) // Very cheap
MeshRenderer.setSphere(entity) // Cheap
MeshRenderer.setPlane(entity) // Very cheapFix 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
_collidermeshes — 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 conversion downscales anything larger (e.g. 2048) to 1024, so authoring above 1024 wastes source size without visual benefit.
- Recommended sizes: 512x512 for most objects, 1024x1024 for hero pieces
- Save at 72 DPI: a higher DPI adds file weight with no visual gain
- Use
.pngfor UI/sprites with transparency - Use
.jpgfor photos and textures without transparency - Prefer compressed formats (WebP) over raw PNG where possible
- Use texture atlases (combine multiple textures into one image) to cut material slots per object and texture count
- Same texture URL = one texture in memory. Point every material at one shared file (
Material.Texture.Common({ src: 'assets/Images/wall.jpg' })) instead of duplicating texture files. It saves memory, not draw calls or the material count (see Repeated Models).
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
// 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
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
const systemFn = (dt: number) => {
/* ... */
}
engine.addSystem(systemFn)
// When no longer needed:
engine.removeSystem(systemFn)Asset Preloading (AssetLoad Component)
Preload assets needed later, not at startup (things that appear on interaction or in a spawn burst) with AssetLoad, so they display instantly. Assets used at startup need no preload. Keep ≤40 paths per AssetLoad entity: past about 50 assets on one entity the loading-state callback silently stops firing. API, the per-asset loading-state callback, batching recipe, LoadingState values and caveats (no unload, preload renders nothing): {baseDir}/references/asset-preloading.md.
Local Asset Bundle Preview
The Desktop Explorer converts every local scene's .gltf/.glb models and .ttf fonts to asset bundles on your machine, on every npm run start or Creator Hub Play, with no flag. This is gated by a remote feature flag (alfa-lsd-forced-ab, on at the time of writing); with the flag off or unreachable the session loads raw GLTFs and the built-in font, silently. That reproduces the server-side conversion before publishing: it catches conversion issues (missing textures, broken models after compression) and makes the preview render with production-quality optimized models. Desktop Client only (not Bevy Web).
- The Explorer starts an embedded converter and holds scene loading until the whole scene is converted. The first run on a heavy scene takes minutes; results are cached, so later previews reconvert only new or changed files. Progress and per-file verdicts are in the AB panel of the Explorer's debug menu.
- A bundle that fails to convert or load falls back to that model's raw GLTF, with a warning in the Explorer log (
asset bundle unavailable (…), loading the raw GLTF instead). If the converter cannot start or its manifest cannot be fetched, the whole session degrades to raw GLTFs and the built-in font. - Two Explorers on the same scene share one converter. A converter left on the port by another Explorer build or another scene is not touched: the session degrades to raw GLTFs and the AB panel names what holds the port and what to do (quit that client, or stop the pid it names, then relaunch). The converter is the
abgensidecar on127.0.0.1:5147, a child process the Explorer stops on a normal quit; after a force-kill or crash it can survive. The next launch adopts a survivor that is the same Explorer build, serves the same scene and reports ready (AbgenSidecar.ReconcileResidentServerAsync); any other resident blocks the port until it is stopped (pgrep -fl abgen, stop the pid the panel names, relaunch once; observed 2026-11). A survivingabgenalso blocked a Decentraland launcher update because its files were in use — stop it before updating. Check that panel first when models load unoptimized or afontSrcfont shows its fallback. npm run start -- --asset-bundles, thelocal-abdeep-link param and the Creator Hub's Compress Assets toggle are still accepted but change nothing: conversion is always on. To preview raw GLTFs instead, passlsd-raw-gltfthrough to the Explorer (npm run start -- -- --lsd-raw-gltf; a manual launch takes--lsd-raw-gltf).
This is the local equivalent of the server-side conversion that runs after every publish (see the deploy-scene skill). Read the AB panel 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:
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
Pitfalls with no section of their own above (pooling, LOD, textures, throttling, repeated models and preloading each have one):
| Pitfall | Symptom | Fix |
| Unused colliders on decorations | Physics body limit exceeded | Remove MeshCollider from non-interactive objects |
| 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 |
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): draw count is rendered objects × their material slots. Atlas and reduce transparency to cut slots per object, and reduce the number of rendered objects (see Repeated Models).
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 | Optimize GLB files (resize textures, 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 |
# Optimize a GLB for every client (Draco off: the mobile app cannot decode it)
npx @gltf-transform/cli optimize input.glb output.glb --compress falseOn mobile a Draco model fails silently whenever the scene has no optimized assets, which includes every preview. Full rule: add-3d-models → Model Best Practices.
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.ymutated every frame via a singleengine.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 inmain(), not per frame). - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/73,-2-dbmonster — UI stress test: dozens of nested
<Label>in a ReactEcs tree rebuilt every frame withMath.random()values. Demonstrates the UI render function re-runs each frame, so heavy per-frame allocation in.tsxis a real cost. - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/88,-12-asset-load —
AssetLoadpreloading with the per-assetassetLoadLoadingStateSystemstate callback (mp3 / texture / video / glb, plus a deliberately missing path that resolves toNOT_FOUND). - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/94,-10-gltf-reuse-vs-merge — benchmark: GltfContainer reuse (one
.glb, N entities) vs duplicate files vs merged mesh, measuring renderer/material counts, memory, and load smoothness. UsesAssetLoadpreloading and collision-mask tuning for burst spawning.
Cross-References
- deploy-scene — post-publish asset bundle conversion timing, troubleshooting,
/detectabscommand - add-3d-models — model loading, colliders, file organization, and driving Blender to fix or author models (
references/blender-authoring.md) - advanced-rendering — texture modes, material reuse, and LOD with VisibilityComponent
- scene-runtime —
EngineInfo.sceneHiddento pause expensive systems when the scene is hidden behind fullscreen Explorer UI
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

