Draft Work in progress. Wording, structure, and claims may still change. Feedback welcome. ← Back to roadmap

06 pbr

A lie the light cannot catch

Per-pixel normals perturbed from a texture, faking high-frequency surface detail without geometry. Tangent space and the TBN basis, why this is the change that finally grows the vertex layout, and deriving tangents at import when the file does not ship them. The lighting pass does not change by one line. The derived bitangent pointed the wrong way through three milestones, and the lab's own tests could not see it, because the fixture had been written to match the bug.

A lie the light cannot catch

Shading asks a surface exactly one question about its shape. Answer it differently per pixel and the surface changes.


TL;DR

  • Post 5 gave the surface a physical response to light, and left it geometrically flat between vertices. Roughness describes detail below the pixel and the mesh describes detail at the vertex. Everything in between had nothing to describe it.
  • A BRDF asks the geometry one question: which way are you facing. A normal map answers that question from a texture instead of from the mesh, and gets detail the triangles never had.
  • The map cannot store the answer in world space, because it would then be valid for one mesh in one pose. It stores it relative to the surface, which means the shader has to build a per-pixel basis to read it in.
  • The normal supplies one axis of that basis. The other two come from the UV layout, which is the only thing on a mesh that says which direction “along the image” points.
  • The vertex grew a vec4 tangent, glTF’s TANGENT verbatim: xyz is the direction of increasing U, w is the handedness that recovers the bitangent. 32 bytes to 48, and the first time the vertex layout has moved since post 2.
  • Files that ship tangents keep them. Everything else gets them derived at import by a pure function over positions and UVs.
  • The lighting pass did not change by one line, because the slot it already read is the slot the perturbed normal goes in. One map works under all four shading modes for free.
  • The handedness was backwards through three milestones, and the lab’s own tests could not see it, because the fixture had been written to match the bug. An external suite saw it in an afternoon.

The surface post 5 built is perfectly smooth

The last post replaced a tuned highlight with a microfacet model, and the argument for it was that roughness is a parameter you can look up rather than one you dial in. Roughness describes the surface at a scale below the pixel: facets too small to draw, aggregated into how wide the highlight spreads.

The mesh describes the surface at the scale of its vertices.

Between those two there is a band with nothing in it at all. Brick has mortar courses, leather has a grain, and a cast metal panel has the texture of the mould it came out of. None of that is below the pixel, so roughness cannot express it. All of it is far too fine to spend triangles on, so the mesh will not carry it.

[image: a Cook-Torrance sphere at roughness 0.4, and the same sphere with the built-in bump map applied, same light, same material]

The frame on the left is physically correct and reads as plastic. Not because the BRDF is wrong, but because real surfaces are almost never smooth at the scale the eye checks first, and a correct response to light on a shape that does not exist in the world is still a shape that does not exist in the world.


Shading asks one question

Here is what makes the fix possible, and it is worth stating plainly because everything else in this post follows from it.

Look at what the BRDF actually consumes.

// include/lights.glsl, the Cook-Torrance branch from post 5.
float NoL = max(dot(s.normal, lightDir), 0.0);
float NoV = max(dot(s.normal, s.view), 0.0);
float NoH = max(dot(s.normal, halfDir), 0.0);
float VoH = max(dot(s.view, halfDir), 0.0);

Albedo, roughness and metallic are material parameters: the surface’s chemistry, not its shape. Position enters only to work out which direction the light and the camera are.

The geometry contributes exactly one thing to the shading, and it is s.normal.

Every dot product above is the normal against something else. Which means the entire visual consequence of shape, at the shading stage, is compressed into a single direction per pixel. Change that direction and the surface is a different surface as far as the light is concerned, and it costs nothing structural to do it: no extra geometry, no extra pass, and no change to the model that consumes it.

That is a remarkably large lever on a remarkably small input, and normal mapping is what happens when you pull it.

It is also worth being honest immediately about what the lie does not cover. The silhouette does not move, because the silhouette is decided by the rasteriser, which never asks about the normal. Neither does the depth buffer, so a bump does not occlude the bump behind it, and a grazing view of a bumpy plane still shows a razor-straight edge. Those are real costs and there is no version of this technique that avoids them. They are just remarkably cheap ones for the amount of surface you get back.


Why the map cannot simply hold a normal

The obvious implementation is to store world-space normals in the texture and read them out. It does not work, and the reason it does not work is the interesting part.

