Skip to main content

Connect Geometry and Materials in the Render Pipeline

Geometry supplies the draw shape; materials supply the appearance

When a node renders a mesh, VROGeometry provides two positional arrays: VROGeometrySource objects for vertex attributes and VROGeometryElement objects for index data and primitive descriptions. VROMaterial provides the appearance and render state for those elements. Construct the geometry with its sources and elements, then install at least one material before the node reaches the render path:

std::vector<std::shared_ptr<VROGeometrySource>> sources =
VROShapeUtilBuildGeometrySources(vertexData, numVertices);
std::vector<std::shared_ptr<VROGeometryElement>> elements;
elements.push_back(std::make_shared<VROGeometryElement>(
indexData,
VROGeometryPrimitiveType::Triangle,
kNumBoxVertices / 3,
sizeof(int)));

std::shared_ptr<VROGeometry> geometry =
std::make_shared<VROGeometry>(sources, elements);
geometry->setMaterials({std::make_shared<VROMaterial>()});

VROBox::updateBox() shows the built-in primitive version of this relationship. It creates a default VROMaterial when the box has none, builds sources from packed vertex data, and calls setSources and setElements. A box normally has one triangle element, but six installed materials cause VROBox to create six element ranges—one for each face (VROBox.cpp:52-96).

Material assignment is not stored inside VROGeometryElement. VROGeometry::getMaterialForElement(int elementIndex) selects the material by modulo:

return _materials[elementIndex % _materials.size()];

Equal-sized arrays therefore map element 0 to material 0, element 1 to material 1, and so on. A shorter material array repeats; with three materials, element 5 uses material 5 % 3, or material 2. This also means a geometry must have a non-empty material vector before rendering or sort-key generation.

Changing the mesh data through VROGeometry::setSources or setElements calls updateSubstrate(), so the platform representation is rebuilt. Those setters do not recompute the cached bounds: call updateBoundingBox() when replacing vertex positions. VROGeometry::getBoundingBox() computes the bounds lazily, while setGeometrySourceForSemantic is available when you need to replace all sources for a semantic such as vertex, normal, or texture coordinate.

Importers preserve the positional pairing

Importers build the same two geometry arrays and then make their material vector order match the element order. The glTF loader processes each primitive into an element and its attributes, obtains that primitive's material, and appends it to materials. It then constructs VROGeometry(sources, elements) and calls setMaterials(materials) (VROGLTFLoader.cpp:1269-1320):

std::vector<std::shared_ptr<VROGeometrySource>> sources;
std::vector<std::shared_ptr<VROGeometryElement>> elements;
std::vector<std::shared_ptr<VROMaterial>> materials;

for (tinygltf::Primitive gPrimitive : gPrimitives) {
bool successVertex = processVertexElement(gModel, gPrimitive, elements);
bool successAttributes = processVertexAttributes(
gModel, gPrimitive.attributes, sources, elements.size() - 1, driver);
// ... obtain the primitive material ...
if (material != nullptr) {
materials.push_back(material);
}
}

if (materials.size() == 0) {
materials.push_back(std::make_shared<VROMaterial>());
}

std::shared_ptr<VROGeometry> geometry =
std::make_shared<VROGeometry>(sources, elements);
geometry->setName(gMesh.name);
rootNode->setGeometry(geometry);
geometry->setMaterials(materials);

OBJ follows the same contract but explicitly pushes a defaultMaterial when an OBJ group has no MTL material (VROOBJLoader.cpp:488-515). FBX creates its sources over a shared VROVertexBuffer, creates indexed elements from the imported data, and configures a VROMaterial from the imported scalar properties before assigning the material vector (VROFBXLoader.cpp:443-484). Keep the material vector aligned with element order when constructing custom geometry; the element itself does not carry a material reference.

Configure material visuals and lighting

