Aerial perspective¶
Aerial perspective, the air between the eye and what it sees.
Light from a surface distance away is dimmed by the air it crosses and the air adds its own, scattered toward the eye: L = L_air + (L_hit - L_air) T, T = exp(-sigma t), the transmittance T of a uniform air of extinction sigma per metre over t, and L_air the radiance of a path long enough that none of the surface's light is left: the horizon sky's, toward which far ground fades. sigma = 0 leaves the radiance as it is (T = 1), and a surface far past 1 / sigma is drawn as the air alone. Source: Preetham, Shirley and Smits, A Practical Analytic Model for Daylight (SIGGRAPH 1999), section 5 (aerial perspective, a uniform extinction); Beer and Lambert's law for T. Checked against a reference in numpy, which has its own tests.
- Source
- Preetham, Shirley and Smits, A Practical Analytic Model for Daylight (SIGGRAPH 1999), section 5; Beer and Lambert's law.
- Proof
- reference, invariant, limit.
haze.trait
namespace lusine.render;
vector<float<W/m2/sr>, 3> haze(vector<float<W/m2/sr>, 3> radiance, vector<float<W/m2/sr>, 3> air,
float<m^-1> extinction, float<m> distance)
{
let transmittance = exp(-(extinction * distance));
return air + (radiance - air) * transmittance;
}
A surface's light dimmed by the air it crosses and the air's own radiance added, L = L_air + (L_hit - L_air) exp(-sigma t). lusine.render.table applies it to each ray that meets a surface, before the tonemap, so far ground fades toward the horizon sky.
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/haze.trait'))
// lusine.render.haze: aerial perspective, the air between the eye and what it sees. Light from a
// surface `distance` away is dimmed by the air it crosses and the air adds its own, scattered toward the eye:
// L = L_air + (L_hit - L_air) T, T = exp(-sigma t),
// the transmittance T of a uniform air of extinction sigma per metre over t, and L_air the radiance of a path long
// enough that none of the surface's light is left: the horizon sky's, toward which far ground fades. sigma = 0 leaves
// the radiance as it is (T = 1), and a surface far past 1 / sigma is drawn as the air alone.
// Source: Preetham, Shirley and Smits, A Practical Analytic Model for Daylight (SIGGRAPH 1999), section 5 (aerial
// perspective, a uniform extinction); Beer and Lambert's law for T.
// Checked against a reference in numpy, which has its own tests.
namespace lusine.render;
vector<float<W/m2/sr>, 3> haze(vector<float<W/m2/sr>, 3> radiance, vector<float<W/m2/sr>, 3> air,
float<m^-1> extinction, float<m> distance)
{
let transmittance = exp(-(extinction * distance));
return air + (radiance - air) * transmittance;
}
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/haze.trait', 'haze'))
from lusine.kernels.render.haze import haze as reference
namespace probe;
lusine.version version = { { number = 1 } };
struct item {
host vector<float<W/m2/sr>, 3> radiance;
host vector<float<W/m2/sr>, 3> air;
host float<m^-1> extinction;
host float<m> distance;
vector<float<W/m2/sr>, 3> result_0;
};
lattice<item, 1> items(spacing = 1 m, boundary = wall);
void main() { items.result_0 = lusine.render.haze(items.radiance, items.air, items.extinction, items.distance); }
Reference: grass toward the horizon¶
Grass's radiance at distances from a metre to ten kilometres under three extinctions: the kernel within 64 binary32 roundings of the float64 reference, measured against the air's radiance (binary32's exponential and the difference it scales), far below one 8-bit level.
grass, air = np.array([0.05, 0.12, 0.03]), np.array([0.5, 0.55, 0.6])
t = np.geomspace(1.0, 1e4, 120)
for sigma in (1e-4, 1e-3, 1e-2):
got = trait_run.law('render/haze.trait', 'haze', radiance=np.tile(grass, (len(t), 1)), air=np.tile(air, (len(t), 1)),
extinction=sigma, distance=t)
want = reference(grass, air, sigma, t)
err = np.max(np.abs(got - want)) / np.max(np.abs(air)) # against the radiance's scale
assert err <= 64 * U, (sigma, err / U)
plt.semilogx(t, got[:, 1], label=f'sigma = {sigma:g} / m')
plt.axhline(air[1], color='grey', lw=0.5)
plt.xlabel('distance (m)'); plt.ylabel('green radiance (W/(m^2 sr))'); plt.legend()
print('within 64 x 2^-24 of the air\'s radiance of the reference at every distance and extinction')
within 64 x 2^-24 of the air's radiance of the reference at every distance and extinction
Invariant: two stretches of air are the whole path¶
Beer and Lambert's law composes: hazing over a then over b is hazing over a + b, to binary32's roundings.
a = rng.uniform(1.0, 200.0, 200)
b = rng.uniform(1.0, 200.0, 200)
rad = rng.uniform(0.0, 1.0, (200, 3))
sky = np.tile(air, (200, 1))
first = trait_run.law('render/haze.trait', 'haze', radiance=rad, air=sky, extinction=0.01, distance=a)
twice = trait_run.law('render/haze.trait', 'haze', radiance=first, air=sky, extinction=0.01, distance=b)
whole = trait_run.law('render/haze.trait', 'haze', radiance=rad, air=sky, extinction=0.01, distance=a + b)
assert np.max(np.abs(twice - whole)) <= 1e-5
print(f'two stretches against the whole path: largest difference {np.max(np.abs(twice - whole)):.2e}')
two stretches against the whole path: largest difference 5.96e-08
Limit: no air, the eye, and the far distance¶
No extinction leaves the radiance as it is, and so does a surface at the eye, each to a rounding (the law adds the air back after taking it away); one far past 1 / sigma is drawn as the air alone.
rad = np.array([[0.05, 0.12, 0.03]] * 3)
sky = np.array([air] * 3)
out = trait_run.law('render/haze.trait', 'haze', radiance=rad, air=sky, extinction=np.array([0.0, 0.02, 0.02]),
distance=np.array([1e6, 0.0, 1e5]))
assert np.allclose(out[:2], rad[:2], rtol=0, atol=4 * U) # air + (radiance - air) x 1, to a rounding
assert np.allclose(out[2], air, atol=1e-7)
print('no air and at the eye: unchanged to a rounding; far: the air alone')
no air and at the eye: unchanged to a rounding; far: the air alone