Shader contract
The interface between the C++ side and shader.slang. Both sides
must agree exactly; nothing validates it at compile time, so this note is the
authority. Vulkan validation layers catch the mismatches at pipeline creation,
which is the next best thing.
Vertex input
From core::Vertex, declared once in core_vertex.h and pushed into every
pipeline config by populatePipelineConfig.
| Location | Type | Member |
|---|---|---|
| 0 | float3 | position |
| 1 | float3 | normal |
| 2 | float2 | uv |
No TANGENT. The fragment shader rebuilds the tangent frame from screen space
derivatives.
Push constants
| Bytes | Content | Stages |
|---|---|---|
| 0–63 | float4x4 model | vertex |
Declared as rhi::MeshPushConstants. The guaranteed minimum push constant block
is 128 bytes on any Vulkan device, and this already spends half of it — which is
why per-material data goes in a uniform buffer instead.
Set 0 — global
Created in Renderer::initialize, one set for the whole frame.
| Binding | Type | Stages | C++ |
|---|---|---|---|
| 0 | uniform buffer | vertex + fragment | UniformBufferObject |
struct UniformBufferObject {
glm::mat4 viewProj;
glm::vec4 cameraPosition; // w is padding
};Both stages, not just vertex: the fragment shader needs the camera position to
build the view vector. Declaring this binding vertex-only makes
vkCreateGraphicsPipelines fail with
VUID-VkGraphicsPipelineCreateInfo-layout-07988.
cameraPosition is a vec4 because a vec3 aligns on 16 bytes in std140 — the
fourth component is padding, not data.
Set 1 — material
One set per material, allocated by AssetManager from
Renderer::materialLayout(). Binding indices are the values of
rnd::TextureSlot, so the enum is the contract.
| Binding | Type | Slot | Device format |
|---|---|---|---|
| 0 | sampler2D | BaseColor | R8G8B8A8_SRGB |
| 1 | sampler2D | MetallicRoughness | R8G8B8A8_UNorm |
| 2 | sampler2D | Normal | R8G8B8A8_UNorm |
| 3 | sampler2D | Occlusion | R8G8B8A8_UNorm |
| 4 | sampler2D | Emissive | R8G8B8A8_SRGB |
| 5 | uniform buffer | — | MaterialParams |
Binding 5 is rnd::k_PARAMS_BINDING, defined as k_TEXTURE_SLOT_COUNT so the
two can never drift.
Base color and emissive carry sRGB encoded colors the hardware converts on every fetch. The other three carry linear measurements that must reach the shader untouched. The format follows the slot, not the file — the same image used in two slots is two GPU resources, which is why the texture cache is keyed by name and format.
Every binding is written on every material. A slot with no texture gets a 1×1
neutral: white where the factor multiplies, (128, 128, 255) for the normal
slot, which is the encoding of (0, 0, 1).
MaterialParams
48 bytes, no padding. The member order is deliberate — metallicFactor fills
the four bytes std140 leaves behind a vec3.
| Offset | Type | Member |
|---|---|---|
| 0 | vec4 | baseColorFactor |
| 16 | vec3 | emissiveFactor |
| 28 | float | metallicFactor |
| 32 | float | roughnessFactor |
| 36 | float | normalScale |
| 40 | float | occlusionStrength |
| 44 | float | alphaCutoff |
alphaCutoff is set to 0.0 for anything that is not AlphaMode::Mask. Alpha
is never negative, so baseColor.a < 0.0 is never true and nothing discards —
an opaque material ignores its alpha and a blended one composites it, with no
branch and no extra uniform in the shader.
Attachments
| Format | |
|---|---|
| Color | swapchain format (B8G8R8A8_SRGB) |
| Depth | D32_SFloat |
The color attachment being sRGB means the shader writes linear values and the hardware encodes them. Do not apply gamma in the shader.
Compilation
game/shaders/shader.slang holds both stages, compiled by slangc through the
shader_compile CMake target, which example_gamegine depends on. The .spv
regenerates on build — there is no manual step.