A VROMaterial exposes VROMaterialVisual channels rather than a single texture slot. The channels include getDiffuse(), getRoughness(), getMetalness(), getAmbientOcclusion(), getSpecular(), getReflective(), and getNormal(), as well as declared channels for emission, multiply, and self-illumination. Each visual combines a color, optional texture, and intensity. Texture kinds are constrained by the visual: roughness, metalness, and ambient occlusion accept 2D textures; specular and normal are 2D-only; reflective is cube-only; diffuse also accepts cube and EGL-image textures.

A complete textured PBR setup is exercised by VROPBRTexturedTest.cpp:

std::shared_ptr<VROSphere> sphere =
VROSphere::createSphere(radius, 20, 20, true);
const std::shared_ptr<VROMaterial> &material =
sphere->getMaterials().front();
material->getDiffuse().setTexture(albedo);
material->getRoughness().setTexture(roughness);
material->getMetalness().setTexture(metalness);
material->getNormal().setTexture(normal);
material->getAmbientOcclusion().setTexture(ao);
material->setLightingModel(VROLightingModel::PhysicallyBased);

For a conventional lit material, VROBoxTest.cpp selects Blinn lighting and supplies diffuse and specular textures:

std::shared_ptr<VROBox> box = VROBox::createBox(2, 2, 2);
std::shared_ptr<VROMaterial> material = box->getMaterials()[0];
material->setLightingModel(VROLightingModel::Blinn);
material->getDiffuse().setTexture(bobaTexture);
material->getDiffuse().setColor({1.0, 1.0, 1.0, 1.0});
material->getSpecular().setTexture(
VROTestUtil::loadSpecularTexture("specular"));

VROGLTFLoader::processPBR maps glTF's base-color factor to getDiffuse().setColor, metallic and roughness factors to visual intensities, and a shared metallic-roughness texture to both getMetalness() and getRoughness(). It maps base color, normal, and occlusion textures to their corresponding channels and selects VROLightingModel::PhysicallyBased (VROGLTFLoader.cpp:1883-1930). A glTF asset without PBR values is assigned Lambert lighting instead. glTF MASK alpha is currently converted to VROTransparencyMode::AOne; the loader comments identify transparent-mask support as a TODO.

The default VROMaterial lighting model is Constant, with per-pixel lighting enabled. VRONode::render makes the lighting environment observable at render time: after binding the material, it renders an element when there are computed lights, when the model is Constant, or when the model is PhysicallyBased and the VRORenderContext has an irradiance map. A Lambert, Blinn, or Phong material is consequently not rendered by this gate when there are no computed lights.

Render-time connection and state binding

The normal path is driven by VRONode, one geometry element at a time:

VRONode::render
-> VROGeometry::getMaterialForElement(i)
-> VROMaterial::bindShader(lights, context, driver)
-> VROMaterial::bindProperties(driver)
-> VROGeometry::render(i, material, transforms, opacity, context, driver)
-> VROGeometrySubstrate render
-> bind view/geometry state, textures, and indexed primitives

The order is significant. VRONode.cpp:233-259 calls bindShader first and skips the element if it returns false. It then calls bindProperties, performs the lighting visibility gate, and calls VROGeometry::render with the node's world transform, inverse-transpose normal transform, and computed opacity. VROMaterial::bindShader chooses a shader using the material properties, lights, render context, and shader modifiers; bindProperties applies material state after that shader is active.

VROGeometry::render ensures that a driver-specific geometry substrate exists and delegates the element draw. In the OpenGL substrate, VROGeometrySubstrateOpenGL::render obtains the material substrate, binds view uniforms—including transform, view and projection matrices, normal matrix, camera position, and eye type—binds the element's VAO, and calls renderMaterial (VROGeometrySubstrateOpenGL.cpp:318-333). renderMaterial binds geometry uniforms with substrate->bindGeometry(opacity, geometry), walks the material's texture references, and binds each available texture. If a texture substrate is not ready, OpenGL selects a blank texture of the appropriate type. The final call is glDrawElements; geometries with a VROInstancedUBO use glDrawElementsInstanced instead (VROGeometrySubstrateOpenGL.cpp:335-384).

Metal makes the material-to-element mapping explicit while building its pipeline state. VROGeometrySubstrateMetal::updatePipelineStates selects materials[i % materials.size()] for each element, creates a render pipeline state from that material, and creates a matching depth-stencil state (VROGeometrySubstrateMetal.cpp:149-165). This is why material ordering and geometry-substrate freshness matter on Metal: VROGeometry::setMaterials stores the new vector but does not itself invalidate the geometry substrate.

Silhouette paths reuse the geometry/material connection with different material semantics. VRONode::renderSilhouettes uses VROGeometry::renderSilhouette for a flat silhouette, which ignores texturing and lighting, or swaps each element's diffuse texture into the supplied silhouette material and calls renderSilhouetteTextured. This keeps the element selection and indexed geometry while changing the appearance pass.

Resource hydration, mutation, and render state

You can create GPU-facing resources ahead of visibility when a live VRODriver is available:

geometry->prewarm(driver);
material->prewarm(driver);

VROGeometry::prewarm creates the geometry substrate only when the geometry has usable sources and elements. VROMaterial::prewarm hydrates the material substrate and its textures. Without either call, the render cycle creates these resources lazily. For texture uploads that should happen asynchronously, use hydrateAsync; it queues each non-None, not-yet-hydrated visual texture, invokes the callback once per upload, and returns the number queued.

Visual edits such as setColor, setTexture, and setIntensity invalidate the material substrate through the owning material's update path. Capability-affecting changes such as setLightingModel, depth read/write settings, shadow flags, and shader modifiers likewise force lazy substrate recreation. swapTexture is the distinct hot-swap operation for replacing an existing texture; adding the first texture requires a new substrate rather than only a texture update.

Material render state is applied in bindProperties. It includes culling, depth reads and writes, color-write masks, blending, and the material substrate properties. Other state participates in sorting or shader capability selection: transparency can come from node opacity, material transparency, or diffuse alpha; bloom and post-process settings add pass requirements; VROMaterial::updateSortKey records material identity and delegates shader/texture ordering to the material substrate. During a material animation snapshot, VROGeometry::updateSortKeys can emit a key for the outgoing material as well as the current one, allowing the geometry to cross-fade between material states.

VROMaterial is renderer-thread restricted. Native setters in capi/Geometry_JNI.cpp demonstrate the expected coordination by capturing references and dispatching setMaterials asynchronously with VROPlatformDispatchAsyncRenderer. Coordinate material mutation, substrate invalidation, hydration, and rendering with the renderer thread.

Troubleshooting the geometry/material boundary

  • No materials: getMaterialForElement and the Metal substrate use modulo by the material count. Install at least one material before rendering. VROBox and the glTF loader insert defaults when needed.
  • Wrong material on an element: mapping is positional and modulo-based, not embedded in VROGeometryElement. Check that importer or custom-geometry material order matches element order.
  • Bounds do not follow replaced vertices: setSources refreshes the geometry substrate but not the cached bounding box. Call updateBoundingBox() after changing vertex positions. The JNI semantic-source replacement path does not do this automatically.
  • First textured frame is not populated: texture hydration can be asynchronous. OpenGL binds a blank placeholder when a texture substrate is unavailable; setting a texture does not guarantee that its GPU data exists on the first frame.
  • Material changes appear stale on Metal: setMaterials does not rebuild the geometry substrate. Metal creates one pipeline and depth state per element/material mapping, so replacing materials may require geometry-substrate recreation.
  • Backend source-layout constraints: the Metal geometry substrate expects the supported shared/interleaved source-data arrangement and accesses the first source during construction. Empty sources or unsupported layouts must not reach that path.
  • Unsupported visual channels or texture types: emission, multiply, and self-illumination are marked unsupported in VROMaterial; invalid texture kinds for a visual are rejected by VROMaterialVisual.
  • Unexpected glTF alpha behavior: MASK currently falls back to AOne, as documented by the TODO in VROGLTFLoader::getMaterial.
  • Lit material disappears in an unlit scene: VRONode::render requires computed lights for non-Constant models, except that PBR can pass with an irradiance map. Select Constant when the material is intended to render without either lighting source.