Fresnel's reflectance by Schlick¶
The share of light a surface reflects at an angle, Schlick's approximation of Fresnel's equations: F(cos) = f0 + (1 - f0) (1 - cos)^5, per channel, cos the cosine between the light and the facet's normal (held to 0..1), f0 the reflectance at normal incidence (lusine.properties.fresnel_f0 of the surface's n and k).
Where f0 is zero in every channel there is no interface and nothing is reflected: F is f0, as lusine.render.pbr takes it. The power is a product. Source: Schlick, An Inexpensive BRDF Model for Physically-based Rendering (Eurographics 1994), equation 24. Checked against a reference in numpy, which has its own tests.
- Source
- Schlick, An Inexpensive BRDF Model for Physically-based Rendering (Eurographics 1994), equation 24.
- Proof
- reference, invariant, limit.
schlick.trait
namespace lusine.render;
vector<float, 3> schlick(vector<float, 3> f0, float cosine)
{
let m = clamp(1.0 - cosine, 0.0, 1.0);
let m5 = m * m * m * m * m;
return dot(f0, f0) != 0.0 ? f0 + (vector<float, 3>(1.0, 1.0, 1.0) - f0) * m5 : f0;
}
The share of light a surface reflects at an angle, f0 + (1 - f0) (1 - cos)^5, nothing where there is no interface (f0 zero). One of the kernels lusine.render.table draws a table's shapes with.
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/schlick.trait'))
// lusine.render.schlick: the share of light a surface reflects at an angle, Schlick's approximation of Fresnel's
// equations: F(cos) = f0 + (1 - f0) (1 - cos)^5, per channel, cos the cosine between the light and the facet's normal
// (held to 0..1), f0 the reflectance at normal incidence (lusine.properties.fresnel_f0 of the surface's n and k).
// Where f0 is zero in every channel there is no interface and nothing is reflected: F is f0, as lusine.render.pbr
// takes it. The power is a product.
// Source: Schlick, An Inexpensive BRDF Model for Physically-based Rendering (Eurographics 1994), equation 24.
// Checked against a reference in numpy, which has its own tests.
namespace lusine.render;
vector<float, 3> schlick(vector<float, 3> f0, float cosine)
{
let m = clamp(1.0 - cosine, 0.0, 1.0);
let m5 = m * m * m * m * m;
return dot(f0, f0) != 0.0 ? f0 + (vector<float, 3>(1.0, 1.0, 1.0) - f0) * m5 : f0;
}
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/schlick.trait', 'schlick'))
from lusine.kernels.render.schlick import schlick as reference
namespace probe;
lusine.version version = { { number = 1 } };
struct item {
host vector<float, 3> f0;
host float cosine;
vector<float, 3> result_0;
};
lattice<item, 1> items(spacing = 1 m, boundary = wall);
void main() { items.result_0 = lusine.render.schlick(items.f0, items.cosine); }
Reference: against Fresnel's equations¶
Across every angle for glass (n = 1.5, f0 = 0.04) the kernel is the reference to binary32's rounding, and both within 0.04 of Fresnel's equations for an unpolarised beam.
def F(f0, cosine):
return trait_run.law('render/schlick.trait', 'schlick', f0=f0, cosine=cosine)
from lusine.kernels.render.schlick import fresnel
c = np.linspace(0.0, 1.0, 401)
f0 = np.tile(F32(0.04), (len(c), 3)).astype(float)
got = F(f0, c)[:, 0]
want = reference(f0, c)[:, 0]
assert np.max(np.abs(got - want)) <= 8 * U
exact = fresnel(1.5, c)
assert np.max(np.abs(got - exact)) < 0.04
fig, ax = plt.subplots(figsize=(7, 3))
ax.plot(np.degrees(np.arccos(c)), got, label='Schlick (kernel)')
ax.plot(np.degrees(np.arccos(c)), exact, '--', label='Fresnel, n = 1.5')
ax.set_xlabel('angle of incidence (degrees)'); ax.set_ylabel('reflectance'); ax.legend()
plt.show()
Invariant: more at grazing¶
The reflectance never falls as the light grazes more (the cosine falls), in every channel.
assert np.all(np.diff(got) <= 0)
print('never less at grazing')
never less at grazing
Limit: head on, grazing, and no interface¶
Head on it is f0 exactly; at grazing, all of it; with f0 zero in every channel, nothing at any angle.
ends = F(np.array([[0.04] * 3, [0.04] * 3, [0.0] * 3]), np.array([1.0, 0.0, 0.2]))
assert np.all(ends[0] == F32(0.04)) and np.all(ends[1] == 1.0) and np.all(ends[2] == 0.0)
print('f0 head on, one at grazing, none without an interface')
f0 head on, one at grazing, none without an interface