Hair Rendering

There are always twists to the typical BRDF models, and Scheuermann’s bidirectional scattering distribution function model (BSDF, or BRDF with light integrated over a sphere that aggregates all the light interactions within hair fibers) is a great piece to discuss because we can focus our attention on a simple pixel shader.

Some materials lend themselves to simple light interactions, with uniform diffuse responses and specular calculations that don’t directly assume anything about the possible bounces that occur underneath the surfaces (i.e., subsurface scattering). Hair requires a bit more thought regarding the interactions with surrounding strands and potentially more.

To understand the fundamental drivers of hair color, we need to understand a few concepts. Scheuermann’s shader is a mix of Kajiya and Kay’s BRDF model, which considers hair as volumes consisting of organized and infinitesimal cylindrical fibers, and the model by Marschner, which is based on his measurements of light scattering in human hair fibers. The former reconfigures specular lighting calculations to use the tangent instead of the normal, whereas the latter depicts different components of scattering in a single hair strand, like so:

R represents a white specular peak shifted toward the root and appears as a white specular reflection on the hair. TT isn’t quite represented in our shader, but it typically adds brightness to light (e.g., blond) hair when backlit. As the crux addition to what would normally be just a single specular response, TRT manifests as a secondary specular highlight and is colored due to the absorption of light as it travels through the fiber. In my demonstration, I kept it the same color as the diffuse response, where some applications recommend maintaining a middle-gray hue. It appears as glints on strands and presents as a non-uniform illumination.

Concretely, we have a particular definition of specular contributions via the Kajiya–Kay model followed by an approximation of two (as opposed to just one) specular highlights as determined by Marschner. To render the specular highlights, we need a few textures to help us perform our calculations.

One such texture is the specular noise texture, for which I chose the following glitter pattern:

This pattern was used to modulate the secondary specular response and produce the glints that I mentioned earlier.

The diffuse lighting calculation centers around the usual Lambertian factor (i.e., N dot L), except this time, we scale and bias the term to brighten up areas facing away from the light for a softer overall look:

vec3 diffuse = clamp(mix(0.25f, 1.0f, dot(N, L)), 0.0f, 1.0f) * DIFFUSE_COLOR * gLightColor;

The flatness of the response lies in stark contrast to the specular contributions, which add significant detail due to a more complete extrapolation of lighting behavior onto the underlying strand volumes. We calculate the specular colors in a similar fashion by first shifting tangents along the normal of the surface of the hair to reposition the specular highlights along the hair strand:

const float PRIMARY_SHIFT = -1.0f;
const float SECONDARY_SHIFT = 1.5f;

// ...

const vec3 T = normalize(dFdx(fs_in.WorldPos) * dFdy(fs_in.TexCoords).t - dFdy(fs_in.WorldPos) * dFdx(fs_in.TexCoords).t);
vec3 calcWorldSpaceNormal(vec3 tangentSpaceNormal)
{
    vec3 N = normalize(fs_in.WorldNormal);
    vec3 B = -normalize(cross(N, T));
    mat3 TBN = mat3(T, B, N);
    return normalize(TBN * tangentSpaceNormal);
}
const vec3 TANGENT_SPACE_N = texture(gNormalMap, fs_in.TexCoords).xyz * 2.0f - 1.0f;
const vec3 N = calcWorldSpaceNormal(TANGENT_SPACE_N);
const vec3 L = normalize(gLightPos - fs_in.WorldPos);

vec3 shiftTangent(float shift)
{
    vec3 shiftedT = T + shift * N;
    return normalize(shiftedT);
}

// ...

vec4 calcHairColor()
{
    // ...

    float baseShiftAmount = texture(gShiftTexture, fs_in.TexCoords).r - 0.5f;
    vec3 t1 = shiftTangent(PRIMARY_SHIFT + baseShiftAmount);
    vec3 t2 = shiftTangent(SECONDARY_SHIFT + baseShiftAmount);

    // ...
}

The reason behind adjusting our desired shift amounts with a texture lookup is to add a bit of randomness to what would otherwise be a fairly uniform look for our specular highlights over the hair. It’s possible to use a different map that better resonates with the overall aesthetics, but for this particular demo, the displacement texture yields an explicit enough rendering of the streaks commonly observed in hair.

We want to shift the tangent in opposite directions so that each of the specular contributions adds something different to our result. However, the final specular calculations are mainly going to center around the reconstituted tangents, different exponents that control the emphasis of each contribution, and a half-angle vector.

// ...
const float PRIMARY_SPECULAR_EXP = 0.98f;
const float SECONDARY_SPECULAR_EXP = 0.49f;
const vec3 DIFFUSE_COLOR = vec3(0.416f, 0.424f, 0.431f);
const vec3 SECONDARY_SPECULAR_COLOR = DIFFUSE_COLOR;

// ...

float calcStrandSpecularLighting(vec3 T, float exponent)
{
    vec3 V = normalize(gViewPos - fs_in.WorldPos);
    vec3 H = normalize(L + V);
    float ToH = dot(T, H);
    return smoothstep(-1.0f, 0.0f, ToH) * pow(sqrt(1.0f - ToH * ToH), exponent);
}

vec4 calcHairColor()
{
    // ...

    vec3 specular1 = texture(gSpecularMap, fs_in.TexCoords).rgb * calcStrandSpecularLighting(t1, PRIMARY_SPECULAR_EXP) * gLightColor;

    float mask = texture(gNoiseTexture, fs_in.TexCoords).r;
    vec3 specular2 = SECONDARY_SPECULAR_COLOR * calcStrandSpecularLighting(t2, SECONDARY_SPECULAR_EXP) * gLightColor;

    vec4 hairColor;
    hairColor.a = texture(gAlphaTexture, fs_in.TexCoords).r;
    // ...
    hairColor.rgb = (diffuse + specular1 + mask * specular2) * color;
    hairColor.rgb *= texture(gAOMap, fs_in.TexCoords).r;
    return hairColor;
}

There is one caveat I should point out, and that’s the modulation of the R-specular calculation (i.e., specular1) using the specular map itself. This pattern is commonly used in more canonical lighting calculations (e.g., Blinn–Phong reflectance).

The straight dope behind the Kajiya–Kay model is precisely the anisotropic treatment of light; that is, we center our specular lighting calculations around the tangent with respect to each strand of hair to better simulate how our highlights manifest. Given a normal defined by the map with respect to our mesh, we can assume that it lies in a plane spanned by the associated tangent and the view vector, and we adjust our calculations accordingly. In particular, defining the specular effects, each in terms of sin(T, H) raised to a specular exponent, leaves us with a more thorough rendering of light on hair.

Resources

Thorsten Scheuermann’s SIGGRAPH Talk on Practical Real-Time Hair Rendering and Shading
Thorsten Scheuermann’s GDC Talk on Hair Rendering and Shading
Practical Real-Time Hair Rendering and Shading Paper

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s