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.