This note covers materials: how to organize the lighting parameters from the previous note into something artists can work with, and why the industry converged on PBR.
A material is some parameters and some textures
A Phong material looks roughly like this:
Ctypedef struct {
vector_float3 diffuse;
vector_float3 specular;
float shininess;
} PhongMaterial;
The problem with these parameters is that none of them corresponds to a measurable physical quantity. What should specular be? Is shininess 32 or 64? The artist can only guess and iterate. Worse, the same material needs re-tuning under different lighting, because the parameters carry an implicit assumption about light intensity.
PBR β physically based rendering β exists to solve exactly this: describe a material with measurable physical properties, so one material behaves correctly under any lighting.
The metallic-roughness workflow
The de facto standard today is four channels:
Ctypedef struct {
vector_float3 baseColor; // albedo, linear space
float metallic; // 0 = dielectric, 1 = metal
float roughness; // 0 = mirror, 1 = fully diffuse
float ao; // ambient occlusion
} PBRMaterial;
baseColor is the diffuse colour for dielectrics and the reflectance colour for metals, which have no diffuse component at all. This is counterintuitive and important: gold is not "yellow diffuse", it is a specular reflection that returns more red and green than blue.
metallic is physically binary: a material either is a metal or is not. Intermediate values exist only for transition regions β rusting iron, chipped paint β not for a "half-metal" that does not exist. If you find yourself dialling metallic to 0.5 to get a look, something else is usually wrong.
roughness describes the statistical distribution of the microsurface. It is the channel artists reach for most, because it maps directly onto "does this look new or worn, dry or wet".
The textures are usually packed as albedo (sRGB) + normal (linear) + ORM β AO, roughness and metallic packed into one RGB image, linear. The ORM packing exists to save a texture fetch.
Normal maps and TBN
A normal map stores a perturbation of the normal in tangent space. Tangent space rather than world space, because one map can then be applied to a model in any orientation, and the model can deform and animate.
Converting a tangent-space normal to world space needs a basis: tangent T, bitangent B, normal N.
MSLstruct VertexOut {
float4 position [[position]];
float3 normalWS;
float3 tangentWS;
float3 bitangentWS;
float2 uv;
};
// in the vertex function
out.normalWS = normalize(uniforms.normalMatrix * in.normal);
out.tangentWS = normalize(uniforms.normalMatrix * in.tangent.xyz);
out.bitangentWS = cross(out.normalWS, out.tangentWS) * in.tangent.w;
That in.tangent.w is handedness, either +1 or β1. It records whether the UVs are mirrored. Character models almost always share one UV half across the body; on the mirrored side the bitangent points the other way, and without this sign the normal map on that half is inverted β usually visible as one side reading concave and the other convex.
In the fragment function:
MSLfloat3 tangentNormal = normalTex.sample(s, in.uv).xyz * 2.0 - 1.0;
float3x3 TBN = float3x3(normalize(in.tangentWS),
normalize(in.bitangentWS),
normalize(in.normalWS));
float3 n = normalize(TBN * tangentNormal);
The * 2.0 - 1.0 is because the texture stores [0,1] while normal components range over [-1,1]. This is also why normal maps must never use an sRGB format β the gamma curve destroys that mapping completely.
Model I/O can generate tangents at load time:
SwiftmdlMesh.addTangentBasis(forTextureCoordinateAttributeNamed: MDLVertexAttributeTextureCoordinate,
tangentAttributeNamed: MDLVertexAttributeTangent,
bitangentAttributeNamed: nil)
Be aware, though, that the engine's tangents must match the ones used when the normal map was baked in the DCC tool, or you get subtle shading errors. The industry generally standardizes on MikkTSpace for this reason.
The Cook-Torrance BRDF
The PBR specular term is usually the Cook-Torrance microfacet model:
Plain Textf_spec = D * F * G / (4 * NdotL * NdotV)
Each term has a job.
D, the normal distribution function, describes what fraction of microfacets are oriented to reflect light exactly towards the eye. It determines the shape of the highlight. GGX / Trowbridge-Reitz is the current default because its long tail matches measured data best β that soft halo around the core of a highlight is the tail.
MSLfloat D_GGX(float NdotH, float roughness) {
float a = roughness * roughness;
float a2 = a * a;
float d = NdotH * NdotH * (a2 - 1.0) + 1.0;
return a2 / (M_PI_F * d * d);
}
Note the roughness * roughness: artists author perceptual roughness, while GGX wants alpha. That squaring is what makes the slider behave intuitively through its middle range.
F, the Fresnel term, describes how reflectance changes with viewing angle. Every material approaches total reflection at grazing angles β which is why you can see a distant reflection on wet ground but not one at your feet. Schlick's approximation:
MSLfloat3 F_Schlick(float VdotH, float3 F0) {
return F0 + (1.0 - F0) * pow(1.0 - VdotH, 5.0);
}
F0 is the reflectance at normal incidence. Dielectrics sit around 0.04 almost universally (a very useful constant), and a metal's F0 is its baseColor:
MSLfloat3 F0 = mix(float3(0.04), baseColor, metallic);
G, the geometry term, describes how much the microfacets shadow each other. On a rough surface at grazing angles, bumps occlude valleys and less energy is reflected than D alone would predict.
Finally, combine diffuse and specular with energy conservation in mind. Metals have no diffuse, hence the (1 - metallic):
MSLfloat3 kD = (1.0 - F) * (1.0 - metallic);
float3 color = (kD * baseColor / M_PI_F + specular) * lightColor * NdotL;
The / M_PI_F is often omitted, but leaving it out makes the diffuse Ο times brighter than the specular and destroys the balance between them.
Material variants with function constants
Not every material has a normal map. Writing a shader per combination explodes; branching at runtime wastes performance. Metal's function constants solve exactly this:
MSLconstant bool hasNormalMap [[function_constant(0)]];
constant bool hasMetallicMap [[function_constant(1)]];
fragment float4 pbr_fragment(...) {
float3 n = in.normalWS;
if (hasNormalMap) { // eliminated at compile time, not a runtime branch
n = sampleNormalMap(...);
}
...
}
Supply the constant values when creating the pipeline, and Metal specializes a version with the branch removed. This is far easier to manage than a pile of #define combinations, and faster than branching at runtime.
The next note covers organizing this rendering work into multiple passes.