Bump Maps Need Units
A height-based definition of bump mapping grounded in Blinn and Mikkelsen, with surface-gradient shader math and concrete fixes for Three.js, MaterialX, Blender, and glTF's EXT_materials_bump.
Ben Houston • • 13 min read
A height texture moves between rendering tools easily. Its strength parameter does not. Three examples from current code:
- Three.js r184: bump strength depends on how large the surface appears on screen. Move the camera closer, or double the device pixel ratio, and the same bumps get flatter. Tilt the surface toward grazing and they get stronger along the foreshortened axis.
- MaterialX 1.39.4:
bumpisheighttonormalfollowed bynormalmap, andbump.scaleis the normal map's X/Y scale. It is a slope multiplier, not a height. - glTF
EXT_materials_bump:bumpFactoris a multiplier with no unit, no space, and no rule for object scale. I wrote that draft, so part of this essay is a case for revising my own work.
I ran into this while matching MaterialX materials between Blender and Three.js: identical texture samples are not enough if the renderers disagree on what they mean geometrically. Garrett Johnson's proposed world-unit height fix (#35330) and texel-gradient fix (#35332) prompted this essay and made me revisit my earlier PR (#26899), where I incorrectly normalized the surface derivatives.
The fix is to treat a bump map as what it originally was: a displacement, measured in a declared space, whose effect on the normal we evaluate without moving the geometry. The strength parameter then becomes a length, and each tool only has to say which ruler measures it.

