Build and Traverse a Viro Scene Graph
Ownership starts at the scene root
When you build a renderer scene, attach content below the root returned by VROScene::getRootNode(). VROScene creates a VROPortal named Root, makes it the active portal, and marks it passable. The scene holds that root strongly; each VRONode holds its children strongly in _subnodes, while _supernode and _scene are weak links. This gives the graph a strong ownership path from scene to descendants without making parent links retain cycles.
VROSceneController completes the scene membership setup that VROScene itself does not: its constructor calls getRootNode()->setScene(_scene, true). If you construct VROScene directly, perform the equivalent attachment before relying on VRONode::getScene() or automatic physics registration.
A typical graph has transform-only container nodes, geometry nodes, and a camera node. VROBoxTest::build uses exactly that arrangement:
_sceneController = std::make_shared<VROSceneController>();
std::shared_ptr<VROScene> scene = _sceneController->getScene();
std::shared_ptr<VROPortal> rootNode = scene->getRootNode();
rootNode->setPosition({0, 0, 0});
std::shared_ptr<VROLight> ambient = std::make_shared<VROLight>(VROLightType::Ambient);
ambient->setColor({ 0.6, 0.6, 0.6 });
rootNode->addLight(ambient);
std::shared_ptr<VROBox> box = VROBox::createBox(2, 2, 2);
std::shared_ptr<VRONode> boxParentNode = std::make_shared<VRONode>();
boxParentNode->setPosition({0, 0, -5});
VROMatrix4f scalePivot;
scalePivot.translate(1, 1, 0);
boxParentNode->setScalePivot(scalePivot);
std::shared_ptr<VRONode> boxNode = std::make_shared<VRONode>();
boxNode->setGeometry(box);
boxParentNode->addChildNode(boxNode);
rootNode->addChildNode(boxParentNode);
std::shared_ptr<VRONodeCamera> camera = std::make_shared<VRONodeCamera>();
std::shared_ptr<VRONode> cameraNode = std::make_shared<VRONode>();
cameraNode->setCamera(camera);
rootNode->addChildNode(cameraNode);
addChildNode requires a non-null node, appends it to the parent's child vector, sets the child's weak parent link, and—when the parent already belongs to a scene—calls setScene(scene, true) on the new subtree. removeFromParentNode erases the node from the parent's strong child vector and recursively clears scene membership. getChildNodes() returns a copy, so editing that returned vector does not edit the graph. Also remove a node from its old parent before reparenting: addChildNode does not remove the old strong membership first.
Scene membership also connects physics. VRONode::setScene removes an existing physics body from its old scene's physics world and adds it to the new scene's lazily created world. For example, VROPhysicsTest attaches a ground node before creating its static body:
std::shared_ptr<VRONode> groundNode = std::make_shared<VRONode>();
groundNode->setGeometry(groundBox);
groundNode->setPosition({0, -10, -5});
rootNode->addChildNode(groundNode);
std::shared_ptr<VROPhysicsBody> physicsGround = groundNode->initPhysicsBody(
VROPhysicsBody::VROPhysicsBodyType::Static, 0, nullptr);
physicsGround->setRestitution(1.0);
std::shared_ptr<VROPhysicsWorld> physicsWorld = scene->getPhysicsWorld();
physicsWorld->setGravity({0, -9.81f, 0});
One scene-level traversal drives each frame
Do not treat VRONode traversal as an isolated render call. VRORenderer owns the pass order. It computes transforms before updating the camera because the camera can itself be below transformed node parents:
if (_sceneController) {
std::shared_ptr<VROScene> scene = _sceneController->getScene();
scene->computeTransforms();
}
VROCamera camera = updateCamera(viewport, fov, headRotation, projection);
_context->setCamera(camera);
const VRORenderContext &context = *_context.get();
if (_sceneController) {
std::shared_ptr<VROScene> scene = _sceneController->getScene();
scene->computeIKRig(context);
scene->computePhysics(context);
scene->applyConstraints(context);
scene->updateParticles(context);
scene->updateVisibility(context);
scene->updateSortKeys(_renderMetadata, context, driver);
scene->syncAtomicRenderProperties();
}
Each VROScene method is a façade over the root: computeTransforms invokes VROPortal::computeTransforms, applyConstraints invokes the root's recursive constraint pass, and the particle, visibility, IK, and application-property synchronization methods likewise begin at the root. computePhysics is conditional: it does nothing unless getPhysicsWorld() has previously created a world.
updateSortKeys has additional scene-wide work. It clears and recursively collects lights from the graph, places them in VRORenderParameters, recursively calls VRONode::updateSortKeys, rebuilds the portal tree relative to _activePortal, sorts portal siblings by camera distance, and stores the furthest object distance. Normal rendering therefore uses scene sorting and the resulting metadata rather than the deliberately less efficient direct recursive VRONode::render(context, driver) path.
Transforms cascade through parents
VRONode::computeTransforms(parentTransform, parentRotation) first calls doComputeTransform, computes the node's world rotation, updates spatial sound positions, computes umbrella bounds, and then passes _worldTransform and _worldRotation to every child. The effective world transform is the parent transform multiplied by the node's local transform.
doComputeTransform builds the local transform from scale, optional scale pivot, rotation, optional rotation pivot, and translation. The source records the intended composition as:
// _worldTransform = parentTransform * T * Rpiv * R * Rpiv -1 * Spiv * S * Spiv-1
The implementation then computes _worldTransform = parentTransform * _localTransform and extracts world position from the resulting matrix. This is why the boxParentNode in VROBoxTest can carry position, pivot, and animation while its child only carries geometry: the child's world transform receives the entire parent transform during recursive traversal.
Use setWorldTransform for a child whose parent transform exists. That method converts the requested world position and rotation into local coordinates using the parent's inverse, then performs a transform pass. It is not safe for a root or unparented node because the implementation dereferences getParentNode().
VRONode also maintains application-thread mirrors. Ordinary transform setters and graph operations are renderer-thread restricted. The atomic setters—such as setPositionAtomic, setRotationAtomic, and setScaleAtomic—update the application-side mirror immediately and dispatch the corresponding renderer-thread change. computeTransformsAtomic computes one node's mirror transform and intentionally does not recurse; the application-side caller must recurse because the renderer has no application-thread graph copy. At the end of the renderer pass, VROScene::syncAtomicRenderProperties() starts the recursive synchronization back to application code.
Geometry and umbrella bounds make traversal spatial
setGeometry stores a std::shared_ptr<VROGeometry> on the node. During transform computation, a normal geometry node keeps three useful levels of bounds: the geometry's untransformed bounding box, the box after local transforms, and the box after the full world transform. A node with no geometry receives a zero-size box at its position, so a transform-only parent still has a spatial location even though its umbrella bounds are ultimately determined by descendants.
The umbrella box is the union of the node's geometry and all descendant geometry. computeUmbrellaBounds walks children with accumulated local transforms, while world bounds use each child's already computed world box. If an entire subtree has no geometry, the world umbrella box becomes a point at the node's world position. Particle instancing has a special path in doComputeTransform; the source explicitly notes that its local particle bounds are not set correctly, so avoid treating those local bounds as equivalent to ordinary geometry bounds.
Visibility tests use the world umbrella box rather than testing every geometry immediately. VRONode::updateVisibility tests the current camera frustum. If the camera is inside the umbrella box, it treats the result as Intersects; otherwise it uses the frustum result. Inside marks the node and complete subtree visible, Outside marks the complete subtree invisible, and Intersects recurses into children. Consequently, a camera enclosed by a model or surrounding effect is not incorrectly culled, and an outside umbrella box can prune a whole branch.
A newly created node starts with _visible == false. It becomes eligible for sorting only after VROScene::updateVisibility has run. VRONode::updateSortKeys returns immediately for an invisible node because the umbrella test has already established that none of its descendants are visible.
Sorting computes render state, not just order
For every visible, non-held node, VRONode::updateSortKeys computes the inverse-transpose world transform, cascaded opacity, relevant lights, hierarchy information, and geometry sort keys. Cascaded opacity is calculated as:
_computedOpacity = opacities.top() * _opacity * _opacityFromHiddenFlag;
Lights collected by VROScene are filtered per node by the light influence bit mask and attenuation distance. Ambient and directional lights bypass the attenuation-distance cull. Set setLightReceivingBitMask(mask, true) when the mask should cascade to descendants; the default non-recursive form changes only the current node.
The hierarchicalRendering flag changes the sort state of a subtree. A node marked hierarchical starts a hierarchy, and descendants inherit that mode and share the parent's camera distance for stable ordering. Otherwise, geometry distance is currently based on the center of _worldBoundingBox; the source comments that using distance to the box itself causes artifacts. The geometry receives these values through VROGeometry::updateSortKeys.
A held node is different from an invisible node. setHoldRendering(true) makes updateSortKeys stop before descending and also makes direct rendering stop; the header describes this as a model-load mechanism. Use visibility for normal scene culling, not hold-rendering as a second visibility system.
Rendering, portals, and input all traverse the same graph
The direct VRONode::render(context, driver) method checks _holdRendering, requires geometry opacity above kHiddenOpacityThreshold, binds each element's material, and renders only when there are relevant lights, the material uses VROLightingModel::Constant, or a physically based material has an irradiance map. It then recurses through every child. Thus, a lit material can still produce no draw from this path when no usable light is present. The method is explicitly unsorted and unbatched; the normal path is the scene's sort-key and render-pass pipeline.
setHidden sets _hidden immediately but animates _opacityFromHiddenFlag to 0.0 or 1.0. Hidden state therefore participates in the same opacity cascade as explicit setOpacity, and rendering can fade rather than switch a node through an instantaneous boolean. The rendering threshold is applied to computed opacity.
Portals add a scene-level traversal layer after node sorting. VROScene::createPortalTree clears the previous tree, traverses from the active portal, and sorts sibling portals front-to-back using their world positions and camera distance. setActivePortal verifies that the requested portal is under the scene root before changing the active portal. The portal tree is consequently rebuilt each frame from the active-portal perspective rather than assumed to begin at the ordinary root.
Input begins at that ordinary root. VROInputControllerBase::hitTest calls sceneRootNode->hitTest(...), sorts all returned VROHitTestResult objects by distance, and returns the closest result that does not ignore event handling. Node hit testing also considers visibility, computed opacity, selectability, bounds, and—when configured—geometry accuracy. The scene graph therefore supplies both render traversal and interaction traversal, but each uses its own node-level filtering.
Scene-graph gotchas
VROScenecreates its root but does not attach the root's weak scene link;VROSceneControllerdoes this explicitly.addChildNodedoes not detach an existing parent. CallremoveFromParentNode()before reparenting.getChildNodes()is a snapshot copy, not a mutable graph handle.- Do not call
setWorldTransformon a root or unparented node.- Run renderer-thread operations on the renderer thread; use atomic setters or the JNI dispatch bridge for cross-thread application updates.
- Visibility must be updated before sorting, and particle-instancing local bounds have a source-documented TODO and should not be assumed to behave like ordinary geometry bounds.