GPU scene representation and upload
4648adc · 2026-07-26 ↗
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["bottom-level and top-level<br/>acceleration structures"]
F --> B["materials, lights, textures"]
AS --> K["path-tracing kernels"]
B --> K
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 application binary interface (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 (BLAS) while
retaining different transforms and object IDs. A top-level acceleration
structure (TLAS) contains the transformed instances and their instance
identities.
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 render hardware interface (RHI) is the backend-neutral layer used here to allocate buffers, textures, and acceleration structures. Its types are part of the host/device contract even though the active backend on this machine is Metal.
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:
- vertex and index byte layout;
- matrix convention with non-commuting transforms;
- material sharing across different objects;
- multiple subsets on one mesh;
- point-instancer expansion;
- texture slot and color-space flags;
- light strategy and environment separation;
- 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.
The production upload test makes several of those obligations executable. The abridged assertions below omit only their repeated diagnostic string arguments:
DBG_ASSERT(result1.InstanceMaterialIds[0] == 0);
DBG_ASSERT(result1.InstanceMaterialIds[1] == 1);
DBG_ASSERT(result1.InstanceObjectIds[0] == 11);
DBG_ASSERT(result1.InstanceObjectIds[1] == 11);
// Instances 0 and 1 share a MeshKey.
DBG_ASSERT(
result1.InstanceVertexAddrs[0]
== result1.InstanceVertexAddrs[1]);
DBG_ASSERT(
result1.InstanceVertexAddrs[0]
!= result1.InstanceVertexAddrs[2]);
const auto result2{uploader.Upload(input)};
DBG_ASSERT(uploader.BlasCacheSize() == 2);
DBG_ASSERT(uploader.BlasBuildCount() == 2);
The first assertions keep material identity distinct from object identity. The
address comparisons prove that two instances with one MeshKey share uploaded
geometry, while a different key does not. The second upload rebuilds the TLAS
but leaves the BLAS build count unchanged, testing cache reuse rather than
inferring it from performance. The complete
test_gpu_scene_upload.cpp
also checks optional normal/UV buffers and geometry-version invalidation.
Larger scene graphs in motion
The following captures are integration checks for assets, textures, transforms, lighting, and camera state. They do not replace the representation tests above.
dbca10b, GPU ReSTIR,
24 spp per frame, seed 0, firefly filtering and OIDN enabled. The camera
layer uses negative time codes so the bike remains at its first authored
animation sample; fixed lights replace the camera headlight.
A successful image exercises the upload path but does not prove
object/material identity correspondence, which still requires the tests
listed above.
The reproducible camera and lighting layer is composed without modifying the packaged asset. Each frame is rendered in a fresh process because the long multi-frame process exhausted renderer resources on this machine.
The Open Chess Set does not author a light. For this orbit, a separate USD layer authors the animated camera and two fixed rectangular lights, and the capture disables the automatic camera light. Consequently only the viewpoint changes between frames; geometry, materials, emitters, seed, and render settings remain fixed.
dbca10b, GPU ReSTIR, 64 spp per frame, seed 0,
resolutionLevel=1, firefly filter, chromaticity blur, and
OIDN enabled. The same camera layer and capture procedure can be used
with the earlier GPU checkpoint 4648adc; the supplied WebM
is the later recording. The two area lights and camera trajectory are
recorded in the
camera layer.
The static high-sample reference and convergence curves for this asset are reported separately in Convergence across scenes.