Rether / docs
engine/gamegine/log/PBR materials
logFive texture slots, the factors, and four pipeline variants

PBR materials

updated 2026.08.10created 2026.08.10~4 min

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

ComponentChange
asset::MaterialData1 index → 5 indices, 7 factors, alpha mode, double sided
impt_gltfresolveImageIndex, toAlphaMode; deleted a broken readMetallicFactor
rnd::Materialone texture → array<Texture*, 5>, owns its factors UBO
rnd::Textureformat parameter, plus a raw-pixel constructor for neutrals
Renderermaterial layout 1 → 6 bindings, 4 pipeline variants, camera position in the global UBO
AssetManagerper-slot loading, neutral fallbacks, cache keyed by format
rhi::ShaderStagenew VertexFragment
vlk_contextsampler pool sized maxSets × 8
shader.slangfull 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::Vertex has 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 .ktx2 textures stb_image cannot decode; they fall back to neutrals. Needs the TextureData refactor first.

Keyboard

search this vaultK / /
step the sidebarjk
open selected
switch browse mode123
switch vaultgv
toggle themet
close / clearesc
this help?
esc to close