Quick answer: Sample the displacement function at two neighbor offsets, compute derivatives, take the cross product. Write to the vertex normal output.
A water shader displaces vertices for waves. The surface waves visually but lighting stays flat — normals weren’t recomputed.
Why Normals Need Update
Vertex Position output moves the geometry but the input normal is per-vertex from mesh data. Original normals point as if the surface were flat. Light reflects wrong.
Reconstruct via Finite Differences
In ShaderGraph (or HLSL custom function):
// Sample displacement at center + epsilon offsets
float h0 = DisplaceFn(uv);
float h_dx = DisplaceFn(uv + float2(0.01, 0));
float h_dy = DisplaceFn(uv + float2(0, 0.01));
float3 tangent = float3(0.01, h_dx - h0, 0);
float3 bitangent = float3(0, h_dy - h0, 0.01);
float3 normal = normalize(cross(bitangent, tangent));
The cross product gives the local normal of the displaced surface.
Custom Function Node
In ShaderGraph: add a Custom Function node with HLSL above. Inputs: UV, displacement function. Output: float3 normal. Wire into the Master node’s Normal (Object Space) input.
Performance Tip
Three samples of DisplaceFn per vertex = 3× cost. For complex displacement (Perlin noise), pre-bake displacement to a texture and sample. Cheap derivatives via ddx/ddy if you compute per-fragment instead of per-vertex.
Verifying
Animate waves. Light reflects following wave peaks/troughs. Specular highlights move with the surface. Side-by-side with un-recomputed: clear improvement.
“Displaced vertices need re-derived normals. Finite differences are the standard trick.”
For static displacement (like a one-time terrain offset), bake the resulting mesh + normals offline — no runtime cost.