Bedroom Digital Twin
This learning path turns a structured bedroom model into an interactive Vision Pro digital twin.
The purpose is to show how Untold Engine can give different runtime behavior to different parts of the same room:
- Window geometry can act as a light portal.
- Window geometry can use passthrough ghost rendering.
- Context geometry can be rendered normally, hidden, or shown as wireframe.
- Named objects can remain selectable and show mock digital-twin state.
- The whole room can be moved and rotated with spatial gestures.
By the end of the path, you will have a Vision Pro scene where the bedroom is not just rendered as a model. It behaves like a structured spatial twin.
What You Will Build
The example scene is a bedroom with:
- floor, ceiling, and walls
- a window with glass
- selectable furniture and devices
- mock status data for selected objects
- Vision Pro spatial manipulation
The main engine systems are:
setEntityMeshAsyncfor loading the roomloadSceneAuthoredfor Blender-authored color management (see the note in Replace The Starter Asset With Your Own Bedroom Scene about lights and cameras)SceneChannelfor geometry categories- a light portal for window lighting
- passthrough ghost render mode for mixed reality
- XR spatial input for selection and room manipulation
This path uses the starter digital-twin asset pack so you can focus on the engine workflow first. After this is working, you can replace the starter asset with your own Blender export.
Want to run it right away instead of building it step by step? The finished project, BedroomTwin, is in the UntoldArcade demo collection: github.com/untoldengine/UntoldArcade/tree/main/BedroomTwin. Clone it, open the Xcode project, and run.
Create The Vision Pro Project
Create a standalone visionOS project:
cd ~/Projects
untoldengine create BedroomTwin --platform visionos
open BedroomTwin/BedroomTwin.xcodeproj
Open:
Install The Starter Digital Twin Asset
From the generated project folder, install the starter digital-twin asset pack:
The CLI finds the project's GameData folder and merges the asset files into it.
The important file for this path is the DigitalTwin.untold model, installed under:
Room Naming Reference
The starter pack's DigitalTwin.untold model is already structured for this path:
- Ordinary names for the room shell and static furniture (
Floor,Ceiling,Walls,Bed,Chair, and similar) — these fall into the default.contextGeometrychannel. - A
WIN_prefix on the window and its glass:WIN_Window,WIN_Window_Glass. - An
NM_prefix on the objects that stay selectable and carry mock digital-twin state.
| Name | Role |
|---|---|
NM_Table_Lamp |
Light fixture — on/off, brightness |
NM_CeilingLamp_Left / NM_CeilingLamp_Right |
Light fixtures — on/off, brightness |
NM_Door |
Smart lock — open/closed, locked/unlocked |
NM_Curtains |
Smart blinds — position, automation |
NM_Laptop |
General smart device |
This naming gives the engine enough structure to treat the room shell, the window, and operational objects differently. If you want to build this scene from your own source model instead of the starter pack, see Replace The Starter Asset With Your Own Bedroom Scene.
Define Room Channels
In your game scene code, define a custom scene channel for the window:
Then register the WIN_ prefix before loading the room:
When the exported entities are registered, objects named WIN_Window and WIN_Window_Glass will be assigned to .windowGeometry.
Objects with the NM_ prefix already use the engine's selectable-object convention. They default to .selectableGeometry and .preserveIdentity, which keeps them pickable and prevents them from being merged into context batches.
Configure Channel Behavior
Set up the room behavior before or immediately after loading the asset:
setSceneChannel(.contextGeometry, .pickParticipation(false))
setSceneChannel(.contextGeometry, .renderMode(.normal))
setSceneChannel(
.windowGeometry,
.renderMode(.passthroughGhost(opacity: 0.0))
)
setSceneChannel(
.windowGeometry,
.lightPortal(.enabled(
intensity: 0.5,
range: 4.0,
useRealWorldTint: true,
maxActivePortals: 4,
activationDistance: 10.0
))
)
This setup does three things:
- Context geometry such as walls, floor, and ceiling is not pickable.
- Window geometry becomes a passthrough ghost surface in mixed reality.
- Window geometry also acts as a light-portal source.
For a more diagnostic look, switch context geometry to wireframe:
For a cutaway view, hide context geometry:
Selectable NM_ objects remain visible and pickable while the room shell changes render modes.
Load The Bedroom
In init(), configure the engine systems, register the prefix, configure channels, then load the asset:
configureEngineSystems()
registerSceneChannelPrefix("WIN_", channels: .windowGeometry)
setSceneChannel(.contextGeometry, .pickParticipation(false))
setSceneChannel(.contextGeometry, .renderMode(.normal))
setSceneChannel(
.windowGeometry,
.renderMode(.passthroughGhost(opacity: 0.0))
)
setSceneChannel(
.windowGeometry,
.lightPortal(.enabled(
intensity: 0.5,
range: 4.0,
useRealWorldTint: true,
maxActivePortals: 4,
activationDistance: 10.0
))
)
let room = createEntity()
setEntityName(entityId: room, name: "DigitalTwin")
setEntityMeshAsync(entityId: room, filename: "DigitalTwin", withExtension: "untold") { success in
guard success else {
setSceneReady(false)
return
}
loadSceneAuthored(filename: "DigitalTwin", withExtension: "untold")
setSceneReady(true)
}
setEntityMeshAsync looks for the asset by name in GameData, so the filename is "DigitalTwin" and the extension is "untold".
Configure XR Input And Rendering
Use the same Vision Pro setup pattern as the archviz path:
private func configureEngineSystems() {
gameMode = true
registerXREvents()
setInput(.xr(.pickingBackend(.octreeGPUPreferred)))
setInput(.xr(.twoHandRotateAxisMode(.dynamicSnapped)))
setInput(.xr(.sceneReady(true)))
setRendering(.postProcessing(.enabled))
setRendering(.antiAliasing(.msaa))
setPostFX(.ssao(.enabled(false)))
setRendering(.environment(.lightingMode(.realWorldEstimate)))
setRendering(.environment(.realWorldLightingContribution(1.0)))
TextureStreamingSystem.shared.apply(.superdetailed)
}
The real-world lighting calls are useful when useRealWorldTint is enabled for the light portal.
Add Mock Digital-Twin State
For the first version, keep the data local and simple. The tutorial is about the engine workflow, not live IoT infrastructure.
struct TwinObjectInfo {
let title: String
let status: String
let detail: String
}
let twinInfoByName: [String: TwinObjectInfo] = [
"NM_Table_Lamp": TwinObjectInfo(
title: "Table Lamp",
status: "On",
detail: "Brightness: 80%"
),
"NM_CeilingLamp_Left": TwinObjectInfo(
title: "Ceiling Lamp (Left)",
status: "On",
detail: "Brightness: 60%"
),
"NM_CeilingLamp_Right": TwinObjectInfo(
title: "Ceiling Lamp (Right)",
status: "Off",
detail: "Brightness: 0%"
),
"NM_Door": TwinObjectInfo(
title: "Bedroom Door",
status: "Closed",
detail: "Lock: Engaged"
),
"NM_Curtains": TwinObjectInfo(
title: "Curtains",
status: "Online",
detail: "Position: 30% | Automation: Enabled"
),
"NM_Laptop": TwinObjectInfo(
title: "Laptop",
status: "Sleep",
detail: "Battery: 62%"
)
]
In a real app, this data could come from a building system, smart-home API, database, or web service. In this learning path, mocked state keeps the focus on selection and runtime behavior.
Select Room Objects
A multi-material object exports as one entity per material — NM_Table_Lamp becomes NM_Table_Lamp_mat0, NM_Table_Lamp_mat1, and so on, each a top-level entity with no parent wrapper. Only single-material objects (NM_Curtains, NM_Laptop in this pack) export with their bare name intact. Strip a trailing _mat<N> before looking up twinInfoByName so picking any submesh of a multi-material object still resolves:
func twinLookupName(for entityId: EntityID) -> String {
var name = getEntityName(entityId: entityId)
if let range = name.range(of: #"_mat\d+$"#, options: .regularExpression) {
name.removeSubrange(range)
}
return name
}
Use spatial tap state to select NM_ objects:
private var selectedTwinObject: TwinObjectInfo?
func handleInput() {
guard gameMode, isSceneReady() else { return }
var state = getXRSpatialInputState()
if state.spatialTapActive, let picked = state.pickedEntityId {
selectedTwinObject = twinInfoByName[twinLookupName(for: picked)]
if let info = selectedTwinObject {
Logger.log(message: "\(info.title): \(info.status) - \(info.detail)")
}
}
SpatialManipulationSystem.shared.processAnchoredSceneManipulationLifecycle(
from: state,
dragSensitivity: 10.0,
rotateSensitivity: 1.0
)
}
Because .contextGeometry picking is disabled, taps should ignore the room shell and return selectable NM_ objects instead.
In a full app, selectedTwinObject would drive a SwiftUI overlay, inspector panel, or floating label. For this path, logging the selected object is enough to confirm that the selection model works.
Add Runtime View Modes
A digital twin viewer often needs more than one view of the same model. Use scene-channel render modes to expose simple modes:
enum BedroomViewMode {
case normal
case wireframeShell
case hiddenShell
case passthroughWindow
}
func applyViewMode(_ mode: BedroomViewMode) {
switch mode {
case .normal:
setSceneChannel(.contextGeometry, .renderMode(.normal))
setSceneChannel(.windowGeometry, .renderMode(.normal))
case .wireframeShell:
setSceneChannel(.contextGeometry, .renderMode(.wireframe))
setSceneChannel(.windowGeometry, .renderMode(.normal))
case .hiddenShell:
setSceneChannel(.contextGeometry, .renderMode(.hidden))
setSceneChannel(.windowGeometry, .renderMode(.normal))
case .passthroughWindow:
setSceneChannel(.contextGeometry, .renderMode(.normal))
setSceneChannel(.windowGeometry, .renderMode(.passthroughGhost(opacity: 0.0)))
}
}
This is the core digital-twin idea in the tutorial: the model is structured, so each category can have a different runtime behavior.
Verify Light Portals
Use diagnostics when setting up the scene:
let candidates = discoverSceneLightPortalCandidates()
let discovery = getLightPortalDiscoveryDiagnostics()
print(candidates)
print(discovery)
If no candidates are found, check:
- window objects use the
WIN_prefix registerSceneChannelPrefix("WIN_", channels: .windowGeometry)runs before loading- window geometry has renderable bounds
- the channel has
.lightPortal(.enabled(...))
What This Learning Path Demonstrates
The bedroom scene behaves as a digital twin because the exported model has runtime structure:
- The shell is context geometry.
- The window has a project-specific channel.
- Named room objects stay selectable.
- The light portal uses the window channel.
- Passthrough ghost rendering uses the same window channel.
- Spatial input lets the user inspect and manipulate the room.
- Mock metadata gives selections meaning beyond rendering.
This is the foundation you would extend with live device data, building-management APIs, object-specific controls, annotations, or persistence.
Replace The Starter Asset With Your Own Bedroom Scene
Once the starter pack works, the next step is exporting your own bedroom (or similar room) model.
This path's starter pack was built from a stock archviz bedroom asset whose objects were not pre-named for the engine's conventions. Getting from a similar stock asset to something like DigitalTwin.untold means:
- Rename context geometry normally — the room shell and static furniture keep ordinary names.
- Add a
WIN_prefix to window/glass geometry only. Curtains, blinds, and other window dressing are not glazing — leave them as context geometry or a selectable twin object instead, since the light portal expects a transparent surface. - Add an
NM_prefix to whatever should stay selectable and carry mock state — lamps, a door, blinds, small devices.
Three cleanup steps mattered when preparing this pack's source model, and are worth checking on any similar stock asset:
- Drop baking-helper geometry. The source file had a
Light_Blocking_Volumemesh — an EEVEE baking helper sized to enclose the room, not meant to render at runtime. Exclude anything like it, or mark it hidden/non-pickable context geometry. - Parent light-fixture glow meshes to their fixture. Small emissive "bulb" meshes are sometimes separate top-level objects rather than children of the fixture they belong to. Parent them before renaming, the same way glass should be parented under its window frame — otherwise selecting the fixture only picks up the shell, not its glow.
- Check for Blender light and camera objects. Stock EEVEE archviz assets sometimes bake all lighting into emissive materials and ship with zero
LIGHT/CAMERAdatablocks.loadSceneAuthoredwill still import color management, but there's nothing for it to bring in on the lighting/camera side. If you want real light contribution — especially relevant onceuseRealWorldTintis enabled for the light portal — add actual Blender light objects before exporting.
Export the result to .untold with the Blender add-on or CLI exporter, place it under the project's GameData folder, and update the filename passed to setEntityMeshAsync and loadSceneAuthored.
For example, if your exported model is MyBedroom.untold:
setEntityMeshAsync(entityId: room, filename: "MyBedroom", withExtension: "untold") { success in
guard success else {
setSceneReady(false)
return
}
loadSceneAuthored(filename: "MyBedroom", withExtension: "untold")
setSceneReady(true)
}