A texture is attached to a surface through UV coordinates, and those coordinates have no idea where the surface is or which way it is pointing. The same brick texture goes on a wall facing north and a wall facing south. World-space normals in the image would be correct for exactly one of them. Rotate the object and they are correct for neither. Animate it and they are wrong in a new way each frame.

So the map stores directions relative to the surface it is applied to. Straight out of the surface is (0, 0, 1), which is why unused normal maps are that particular lavender: (0.5, 0.5, 1) in the encoding, which decodes to no perturbation at all.

Relative to the surface means there has to be a frame of reference, per fragment, to interpret it in. The normal gives one axis of that frame. The other two have to be pinned to something the map itself agrees with, which means they have to be pinned to the image: one axis along increasing U, one along increasing V.

That is the whole of tangent space. It is not a clever construction, it is the minimum structure needed to make “up, relative to this image, on this surface” a well-defined direction. And it is the point at which a rendering technique stops being arithmetic and becomes an agreement between four parties who each wrote down their convention separately: the tool that baked the map, the file format that ships it, the code that derives the basis, and the shader that reads it.

Hold that thought. It comes back at the end of this post with an actual defect attached to it.


A vec4 that is not a direction

The basis needs three axes and the vertex already carries one. glTF stores the second as a vec4, and the fourth component is not part of the direction.

// RenderLab.Assets/Vertex3D.cs, as this post leaves it.
// It has since grown a second UV set and a vertex colour, which is a
// later post's problem.
[StructLayout(LayoutKind.Sequential)]
public readonly struct Vertex3D(Vector3 position, Vector3 normal, Vector4 tangent, Vector2 uv)
{
    public readonly Vector3 Position = position;
    public readonly Vector3 Normal = normal;
    public readonly Vector4 Tangent = tangent;
    public readonly Vector2 UV = uv;
}

Tangent.xyz is the surface direction of increasing U. Tangent.w is handedness: +1 or -1, and it is what recovers the third axis.

bitangent = cross(normal, tangent.xyz) * w

The third axis is not stored, because cross(N, T) already gets you a vector perpendicular to both and the only thing it can get wrong is the sign. Which it does get wrong, routinely, wherever the UV layout mirrors a piece of the model. Character models are mirrored across the middle constantly, because it halves the texture budget, and on the mirrored half the image’s V runs the opposite way in space while U does not.

So one bit of information is genuinely needed, and the question is where to put it.

Storing the bitangent outright costs twelve bytes to carry one bit of content, and it invites the two stored axes to drift out of agreement with each other after interpolation. Storing the sign costs four, and the vertex lands at 48 bytes instead of 56.

But the byte count is the smaller half of the reason. The bigger half is that this is exactly, to the component, what glTF’s TANGENT accessor holds. Storing it in the format’s own shape means the import path for a file that ships tangents is a copy, and means there is no conversion sitting between the engine’s convention and the specification’s where the two could disagree. A conversion is a place a convention can be misread. Not having one is worth more than four bytes.

// RenderLab.Assets/GltfLoader.cs
// glTF's TANGENT is already xyz + handedness, the same shape Vertex3D
// stores, so an asset that ships tangents keeps the ones it was baked with.
var tangents = prim.GetVertexAccessor("TANGENT")?.AsVector4Array();

The change that finally grows the vertex

Nothing in this project had touched the vertex layout since post 2. The G-Buffer did not need it. Four shading models did not need it. Swapping the entire BRDF in post 5 did not need it: that was a shader and a material record, and the data flowing into the geometry pass was the data that had always flowed into it.

Normal mapping needs it, and that makes this the first post where a change reaches all the way down to the bytes.

What has to move, all of it at once:

  • Vertex3D, obviously.
  • The pipeline’s attribute descriptions, which tell Vulkan the format and byte offset of each input.
  • The vertex shader’s layout(location = ...) declarations, which have to match those descriptions.
  • Every loader that builds a vertex: glTF, OBJ, and the procedural generators.

Four places, and here is the part worth pausing on: every way they can disagree is silent.

Nothing validates that location 2 in the shader is the same field as location 2 in the attribute descriptions. They are matched by number, and the numbers type-check on both sides regardless of what they point at. Bind a tangent where the shader expects UVs and Vulkan will not say a word. The frame renders, and it renders wrong, and there is nothing in any log to suggest why.

There is a subtler version of the same failure underneath it. The vertex buffer is uploaded as a raw memory copy of the managed array, while the pipeline is told the stride and the offsets through Marshal. Those are two different layout algorithms, and they agree only as long as every field stays 4-byte aligned.

