Skip to content

Instantly share code, notes, and snippets.

@pixnblox
Last active December 4, 2025 02:10
Show Gist options
  • Select an option

  • Save pixnblox/5e64b0724c186313bc7b6ce096b08820 to your computer and use it in GitHub Desktop.

Select an option

Save pixnblox/5e64b0724c186313bc7b6ce096b08820 to your computer and use it in GitHub Desktop.
Address the shadow terminator problem by computing a new shading position

Original Tweet - June 6, 2020

I really should have known this "shadow terminator" ray tracing issue was going to happen, but I stumbled into it anyway. It is a surprisingly tricky / annoying / common problem, but I got it fixed with some help from @pointinpolygon and others.

image

This arises from a difference between shading normals and geometric normals, particularly with low-poly geometry. This POV-Ray page has a nice diagram that explains it: http://wiki.povray.org/content/Knowledgebase:The_Shadow_Line_Artifact. This is not a floating-point precision issue.

This is also different from the "bump" terminator problem, which is about normal maps and not low-poly self-shadowing. See "Ray Tracing Gems" chapter 12 for details on that. The solution there is to modify the BSDF to add shadowing, and here I need to remove shadowing.

For me the answer was to compute a shading position above the triangle, and use that for the shadow ray origin. This is similar to curved PN triangle tessellation. Code speaks louder than tweets, so see the HLSL gist here.

image
// Projects the specified position (point) onto the plane with the specified origin and normal.
float3 projectOnPlane(float3 position, float3 origin, float3 normal)
{
return position - dot(position - origin, normal) * normal;
}
// Computes the shading position of the specified geometric position and vertex positions and
// normals. For a triangle with normals describing a convex surface, this point will be slightly
// above the surface. For a concave surface, the geometry position is used directly.
// NOTE: The difference between the shading position and geometry position is significant when
// casting shadow rays. If the geometric position is used, a triangle may fully shadow itself when
// it should be partly lit based on the shading normals; this is the "shadow terminator" problem.
float3 computeShadingPosition(
float3 geomPosition, float3 shadingNormal,
float3 positions[3], float3 normals[3], float3 barycentrics)
{
// Project the geometric position (inside the triangle) to the planes defined by the vertex
// positions and normals.
float3 p0 = projectOnPlane(geomPosition, positions[0], normals[0]);
float3 p1 = projectOnPlane(geomPosition, positions[1], normals[1]);
float3 p2 = projectOnPlane(geomPosition, positions[2], normals[2]);
// Interpolate the projected positions using the barycentric coordinates, which gives the
// shading position.
float3 shadingPosition = p0 * barycentrics.x + p1 * barycentrics.y + p2 * barycentrics.z;
// Return the shading position for a convex triangle, where the shading point is above the
// triangle based on the shading normal. Otherwise use the geometric position.
bool convex = dot(shadingPosition - geomPosition, shadingNormal) > 0.0f;
return convex ? shadingPosition : geomPosition;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment