The final repository checkpoint moves scene traversal and light transport to the GPU. Four articles use commit 4648adc: this one covers the host-to-device scene representation, followed by separate articles for path tracing and ReSTIR. Accumulation and Hydra transfer are mentioned only where they affect the lifetime or measurement of those algorithms.

flowchart LR
  H["Hydra synchronization<br/>CPU objects"] --> F["flattened GPU scene"]
  F --> AS["BLAS and TLAS"]
  F --> B["materials, lights, textures"]
  AS --> K["path-tracing kernels"]
  B --> K
Commit contents Files discussed in this chapter gpu_scene_builder.cpp · gpu_scene_upload.cpp · gpu_types.h · test_gpu_scene_upload.cpp
Complete commit diff ↗

Why a second scene representation is required

The CPU scene contains virtual interfaces, Pixar types, containers, pointers, and ownership relationships that are not a shader ABI. GPU kernels require plain data with defined size and alignment, buffer addresses, texture handles, and an acceleration structure.

The conversion is not a byte copy. It performs:

  • mesh-instance expansion;
  • subset-to-material association;
  • transformation into the RHI matrix convention;
  • material deduplication;
  • texture extraction and upload;
  • analytic-light conversion;
  • environment separation;
  • BLAS reuse and TLAS instance construction.

Each operation must preserve an identity used later to reconstruct a hit.

Matrix convention at the ABI boundary

OpenUSD matrices in this renderer use row-vector transforms with translation in row 3. The RHI acceleration-structure instance expects a row-major 3×4 matrix for column-vector multiplication. The conversion is explicit:

template <typename Matrix4> [[nodiscard]] float3x4 _toRhiTransform(const Matrix4 &m)
{
    const gsl::span<const typename Matrix4::ScalarType> d{m.data(), 16};
    float3x4                                            out{};
    for (int r{0}; r < 3; ++r)
    {
        for (int c{0}; c < 3; ++c)
        {
            out.m[r][c] = static_cast<float>(d[(c * 4) + r]);
        }
        out.m[r][3] = static_cast<float>(d[12 + r]);
    }
    return out;
}

The upper-left 3×3 block is transposed and the translation is moved into the fourth component of each output row. An ordinary memcpy would compile and produce a matrix-shaped buffer, but instances would be transformed according to the wrong convention.

The same file avoids using a native shader matrix inside raw light data. Instead it stores three float4 rows, whose scalar layout is explicit on both sides of the ABI.

Expanding instances and subsets

The builder walks visible meshes. Point-instanced meshes expand one GPU instance for every instance transform and mesh subset:

for (IMesh *mesh : scene.GetMeshes())
{
    if (!mesh->IsVisible())
    {
        continue;
    }

    if (!mesh->GetInstancerId().IsEmpty())
    {
        if (IInstancer * instancer{scene.GetInstancer(mesh->GetInstancerId())})
        {
            const VtMatrix4dArray transforms{instancer->ComputeInstanceTransforms(mesh->GetId())};
            for (const auto &transform : transforms)
            {
                for (const auto &subset : mesh->GetSubsets())
                {
                    appendInstance(*mesh, subset, GfMatrix4f(transform) * mesh->GetTransform());
                }
            }
        }
        continue;
    }

    for (const auto &subset : mesh->GetSubsets())
    {
        appendInstance(*mesh, subset, mesh->GetTransform());
    }
}

This excerpt preserves the exact loop from gpu_scene_builder.cpp. The multiplication order is part of the OpenUSD row-vector convention. A test must use a non-commuting pair of transforms—such as rotation followed by translation—because two translations would not expose a reversed order.

Geometry identity and BLAS reuse

For each subset, MeshKey is derived from the stable address of the subset’s index array and paired with GeometryVersion. Instances that share geometry can therefore reuse one bottom-level acceleration structure while retaining different transforms and object IDs.

This key is valid only under the lifetime contract stated in the source: the subset index array address remains stable until topology changes, and topology changes increment the geometry version or rebuild the scene snapshot. Pointer identity is not a persistent asset identifier and must not cross that lifetime.

Material and object identifiers

Materials are deduplicated by CPU object identity for the uninterrupted render:

const IMaterial &material{scene.GetMaterial(matId)};
auto [it, inserted]{
    materialIndex.try_emplace(
        &material,
        static_cast<std::uint32_t>(_materials.size()))};

// ...

flat.MaterialId = it->second;
flat.ObjectId = scene.GetSceneObjectId(mesh.GetId());
flat.DoubleSided = mesh.IsDoubleSided();

MaterialId indexes compact GPU shading data. ObjectId preserves scene identity for temporal-history validation. They answer different questions: two objects may share one material, while one object can contain subsets with different materials.

Preview Surface scalar parameters are resolved through the CPU material implementation after texture bindings are cleared. Textures are then uploaded separately with a material ID and slot. Diffuse data uses the color interpretation selected by the texture factory; normal, metallic, and roughness data are uploaded as linear data.

Lights and environment

CPU TfToken sampling strategies are converted once to small integer values because shader code does not depend on RTTI-enabled Pixar token types. Point, distant, and rectangle lights occupy the regular light array. A dome light is uploaded as environment data rather than duplicated in that array.

The mapping function has a defined fallback for an unrecognized strategy. Its assertion records the unexpected value in debug builds, while the return value avoids undefined behavior in an optimized build. Adding a new light type therefore requires updating both the CPU mapping and shader dispatch.

Validation requirements

A scene-upload test should isolate:

  1. vertex and index byte layout;
  2. matrix convention with non-commuting transforms;
  3. material sharing across different objects;
  4. multiple subsets on one mesh;
  5. point-instancer expansion;
  6. texture slot and color-space flags;
  7. light strategy and environment separation;
  8. object/material IDs reconstructed from a ray hit.

A plausible final image is insufficient for this boundary. Swapping object and material IDs can still produce a shaded image while invalidating temporal reuse and material attribution.

Record this: On commit 4648adc, capture a scene containing one instanced mesh, two subsets, two materials, and one dome light. Add a debug table or overlay showing instance, object, material, and light counts before and after upload.
Caption to keep: The GPU scene is a flattened ABI representation of the renderer scene; tests must preserve geometry, transform, material, light, and object identity separately.