Renderer Backend Port
Purpose
A renderer reaches a new platform by swapping the backend under a stable seam, not by forking the whole renderer. The port's job is to keep the platform-agnostic core and the public contract identical, reimplement only the RHI and the shaders, and report capability differences honestly instead of faking them. A port that edits the core or the public ABI has found a leak in the seam — fix the seam, not the core.
Workflow
Identify the three layers and the seam. Public C ABI on top → platform-agnostic core (API impl, scene extraction, camera, material setup) → RHI seam (an opaque GPU handle plus buffer/pipeline/frame/trace verbs) → backend implementation + shaders. Only the bottom two move. In the nanoUSD renderers the seam is
gpu.h, the core isrenderer.c/scene.c/camera.c/material.cpp, and a backend isgpu_<api>.{c,mm}plus its shader set. Read the existing seam before porting; do not invent a parallel one.Keep the public ABI identical and include it verbatim. The new backend satisfies
renderer-capability-abi: same canonical header, same capability bitmask, a backend-info query that names the new backend. Do not redefine flags locally. Differences are expressed as cleared capability bits, not as ABI edits.Scope by capability; stub the rest explicitly. Enumerate the features the target genuinely cannot do (vendor-specific interop, vendor upscalers, windowing assumptions) and the ones it does differently. Each unsupported entry point returns the unsupported result and clears its capability bit — never a no-op success. Keep a short "what was dropped and why" ledger; it feeds
renderer-feature-validation-matrixandlegacy-retirement-clean-history.Translate shaders deliberately. Decide ahead-of-time versus runtime shader compilation up front — it dictates how kernels share helpers and whether includes resolve. Keep the reference shaders beside the port as the spec. Two recurring traps: cross-language struct and push-constant alignment (a struct that is N bytes in the host language may be padded larger by the shading language — size the host upload to the shading language's layout, not the host's); and runtime compilers that do not resolve user includes (put co-dependent kernels in one translation unit). A shared shader-codegen library (e.g. MaterialX) can have a mature generator for one target and an immature one for another — pin to the version where your target's generator reached parity and force a fresh fetch, or an older shared build silently lacks your backend.
Honor cross-API invariants — the ones that silently corrupt. Frame each as an invariant plus a failure signature, because the APIs differ but the failures repeat:
- Transform convention. Agree on row- versus column-vector and where translation
lives; a flat element copy across that boundary silently drops translation and
the object renders at the origin (and per-frame transform updates look like
no-ops). Use one named conversion at the GPU boundary and verify with a
translate-and-re-render test, not a static frame. This exact bug shipped in more
than one nanoUSD backend's raycast path. Know precisely where the storage
reinterpret cancels: storing a row-major host matrix into a column-major shader
mat4is an implicit transpose that the shader'svec * matmultiply order cancels — the same host bytes are correct in GLSLM * vand MSLv * Mwith no host conversion — but a matrix consumed by fixed-function hardware (an acceleration-structure instance transform) does NOT cancel and needs an explicit element transpose. Treating both as one rule corrupts one path. - Command-encoder/buffer state. On some APIs, ending and reopening an encoder (e.g. for a mid-frame acceleration-structure refit) drops every binding. Re-bind through a single shared helper used by both the initial and the post-refit path; a forgotten rebind reads garbage with no error.
- Acceleration-structure residency. A TLAS may reference BLASes the API will not keep resident automatically; mark them used or they are evicted and rays dereference freed memory.
- Resource storage modes, texture usage flags, and memory accounting differ; mirror the reference's semantics (including which buffers count toward reported memory) so telemetry and downstream tests stay comparable.
- Output color-space/encoding. Match the reference's swapchain/target encoding or pixel-equality tests fail by a fixed, mystifying delta.
- Transform convention. Agree on row- versus column-vector and where translation
lives; a flat element copy across that boundary silently drops translation and
the object renders at the origin (and per-frame transform updates look like
no-ops). Use one named conversion at the GPU boundary and verify with a
translate-and-re-render test, not a static frame. This exact bug shipped in more
than one nanoUSD backend's raycast path. Know precisely where the storage
reinterpret cancels: storing a row-major host matrix into a column-major shader
Prove parity; do not assert it. Use
renderer-image-comparison-testingwith backend-specific goldens and, where parity is a contract, cross-backend diffs on the reduced corpus. A port phase is done when its gate matches the reference backend on a fixture, not when it compiles and renders something.When the port reveals a bug in shared-core logic, fix it at the source of truth and propagate. Ports surface latent bugs in the supposedly-shared code (a wrong transform copy, a test that passed tautologically). Fix it in the canonical core and push the fix to every backend in the same change. Patching only the new backend is how "shared" files drift thousands of lines apart and how a fix found in one backend never reaches the others.
Anti-Patterns
- Forking the core per backend instead of sharing it behind the RHI seam — it drifts (observed: a scene loader diverging thousands of lines between two backends).
- Faking an unsupported feature as no-op success instead of clearing its capability bit and returning the unsupported result.
memcpyacross a matrix-convention or struct-alignment boundary "because both are 3x4 floats."- Re-deriving bindings after an encoder swap by guesswork instead of one shared bind helper.
- Fixing a shared-core bug only in the backend that surfaced it.
- Calling "it builds and renders a triangle" parity.
Handoff
Report the layers touched (should be only the RHI and shaders), the capabilities supported versus stubbed with reasons, parity evidence against the reference backend on named fixtures, and any shared-core fixes that must be propagated upstream to the other backends.