// The offsets are read off the struct rather than recomputed from field
// sizes: the buffer is a raw copy, so the description has to state the
// layout the runtime actually chose.
Offset = (uint)Marshal.OffsetOf<Vertex3D>(nameof(Tangent)),

That, plus a test that asserts the two algorithms still agree:

[Fact]
public void The_managed_and_marshalled_layouts_agree()
{
    Assert.Equal(Unsafe.SizeOf<Vertex3D>(), Marshal.SizeOf<Vertex3D>());
    Assert.Equal((uint)Unsafe.SizeOf<Vertex3D>(), Vertex3D.BindingDescription.Stride);
}

This is one of the few places in the project where I have written a test for something that is currently, provably, fine. The repo’s own rule is to skip tests of language features and trivial wrapping, and on its face “does sizeof equal sizeof” is exactly that. It earns its place on the failure mode rather than on the logic: the day someone adds a field that breaks the alignment, every mesh in the lab renders as noise with nothing anywhere to say why. A test is cheap insurance against a bug whose debugging cost is an entire evening of suspecting the GPU.


When the file does not ship tangents

Plenty of assets do not carry them, and every procedural mesh the lab generates certainly does not. So the tangent has to be derivable from what is there, which is positions and UVs.

The derivation is Lengyel’s method, and the idea is smaller than the algebra makes it look. For one triangle you know three positions and three UVs. Two edges in space, two edges in UV space, and the question is which direction in space corresponds to “U increases and V does not”. That is a 2x2 system, and solving it gives the U gradient directly.

// RenderLab.Assets/MeshTangents.cs
var edge1 = verts[i1].Position - p0;
var edge2 = verts[i2].Position - p0;

var du1 = verts[i1].UV.X - w0.X;
var du2 = verts[i2].UV.X - w0.X;
var dv1 = verts[i1].UV.Y - w0.Y;
var dv2 = verts[i2].UV.Y - w0.Y;

// A triangle with no area in UV space says nothing about which way
// U runs, and dividing by its determinant would say it loudly.
float det = du1 * dv2 - du2 * dv1;
if (MathF.Abs(det) < 1e-12f) continue;
float r = 1f / det;

var uDir = (edge1 * dv2 - edge2 * dv1) * r;
var vDir = (edge2 * du1 - edge1 * du2) * r;

uGradient[i0] += uDir; uGradient[i1] += uDir; uGradient[i2] += uDir;
vGradient[i0] += vDir; vGradient[i1] += vDir; vGradient[i2] += vDir;

Accumulate onto the vertices rather than assign, because a vertex belongs to several triangles and the stored tangent has to be one direction that serves all of them. Summing before normalising weights each triangle by its area, which is the behaviour you want: a sliver contributes about as much to the vertex’s idea of “along U” as it contributes area to the surface.

Then each vertex resolves its accumulator into the stored vec4.

// Drop the component along the normal: the tangent has to lie in the
// surface, and averaging gradients across a shared vertex tilts it out.
var t = uGradient - n * Vector3.Dot(n, uGradient);

The whole thing is MeshData in, MeshData out, with one field filled and nothing else touched. No GPU, no allocator, no device. That makes it testable the way the graph compiler in post 4 is testable, and the tests that matter are the ones where a wrong answer still looks like an answer: the tangent points along increasing U, a mirrored U flips it, mirrored UVs flip the handedness and move nothing else, and the basis stays orthogonal when the normal is not axis-aligned.

Two degenerate cases needed a decision rather than arithmetic.

A triangle with zero area in UV space contributes nothing, as above, because its determinant is zero and every direction is equally consistent with what it says.

A vertex that ends up with no usable gradient at all gets an arbitrary perpendicular.

if (t.LengthSquared() < 1e-16f)
{
    // No usable gradient reached this vertex - every triangle touching
    // it was degenerate in UV, or it belongs to none. Any direction in
    // the surface is as right as any other, and a normal map sampled
    // against it is wrong in a way no choice here can fix; what this
    // avoids is a NaN basis poisoning the G-Buffer normal.
    t = Perpendicular(n);
}

The reasoning is the same shape as the roughness floor from post 5, and I think it is the most reusable idea in either post. That vertex is going to shade wrong whatever I do, because the information needed to shade it right is not in the file. The only choice available is whether it shades wrong on its own or takes the frame with it. A NaN normal propagates: it writes into the G-Buffer, it comes back out in the lighting pass, it survives every subsequent multiply, and it can reach the tonemap as a hole nowhere near the vertex that produced it. An arbitrary perpendicular produces a locally wrong pixel and stops.

