PBR materials
Went from a material that was one base color texture to the full metallic-roughness model: five texture slots, seven factors in a uniform buffer, alpha modes, and the pipeline variants they need. The importer already read the glTF; almost none of it reached the GPU.
Objective
MaterialData carried a single diffuseImageIndex. Everything else glTF
declares about a material — the factors, the four other maps, the alpha mode —
was parsed and dropped. The goal was to carry all of it through to the shader
and actually light with it.
Decisions
Factors in a per-material uniform buffer, not push constants. The guaranteed push constant block is 128 bytes and the model matrix already takes 64. Seven factors would fit today and stop fitting the moment anything else needs pushing.
MaterialParams ordered for std140, not for readability. metallicFactor
sits at offset 28, in the padding std140 leaves behind emissiveFactor's three
components. 48 bytes with no hole. Copying MaterialData's field order would
have cost 16 bytes and a mismatch risk.
Opaque and Mask share a pipeline. Masking is a discard in the fragment
shader; no pipeline state expresses it. So the variants are
{Opaque, Blend} × {single, double sided} — four, not six.
Alpha mode reaches the shader as data, not as a uniform. Setting
alphaCutoff to 0.0 for everything that is not Mask makes the existing
discard a no-op, since alpha is never negative. No branch, no extra field.
AlphaMode declared twice, once in asset and once in rnd. rnd is
layer 4 and asset is layer 6 — sharing one enum would invert the dependency.
See Gamegine architecture.
Textures resolved by one helper, not five.
resolveImageIndex(model, textureIndex) replaced the per-material accessor. A
glTF texture points at its pixels through a sampler and an image, so there is
real validation to do — that is what earns a function. The factors are direct
field reads and got none.
glm::make_vec3 / make_vec4 instead of hand-written converters. tinygltf
validates factor array lengths at parse time and fails the whole load if they
are wrong, so by the time readMaterials runs the sizes are guaranteed. The
size checks I first wrote were dead code.
What it touched
| Component | Change |
|---|---|
asset::MaterialData | 1 index → 5 indices, 7 factors, alpha mode, double sided |
impt_gltf | resolveImageIndex, toAlphaMode; deleted a broken readMetallicFactor |
rnd::Material | one texture → array<Texture*, 5>, owns its factors UBO |
rnd::Texture | format parameter, plus a raw-pixel constructor for neutrals |
Renderer | material layout 1 → 6 bindings, 4 pipeline variants, camera position in the global UBO |
AssetManager | per-slot loading, neutral fallbacks, cache keyed by format |
rhi::ShaderStage | new VertexFragment |
vlk_context | sampler pool sized maxSets × 8 |
shader.slang | full metallic-roughness BRDF |
The binding table that came out of it is in Shader contract.
Traps
Color space follows the slot, not the image. Base color and emissive are sRGB; metallic-roughness, normal and occlusion are linear. The texture cache was keyed by name alone, so one ORM file used in two slots would have come back with the wrong format for one of them. The key now includes the format.
no_texture.png is a 500×500 magenta and black checkerboard. Correct as a
"missing texture" marker on base color, poison everywhere else — it would have
made emissive glow magenta and occlusion black out half of every surface. The
neutral fallbacks are generated 1×1 images instead, and the checkerboard is now
only used when a material declares a base color texture and the file cannot be
read. A material declaring none gets white, so its factor describes it alone —
which is what the specification says and what the checkerboard was quietly
breaking.
Every descriptor in a set must be written. Binding only the slots a material actually uses is a validation error. Hence a neutral in every empty slot.
The global UBO was vertex-only. The moment the fragment shader read
cameraPosition, vkCreateGraphicsPipelines refused the pipeline with
VUID-VkGraphicsPipelineCreateInfo-layout-07988. ShaderStage had no combined
value; it does now.
Metals render black without an ambient term. A metal has no diffuse lobe and its specular reflects an environment that does not exist yet. A constant ambient stands in for IBL. Worth knowing before spending an hour looking for the bug that is not there.
sizeof(MaterialParams) is worth asserting. The struct's whole point is
matching a std140 block, and nothing else catches a reordered field.
Verification
Validation layers are on in Debug (core_engine.cpp keys them off NDEBUG).
DamagedHelmet.glb renders with all five maps — visible specular on the chrome,
cyan emissive elements, normal map relief. Zero validation errors.
For the pipeline variants I loaded bistro.gltf, which declares all four
combinations: 220 opaque single sided, 11 opaque double sided, 20 MASK double
sided, 3 BLEND double sided. Zero validation errors, zero missing pipelines.
Left open
- Tangents.
core::Vertexhas none; the TBN is rebuilt from screen space derivatives. Works, costs more per fragment, degrades on discontinuous UVs. - IBL. The constant ambient is a placeholder for a prefiltered environment map.
- Transparent sorting. Blended materials draw in ECS order, no back-to-front pass. Invisible with bistro's three blended materials, not for long.
- Lights. One hardcoded directional in the shader. There is no light
component in
scn/comp/. - KTX2. Bistro references
.ktx2texturesstb_imagecannot decode; they fall back to neutrals. Needs theTextureDatarefactor first.