Scaled mesh normals: normalization cannot fix the wrong transform

A unit-length vector can still point in the wrong direction for a surface normal. In this example, scaling a flat triangle by (2,1,0.5), applying that same scale to its normal and then normalizing leaves the vector 53.859° away from the transformed face’s winding normal. Making its length one does not make it perpendicular to the face.

The useful check is against the actual transformed edges. A flat face normal should have zero dot product with both edge directions. Here the incorrect unit vector gives −0.759555 and −0.297044 against the unit world edges. These are dimensionless dot products, not lengths or measurement errors from a sensor.

Start with the transformed triangle

The synthetic local triangle has ordered vertices (0,0,0), (80,0,40) and (0,60,30) mm. Its unit normal is proportional to (−1,−1,2). After scale (2,1,0.5), its two edges from the first vertex become (160,0,20) and (0,60,15) mm.

Taking the cross product of those world edges gives a direction perpendicular to their plane. Applying the forward scale to the original normal gives a different direction. Both vectors can be normalized; only one matches the geometry. Under the uniform control scale (2,2,2), the forward rule happens to work after normalization. That control can hide the bug in a tool that is later given unequal axis scales.

For a nonsingular linear transform A, transport a plane normal with inverse(A).transpose(), then normalize it. Translation is excluded. Khronos’s Vulkan tutorial states this inverse-transpose rule in its Handling Normals section. We use that rule for one constant affine transform, without claiming to test its skinning implementation. Khronos: Handling Normals

You can also see why it works: if n is perpendicular to a local edge e, then (A^-T n) · (A e) = n · e = 0. A subsequent proper rotation preserves that relationship. In our extra case, applying a 30° Z rotation after the nonuniform scale leaves both incorrect dot products unchanged; rotating the whole arrangement does not repair the wrong rule.

Whole actual triangle scaled by two, one and one half, with overlapping geometric and inverse-transpose normal markers in blue and the incorrect normalized forward transform in brown
The actual finite triangle after scale (2,1,0.5), shown in an orthographic scientific view with its display translation removed. Blue geometric and inverse-transpose markers overlap; the brown forward-transformed unit vector is 53.859° from the world-edge normal. All vector markers are 50 mm drawings reconstructed from geometry, not shader or lighting measurements.

Reproduce the mismatch

This short example uses only a nonzero diagonal scale. Dividing the normal components by their respective scales implements the inverse transpose for this diagonal case, not for an arbitrary matrix.

import math

def dot(a, b):
    return sum(x * y for x, y in zip(a, b))

def cross(a, b):
    return [a[1]*b[2] - a[2]*b[1], a[2]*b[0] - a[0]*b[2],
            a[0]*b[1] - a[1]*b[0]]

def unit(v):
    length = math.hypot(*v)
    assert length > 0
    return [x / length for x in v]

# Metres; edges of the ordered triangle starting at (0, 0, 0).
e1, e2 = [0.080, 0, 0.040], [0, 0.060, 0.030]
scale = [2.0, 1.0, 0.5]  # Nonzero DIAGONAL scale only in this snippet.
local_normal = unit(cross(e1, e2))
world_edges = [[s*x for s, x in zip(scale, e)] for e in (e1, e2)]
geometry_normal = unit(cross(*world_edges))
wrong = unit([s*n for s, n in zip(scale, local_normal)])
correct = unit([n/s for s, n in zip(scale, local_normal)])
for name, normal in [("forward", wrong), ("inverse transpose", correct)]:
    dots = [dot(normal, unit(edge)) for edge in world_edges]
    print(name, "unit-edge dots:", [round(x, 6) for x in dots])
assert all(abs(dot(correct, unit(e))) < 1e-12 for e in world_edges)
assert abs(dot(wrong, unit(world_edges[0]))) > 0.7
angle = math.degrees(math.acos(max(-1, min(1, dot(wrong, geometry_normal)))))
print(f"Wrong normal angle from winding normal: {angle:.3f} deg")

The inverse-transpose dots print as zero, within floating-point rounding. The final output is 53.859° for the incorrect direction.

Perpendicularity does not settle every orientation question

With scale (−2,1,0.5), the determinant is negative. The inverse-transpose direction is still perpendicular to both world edges, but it points opposite to their ordered cross product. The identity cross(Ae1,Ae2) = det(A) A^-T cross(e1,e2) accounts for that sign. This open triangle has no solid-outward direction; matching face winding and transporting a plane normal are distinct conventions. Do not silently treat their sign difference as another perpendicularity failure.

A zero determinant is a different boundary. Scale (2,1,0) has no inverse transpose, yet this triangle still has two independent edges and a geometric normal pointing along positive Z. Scale (2,0,0) collapses it to a line: its area is zero and there is no unit face normal. The calculations retain those unavailable values as null rather than substituting an identity matrix or normalizing a zero vector.

An AI-operated Blender 5.2.2 LTS study on October 6, 2026 built six prescribed static arrangements: 21 objects, 63 evaluated vertices and six local triangle faces. The cases used uniform scale, nonuniform scale, that nonuniform scale followed by a 30° Z rotation, negative determinant, a singular map with surviving face area, and a map collapsing the face to a line. Evaluated local and world vertices, matrices, topology and local polygon normals were read from the owned scene. The API normal was local; world normal directions were our reconstruction, not a shader or normal-transform API result.

An independent audit solved Aᵀu=n by pivoted Gaussian elimination, separately recovered the ordered world-edge cross product, and checked the finite 50 mm markers against the actual triangle centroid and directions. The actual nonuniform case gave unit-edge dots of approximately −0.759555 and −0.297044 for the forward rule, and a 53.859466° difference from the winding normal. The inverse-transpose direction was perpendicular to both edges within numeric rounding. The rotated case retained the same error; the reflected case reversed winding agreement. The singular cases preserved unavailable directions as null, including the collapsed face’s geometric normal, rather than inventing markers.

All actual source, inputs, complete geometry, CSV rows, graphs, scene export and original authored/evaluated scene state, context and base file were checked. Coordinate comparisons used 0.0003 mm and direction comparisons used 0.000003; inverse-transpose edge dots and winding agreement used 0.00002. These are numerical consistency allowances, not hardware accuracy or a general conditioning threshold. Separate standalone controls rejected 17 deliberate corruptions and three invalid inputs. Their near-singular scale examples were calculations, not additional Blender runs.

This check does not test rendered lighting, a shader implementation, skinning, custom or smooth vertex/corner normals, projection or nonlinear deformation. Such normals need not equal a single triangle’s flat face normal. For a flat mesh debugging step, compare the candidate direction against both actual world edges before treating normalization as a repair.

The separate scaled-parent and rotated-child study shows why an inherited world transform can skew geometry even when a child’s local scale is one. Use the full linear world transform, excluding translation, for this normal check; local scale fields alone may miss what the parent chain contributes.