Loaders call this unconditionally, and the entry point makes the policy explicit:

public static MeshData EnsureTangents(MeshData mesh) =>
    HasTangents(mesh) ? mesh : Generate(mesh);

Authored tangents pass straight through. That is deliberate and it is not just deference to the file. A normal map is baked against a specific tangent basis, usually by the same tool that produced the map, so re-deriving the basis at import means shading the map through a frame of reference it was not built for. If the asset says how its own texture is meant to be read, second-guessing it is how you get subtle, unreproducible, everything-looks-almost-right wrongness.


Rebuilding the basis per pixel

The vertex shader carries both axes into world space and hands the handedness across untouched.

// gbuffer.vert
mat3 rotation = mat3(pc.model);
worldNormal = normalize(rotation * inNormal);
worldTangent = vec4(rotation * inTangent.xyz, inTangent.w);

The handedness rides through unmodified because it is a choice of basis, not a direction to rotate. Multiplying -1 by a matrix is a category error.

The fragment shader then rebuilds the frame, and the reason it rebuilds it rather than receiving it is the one thing about this technique that surprised me.

Interpolation does not preserve either of the two properties the basis needs. Two unit vectors interpolated across a triangle give you something shorter than unit length, and two perpendicular vectors interpolated separately do not stay perpendicular to each other. So what arrives at the fragment is a normal and a tangent that are approximately right and definitely not a basis.

// include/material.glsl
vec3 perturbNormal(vec3 n, vec4 tangent, vec2 texcoord) {
    vec3 sampled = texture(uNormal, texcoord).xyz * 2.0 - 1.0;

    // A mesh with no usable tangent leaves this near zero. Falling back to the
    // geometric normal keeps such a surface flat-shaded instead of NaN.
    if (dot(tangent.xyz, tangent.xyz) < 1e-8) return n;

    vec3 t = normalize(tangent.xyz - n * dot(n, tangent.xyz));
    vec3 b = cross(n, t) * tangent.w;
    return normalize(mat3(t, b, n) * sampled);
}

One Gram-Schmidt step against the normal, the bitangent from the cross product and the sign, and a matrix multiply. The normal is treated as authoritative and the tangent is the one that gets corrected, which is the right way round: the normal is what the lighting will actually use, and an error in it is visible directly rather than through the map.

* 2.0 - 1.0 is the decode. The texture stores directions in [0, 1] because that is what an 8-bit UNORM channel holds, and the shader has to undo that mapping to get back to [-1, 1]. Which is also the reason the map has to be sampled as linear data rather than as colour, and that turns out to be a problem the shader cannot solve on its own.


The lighting pass did not change

Here is the payoff, and like post 5’s it is an argument about architecture rather than about shading.

The G-Buffer’s normal attachment already held the direction the lighting pass shades with. After this change it holds the perturbed direction instead.

// gbuffer.frag
outNormal = vec4(perturbNormal(n, worldTangent, uv), max(pc.roughness, MIN_ROUGHNESS));

That is the entire integration.

The lighting pass did not change by one line. Neither did the render graph, for the second post running. Neither did any of the four shading models, which means normal mapping works under Lambertian, Phong, Blinn-Phong and Cook-Torrance without any of them being told it exists. The dropdown from post 5 still compares BRDFs, and now it compares them on a bumpy surface.

This is the deferred architecture paying a dividend that was not the reason for choosing it. The G-Buffer is an interface: for each pixel, here is the surface that ended up visible there. Normal mapping is a change to a producer of that interface, and none of the consumers had to learn about it, because the producer kept its side of the contract. The contract never said the normal had to come from the mesh. It only said there would be one.

Compare this to the forward renderer from post 3’s opening. There, the same change means editing the shading code itself, because there is no seam between “work out what the surface is” and “work out what the light does to it”. They are the same function. Here they are two passes with a defined boundary, and the boundary is what made the change local.

It is the second time in this project that I have gone looking for the place a feature has to integrate and found that it does not have one.


Where the map lives, and what format it is

Two decisions on the CPU side were more interesting than the shader was.

The normal map went on the MaterialAsset root, not on PbrMaterial.

The union from post 5 has two cases, and the tempting move is to hang the new texture slot on the physically based one, since normal mapping is a physically based feature.