Why not bake everything to normal maps? Height is a single channel, so it is a much smaller download for web delivery than a two- or three-channel normal map. It is also what procedural shaders naturally produce, and it is the same signal that drives displacement. Bump maps are worth getting right.
The origin is displacement#
In Simulation of Wrinkled Surfaces (1978), Jim Blinn offsets a surface along its unit normal, derives the new normal, and drops the term involving the derivative of the original normal. The renderer shades with the perturbed normal while leaving the surface in place.
In section 4, Blinn notes that enlarging the surface flattens the wrinkles. His scale-independent alternative makes the texture control normal rotation instead of displacement. That fork is still with us: detail can be authored as a height or as a direction. glTF's normalTexture.scale and MaterialX's bump.scale are direction controls. A bump map should be a height.
Morten Mikkelsen's 2010 paper expresses Blinn's perturbation through the surface gradient of height, with no UV parametrization required, and his 2020 framework combines detail sources as gradients. The math is settled. What is missing is a parameter convention.
Height has a unit and a space#
| Value | Meaning | Units |
|---|---|---|
| Texture or procedural sample, often between 0 and 1 | Dimensionless | |
| Height scale: length per unit change in the sample | Length in the height space | |
heightSpace | The space in which that length is measured | World or object |
The height is . If the space is in metres, makes a 0–1 texture represent 5 mm of height variation. Bump normals depend only on height differences, so a constant offset changes nothing and bump mapping needs no midlevel parameter. The samples are data: read them without an sRGB transfer function, because a nonlinear remapping changes their derivatives.
True displacement moves the point,
. Bump mapping evaluates the resulting normal without moving it. Differentiating the displaced surface gives:
Bump mapping drops , the curvature term: accurate when height is small relative to the curvature radius, exact on a flat patch. It still cannot move a silhouette or self-shadow.
Defined this way, bump normals match the displaced surface away from silhouettes and match a normal map baked from the same height. Moving a material between tools becomes a unit conversion instead of retuning by eye.
Two rulers#
With a positive uniform object scale , . An object-space height has world length . A world-space height has world length .
Take a ramp that rises 5 mm over 10 mm, a slope of 0.5. Enlarge the object tenfold. With object-space height, the ramp rises 50 mm over 100 mm, and the slope stays 0.5. With world-space height, the UV-mapped ramp widens to 100 mm but still rises only 5 mm, and the slope falls to 0.05.
| Pattern coordinates | Height space | Result under uniform object scale |
|---|---|---|
| UV or object | Object | Detail grows with the object; slopes are preserved |
| World | World | Detail keeps its physical size |
| UV or object | World | Detail widens without getting taller; bumps flatten |
Both are useful. Object (local) space suits detail that belongs to the asset and matches how tangent-space normal maps behave under scale. World space suits detail with a physical size and keeps glTF's metres unambiguous under node scale. UV is not a length space: one UV unit could span a millimetre or a kilometre. Height space is also separate from normal-map space, which picks a frame for a direction, not a ruler for a distance.
From height to a normal#
The surface gradient is a tangent vector pointing uphill, with magnitude equal to the maximum rise over run. The bump normal is:
For world-space height, evaluate this with world-space position and normal and . For object-space height, evaluate it with object-space inputs and rotate the result into world space, or use world-space inputs with . Both give the same direction.
A shader implementation#
Let and be screen-space position derivatives, and and the matching height derivatives. Then:
In GLSL, assuming a unit input normal:
vec3 bumpNormal(vec3 position, vec3 normal, float height) { vec3 px = dFdx(position); vec3 py = dFdy(position); float hx = dFdx(height); float hy = dFdy(height); vec3 rx = cross(py, normal); vec3 ry = cross(normal, px); float det = dot(px, rx); // Relative degeneracy check for a nearly singular screen patch. float areaBound = length(px) * length(py); if (areaBound == 0.0 || abs(det) <= 1e-6 * areaBound) { return normal; } vec3 gradient = (hx * rx + hy * ry) / det; return normalize(normal - gradient); }
Pass height = h * heightScale and position and normal in the chosen height space.
Do not normalize px and py. Their lengths are the surface distance covered by one screen step, which is the denominator of the slope. A height change of 0.001 m over 0.002 m of surface is a slope of 0.5; drop the 0.002 and the answer depends on the screen instead of the surface.
The height derivative can come from hardware derivatives, forward differences at uv + dFdx(uv) and uv + dFdy(uv), or an analytic gradient, as long as it refers to the same steps as the position derivatives. Estimators differ in accuracy, not in what they estimate.
Non-uniform scale and shear break the scalar relationship: an object-normal displacement does not transform to a displacement along the world normal. A renderer that needs that case can compute the bump normal in object space and apply the inverse-transpose normal transform.
Height maps and normal maps#
A tangent-space normal map stores the slopes directly. For an orthonormal frame, slopes and give , and a decoded normal implies slopes and . Because the result is already dimensionless, normal maps preserve slope under uniform scale.
Height samples alone cannot provide slopes. A texture ramp from 0 to 1 with m has slope 0.5 if one UV unit covers 10 mm of surface, and 0.005 if it covers a metre. Converting height to slope needs the local surface metric. With :
This handles stretched and skewed UVs and needs only local derivatives. A bake then projects the gradient into the tangent frame the renderer uses. An image editor's "height to normal" slider is a guess at this metric; without the mesh or a declared tile size there is no unique answer.
The two representations also diverge under retiling. Tile a height map twice as often and slopes double. Tile a normal map twice as often and slopes stay the same, implying half the height. Going from normals back to height requires integrating the slope field, and edited normal maps often are not integrable.
What each tool should change#
Three.js#
The r184 GLSL bump chunk and TSL BumpMapNode normalize the position derivatives, deliberately: "normalize is done to ensure that the bump map looks the same regardless of the texture's scale." The height differences are taken over one screen step of UV. The result is height change per pixel rather than per unit of surface, so bump strength varies with camera distance and output resolution. Because the x and y derivatives are normalized separately, it also varies with viewing angle. The normalization does remove dependence on texture scale, but only by replacing it with dependence on the camera. A real unit removes both.
Meanwhile, the displacement chunk offsets along the object normal before the model transform, so displacementScale is an object-space length. Bump and displacement do not share units.
Fix: add an opt-in physical bump mode that keeps the derivative lengths, treats bumpScale as a length, and adds a height-space option (world or object). Keep the current behaviour as the default for existing content.
MaterialX#
In 1.39.4, NG_bump_vector3 is heighttonormal at its default scale feeding normalmap, with bump.scale wired to normalmap.scale. So bump.scale scales the normal's X/Y: a slope multiplier. The GLSL heighttonormal builds its tangent frame from screen-space derivatives of the texture coordinates and the height, with no position input. The slope it produces is height change per UV unit divided by 16 (for Sobel parity), which bump.scale then multiplies. One UV unit has no physical size, so the result changes whenever the UV mapping does.
Fix: document bump and heighttonormal as texture-domain slope operations, and add a physical height-to-normal node with a length scale and a height-space input, computed from position derivatives (or from an explicit tile size for planar mappings).
Blender#
At commit 0c3a5f91a2d0, the Bump node evaluates the gradient against world-space position derivatives in Cycles SVM, OSL, and Eevee, without normalizing their lengths. With Strength 1, Distance is in world units. Object-space height is available only by routing an Object-space Displacement node into Material Output with Bump Only, which goes through Cycles' bump_from_displacement() and Eevee's displacement_bump().
Fix: expose the height space on the Bump node itself. Cycles' BumpNode already has a use_object_space socket that the Blender translator never sets; Eevee needs the equivalent. Redshift's Bump Map node already exposes this choice as "Affected By Object Scale".
glTF and EXT_materials_bump#
Core glTF has no scalar height. normalTexture.scale scales the X/Y of a tangent-space normal, which is a slope.
My EXT_materials_bump draft (PR #2339) reads height from the red channel of bumpTexture and multiplies it by bumpFactor (default 1, range 0–100). It says nothing about units, space, or object scale. The Three.js r184 loader maps bumpFactor straight to bumpScale, so in practice the extension currently means "whatever Three.js does."
Fix: amend the draft before it is merged:
- Add a
heightSpaceproperty with values"world"and"object", where object space is the node's local coordinates and scales with the node transform. - When
heightSpaceis present,bumpFactoris the height variation in metres across the 0–1 range of the texture, with the scale behaviour from the table above. - When it is absent, keep the current unspecified behaviour, so files already written against the Three.js loader still load as they do today.
New exporters should always write heightSpace. Existing files keep working, and new files get a meaning every renderer can implement.
Others#
Autodesk's tools have the same gap: Maya's bump2d Bump Depth, Arnold's bump2d Bump Height, and the 3ds Max Physical Material bump multiplier document neither a unit nor a height space.
How to test it#
Use a plane with a linear ramp, which has an exact answer: , m, and one UV unit spanning 0.01 m in X. The expected slope is 0.5, the normal is , and at object scale 10 the slope follows the Two rulers table. It should not change with camera distance, viewing angle, or resolution, and it should match a normal map baked with the same UV metric and tangent convention. Use a sphere against a densely tessellated displaced reference to check curvature. Compare normal buffers rather than beauty renders.
A parameter that means something#
The proposal is to standardize : a sample, a length, and the space that length is measured in. Renderers can use any derivative estimator, and an artistic strength can still exist as long as it is labelled as dimensionless.
What I want to be able to say is: this texture describes 5 mm of height variation in world space, or in object space. Then every renderer knows what the bumps should look like at any scale, and the result can be tested against a normal map baked for that surface.