Skip to content

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.

python -m examples.hdr_sh.regenerate

This single command regenerates every committed reference image, JSON report and coefficient header under examples/output/:

  • sh_original.png and sh_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

python -m pytest -q
sha256sum examples/output/sh_original.png examples/output/sh_rotated.png

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.