public abstract record MaterialAsset(MaterialId Id, string Name)
{
    public abstract Optional<TextureId> AlbedoMap { get; init; }

    /// The tangent-space normal map this material samples, or None for a flat
    /// surface. Carried by every kind rather than by the PBR one alone: the
    /// G-Buffer stores a single perturbed normal that all four shading modes
    /// read, so normal mapping is orthogonal to which BRDF consumes it.
    public abstract Optional<TextureId> NormalMap { get; init; }
}

The section above is the argument. The map perturbs the normal the G-Buffer stores, and every shading mode reads that slot, so the map has nothing to do with which BRDF is selected. Attaching it to the PBR case would mean a material loses its normal map when you convert it to Blinn-Phong in the Inspector, which is the sort of data loss that has no defensible reason behind it: it would be losing the map to a bookkeeping detail rather than to anything about Blinn-Phong.

There is a second reason that has nothing to do with shading. “Is this texture still referenced by anything” has to be one question with one answer, because the asset library asks it before deleting a texture. A reference check that pattern-matches on material kinds would quietly stop covering the next kind added. Abstract on the root, and a new case cannot compile without answering.

And the importer had to learn that an image’s format is not a property of the image.

Colour textures are stored non-linearly and have to be decoded on read. Normal maps are not colour. Running the sRGB transfer over a normal map bends every direction it holds, by an amount that varies per channel and per value, and the result is a surface that is subtly and consistently wrong in a way you would spend a long time blaming on the maths.

The file does not say which it is. A PNG is a PNG. The only thing in a glTF that knows an image is a normal map is the material that points at it through the normalTexture slot.

So the loader had to do something it had not needed before: look ahead.

// RenderLab.Assets/GltfLoader.cs
// An image's format depends on what it is for, not on what it contains,
// and only the materials know that. So the roles are collected first.
var linearImages = LinearImageIndices(model);

Materials are scanned for roles before a single image is decoded, and each image’s format falls out of the role it was put to. There is even a tie to break, because one image can serve two roles in two materials:

/// One image serving two roles cannot be both, so a tie resolves as linear:
/// a normal map read as colour is visibly wrong everywhere, while a base
/// colour read as linear is wrong only in its midtones.

I like that comment because it is not a shrug. Both answers are wrong for one of the two uses, so the tie-break is decided on which wrongness is smaller, and that reasoning is written where the next person will find it.


What it costs, and what it does not

The honest accounting.

The silhouette does not move, as promised at the top. Nor does the depth buffer, so bumps do not occlude each other and a grazing angle gives the game away instantly. Parallax and displacement techniques exist for exactly that gap, and neither is in this engine.

Fifty percent more vertex. 32 bytes to 48, paid by every mesh in the scene whether it has a normal map or not. Vertex bandwidth is not usually what a frame is short of, but the cost is real and it is unconditional, and I would rather write that down than pretend the tangent is free because the frame time did not visibly move.

One thing got dropped on the way in, and it is worth naming. glTF’s normalTexture carries a scale, which is “how much of this map do I want”. This phase read it and threw it away, because the G-Buffer stores one perturbed normal and had nowhere to put a strength beside it, so honouring the scale would have meant baking it into the map. Dropping it was the reversible option and the wrong-looking one, and it stayed dropped until the material moved into a storage buffer later and could carry its own scalars. It is a small feature. The reason it is in this post is that it is a clean example of what a G-Buffer’s fixed width actually costs you: not “this is hard”, but “there is no channel, so the choice is bake it or lose it”.

And every scene authored before this renders exactly as it did. A material with no normal map resolves to a built-in 1x1 flat texel at the resolver boundary, exactly as the white texel already stood in for a missing albedo map. That keeps the shader’s sample unconditional, with no branch and no second pipeline. The flat texel decodes to straight out of the surface, straight out of the surface is the geometric normal, and the geometric normal is what those scenes were always shaded with.

There is one wrinkle I enjoy more than it deserves. Eight bits have no exact code for 0.5, so (128, 128, 255) decodes to a direction leaning a fraction of a degree off the geometric normal rather than to the geometric normal itself. The shader normalises, and it goes no further than that. It is not a bug and nothing can see it, but “the flat normal map is not exactly flat” is the kind of thing that is much more comfortable to know than to discover.


The sign that was wrong for three milestones

Now the part the roadmap told me to put in the post rather than hide.

