Headless HDR → SH → diffuse lighting reference¶
Run from the repository root with Python and gem available. No OpenGL context, GPU, downloaded asset or image library is needed. Every image is generated by the included CPU code using core SH operations.
This single command regenerates every committed reference image, JSON
report and coefficient header under examples/output/:
sh_original.pngandsh_rotated.png: the same diffuse sphere before/after active +90° Z lighting rotation.visualization.json: scene settings, quaternion, original/rotated radiance and irradiance coefficients, selected linear pixel values and image hashes.sh_original_coefficients.glsl/sh_rotated_coefficients.glsl: matching shader-ready irradiance constants; include only the selected header.reference.json/irradiance_coefficients.glsl: numerical comparisons for the small asymmetric fixture, independent of the sharper sphere environment.
The visualization uses a deterministic 128x64 linear-HDR environment generated
entirely in visualize.directional_environment: ambient RGB [.08,.06,.04]
plus [45,30,18]*max(dot(direction,[.8,0,.6]),0)^32. No randomness is involved.
Projection is performed once, followed by analytical coefficient rotation.
Both 192x192 images use the same orthographic camera at +Z, sphere radius
0.9 of the image half-width, linear albedo [.65,.65,.65], exposure 1, Reinhard
tone mapping and final sRGB encoding. All SH computations delegate to core
gem.spherical_harmonics; no resampling of lighting is used for rotation.
Numeric workflow and input formats¶
python -m examples.hdr_sh.reference --output /tmp/reference.json \
--glsl-output /tmp/irradiance.glsl
python -m examples.hdr_sh.reference --input probe.float --format raw \
--mapping angular --width 512 --height 256 --output /tmp/angular.json
python -m examples.hdr_sh.reference --input environment.hdr --format rgbe \
--mapping latlong --output /tmp/latlong.json
Real inputs require an explicit mapping; a filename/encoding does not establish
projection. Raw input is native-endian float32 RGB, row-major, with explicit
width/height. Angular mapping delegates directly to core pixel-center angular
projection: right +X, up +Y, center +Z, theta=pir, weight
4pi²/(widthheight)sinc(theta), and pixels outside the stretched disk ignored.
It does not reinterpret the legacy positive-X/Y coefficient basis. If using
historical .coeffs from another program, first call legacy_to_canonical.
The example's latitude-longitude adapter uses pixel-center theta=pi(row+0.5)/height and phi=2pi(column+0.5)/width. Top is +Z, bottom -Z; the seam is +X, increasing azimuth goes toward +Y. Exact pixel solid angle is (2pi/width)(cos(theta_top)-cos(theta_bottom)). These weights sum to 4pi, but radiance/basis sampling at the center is still an approximation, not exact integration of varying functions. This mapping is deliberately distinct from an angular disk. Mirrored-ball photographs are not supported as either representation.
The small standard-library Radiance RGBE reader accepts #?RADIANCE/#?RGBE,
FORMAT=32-bit_rle_rgbe, standard -Y height +X width scan order, flat pixels
and modern scanline RLE. It excludes XYZE, legacy run markers, alternate scan
orders and EXR. Stored RGBE values decode to linear RGB. Exposure/color/profile
metadata is not applied; file values and channels are preserved. Convert other
formats, primaries or orientations explicitly before this adapter. No optional
or mandatory third-party image dependency is needed.
fixtures/asymmetric.json defines a small synthetic linear-HDR environment
L(d)=[2+X,3+Y,4+Z] at 64x32 centers. Its analytic low-order integrals and the
rotated function [2+Y,3-X,4+Z] provide independent numeric references. At
64x32, tests allow absolute irradiance error .009 for constant RGB [2,3,4]
and .013 for this asymmetric fixture at the listed normals. These are
fixture-specific integration tolerances, not general bounds for sharp images.
Supplying the shader¶
Canonical coefficients are nine RGB rows, index l*(l+1)+m, in this basis order:
[1,-Y,Z,-X,XY,-YZ,3Z²-1,-XZ,X²-Y²], with core orthonormal scale factors.
Use diffuse.glsl and the selected exported coefficient header. To upload
uniforms, flatten rows as R0,G0,B0,R1,G1,B1,... and upload nine vec3 values.
A fragment shader can declare uniform vec3 uIrradianceSH[9] and call
evaluateSHLambertian(unitWorldNormal, albedoLinear, uIrradianceSH).
The JSON includes the exact nine rows and known unit normals for comparison. The GLSL export uses ten significant decimal digits; shader floats generally round these to binary32. Tests evaluate the actual shader expression on the CPU with per-operation float32 rounding, comparing it with Python reconstruction. No GPU execution or bit-identical GPU result is claimed; fused operations and compiler choices may affect final rounding.
Projection yields radiance coefficients. Active rotation satisfies
f_rotated(d)=f_original(R^-1 d). Cosine convolution is applied once to obtain
irradiance coefficients. evaluateSHIrradiance returns irradiance;
evaluateSHLambertian multiplies by linear albedo/pi to return reflected
Lambertian radiance. Neither shader evaluator convolves again. Normals must be
unit length in the same coordinate frame as the environment.
All lighting computations stay linear. referenceDisplayRGB matches the PNG
path: clip negative display values, apply linear exposure and Reinhard, then
sRGB encoding. Do not also enable framebuffer sRGB encoding for that output.
reference.json separately records unclipped linear quantities and direct sRGB
encoding without tone mapping; values above 1 remain visible numerically.
Display conversion assumes linear-sRGB working primaries; adapt other primaries
before displaying. Low-order truncation can produce ringing/negative irradiance
away from the narrow light; the manifest keeps those pre-display values and
counts. No coefficient clipping or hidden renormalization is performed.
Verification and reproducibility¶
Measured hashes:
dfc2e0f5aa7cdee17dc32231ae3258c551cc886eff2bbb01cc43f2e38360174a sh_original.png
c9b205dc7dee31d731685d996326badf9da62c48fe69713b6fbe2fea4fccaac7 sh_rotated.png
Fresh-wheel regeneration in an empty execution directory and fresh virtual
environment produced identical PNGs, JSON and coefficient headers. See
audit/PHASE2F5D.md for the exact executed commands/environment. Python/libm
rounding can change a quantized pixel on other platforms, and zlib versions
can change compressed bytes even when pixels match. The measured environment
is Python 3.12.14, six 1.17.0, zlib 1.3.2 on Linux. Hash agreement is verified
for this environment, not promised across all platforms.
Independent tests cover L0/L1/rotated L1, channel isolation, analytic integrals, selected pre-tone-map pixels, PNG pixels/CRCs, GLSL expressions, actual RGBE flat/RLE decoding and the two projections. The image files are supplementary evidence; computed values and source code establish correctness.