Bumps¶
A shading normal tilted by a height field over the surface.
The field's gradient g (its change per metre, any vector: the part along the normal is dropped) has its surface gradient g - (n . g) n, and the bumped normal is n' = normalize(n - (g - (n . g) n)). On the plane y = 0 with heights h(x, z), n' is the height field's own normal, (-dh/dx, 1, -dh/dz) made unit length. n is a unit vector; heights small against the surface's curvature, as a bump's are. Source: Blinn, Simulation of Wrinkled Surfaces (SIGGRAPH 1978); Mikkelsen, Bump Mapping Unparametrized Surfaces on the GPU (Journal of Graphics, GPU, and Game Tools, 2010), the surface gradient. Checked against a reference in numpy, which has its own tests.
- Source
- Blinn, Simulation of Wrinkled Surfaces (SIGGRAPH 1978); Mikkelsen, Bump Mapping Unparametrized Surfaces on the GPU (JGGGT, 2010).
- Proof
- reference, invariant, limit.
bump_normal.trait
namespace lusine.render;
vector<float, 3> bump_normal(vector<float, 3> normal, vector<float, 3> gradient)
{
let surface = gradient - normal * dot(normal, gradient);
return normalize(normal - surface);
}
A shading normal tilted by a height field's gradient over the surface: the surface gradient g - (n . g) n taken from the normal and the result made unit length, so a surface shades as if its heights were there without its geometry moving (Blinn 1978; Mikkelsen 2010). One of the kernels lusine.render.table draws a table's shapes with, through a game's surface layers (lusine.render.surface).
import os, sys
root = os.getcwd()
while not os.path.isfile(os.path.join(root, 'docsite.json')):
root = os.path.dirname(root)
sys.path.insert(0, os.path.join(root, 'tools'))
import numpy as np
import matplotlib.pyplot as plt
from lusine import trait_run
F32 = np.float32
U = float(np.finfo(F32).eps) / 2 # binary32's unit roundoff, 2^-24
rng = np.random.default_rng(2026)
The kernel¶
The law as the library states it.
print(trait_run.source('render/bump_normal.trait'))
// lusine.render.bump_normal: a shading normal tilted by a height field over the surface. The field's gradient g (its
// change per metre, any vector: the part along the normal is dropped) has its surface gradient g - (n . g) n, and the
// bumped normal is n' = normalize(n - (g - (n . g) n)). On the plane y = 0 with heights h(x, z), n' is the height
// field's own normal, (-dh/dx, 1, -dh/dz) made unit length. n is a unit vector; heights small against the surface's
// curvature, as a bump's are.
// Source: Blinn, Simulation of Wrinkled Surfaces (SIGGRAPH 1978); Mikkelsen, Bump Mapping Unparametrized Surfaces on
// the GPU (Journal of Graphics, GPU, and Game Tools, 2010), the surface gradient.
// Checked against a reference in numpy, which has its own tests.
namespace lusine.render;
vector<float, 3> bump_normal(vector<float, 3> normal, vector<float, 3> gradient)
{
let surface = gradient - normal * dot(normal, gradient);
return normalize(normal - surface);
}
The program¶
Each section runs the kernel over a lattice of items, one a case: the host sets every parameter, and main() binds the kernel over the items.
print(trait_run.law_program('render/bump_normal.trait', 'bump_normal'))
namespace probe;
lusine.version version = { { number = 1 } };
struct item {
host vector<float, 3> normal;
host vector<float, 3> gradient;
vector<float, 3> result_0;
};
lattice<item, 1> items(spacing = 1 m, boundary = wall);
void main() { items.result_0 = lusine.render.bump_normal(items.normal, items.gradient); }
from lusine.kernels.render.bump_normal import bump_normal as reference
Reference: normals and slopes at random¶
Unit normals in every direction and slopes up to about 1: the kernel's normal within binary32's roundings of the float64 reference's.
def bump(normal, gradient):
return trait_run.law('render/bump_normal.trait', 'bump_normal', normal=normal, gradient=gradient)
n = rng.normal(size=(2000, 3))
n = F32(n / np.linalg.norm(n, axis=1, keepdims=True)).astype(float)
g = F32(rng.normal(size=(2000, 3)) * 0.5).astype(float)
got = bump(n, g)
err = np.max(np.abs(got - reference(n, g)))
assert err <= 16 * U, err
print(f'{len(n)} normals within {err:.2g} of the reference')
2000 normals within 1.2e-07 of the reference
Invariant: unit length, leaning downhill, and blind to the normal's own direction¶
The bumped normal is a unit vector; it leans away from the uphill direction; and a gradient's part along the normal tilts nothing.
assert np.max(np.abs(np.linalg.norm(got, axis=1) - 1.0)) <= 8 * U
s = g - n * np.sum(n * g, axis=1, keepdims=True)
assert np.all(np.sum(got * s, axis=1) <= 8 * U)
along = bump(n, F32(g + 2.0 * n).astype(float))
assert np.max(np.abs(along - got)) <= 1e-5
print('unit; leaning downhill; the normal\'s own part ignored')
unit; leaning downhill; the normal's own part ignored
Limit: the plane's heights, and no slope¶
On the plane y = 0 the bumped normal is the height field's own, (-dh/dx, 1, -dh/dz) made unit length; no slope leaves the normal as it is.
The heights' normal across a sine of 1 mm in height and 8 mm in wavelength, a ball's groove's scale, leans about 38 degrees at its steepest.
up = np.broadcast_to([0.0, 1.0, 0.0], (5, 3))
hx = np.array([0.0, 0.25, -0.5, 1.0, 2.0])
hz = np.array([0.0, -0.75, 0.5, 0.0, -1.0])
plane = bump(up, np.stack([hx, np.full(5, 0.3), hz], axis=1))
want = np.stack([-hx, np.ones(5), -hz], axis=1)
want /= np.linalg.norm(want, axis=1, keepdims=True)
assert np.max(np.abs(plane - want)) <= 8 * U
assert np.max(np.abs(bump(n, np.zeros_like(n)) - n)) <= 8 * U
x = np.linspace(0.0, 0.016, 161)
dh = 0.001 * 2 * np.pi / 0.008 * np.sin(2 * np.pi * x / 0.008)
tilt = np.degrees(np.arccos(bump(np.broadcast_to([0.0, 1.0, 0.0], (161, 3)),
np.stack([dh, np.zeros(161), np.zeros(161)], axis=1))[:, 1]))
fig, ax = plt.subplots(figsize=(7, 2.5))
ax.plot(x * 1000, tilt)
ax.set_xlabel('across the groove (mm)'); ax.set_ylabel('tilt (degrees)')
plt.show()
print(f'the plane\'s heights; no slope, no tilt; a groove leans {tilt.max():.1f} degrees at most')
the plane's heights; no slope, no tilt; a groove leans 38.1 degrees at most