OHAO · Implementation Monograph

Monograph · 04 · Materials & PBR

04

Foundations · full depth

Materials & PBR

Chapter contract

Follow one material from a GLB field all the way into GBuffer lighting and path-tracer NEE — pack layout, BRDF math as coded, light structs, and the failure modes when the two pipelines disagree.

End-to-end material pipeline

WORKFLOW · MATERIAL GLB/FBX Component Bindless Pack SSBO GBuffer PC closesthit BRDF shade FIG. MAT-1 · ONE LANGUAGE, TWO CONSUMERS
Fig. MAT-1

Loaders never touch Vulkan. Upload + pack is GPU chapter territory; this chapter owns the language both sides speak.

Field → shaded pixel
  1. Loader reads baseColor / metallic / roughness / texture URIs (glTF metallic-roughness or Assimp)
  2. MaterialComponent holds runtime scalars + logical texture names
  3. On scene upload: images → BindlessTextureManager → integer indices
  4. RT path: pack 3×vec4 into matColors SSBO; per-triangle matID; closesthit samples
  5. Deferred path: per-draw push constants carry scalars + tex indices; gbuffer.frag samples
  6. Lighting or NEE multiplies by BRDF (GGX + Lambert)

CPU representation

Typical fields after load (names vary slightly by loader):

  • baseColor / albedo — linear RGB (or sRGB textures sampled then used as albedo)
  • metallic, roughness — scalars in \([0,1]\); maps multiply later
  • normal map, ORM (occlusion-roughness-metallic), emissive — optional
  • alpha / cutout — any-hit path for cards; some OBJ materials filtered entirely
Jargon · metallic-roughness

glTF’s default PBR model: metalness blends dielectric F₀≈0.04 into base color; roughness spreads the specular lobe. OHAO does not use a separate specular-glossiness path as the beauty SoT (though tinygltf can read the extension).

Texture upload & bindless

BindlessTextureManager
  1. Create/update VkImage + view + sampler for each unique texture
  2. Write into descriptor array sampler2D textures[] (PT binding 12; GBuffer similar)
  3. Store index on material; 0xFFFFFFFF means “no texture”
  4. Shaders: nonuniformEXT(idx) when indexing dynamically
Why bindless

Multi-material GLBs would thrash descriptor sets per draw. One array + indices scales to thousands of textures with one layout.

MaterialGpuPack — 48 bytes / material

layout_meta.hpp · rt_build · closesthit
// matColors[matID * 3 + …]
+0  vec4(baseColor.rgb, asfloat(diffuseTexIdx))
+1  vec4(roughness, metallic, normalTexIdx, emissiveTexIdx)
+2  vec4(roughMetalTexIdx, unused, unused, unused)
// kNoTexture = 0xFFFFFFFFu
// static_assert pack == 48 bytes

Texture indices are bit-cast through floats so a single vec4 stream stays std430-friendly. OHAO_ASSERT_GPU_LAYOUT / concepts catch C++ vs GLSL drift at compile time.

Invariant

matID / instance order

TLAS instances and material rows are built together in rt_build.cpp. Reordering one list without the other swaps materials across the scene silently.

Deferred push-constant path

GBuffer draws do not use the full matColors SSBO for every parameter. Per draw (~224–240 B PC under NVIDIA’s 256 B limit):

  • model, viewProj, prevMVP (velocity)
  • materialParams: metallic, roughness, roughMetalTexIdx, albedoTexIdx
  • albedoColor.rgb + normalTexIdx in .a
  • emissive tex index + strength

gbuffer.frag then: sample albedo; optional normal map; if ORM index < 4096 multiply ao/rough/metal (R/G/B); rough = max(rough, 0.04); write MRT packing (Ch. 05).

GGX / Cook-Torrance as shipped

File: shaders/includes/brdf/brdf_ggx.glsl. Specular:

\[ D_{\mathrm{GGX}}(h)=\frac{\alpha^{2}}{\pi\bigl((n\!\cdot\!h)^{2}(\alpha^{2}-1)+1\bigr)^{2}},\quad \alpha=\mathrm{roughness}^{2} \]
\[ F_0=\mathrm{lerp}(0.04,c,m),\quad F=F_0+(1-F_0)(1-v\!\cdot\!h)^{5} \]

Geometry: height-correlated Smith (Frostbite-style). Diffuse: Lambert \(c/\pi\) energy-scaled by \((1-m)(1-F)\). Path-tracer NEE evaluates a compatible GGX path in raygen (including optional anisotropy helper). Roughness floor exists so mirrors do not NaN and NRD sign-bits survive.

Why GGX

Industry default for real-time + offline PBR assets. Matches glTF expectations so DamagedHelmet-class models look “right” without custom artist tuning.

Light structs & budgets

Path tracerDeferred
StructGPULight 80 B (5×vec4)LightData 128 B (+ light-space matrix)
Countup to 64 SSBOup to 8 UBO
Typessphere, dir, spot, areadir / point / spot (+ CSM)
Envmiss + CDF IS + MISequirect IBL LOD

Same scene lights are repacked differently. Realtime budget is intentional — many-light PT uses NEE sampling; deferred loops a small UBO.

Failure modes

When materials “look wrong”

Deferred metal black: missing IBL / LUT (Ch. 05) — not necessarily pack failure.
Wrong material on mesh: TLAS/mat order desync.
Pink/black textures: bindless index OOB or 0xFFFFFFFF mishandled.
PT vs deferred mismatch: different roughness multiply or missing ORM on one path.

layout_meta.hppPack + byte sizes
brdf/brdf_ggx.glslD/F/G eval
gbuffer.fragDeferred sample + MRT
pt_closesthit.rchitRT sample → payload
gpu_light.hpp / light_upload.cppLights
bindless_texture_manager.*Indices

Design units in this module

Each card is a focused design page (what / how / why + sources). Full tree: Sitemap.