Everything above shipped, looked right, and was wrong.

The handedness in the derived path was computed from which way V runs, and it took the wrong direction. Every tangent basis the lab derived had its bitangent upside down, which means every normal map read through a derived basis was flipped along one axis. Bumps rendered as dents, and lighting from above rendered as lighting from below.

It survived three milestones and every golden blessed in them, and I want to be precise about why, because the reason is more interesting than the bug.

The lab’s own tests could not see it. The scene behind the lab’s goldens gets its normal map from the built-in procedural generator. That generator was written while this code was being written, and it was written to make the scene look right. Which it did, by encoding its bumps upside down to match a basis that was upside down.

Two errors that cancel. The golden was correct, the test passed, the picture was right, and both halves were wrong.

This is the failure mode that the entire glTF compliance block exists to prevent, and I could not have constructed a cleaner demonstration of it if I had tried. A test whose fixture was produced by the code under test does not test the code. It tests that the code is consistent with itself, which it always is, right up until something external disagrees.

Something external disagreed. The Khronos sample suite ships two models for exactly this, and the pair is beautifully designed. NormalTangentTest carries no tangents, so an engine has to derive them. NormalTangentMirrorTest ships them, so the engine has to use what it is given. Each is a grid of mapped shapes next to geometric twins, and a correct renderer makes the two indistinguishable.

[image: NormalTangentTest before and after, mapped shapes against their geometric twins]

The mapped spheres reflected the horizon inverted or rotated against their geometric twins. The mirror test, which ships tangents, was right all along.

That is not just a failing test. It is a failing test that localises the bug for you: read tangents are fine, derived tangents are not, so the defect is in the derivation and nowhere else. Two models designed as a pair turned “it looks slightly off somewhere” into a one-function search.

The fix is a single character.

-float handedness = Vector3.Dot(Vector3.Cross(n, t), vGradient) < 0f ? -1f : 1f;
+float handedness = Vector3.Dot(Vector3.Cross(n, t), vGradient) > 0f ? -1f : 1f;

The convention it was getting wrong is the one flagged four sections ago. glTF’s normal maps are green-up, meaning +Y in the map points up the image. glTF’s V coordinate runs down the image. So the bitangent is the direction of decreasing V, which is also what MikkTSpace answers when it is run the way glTF’s exporters run it, on V-flipped coordinates.

Every part of that sentence is somebody else’s decision, written down in a different document, and the arithmetic around it was never wrong.

And because the built-in bump map had been written to compensate, fixing the code alone would have made the lab’s own scene render upside down. Both had to be corrected together, after which the conformance golden came back to the picture it had held all along, to within a rounding step of the mirrored encoding. Which is what two compensating sign errors look like from the outside: a fix that changes nothing, in a frame that was wrong the whole time.

The new test that came out of this is not a test of MeshTangents. It is a test of the fixture: that the built-in bump map encodes green-up, checked by sampling a texel above the centre of a dome and asserting it leans up the image. The thing that hid the bug was a fixture agreeing with it, so the fixture is what needed pinning down.


Final thoughts

Normal mapping is the least mathematically demanding thing in this project so far. There is one cross product, one Gram-Schmidt step, one 2x2 solve at import, and a linear remap of a texture fetch. Nothing in it is harder than post 5’s Smith visibility term, and most of it is easier than post 4’s topological sort.

It is also the first feature in the lab that has been outright wrong in shipped code for an extended period.

The two facts are the same fact. Every difficult thing in this post is a convention: which axis the map’s green channel means, which way V runs, whether the bitangent follows V or opposes it, whether the tangent is orthogonalised before or after, whether the map is colour or data. Not one of those has a right answer derivable from first principles. They each have a right answer because a specification says so and a tool chain agrees, and both of those live somewhere other than in this repository.

Arithmetic you can check by reading it. A convention you can only check against somebody else’s answer, and if you do not have one of those, your tests will happily certify whatever you first assumed, and your fixtures will drift into agreement with it because you built them while looking at the same frame.

Which is an argument for the next block of this project rather than a moral about normal maps. The lab has been keeping score against itself. It has been winning every time, which should have been the suspicious part.

Post 7 finishes the physically based block by retiring the last hand-tuned constant in the shading path: the hemispheric ambient term, which is still a sky colour, a ground colour, and a lerp on the normal’s Y. A normal map is considerably more convincing under an environment that has something in it than under a two-colour gradient, so the order is not arbitrary.

After that, the external bar.