About advanced-rendering
advanced-rendering is a rendering guide for Decentraland SDK7 scene developers. It explains entity orientation, 3D text, materials, visibility, and GLTF node controls, with component examples and implementation caveats.
Key features
- 01Orient entities with Billboard
- 02Render text in 3D space
- 03Configure PBR, transparent, and emissive materials
- 04Toggle entity visibility
- 05Modify GLTF node materials and shadows
Use cases
- 01Create camera-facing signs
- 02Add 3D labels to scene objects
- 03Build transparent or glowing surfaces
- 04Hide scene entities conditionally
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ decentraland/ sdk-skills --skill advanced-renderingAfter installing, type/advanced-renderingin Claude Code to confirm it loaded.
Bundled filesreferences/rendering-patterns.md
Details
From the project READMEAdvanced Rendering in Decentraland
When to Use Which Rendering Feature
| Need | Component | When |
|---|---|---|
| Entity faces the camera | Billboard | Name tags, signs, sprite-like objects |
| Text in the 3D world | TextShape | Labels, signs, floating text above entities |
| Custom material appearance | Material.setPbrMaterial | Metallic, rough, transparent, emissive surfaces |
| Show/hide without removing | VisibilityComponent | LOD systems, toggling objects, conditional display |
| Modify GLTF model nodes | GltfNodeModifiers | Override materials or shadow casting on specific mesh nodes |
Decision flow:
- Need text on screen? → Use build-ui (React-ECS Label) instead
- Need text in 3D space? →
TextShape(+Billboardto face camera) - Need glowing/transparent materials? →
Material.setPbrMaterialwith emissive/transparency - Need to override material on a model node? →
GltfNodeModifierswithmodifiersarray
Not available in SDK7: reflections, mirrors, render-to-texture. There is no reflection probe, mirror or camera-to-texture component (@dcl/ecs component list, 7.30; Material.reflectivityColor is a specular tint, not a reflection). A mirror is faked by duplicating the reflected geometry behind a tinted, slightly transparent glass plane and driving a double of anything that moves (the player double is in npcs → "Copies of the player"). Budget the space: the reflection needs as much depth behind the glass as the room in front of it, and content outside the parcels is clipped (add-3d-models), so a mirror on a parcel edge means adding a parcel. Trim the reflected side walls, floor and ceiling where they meet the real ones — overlapping copies z-fight at the seam (observed 2026-10-12).
Billboard (Face the Camera)
Make entities always rotate to face the player's camera:
import { engine, Transform, Billboard, BillboardMode, MeshRenderer } from '@dcl/sdk/ecs'
import { Vector3 } from '@dcl/sdk/math'
const sign = engine.addEntity()
Transform.create(sign, { position: Vector3.create(8, 2, 8) })
MeshRenderer.setPlane(sign)
// Rotate only on Y axis (most common — stays upright)
Billboard.create(sign, {
billboardMode: BillboardMode.BM_Y
})
Billboard Modes
BillboardMode.BM_Y // Rotate on Y axis only (stays upright) — most common
BillboardMode.BM_ALL // Rotate on all axes (fully faces camera)
BillboardMode.BM_X // Rotate on X axis only
BillboardMode.BM_Z // Rotate on Z axis only
BillboardMode.BM_NONE // No billboard rotation
- Prefer
BM_YoverBM_ALLfor most use cases — it looks more natural and is cheaper to render. BM_ALLis useful for particles or effects that should always directly face the camera.- The entity's local
−Zfaces the camera. The Explorer aims local +Z away from the viewer —Quaternion.LookRotation(billboardPos − cameraPosition)— so on a billboarded parent a child at local+Zsits behind it and a child at local−Zrenders in front, from every viewpoint. Verified against unity-explorerExplorer/Assets/DCL/SDKComponents/Billboard/System/BillboardSystem.cs; a reference scene whose comment claims+Zfaces the camera is wrong on Explorerv0.186.0-alpha. - By default a billboard faces the CAMERA, not the avatar — the active virtual camera's world position, same file. In third person the camera and the player stand in different places and see different facings, so verify a hide-behind/peek-out trick from the player's own camera, never from a free-cam sweep. To face something else — the player's avatar (
targetEntity: engine.PlayerEntity) or any other entity — settargetEntity(see Face another entity —targetEntitybelow). - No
oppositeDirectionflag. The SDK7Billboardcomponent exposes onlybillboardModeandtargetEntity— there is no way to invert which model face points at the camera. If a model shows its back instead of its front, rotate the model 180° on Y (Quaternion.fromEulerDegrees(0, 180, 0)). On a parent-Billboard + child-model setup, apply the rotation to the child — the Billboard owns the parent's rotation. - Porting note: SDK6 → SDK7 ports occasionally show a billboarded model facing away from the camera that was correct under SDK6. The two SDKs appear to disagree on which face the billboard points at the camera. Same fix — rotate the displayed model 180° on Y. See [[migrate-sdk6-to-sdk7]] (Common Pitfalls) for context.
Face another entity — targetEntity
Billboard has an optional targetEntity?: Entity field. When set, the entity reorients to face that target entity instead of the camera.
// Face a specific entity instead of the camera
Billboard.create(card, { targetEntity: sphere })
// Yaw-only tracking of a target (BM_Y respected while targeting)
Billboard.create(card, { targetEntity: target, billboardMode: BillboardMode.BM_Y })
// Retarget at runtime
Billboard.getMutable(card).targetEntity = otherEntity
- Unset (default) → faces the main camera, exactly as before.
targetEntityis fully backwards-compatible. - Setting
targetEntityto the camera reserved entity (engine.CameraEntity, id2) is equivalent to leaving it unset. billboardModestill applies:BM_Ywith atargetEntityyaws to face the target on the Y axis only.- Gotcha: if the referenced target entity does not exist or is deleted, billboard reorientation is disabled (the entity freezes at its last orientation) until the target exists again.
- Multiplayer: a camera-facing billboard is computed locally per player (each player sees it facing themselves, nothing is synced). A
targetEntitybillboard instead faces the same way for all players, because the target's position is scene state. UsetargetEntitywhen every player must see the same orientation.
TextShape (3D Text)
Render text directly in 3D space:
import { engine, Transform, TextShape, TextAlignMode } from '@dcl/sdk/ecs'
import { Vector3, Color4 } from '@dcl/sdk/math'
const label = engine.addEntity()
Transform.create(label, { position: Vector3.create(8, 3, 8) })
TextShape.create(label, {
text: 'Hello World!',
fontSize: 5, // world units: 10 ≈ 1 m tall, so this is about 0.5 m
textColor: Color4.White(),
outlineColor: Color4.Black(),
outlineWidth: 0.1,
textAlign: TextAlignMode.TAM_MIDDLE_CENTER
})
fontSizeis in world units, not pixels:10makes the text about 1 m tall (SDK docs; the Explorer passes it straight to a world-space TextMeshPro,TMPProSdkExtensions.cs), and the parentTransform.scalemultiplies it.16–32is big signage read from across a parcel; a monitor, plaque or label needs about1–4, or a small size under a scaled parent. Text spilling out of a monitor model (2026-10-08) was this unit mismatch, not a layout bug.- Do not use
TextShape(or a textured plane) as a rulebook: long paragraphs in-world are too small and too long to read, and players will not stop to squint. One idea per sign, ≤ 10 words, large type; move the rest into visuals and gameplay (see game-design → "Rules: show, don't tell"). - Always add
outlineColorandoutlineWidthfor legibility against any background. - For a custom typeface, set
fontSrcto a.ttfin the scene, withfontas the fallback. See fonts for the SDK version, explorer and mobile support, and previewing it. - A TextShape faces its local -Z. It reads correctly for a viewer standing on the entity's -Z side, so to label the +Z face of a model, parent the label to it at a small
+Zoffset and rotate it 180° about Y. Observed in-world twice (SDK 7.30.1, 2026-10-12); get it wrong and the sign is mirrored or invisible from the side players approach. - Spaces do not align columns — not even with
Font.F_MONOSPACE. Padding a row with spaces to build a table renders with collapsed gaps (observed in-world on a 3-column board, SDK 7.30.1, 2026-10-12). Use one TextShape per column, each positioned at its own x and aligned withTAM_MIDDLE_LEFT/TAM_MIDDLE_RIGHT, and keep the rows as\n-separated lines inside each column.
Text Alignment Options
TextAlignMode.TAM_TOP_LEFT
TextAlignMode.TAM_TOP_CENTER
TextAlignMode.TAM_TOP_RIGHT
TextAlignMode.TAM_MIDDLE_LEFT
TextAlignMode.TAM_MIDDLE_CENTER
TextAlignMode.TAM_MIDDLE_RIGHT
TextAlignMode.TAM_BOTTOM_LEFT
TextAlignMode.TAM_BOTTOM_CENTER
TextAlignMode.TAM_BOTTOM_RIGHT
For the floating-label pattern (Billboard + TextShape combined into a camera-facing label), see the Floating Label (Billboard + TextShape) section in {baseDir}/references/rendering-patterns.md.
Fitting a screen or text onto a model's display
A plane or TextShape dropped at a monitor model's origin sits in front of, behind or across the bezel (2026-10: a screen plane 6 cm proud of the bezel, then text twice the width of the display). Read the geometry first: dump the GLB's vertices with the bounding-box script in add-3d-models (filter to the faces whose normal points at the viewer) and take the rectangle of the recessed display and its depth — the monitor in that session had a 0.654 × 0.497 m display 1.6 cm behind the bezel face. Size the plane to that rectangle, put it 2–5 mm in front of the display surface (still behind the bezel face), and for text set fontSize, width/height and textWrapping to the same rectangle instead of guessing and asking the owner to look.
Advanced PBR Materials
Metallic and Roughness
import { engine, Transform, MeshRenderer, Material, MaterialTransparencyMode } from '@dcl/sdk/ecs'
import { Color4, Color3 } from '@dcl/sdk/math'
// Shiny metal
Material.setPbrMaterial(entity, {
albedoColor: Color4.create(0.8, 0.8, 0.9, 1),
metallic: 1.0,
roughness: 0.1
})
// Rough stone
Material.setPbrMaterial(entity, {
albedoColor: Color4.create(0.5, 0.5, 0.5, 1),
metallic: 0.0,
roughness: 0.9
})
Transparency
// Alpha blend — smooth transparency
Material.setPbrMaterial(entity, {
albedoColor: Color4.create(1, 0, 0, 0.5), // 50% transparent red
transparencyMode: MaterialTransparencyMode.MTM_ALPHA_BLEND
})
// Alpha test — cutout (binary visible/invisible based on threshold)
Material.setPbrMaterial(entity, {
texture: Material.Texture.Common({ src: 'assets/Images/cutout.png' }),
transparencyMode: MaterialTransparencyMode.MTM_ALPHA_TEST,
alphaTest: 0.5
})
MTM_ALPHA_TESTis cheaper thanMTM_ALPHA_BLEND— use cutout when smooth transparency isn't needed.
Emissive (Glow Effects)
// Glowing material (emissiveColor uses Color3, not Color4)
Material.setPbrMaterial(entity, {
albedoColor: Color4.create(0, 0, 0, 1),
emissiveColor: Color3.create(0, 1, 0), // Green glow
emissiveIntensity: 2.0
})
// Emissive with texture
Material.setPbrMaterial(entity, {
texture: Material.Texture.Common({ src: 'assets/Images/diffuse.png' }),
emissiveTexture: Material.Texture.Common({ src: 'assets/Images/emissive.png' }),
emissiveIntensity: 1.0,
emissiveColor: Color3.White()
})
- Use
emissiveColorwith a darkalbedoColorfor maximum glow visibility.
Texture Maps
Material.setPbrMaterial(entity, {
texture: Material.Texture.Common({ src: 'assets/Images/diffuse.png' }),
bumpTexture: Material.Texture.Common({ src: 'assets/Images/normal.png' }),
emissiveTexture: Material.Texture.Common({ src: 'assets/Images/emissive.png' })
})
castShadows
Both setPbrMaterial and setBasicMaterial accept castShadows: boolean (default true). Set false to stop a surface from casting shadows without changing its appearance:
Material.setPbrMaterial(entity, { albedoColor: Color4.Green(), castShadows: false })
For disabling shadows on a specific node inside a GLTF model, use GltfNodeModifiers with castShadows: false instead (see below).
GltfContainer Collision Masks
Use collision masks to control which collision layers respond to the different mesh layers in a GLTF model. GLTF models have two mesh layers: visible meshes (what players see rendered), and invisible layers (collider meshes, named internally with _collider):
import { engine, Transform, GltfContainer, ColliderLayer } from '@dcl/sdk/ecs'
import { Vector3 } from '@dcl/sdk/math'
const model = engine.addEntity()
Transform.create(model, { position: Vector3.create(4, 0, 4) })
GltfContainer.create(model, {
src: 'models/myModel.glb',
visibleMeshesCollisionMask: ColliderLayer.CL_PHYSICS | ColliderLayer.CL_POINTER,
invisibleMeshesCollisionMask: ColliderLayer.CL_PHYSICS
})
VisibilityComponent
Show or hide entities without removing them:
import { engine, VisibilityComponent } from '@dcl/sdk/ecs'
// Hide an entity
VisibilityComponent.create(entity, { visible: false })
// Toggle visibility
const visibility = VisibilityComponent.getMutable(entity)
visibility.visible = !visibility.visible
For a distance-based LOD (Level of Detail) system that toggles VisibilityComponent per frame, see the LOD via VisibilityComponent section in {baseDir}/references/rendering-patterns.md.
propagateToChildren
Set propagateToChildren: true on a VisibilityComponent to apply visibility to all children in the hierarchy at once. This avoids having to mark every child entity individually:
VisibilityComponent.create(parentEntity, { visible: false, propagateToChildren: true })
Rules (verified against the 1,0-visibility-comp-propagation test scene):
- If a child has its own
VisibilityComponent, that value wins regardless of what an ancestor propagates — even if the child's ownpropagateToChildrenisfalse, the child stays at its ownvisiblevalue and does not re-inherit the parent's. - If a child has no
VisibilityComponent, it inherits from the nearest ancestor withpropagateToChildren: true. - A child that overrides an invisible parent to
visible: truecan itself setpropagateToChildren: trueto force its own subtree visible again — propagation re-evaluates at each node that carries aVisibilityComponent. - Propagation follows the live
Transform.parenthierarchy: re-parenting an entity at runtime changes which ancestor's propagated visibility applies to it.
Per-Node Modifiers (GltfNodeModifiers)
Override material or shadow casting on specific nodes within a GLTF model:
import { GltfNodeModifiers } from '@dcl/sdk/ecs'
GltfNodeModifiers.create(entity, {
modifiers: [
{
path: 'RootNode/Armor', // GLTF hierarchy path
castShadows: false // Disable shadow casting for this node
}
]
})
To override the material or shadow casting of the entire model (path: '') — including the nested material: { material: { $case: 'pbr' | 'unlit', ... } } shape — see the GltfNodeModifiers — Whole-Model Material Override section in {baseDir}/references/rendering-patterns.md.
Modifier details (from the 74,-8-gltfnodemodifier test scene):
pathis the GLTF node hierarchy path,/-separated (e.g.Scene_root/shark_skeleton/Sphere/Sphere.001).path: ''targets the whole model; a nested path targets one node and its descendants.materialaccepts either$case: 'pbr'(full PBR:albedoColor,emissiveColor,emissiveIntensity, textures, …) or$case: 'unlit'(diffuseColor, …). Different nodes in the samemodifiersarray can use different cases.- Textures work here too, including video:
pbr: { texture: Material.Texture.Video({ videoPlayerEntity: someEntityWithVideoPlayer }) }. castShadows: falseper node (nomaterialneeded) disables shadow casting for that node only.- One
modifiersarray can contain many entries, each targeting a differentpathin a single call. - Debug trick: passing a
paththat does not exist logs the model's full GLTF node hierarchy to the scene console — use a deliberately wrong path to discover the correct node names. - Update with
GltfNodeModifiers.createOrReplace(entity, { modifiers: [...] }); remove all overrides withGltfNodeModifiers.deleteFrom(entity).
Node paths are engine-visible names baked into the GLB, not arbitrary — if a target node has no material of the requested kind, the override may be ignored.
Avatar Texture
Generate a texture from a player's avatar portrait:
Material.setPbrMaterial(portraitFrame, {
texture: Material.Texture.Avatar({ userId: '0x...' })
})
This will fetch a thumbnail image with a closeup of the player's face, wearing the wearables that this player currently has on.
Texture Modes
Control how textures are filtered and wrapped:
import { TextureFilterMode, TextureWrapMode } from '@dcl/sdk/ecs'
Material.setPbrMaterial(entity, {
texture: Material.Texture.Common({
src: 'assets/Images/pixel-art.png',
filterMode: TextureFilterMode.TFM_POINT, // crisp pixels (no smoothing)
wrapMode: TextureWrapMode.TWM_REPEAT // tile the texture
})
})
Filter modes: TFM_POINT (pixelated), TFM_BILINEAR (smooth), TFM_TRILINEAR (smoothest).
Wrap modes: TWM_REPEAT (tile), TWM_CLAMP (stretch edges), TWM_MIRROR (mirror tile).
Default UVs on box and plane primitives
MeshRenderer.setBox / setPlane without uvs use the desktop Explorer's built-in layout (unity-explorer Utility/Primitives/BoxFactory.cs and PlaneFactory.cs, read Oct 2026), and on a box it differs per face. With the box unrotated, and "upright" meaning the texture's top row is at the top as a viewer facing that face sees it:
| Face | How the texture reads |
|---|---|
| −z | upright |
| +z | rotated 180° |
| −x and +x | rotated 90° (the texture's u axis runs vertically) |
| top (+y) and bottom (−y) | rotated 90° (u runs along z, v along x; for a viewer facing +z) |
So any texture with an orientation — lettering, brick courses, wood grain, a floor pattern — comes out turned on two of the four walls and on floors and ceilings (observed in-world 2026-10: "walls and the ceiling came out rotated"). Yawing the box 90° only moves the problem to the other pair of walls. Fixes, best first: pass explicit uvs — 48 floats, 6 faces × 4 corners × (u, v), see Set UVs on the docs' Materials page for the helper; or build the surface from setPlane entities, one per face; or ship a GLB.
A plane is two-sided: u runs along local +x and v along local +y, so the texture reads upright and unmirrored from the −z side and mirrored left-to-right from the +z side. [DERIVED, not checked in-world: laid flat with Quaternion.fromEulerDegrees(90, 0, 0), the unmirrored side faces up and the texture's top points to world +z.]
Texture Tweens
For animated texture patterns (Tween.setTextureMoveContinuous scrolling, Tween.setTextureMove slide-once, TMT_OFFSET vs TMT_TILING movement types, and looping via TweenSequence) — all requiring a texture with wrapMode: TWM_REPEAT — see the Texture Tweens section in {baseDir}/references/rendering-patterns.md.
FlatMaterial Accessors
The Material component provides shortcut methods that skip the nested union structure, making material access more ergonomic:
| Method | Returns | Throws if no material? |
|---|---|---|
Material.getFlat(entity) | Read-only FlatMaterial | Yes |
Material.getFlatOrNull(entity) | Read-only FlatMaterial | null | No |
Material.getFlatMutable(entity) | Read/write FlatMaterial | Yes |
Material.getFlatMutableOrNull(entity) | Read/write FlatMaterial | null | No |
// Read a property safely
const src = Material.getFlatOrNull(entity)?.texture?.src
// Mutate a texture in-place without knowing PBR vs Basic
Material.getFlatMutableOrNull(entity)!.texture = Material.Texture.Common({ src: 'assets/Images/new.png' })
Example scenes
Engine-team test scenes exercising these APIs against the real runtime:
- https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/74,-8-gltfnodemodifier —
GltfNodeModifiersoverriding PBR/unlit materials, video textures, per-node colors andcastShadowson specific GLTF nodes;createOrReplace/deleteFrom; wrong-path console-dump debug trick. - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/1,0-visibility-comp-propagation —
VisibilityComponentpropagateToChildrenacross a parent/child/grandchild hierarchy with runtime re-parenting, covering every override combination. - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/1,1-billboard-target-entity —
Billboard.targetEntity: unset (faces the camera), tracking a moving entity, a target deleted and re-created (orientation freezes while the target is absent, resumes when it returns),BM_YvsBM_ALLagainst the same target, andtargetEntity: engine.CameraEntitybehaving identically to unset. - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/0,3-texture-movement — texture tweens via
Tween.setTextureMovewithTextureMovementType.TMT_OFFSETandTMT_TILING, paired withTweenSequenceloops; alsoBillboard+TextShape. - https://github.com/decentraland/sdk7-test-scenes/tree/main/scenes/52,-52-testing-gallery — PBR material sweeps (metallic/roughness/emissive/normal-map) and
GltfContainercollision-mask combinations shown side by side.