forge-ibl-bake 0.1.0

Drop in an HDRI, get wgpu/Bevy-ready IBL textures. No Blender, no Substance, no C++ build.
Documentation
// SPDX-License-Identifier: MIT OR Apache-2.0
// forge-ibl-bake · deveraux.dev
//! SH9 projection of an equirectangular HDR environment onto 9 real SH bands.
//!
//! Inputs: `HdrImage` pixels + sample count. Outputs: 9 × RGB coefficients
//! usable as a drop-in ambient term in any PBR fragment shader. Sampling uses
//! a Fibonacci sphere (equal solid angle, deterministic). DESIGN_TARGET:
//! reconstruction accuracy depends on sample count — 256+ recommended.

use std::f32::consts::PI;

use crate::hdr::HdrImage;
use crate::sample::sample_equirect;

// Standard real-SH normalization constants (l=0..2).
const K0: f32 = 0.282_094_8; // Y00  = 0.5 √(1/π)
const K1: f32 = 0.488_602_5; // Y1m  = 0.5 √(3/π)
const K2A: f32 = 1.092_548_4; // Y2{-2,-1,1}
const K2B: f32 = 0.315_391_6; // Y20
const K2C: f32 = 0.546_274_2; // Y22
const GOLDEN_ANGLE: f32 = 2.399_963_2; // π(3 − √5) rad

/// Project `env` onto 9 real SH coefficients using a Fibonacci sphere.
pub fn project_sh9(env: &HdrImage, samples: u32) -> [[f32; 3]; 9] {
    let mut sh = [[0.0f32; 3]; 9];

    for i in 0..samples {
        // Equal-solid-angle Fibonacci sphere direction.
        let y = 1.0 - 2.0 * (i as f32 + 0.5) / samples as f32;
        let r = (1.0 - y * y).max(0.0).sqrt();
        let phi = i as f32 * GOLDEN_ANGLE;
        let dir = [r * phi.cos(), y, r * phi.sin()];

        let rad = sample_equirect(&env.pixels, env.width, env.height, dir);
        let [x, ny, z] = dir;
        let basis: [f32; 9] = [
            K0,
            K1 * ny,
            K1 * z,
            K1 * x,
            K2A * x * ny,
            K2A * ny * z,
            K2B * (3.0 * z * z - 1.0),
            K2A * x * z,
            K2C * (x * x - ny * ny),
        ];
        for (c, &b) in basis.iter().enumerate() {
            sh[c][0] += rad[0] * b;
            sh[c][1] += rad[1] * b;
            sh[c][2] += rad[2] * b;
        }
    }

    // Equal-solid-angle Monte-Carlo weight: integral ≈ (4π / N) × Σ.
    let w = 4.0 * PI / samples as f32;
    for coeff in &mut sh {
        coeff[0] *= w;
        coeff[1] *= w;
        coeff[2] *= w;
    }
    sh
}

/// Serialize SH9 coefficients to a human-readable JSON file.
pub fn write_sh9_json(
    path: &std::path::Path,
    sh: &[[f32; 3]; 9],
) -> Result<(), crate::error::BakeError> {
    let mut s = String::from("{\"sh9_coefficients\":[\n");
    for (i, coeff) in sh.iter().enumerate() {
        s.push_str(&format!(
            "  [{:.7},{:.7},{:.7}]",
            coeff[0], coeff[1], coeff[2]
        ));
        if i < 8 {
            s.push(',');
        }
        s.push('\n');
    }
    s.push_str("]}\n");
    std::fs::write(path, s.as_bytes())?;
    Ok(())
}