Waves¶
A periodic profile along a direction, its crests `wavelength` metres apart.
The wave varies along the unit vector direction (its crests the planes across it): at a point p its place u = (p . direction) / wavelength, f = u - floor(u) within a period, and the profile s at f, -1 to 1, -1 at f = 0 and 1 at f = 1/2: sine: s = -cos(2 pi f); square: s = clamp(4 (1 - 4 |f - 1/2|), -1, 1), a square wave with soft edges, each half's flat middle 1 or -1 and its edges a ramp an eighth of a half wide (a clamped triangle wave). A wavelength of 0 or less is no wave, 0. Its gradient, lusine.render.wave_gradient, per metre: ds/df / wavelength along the direction; the sine's 2 pi sin(2 pi f), the square's -16 sign(f - 1/2) on its ramps and 0 on its flats. The direction is a unit vector, the caller's to make one (a law here does not normalise it). Source: Ebert et al., Texturing and Modeling: A Procedural Approach, 3rd ed., chapter 2 (periodic patterns). Checked against a reference in numpy, which has its own tests.
- Source
- Ebert et al., Texturing and Modeling, 3rd ed., chapter 2 (periodic patterns): the square a clamped triangle wave.
- Proof
- reference, invariant, limit.
wave.trait
namespace lusine.render;
float wave(vector<float<m>, 3> position, vector<float, 3> direction, float<m> wavelength, bool square)
{
if (wavelength <= 0) return 0.0;
let u = ((position.x * direction.x + position.y * direction.y) + position.z * direction.z) * (1.0 / wavelength);
let f = u - floor(u);
if (square) return clamp(4.0 * (1.0 - 4.0 * abs(f - 0.5)), -1.0, 1.0);
return -cos(6.2831855 * f);
}
vector<float<m^-1>, 3> wave_gradient(vector<float<m>, 3> position, vector<float, 3> direction, float<m> wavelength,
bool square)
{
if (wavelength <= 0) return vector<float<m^-1>, 3>(0.0 m^-1, 0.0 m^-1, 0.0 m^-1);
let k = 1.0 / wavelength;
let u = ((position.x * direction.x + position.y * direction.y) + position.z * direction.z) * k;
let f = u - floor(u);
let t = 4.0 * (1.0 - 4.0 * abs(f - 0.5));
let ramp = abs(t) < 1.0 ? (f < 0.5 ? 16.0 : -16.0) : 0.0;
let slope = square ? ramp : 6.2831855 * sin(6.2831855 * f);
return direction * (slope * k);
}
A periodic profile along a direction, its crests a wavelength apart: a sine, or a soft square wave (a clamped triangle wave, the table view's mown stripes at a wavelength of two bands). Its gradient, wave_gradient, is the profile's slope along the direction, for a layer's bumps (a ball's grooves). 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/wave.trait'))
// lusine.render.wave: a periodic profile along a direction, its crests `wavelength` metres apart. The wave varies along
// the unit vector `direction` (its crests the planes across it): at a point p its place u = (p . direction) /
// wavelength, f = u - floor(u) within a period, and the profile s at f, -1 to 1, -1 at f = 0 and 1 at f = 1/2:
// sine: s = -cos(2 pi f);
// square: s = clamp(4 (1 - 4 |f - 1/2|), -1, 1), a square wave with soft edges, each half's flat middle 1 or -1
// and its edges a ramp an eighth of a half wide (a clamped triangle wave).
// A wavelength of 0 or less is no wave, 0. Its gradient, lusine.render.wave_gradient, per metre: ds/df / wavelength
// along the direction; the sine's 2 pi sin(2 pi f), the square's -16 sign(f - 1/2) on its ramps and 0 on its flats.
// The direction is a unit vector, the caller's to make one (a law here does not normalise it).
// Source: Ebert et al., Texturing and Modeling: A Procedural Approach, 3rd ed., chapter 2 (periodic patterns).
// Checked against a reference in numpy, which has its own tests.
namespace lusine.render;
float wave(vector<float<m>, 3> position, vector<float, 3> direction, float<m> wavelength, bool square)
{
if (wavelength <= 0) return 0.0;
let u = ((position.x * direction.x + position.y * direction.y) + position.z * direction.z) * (1.0 / wavelength);
let f = u - floor(u);
if (square) return clamp(4.0 * (1.0 - 4.0 * abs(f - 0.5)), -1.0, 1.0);
return -cos(6.2831855 * f);
}
vector<float<m^-1>, 3> wave_gradient(vector<float<m>, 3> position, vector<float, 3> direction, float<m> wavelength,
bool square)
{
if (wavelength <= 0) return vector<float<m^-1>, 3>(0.0 m^-1, 0.0 m^-1, 0.0 m^-1);
let k = 1.0 / wavelength;
let u = ((position.x * direction.x + position.y * direction.y) + position.z * direction.z) * k;
let f = u - floor(u);
let t = 4.0 * (1.0 - 4.0 * abs(f - 0.5));
let ramp = abs(t) < 1.0 ? (f < 0.5 ? 16.0 : -16.0) : 0.0;
let slope = square ? ramp : 6.2831855 * sin(6.2831855 * f);
return direction * (slope * k);
}
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/wave.trait', 'wave'))
namespace probe;
lusine.version version = { { number = 1 } };
struct item {
host vector<float<m>, 3> position;
host vector<float, 3> direction;
host float<m> wavelength;
host bool square;
float result_0;
};
lattice<item, 1> items(spacing = 1 m, boundary = wall);
void main() { items.result_0 = lusine.render.wave(items.position, items.direction, items.wavelength, items.square); }
from lusine.kernels.render.wave import wave as reference, wave_gradient as reference_gradient
Reference: across the crests¶
Points across a wave of 0.6 m varying along a direction 30 degrees from +z toward -x, both profiles: the kernel's value within binary32's roundings of the float64 reference's (a square's corner, where a rounding moves the point across it, left out), and its gradient likewise.
a = np.radians(30.0)
across = F32(np.array([-np.sin(a), 0.0, np.cos(a)])).astype(float) # the unit vector it varies along
def wave(position, square, wavelength=0.6, direction=across):
return trait_run.law('render/wave.trait', 'wave', position=position, direction=direction,
wavelength=wavelength, square=float(square))
def slope(position, square, wavelength=0.6, direction=across):
return trait_run.law('render/wave.trait', 'wave_gradient', position=position, direction=direction,
wavelength=wavelength, square=float(square))
s = np.linspace(-1.0, 1.0, 801)
pts = F32(s[:, None] * across).astype(float)
fig, ax = plt.subplots(figsize=(7, 2.5))
for square in (False, True):
got = wave(pts, square)
want = reference(pts, across, float(F32(0.6)), square)
corner = np.abs(np.abs(4.0 * (1.0 - 4.0 * np.abs(((s / 0.6) % 1.0) - 0.5))) - 1.0) < 1e-3
keep = ~corner if square else np.ones_like(corner)
assert np.max(np.abs(got - want)) <= 1e-4
gs = slope(pts, square)
gw = reference_gradient(pts, across, float(F32(0.6)), square)
assert np.max(np.abs(gs - gw)[keep]) <= 1e-3 * np.max(np.abs(gw)), np.max(np.abs(gs - gw)[keep])
ax.plot(s, got, label='square' if square else 'sine')
ax.set_xlabel('across the crests (m)'); ax.set_ylabel('profile'); ax.legend()
plt.show()
Invariant: a wavelength, and nothing along¶
Each profile repeats every wavelength across, and nothing changes along its crests or up.
for square in (False, True):
got = wave(pts, square)
again = wave(F32((s + 0.6)[:, None] * across).astype(float), square)
along = wave(pts + 3.0 * np.array([np.cos(a), 0.0, np.sin(a)]) + np.array([0.0, 2.0, 0.0]), square)
assert np.max(np.abs(again - got)) <= 1e-4 and np.max(np.abs(along - got)) <= 1e-4
print('a period every 0.6 m; nothing along the crests')
a period every 0.6 m; nothing along the crests
Limit: a crest and a trough, the mown bands, and no wave¶
Each profile is -1 at a period's start and 1 half a wavelength on; the square at a wavelength of two bands is the table view's mown stripes; a wavelength of 0 is no wave, and no slope.
ends = np.array([0.0, 0.3])[:, None] * across
for square in (False, True):
v = wave(ends, square)
assert abs(v[0] + 1.0) <= 4 * U and abs(v[1] - 1.0) <= 1e-6
assert np.all(wave(pts, square, wavelength=0.0) == 0.0) and np.all(slope(pts, square, wavelength=0.0) == 0.0)
w = 0.3
stripes = np.clip(4.0 * (1.0 - 4.0 * np.abs(((s * 0.5 / w) % 1.0) - 0.5)), -1.0, 1.0)
assert np.max(np.abs(wave(pts, True, wavelength=2 * w) - stripes)) <= 1e-4
print('-1 and 1; the mown bands; none at a wavelength of 0')
-1 and 1; the mown bands; none at a wavelength of 0