A webapp that renders interactive 3D previews of Decentraland wearables, emotes, and avatars. It can be embedded as an iframe and controlled via query parameters or postMessage API.
- 3D Avatar Preview: Render full avatars with equipped wearables
- Wearable Preview: Preview individual wearables on an avatar
- Emote Preview: Play and control emote animations
- Social Emotes: Preview two-person social interaction emotes
- Interactive Camera: Pan, zoom, and rotate around the preview
- Screenshot Capture: Programmatically capture screenshots via RPC
- iframe Embeddable: Easy integration via iframe with postMessage API
- Multiple Input Sources: Load items via URN, URL, base64, or contract/item IDs
This webapp interacts with the following services:
- Peer/Catalyst Server: Fetches wearable/emote entities, profiles, and content
- Marketplace API: Retrieves item and NFT data by contract address
- Node.js: Version 22.x
- npm: Latest version compatible with Node.js 22.x
npm ciStart the development server:
npm run startBuild for production:
npm run buildConfigure the preview via URL query parameters:
| Parameter | Description |
|---|---|
contract |
The contract address of the wearable collection |
item |
The id of the item in the collection |
token |
The id of the token (to preview a specific NFT) |
profile |
An ethereum address of a profile to load as the base avatar. It can be set to default or a numbered default profile like default15 |
urn |
A URN of a wearable or emote to load. If it is a wearable, it will override anything loaded from a profile. Can be used multiple times |
url |
A URL of a wearable or emote to load. Must return a valid WearableDefinition or EmoteDefinition. Can be used multiple times |
base64 |
A wearable or emote encoded in base64. Once parsed it should be a valid WearableDefinition or EmoteDefinition. Can be used multiple times |
| Parameter | Description |
|---|---|
skin |
A color to be used by the skin material, must be in hex |
hair |
A color to be used by the hair material, must be in hex |
eyes |
A color to be used by the eyes tint, must be in hex |
bodyShape |
Which body shape to use: urn:decentraland:off-chain:base-avatars:BaseMale or urn:decentraland:off-chain:base-avatars:BaseFemale |
| Parameter | Description |
|---|---|
emote |
The emote that the avatar will play. Default: idle. Options: clap, dab, dance, fashion, fashion-2, fashion-3, fashion-4, love, money, fist-pump, head-explode |
socialEmote |
When specified, duplicates the avatar and plays different animations on each to create a social interaction. JSON object with: title (required), loop (required), audio (optional), Armature, Armature_Prop, Armature_Other (optional animation configs) |
| Parameter | Description |
|---|---|
zoom |
The level of zoom, must be a number between 1 and 100 |
zoomScale |
A multiplier for the zoom level. Default: 1, can be increased for extra zoom |
camera |
Which camera type to use: interactive or static. Default: interactive |
projection |
Which projection type to use: orthographic or perspective. Default: perspective |
offsetX/Y/Z |
Apply an offset in the X/Y/Z position of the scene. Default: 0 |
cameraX/Y/Z |
Set the X/Y/Z position of the camera |
wheelZoom |
A multiplier of how much the user can zoom with the mouse wheel. Default: 1 (no zoom). Value of 2 allows zoom up to 2x |
wheelPrecision |
The higher the value, the slower the wheel zooms when scrolled. Default: 100 |
wheelStart |
A value between 0 and 100 determining initial zoom. Default: 50. Value of 0 starts at min zoom, 100 starts at max zoom |
panning |
If true, enables panning capability. Default: true |
lockAlpha |
If true, locks the alpha rotation (horizontal rotation) |
lockBeta |
If true, locks the beta rotation (vertical rotation) |
lockRadius |
If true, locks the radius (zoom distance) |
| Parameter | Description |
|---|---|
background |
The color of the background in hex, e.g.: ff0000 |
disableBackground |
If true, makes the background transparent |
disableAutoRotate |
If true, disables the auto-rotate behaviour of the camera |
disableAutoCenter |
If true, disables the auto-center around the bounding box |
disableFace |
If true, disables the facial features |
disableDefaultWearables |
If true, will not load default wearables (only loads the base body shape) |
disableFadeEffect |
If true, disables CSS transitions (fade in/out effect). Useful for automation tests |
disableDefaultEmotes |
If true and emote is not passed, will not load the default IDLE emote |
showSceneBoundaries |
If true, shows a cylinder representing the recommended scene boundaries |
showThumbnailBoundaries |
If true, shows a square representing the thumbnail boundaries |
| Parameter | Description |
|---|---|
peerUrl |
Set a custom URL for a Catalyst peer |
marketplaceServerUrl |
Set a custom URL for the Marketplace API |
nftServerUrl |
Set a custom URL for the Marketplace API (legacy, marketplaceServerUrl takes priority) |
type |
Set a custom PreviewType for standalone items passed as urn/url/base64. Currently only supports: wearable |
env |
The environment to use: prod (mainnet wearables and catalysts) or dev (testnet) |
Example: https://wearable-preview.decentraland.org?contract=0xee8ae4c668edd43b34b98934d6d2ff82e41e6488&item=5
It's possible to load the wearable-preview in an iframe and communicate with it via postMessage:
If you want to update some options without having to reload the iframe, you can send an update message with the options and their new values:
import { PreviewMessageType, sendMessage } from '@dcl/schemas'
sendMessage(iframe.contentWindow, PreviewMessageType.UPDATE, {
options: {
emote: 'dab',
},
})You can listen to events sent by the iframe via postMessage.
import { PreviewMessageType, PreviewMessagePayload } from '@dcl/schemas'
function handleMessage(event) {
switch (event.data.type) {
// This message comes every time the preview finished loading
case PreviewMessageType.LOAD: {
console.log('Preview loaded successfully')
break
}
// This message comes every time there's an error
case PreviewMessageType.ERROR: {
const { message } = event.data.payload as PreviewMessagePayload<PreviewMessageType.ERROR>
console.error('Something went wrong:', message)
}
// This message comes every time there's a native animation event, they only happen with emotes
case PreviewMessageType.EMOTE_EVENT: {
const { type, payload } = event.data.payload as PreviewMessagePayload<PreviewMessageType.EMOTE_EVENT>
switch (type) {
case PreviewEmoteEventType.ANIMATION_PLAY:
console.log('Animation started')
break
case PreviewEmoteEventType.ANIMATION_PAUSE:
console.log('Animation paused')
break
case PreviewEmoteEventType.ANIMATION_LOOP:
console.log('Animation looped')
break
case PreviewEmoteEventType.ANIMATION_END:
console.log('Animation ended')
break
case PreviewEmoteEventType.ANIMATION_PLAYING:
console.log('Animation playing: ', payload.length)
break
}
}
}
}
window.addEventListener('message', handleMessage)The controller allows to take screenshots and get metrics from the scene, and also control the emote animations (play/pause/stop/goTo).
To use the controller you can send controller_request messages and the response will arrive via a controller_response message.
The available methods are:
- namespace:
scene- method:
getScreenshotparams:[width: number, height: number]result:string - method:
getMetricsparams:[]result:Metrics
- method:
- namespace:
emote- method:
playparams:[]result:void - method:
pauseparams:[]result:void - method:
stopparams:[]result:void - method:
goToparams:[seconds: number]result:void - method:
getLengthparams:[]result:number - method:
isPlayingparams:[]result:boolean - method:
changeZoomparams:[zoom: number]result:void - method:
changeCameraPositionparams:[position: { alpha?: number, beta?: number, radius?: number }]result:void - method:
panCameraparams:[offset: { x?: number, y?: number, z?: number }]result:void
- method:
This is an example of an RPC:
import future, { IFuture } from 'fp-future'
import { PreviewMessageType, PreviewMessagePayload, sendMessage } from '@dcl/schemas'
let id = 0
const promises = new Map<string, IFuture<any>>()
function sendRequest<T>(
namespace: 'scene' | 'emote',
method: 'getScreenshot' | 'getMetrics' | 'getLength' | 'isPlaying' | 'goTo' | 'play' | 'pause' | 'stop',
params: any[],
) {
// create promise
const promise = future<T>()
promises.set(id, promise)
// send message
sendMessage(iframe.contentWindow, PreviewMessageType.CONTROLLER_REQUEST, { id, namespace, method, params })
// increment id for next request
id++
return promise
}
function handleMessage(event) {
switch (event.data.type) {
// handle response
case PreviewMessageType.CONTROLLER_RESPONSE: {
const payload = event.data.payload as PreviewMessagePayload<PreviewMessageType.CONTROLLER_RESPONSE>
// grab promise and resolve/reject according to response
const { id } = payload
const promise = promises.get(id)
if (promise) {
if (payload.ok) {
promise.resolve(payload.result)
} else {
promise.reject(new Error(payload.error))
}
}
break
}
}
}
window.addEventListener('message', handleMessage)Now you can use it like this:
const screenshot = await sendRequest('scene', 'getScreenshot', [512, 512]) // "data:image/png;base64..."Run tests:
npm run testFor detailed AI Agent context, see docs/ai-agent-context.md.