Graphics glossary 44 terms mentioned in this lesson
Select any term for a clear definition. The return button brings you back to the exact term link you used.
A visible surface is not yet a lit surface. The closest-hit query tells us where the ray arrived, which way the geometry faces and which primitive supplied the answer. Lighting requires different information: the surface's material, the light's position and intensity, and the view direction. Keeping those responsibilities separate is more than tidiness. It lets the same geometry answer primary, shadow and secondary queries without accidentally performing the wrong shading work.
The material table and light must therefore become shader-visible data. The local diffuse and specular model remains the one established in Lesson 7: calculate it in linear colour and encode the result only when writing the display image.
What you should be able to account for
- Associate a geometry record with a material index without embedding lighting in the intersection function.
- Reconstruct the light, normal and view directions at the accepted hit.
- Distinguish linear lighting values from the values encoded for display.
Make geometry identify a material
Extend the sphere record to two 16-byte units. The first remains centre and radius. The second carries a material index in its first component and leaves three explicit spare fields. Using an unsigned vector avoids asking GLSL to reinterpret a floating-point bit pattern.
struct alignas(16) SphereGPU {
std::array<float, 4> centreRadius;
std::array<std::uint32_t, 4> metadata;
};
static_assert(sizeof(SphereGPU) == 32);
struct alignas(16) MaterialGPU {
std::array<float, 4> albedoRoughness;
std::array<float, 4> specularPadding;
};
static_assert(sizeof(MaterialGPU) == 32);
For the local model, albedoRoughness.xyz stores diffuse reflectance, its w component may retain a future roughness value, and specularPadding.x stores specular strength. We will supply the exponent separately while investigating it. Explicit names on the CPU are preferable in production; the arrays here make the byte layout plain.
struct SphereGPU {
vec4 centreRadius;
uvec4 metadata;
};
struct MaterialGPU {
vec4 albedoRoughness;
vec4 specularPadding;
};
layout(std430, set = 0, binding = 3) readonly buffer MaterialBuffer {
MaterialGPU materials[];
};
Add binding 3 to the descriptor set layout and upload the material table by the same staged method used for spheres. Validate every material index on the CPU before upload. Bounds checking in shader code can provide a conspicuous error material during development, but it should not be the first time the application notices an invalid scene.
Let the hit record carry identity
The private Hit gains a material index. intersectSphere remains concerned with geometry; the scene loop assigns the index only when it retains that sphere.
struct Hit {
bool found;
float t;
vec3 position;
vec3 normal;
uint materialIndex;
};
// Inside closestSceneHit, after candidate.found:
candidate.materialIndex = scene.spheres[index].metadata.x;
closest = candidate;
closestDistance = candidate.t;
Do not copy the full material into every candidate hit. A stable index is sufficient and keeps geometry records compact. The material is fetched once after the closest answer has been chosen.
Place one light in uniform data
Extend the camera uniform buffer with two aligned vectors: light position and an RGB intensity with a spare component. In a larger engine, camera and lighting data may have different update frequencies and therefore different buffers. One buffer is acceptable here because the objective is the relationship, not an allocation policy.
struct alignas(16) SceneUniforms {
float cameraToWorld[16];
float projection[4];
float lightPosition[4];
float lightIntensity[4];
float cameraPosition[4];
};
Check the offsets used by the host structure against the GLSL block. A matrix layout, three-component member or compiler packing assumption can shift all later values while leaving the descriptor itself valid. During development, write the shader-observed light position into a debug pixel or buffer rather than diagnosing it from a strangely lit sphere.
Worked Vulkan account
How Geometry, Material and Light Stay Separate
The accepted hit identifies a surface. Material and light resources then supply different information for shading without changing that geometric result.
What the code must establish
Intersection establishes position, normal and material identity. Separate material and light resources then provide albedo, specular controls and light position. The shader forms the surface-to-light and surface-to-camera directions and evaluates the local-light terms without rewriting the geometric hit.
What to inspect
Freeze one hit record and inspect the material index, material values, shader-observed light data and the individual ambient, diffuse and specular contributions. Work in linear colour until the display conversion. If a light move appears to change the stored normal or material index, the resource contract is wrong even when the final colour looks attractive.
What this establishes, and what it does not: A plausible final pixel colour does not establish correct buffer packing, direction normalisation or display encoding. The separate inputs and contributions provide the evidence that the combined colour hides.
Reconstruct the four directions
At the accepted position, form the unit normal N, unit direction L towards the light and unit direction V towards the camera. The incoming primary ray points from the camera to the surface, so V = normalize(-ray.direction). The reflection direction for a Phong-style specular term is obtained by reflecting -L around N.
vec3 shadeLocal(Hit hit, Ray ray) {
MaterialGPU material = materials[hit.materialIndex];
vec3 albedo = material.albedoRoughness.xyz;
vec3 toLight = uniforms.lightPosition.xyz - hit.position;
float lightDistance = length(toLight);
vec3 L = toLight / lightDistance;
vec3 N = normalize(hit.normal);
vec3 V = normalize(-ray.direction);
float nDotL = max(dot(N, L), 0.0);
vec3 diffuse = albedo * nDotL;
vec3 reflectedLight = reflect(-L, N);
float specularAngle = max(dot(V, reflectedLight), 0.0);
float exponent = control.specularExponent;
float specular = material.specularPadding.x *
pow(specularAngle, exponent);
vec3 incident = uniforms.lightIntensity.xyz;
vec3 ambient = 0.025 * albedo;
return ambient + incident * (diffuse + vec3(specular));
}
This deliberately simple light has no inverse-square attenuation yet. Add attenuation only when its units and intensity are explained; otherwise the apparent improvement is merely a new arbitrary scale. The ambient term is likewise an explicit teaching approximation, not light transport from the environment.
Keep the normal on the correct side
A primary ray can strike the inside of geometry after the camera moves or once secondary rays exist. Record whether the ray meets the outward-facing side:
bool frontFace = dot(ray.direction, outwardNormal) < 0.0;
hit.normal = frontFace ? outwardNormal : -outwardNormal;
The normal then opposes the arriving ray. Store frontFace if later material behaviour needs to know which medium was entered. Merely flipping the normal without retaining that fact is insufficient for refraction.
Encode only after lighting
The arithmetic above operates in linear colour. If the output image is an 8-bit UNORM format and the presentation path does not perform an sRGB conversion, apply an explicit linear-to-display approximation after clamping negative values. A simple teaching version uses a power of one over 2.2:
vec3 linearColour = hit.found
? shadeLocal(hit, ray)
: vec3(0.015, 0.025, 0.022);
vec3 displayColour = pow(max(linearColour, vec3(0.0)),
vec3(1.0 / 2.2));
imageStore(outputImage, pixel, vec4(displayColour, 1.0));
Do not apply the power twice. If the storage image is copied into an sRGB-capable presentation path whose conversion you rely upon, document where encoding occurs and remove the shader conversion. The rule is one intentional encoding boundary.
Follow one diffuse term
At a hit, let N = (0, 1, 0) and the unit light direction be L = (0.6, 0.8, 0). Their dot product is 0.8, so an albedo (0.5, 0.2, 0.1) contributes diffuse (0.4, 0.16, 0.08) before multiplying by light intensity. Moving the light below the surface makes the dot product negative; the maximum clamps it to zero. A negative diffuse value must not be rescued later by display encoding.
Use diagnostic modes before admiring the picture
Retain selectable output modes for primitive index, material index, normal, diffuse factor and specular factor. A beautiful combined image can conceal a material-buffer offset error because several mistakes produce plausible colours. A material-index view should contain flat, exact regions matching the retained primitive. A diffuse-factor view should vary with only N dot L. A specular-factor view should react strongly when the recorded exponent is changed.
When the combined result disagrees with the manual scene, compare these intermediate quantities at one selected pixel. The GPU has not made the lighting model mysterious; it has made many evaluations simultaneous.
Separate the data responsibilities
A shadow ray finds a sphere. Should it fetch that sphere's red albedo before declaring the light blocked?
The answer
No. A binary shadow query only needs to know whether any valid intersection occurs before the light. Material colour is irrelevant to that question. Transparent-shadow policies could later require material information, but that would be an explicit extension of the query.
Official reference: the Khronos shader-interface chapter defines resource-interface matching, and the data mapping guide gives host and shader layout examples.
Audit one lit pixel numerically
Choose a pixel on each visible sphere. Read back its hit position, normal, material index, N dot L, specular angle and final linear RGB. Recalculate those values on the CPU from the same scene records. Change only the specular exponent and show which recorded values should remain unchanged. Then exchange two material indices without moving geometry and establish that visibility and hit distance remain fixed while appearance follows the material table.
Carry local shading into a visibility test
Retain the material and uniform buffers, front-face normal and linear lighting function. Lesson 23 will interrupt the light contribution with a bounded shadow query. The query will reuse the geometry functions but deliberately avoid the material fetch and local shading work until it knows whether the light is visible.
We now have the same local relationships as the manual tracer, expressed through Vulkan buffers and shader invocations. Geometry chooses the hit, a material describes the surface, and the light supplies incident direction and intensity. The remaining flaw is obvious: every accepted point receives the light even when another primitive stands between them.