Omniverse Spatial Scene Integration and Messaging#
Note
Applies to: Spatial Extensions, Kit 109.0.3+, CloudXR 6
This scene integration reference covers loading USD content into your Kit XR application and using the Kit message bus for bidirectional communication between the server and XR clients. The message bus is the universal integration point: messages arrive at the same place regardless of which CloudXR runtime (Native or WebRTC) or which client (Apple, Meta, or browser) sent them.
The Omniverse Spatial Kit Message Bus#
Both CloudXR runtimes deliver opaque data channel messages to the same Kit message bus:
This means:
A Kit extension listening on the message bus responds to messages from any client
The JSON payload format is identical regardless of transport
You write your server-side logic one time and it works with all clients
Message Format#
Important
Always use the "payload" field (not "message") when sending JSON over the CloudXR opaque data channel. The "payload" path flattens fields directly into event.payload on the server, which is what the USD Viewer built-in handlers expect. The "message" field is a legacy fallback that serializes to a string and requires manual parsing.
All messages sent over the CloudXR opaque data channel use the same JSON structure:
{
"type": "someEventName",
"payload": {
"key": "value"
}
}
type: A string identifying the operation. The CloudXR plugin reads this field to determine which event to dispatch on the server. This field is required; messages without it are rejected.payload: A JSON object with operation-specific data. The CloudXR plugin flattens each child field directly intoevent.payload, so server-side handlers receiveevent.payload["key"]etc.
How CloudXR Delivers Messages to the Server#
The OpenXR plugin (omni.kit.xr.system.openxr) supports two payload delivery modes depending on which fields it finds in the incoming JSON:
"payload"field (preferred): The plugin iterates over the children of the"payload"object and copies each one directly intoevent.payload. This means a handler receivesevent.payload["url"],event.payload["paths"], etc. exactly as expected. This is the mode used by the USD Viewer messaging extension: no bridge or wrapper extension is needed."message"field (fallback): If there is no"payload"field but a"message"field is present, the plugin serializes its value to a string and places it inevent.payload["message"]. Extensions that use this path (such asomni.xr.visibility) parse the JSON string themselves.Neither: If the message contains neither
"payload"nor"message", the plugin logs an error and drops the message.
The full flow for the recommended "payload" path:
Client sends
{"type":"openStageRequest","payload":{"url":"..."}}CloudXR plugin reads
"type"and dispatches the event asopenStageRequestCloudXR plugin reads
"payload"and flattens its children intoevent.payloadThe USD Viewer handler
_on_open_stagefires and readsevent.payload["url"]to load the stage
USD Viewer Built-In Message Handlers#
If you created your application using the USD Viewer template, the setup extension already includes handlers for common operations. These handlers listen on the Kit message bus and respond to incoming messages from any client.
Important
The message handlers listed below are provided by the USD Viewer template’s built-in setup extension. If you built your application from a different template (such as Kit Base Editor or USD Composer), no message handling extension is included by default – incoming data channel messages will arrive on the Kit message bus but nothing will act on them. You will need to create your own extension that subscribes to the bus and handles the messages your client sends. See Writing Custom Message Handlers below for an example.
Available Message Handlers#
Event Type |
Direction |
Description |
|---|---|---|
|
Client -> Server |
Load a USD asset |
|
Server -> Client |
Confirm asset load success/failure |
|
Client -> Server |
Poll whether Kit is ready |
|
Server -> Client |
Report loading state |
|
Client -> Server |
Get child prims of a path |
|
Server -> Client |
Return child prim list |
|
Client -> Server |
Change prim selection |
|
Server -> Client |
Notify client of selection changes |
|
Client -> Server |
Make prims selectable in viewport |
|
Client -> Server |
Reset stage to initial state |
|
Server -> Client |
Report loading progress |
|
Server -> Client |
Report loading activity |
Example Payloads#
Load a USD asset:
{
"type": "openStageRequest",
"payload": {
"url": "${omni.usd_viewer.samples}/samples_data/stage01.usd"
}
}
Reset the stage:
{
"type": "resetStage",
"payload": {}
}
Select specific prims:
{
"type": "selectPrimsRequest",
"payload": {
"paths": ["/World/Geometry/Cube"]
}
}
Query stage children:
{
"type": "getChildrenRequest",
"payload": {
"prim_path": "/World",
"filters": ["USDGeom"]
}
}
Sending Messages from the Apple Generic Viewer#
The cloudxr-apple-generic-viewer includes a ServerActionsView with “Action 1” and “Action 2” buttons. By default, these send plain text strings. To trigger the USD Viewer’s built-in handlers, modify them to send the correct JSON payloads.
Original Code#
In CloudXRViewer/Common/ServerActionsView.swift, the default buttons send plain strings around line 122:
Button("Action 1") {
sendMessage(message: "Action 1")
}
Modified Code: Trigger USD Viewer Handlers#
First, add this helper function to the top of the ServerActionsView struct in ServerActionsView.swift (before any button code). It uses JSONSerialization to build clean JSON with no stray whitespace. The "type" field tells CloudXR which event to dispatch; all other fields become the payload:
/// Build a JSON message for the CloudXR opaque data channel.
/// CloudXR reads "type" for the event name and flattens "payload"
/// children into event.payload on the server.
private func buildMessage(type: String, payload: [String: Any] = [:]) -> String? {
let message: [String: Any] = [
"type": type,
"payload": payload
]
guard let data = try? JSONSerialization.data(withJSONObject: message, options: []),
let jsonString = String(data: data, encoding: .utf8) else {
return nil
}
return jsonString
}
Then replace the button actions:
Button("Load Stage 1") {
if let json = buildMessage(type: "openStageRequest", payload: [
"url": "${omni.usd_viewer.samples}/samples_data/stage01.usd"
]) {
sendMessage(message: json)
}
}
Button("Load Stage 2") {
if let json = buildMessage(type: "openStageRequest", payload: [
"url": "${omni.usd_viewer.samples}/samples_data/stage02.usd"
]) {
sendMessage(message: json)
}
}
The sendMessage function already sends data over the CloudXR opaque data channel using MessageChannel.sendServerMessage(). No changes to the transport layer are needed: only the payload changes.
For more advanced patterns, including type-safe structured messaging, variant switching, camera changes, gesture-driven interaction, and portal windows, see the Apple Code Snippets Cookbook.
Remember to select a data channel before using the UI in the sample client.
Receiving Responses#
The ServerActionsView already listens for incoming messages through channel.receivedMessageStream. Responses from Kit (such as openedStageResult or loadingStateResponse) arrive on this same stream as JSON strings.
Sending Messages from the CloudXR.js React Sample#
The CloudXR.js React sample includes a 3D in-VR control panel with generic Action 1 and Action 2 buttons. By default, these send generic action messages ({ type: 'action', message: { action: 'action_1' } }). To trigger the USD Viewer’s built-in handlers instead, you only need to change two things: the button labels and the message payloads. The transport layer (sendMessage) already works and does not need any changes. The end result is two buttons: Load Stage 1 and Load Stage 2, each loading a different USD asset, matching the Apple client sample above.
The two files involved are:
src/CloudXRUI.tsx– The 3D button layout and labelssrc/App.tsx– The button click handlers and thesendMessagehelper
How sendMessage Works#
For reference, the existing sendMessage function in App.tsx handles all the transport details. It tries the MessageChannel API first and falls back to the legacy sendServerMessage API, returning a boolean indicating success:
const sendMessage = async (message: any) => {
if (!cloudXRSession) {
console.error('CloudXR session not available');
return false;
}
const channels = cloudXRSession.availableMessageChannels;
if (channels.length > 0) {
const channel = channels[0];
try {
const encoder = new TextEncoder();
const data = encoder.encode(JSON.stringify(message));
const success = channel.sendServerMessage(data);
return success;
} catch (error) {
console.error('Error sending via MessageChannel:', error);
return false;
}
}
// Fallback to legacy API
try {
cloudXRSession.sendServerMessage(message);
return true;
} catch (error) {
console.error('Error sending via legacy API:', error);
return false;
}
};
This is the CloudXR.js equivalent of the Apple client’s MessageChannel.sendServerMessage(). The JSON structure is identical across both clients: the server cannot distinguish which platform sent the message.
Receiving Responses#
The React sample already sets up a message receive loop in App.tsx that polls for MessageChannel availability and reads incoming messages. Responses from Kit (such as openedStageResult or loadingStateResponse) arrive as JSON strings. To act on them, add handling logic inside the existing parse block:
const message = JSON.parse(messageText);
if (message.type === "openedStageResult") {
console.log("Stage loaded:", message.url);
} else if (message.type === "loadingStateResponse") {
console.log("Loading state:", message.payload);
}
You do not need to modify sendMessage itself: it works with any JSON payload. Just pass the correct type and payload fields for the Kit handler you want to trigger (see USD Viewer Built-In Message Handlers above).
Note
The default action messages use the "message" field rather than "payload". This means the server receives event.payload["message"] as a serialized JSON string. If you are targeting the USD Viewer built-in handlers, use the "payload" field instead so the plugin flattens the values directly into event.payload.
Writing Custom Message Handlers#
You can add your own message handlers in a Kit extension. This works with any client.
Example: Custom Action Handler#
# In your extension.py
import omni.ext
import omni.kit.app
import carb.events
import json
class MyMessageHandlerExtension(omni.ext.IExt):
def on_startup(self, ext_id):
# Subscribe to the message bus
bus = omni.kit.app.get_app().get_message_bus_event_stream()
self._sub = bus.create_subscription_to_pop_by_type(
carb.events.type_from_string("myCustomAction"),
self._on_custom_action
)
def _on_custom_action(self, event):
payload = event.payload
print(f"Received custom action: {payload}")
# Perform your logic here
# e.g., change material, toggle visibility, animate, etc.
def on_shutdown(self):
self._sub = None
Then from any client, send:
{
"type": "myCustomAction",
"payload": {
"action": "toggleVisibility",
"target": "/World/Geometry/Cube"
}
}
Loading USD Scenes in Omniverse Spatial#
There are three ways to load USD content into a running Omniverse Spatial application. Choose the method that matches your workflow:
Method 1: Command-Line Argument at Launch#
Pass the --/app/auto_load_usd argument when launching Kit to load a scene immediately:
./repo.sh launch -- --/app/auto_load_usd='/path/to/your/scene.usd'
The ${omni.usd_viewer.samples} token can be used to reference bundled sample scenes:
./repo.sh launch -- --/app/auto_load_usd='${omni.usd_viewer.samples}/samples_data/stage01.usd'
Method 2: Client-Driven Loading via Opaque Data Channel#
Send an openStageRequest message from any connected client (Apple or Meta) to load a scene at runtime:
{
"type": "openStageRequest",
"payload": {
"url": "/path/to/your/scene.usd"
}
}
The USD Viewer setup extension handles this message and responds with openedStageResult when loading completes. See USD Viewer Built-In Message Handlers above for the full list of supported messages.
Method 3: Programmatic Loading from a Kit Extension#
Load USD content directly from Python in your Kit extension code:
import omni.usd
# Open a stage (replaces the current scene)
omni.usd.get_context().open_stage("path/to/scene.usd")
# Add a reference to the current stage (non-destructive)
stage = omni.usd.get_context().get_stage()
prim = stage.DefinePrim("/World/MyAsset")
prim.GetReferences().AddReference("path/to/asset.usd")
Advanced: XRUsdLayer for Real-Time USD Integration#
For developers building custom XR tools and interactions, the Kit XR extensions provide XRUsdLayer: a specialized system for managing XR UI elements in the USD stage with minimal latency. XRUsdLayer bypasses normal USD composition for real-time updates and is the foundation for controller models, selection beams, teleport arcs, and 3D UI elements.
For full documentation on XRUsdLayer, see the Scene Integration development guide.
See Also#
Apple Client Setup – Connect from Vision Pro or iPad
Meta Client Setup – Connect from Quest 2/3/3S
XR Settings Reference – Tune quality and performance
System Architecture – Full pipeline overview