optimize-scene

Original🇺🇸 English
Not Translated

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).

3installs
Added on

NPX Install

npx skill4agent add decentraland/sdk-skills optimize-scene

SKILL.md Content

Optimizing Decentraland Scenes

Scene Limits (Per Parcel Count)

All limits scale with parcel count
n
. Triangles, entities, and bodies scale linearly. Materials, textures, and height scale logarithmically.
ResourceFormula1 parcel2 parcels3 parcels4 parcels6 parcels9 parcels16 parcels20 parcels
Trianglesn x 10,00010,00020,00030,00040,00060,00090,000160,000200,000
Entitiesn x 2002004006008001,2001,8003,2004,000
Physics bodiesn x 3003006009001,2001,8002,7004,8006,000
Materialslog2(n+1) x 202031404656668187
Textureslog2(n+1) x 101015202328334043
Height limitlog2(n+1) x 20m20m31m40m46m56m66m81m87m
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.

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
engine.removeEntity(entity) // Frees the entity slot

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 })

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

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 CaseRecommendedMaximum
Scene objects (walls, floors)1024x10241024x1024
Props and furniture512x5121024x1024
UI elements / icons256x256512x512
Skybox / environment maps1024x10241024x1024
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.

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

PitfallSymptomFix
Too many unique materialsHigh draw calls, low FPSMerge into texture atlases, reuse materials
Non-power-of-two texturesMemory bloat, visual artifactsResize all textures to 256/512/1024 (1024 max)
Creating/destroying entities rapidlyFrame stuttersUse entity pooling
Heavy computation every frameConsistent low FPSAdd timer guards, reduce frequency
Unused colliders on decorationsPhysics body limit exceededRemove MeshCollider from non-interactive objects
Large uncompressed texturesSlow loading, file size exceededUse WebP, reduce resolution, use atlases
Working files uploaded on deploy"Scene too large" deploy errorAdd Blender/FBX sources, concept art, docs to
.dclignore
(see deploy-scene)
Too many transparent materialsExtra draw calls, sorting issuesMinimize transparency, use alpha cutoff instead of blend
Adding entities/components in a system without guardsEntity count explodesSystems run every frame — always check before creating
Unbounded entity queriesCPU spikeFilter with specific components, cache results
All detail loaded at all distancesTriangle budget blownImplement LOD system
No asset preloadingPop-in during gameplayUse 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 materials. Merge, atlas, and reduce transparency.

Recommended Optimization Tools

ToolPurpose
Blender Decimate modifierReduce triangle count on imported models
Blender Limited DissolveRemove unnecessary vertices from flat surfaces
Squoosh (squoosh.app)Convert images to WebP, resize to power-of-two
TexturePackerCreate texture atlases from multiple images
gltf-transform CLICompress GLB files with Draco, strip unused data
glTF ValidatorCheck for export errors before importing into DCL
Creator Hub Scene InspectorVisual 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

Example scenes

Engine-team stress-test scenes (treat as ground truth for API shape):

Cross-References

  • add-3d-models — model loading, colliders, and file organization
  • game-design — performance budgets, design patterns, and MVP planning
  • advanced-rendering — texture modes, material reuse, and LOD with VisibilityComponent