This is the documentation for Enlighten.

Light probe evaluation


Overview

A good reference for rendering irradiance lighting from SH coefficients is Ramamoorthi & Hanrahan's paper: "An Efficient Representation for Irradiance Environment Maps" (http://graphics.stanford.edu/papers/envmap/envmap.pdf). Note that compared to the paper, Enlighten swaps the y and z axes. For the L2 coefficients, we also bake in the required constants to convert from radiance to irradiance, to make evaluation in shader code as simple as possible.

This is how L1 and L2 probes are rendered in our sample application GeoRadiosity. For details, see the sample code.

L1 spherical harmonics

Enlighten L1 output has four coefficients for each colour channel, corresponding to the spherical basis functions 1, x, y, z (that is, one ambient term and three directional terms, one along each coordinate axis). In the notation of Ramamoorthi & Hanrahan, the output order is L00, L11, L10, L1-1. For L1, the Enlighten output is radiance, and we need to convert to irradiance for a given normal direction in shader code. The standard linear conversion can result in negative values when the lighting environment is highly directional. To avoid this problem we compute irradiance using a non-linear model that avoids negative values while still preserving the correct energy and dynamic range. This needs to be applied individually for each colour channel. The shader code is given below.

For more information and a derivation of the non-linear model, see the Geomerics CEDEC talk about L1 irradiance reconstruction. There are also more details about how Enlighten uses Spherical Harmonics in this blog post.

float NonlinearL1ComputeChannel(float3 normal, float4 coeffs)
{
    float L0 = coeffs.x;
    float3 L1 = coeffs.yzw;
    float modL1 = length(L1);
    if (modL1 == 0.0f)
    {
        return L0;
    }

    float q = 0.5f + 0.5f * dot(normal, normalize(L1));
    float r = modL1 / L0;
    float p = 1.0f + 2.0f * r;
    float a = (1.0f - r) / (1.0f + r);

    return L0 * lerp((1.0f + p) * pow(q, p), 1.0f, a);
}

//-------------------------------------------------------------------------------------------------
float3 NonlinearL1(float3 normal)
{
    float3 output;

    output.r = NonlinearL1ComputeChannel(normal, g_L1CoeffR);
    output.g = NonlinearL1ComputeChannel(normal, g_L1CoeffG);
    output.b = NonlinearL1ComputeChannel(normal, g_L1CoeffB);

    return max(float3(0.f, 0.f, 0.f), output);
}

Compressed L1 spherical harmonics

Enlighten L1 output can optionally be written as compressed 8-bit-per-channel data. To improve the dynamic range which can be captured, instead of a linear quantisation, the square-root of the L0 (ambient) term is encoded in the first channel, with the range [0,1] mapped to [0...255]. The three L1 terms are quantised linearly: they are first divided by L0, and then the range [-1,1] is mapped to [0...254]. The decode looks like this in C++ code:

float L0sqrt = (static_cast<float>(outputU8[0])) / 255.0f;         // here outputU8 is a pointer to 8-bit encoded SH data
float L0 = L0sqrt * L0sqrt;
float L1_x = ((static_cast<float>(outputU8[1]) - 127.0f) * L0 / 127.0f);
float L1_y = ((static_cast<float>(outputU8[2]) - 127.0f) * L0 / 127.0f);
float L1_z = ((static_cast<float>(outputU8[3]) - 127.0f) * L0 / 127.0f);

In shader code, assuming the data is read from a texture:

float3 RamaCompressedL1(float3 normal, ...)
{
    float3 output;
    float4 n = float4(1.0f, normalize(normal.xyz));   // extend the normal vector with 1.0 in the ambient channel
 
    float4 RCoeff = tex2D( <read the compressed Red L1 coefficients from an 8-bit-per-channel texture> );

    RCoeff.x *= RCoeff.x;
    RCoeff.yzw -= 0.5f;
    RCoeff.yzw *= RCoeff.yzw * 2.0f;

        output.r = RamaL1ComputeChannel(normal, RCoeff); // compute the Red output from the decoded Red L1 coefficients

    <repeat twice more, Green and Blue>

    return max(float3(0.0f, 0.0f, 0.0f), output);     // clamp the result to 0.0
}

When encoding compressed probe output, the Enlighten probe solver clamps data to [-1,1]. If you wish to capture a different range, use the m_U8OutputScale parameter of RadProbeTask or EntireProbeSetTask. This scale is applied to the raw output data before the encoding and quantisation to 8-bit data. For example, to capture data in the range [-4,4], use a m_U8OutputScale of 0.25, and then apply an extra scale factor of 4.0 during the decode process.

L2 spherical harmonics

Enlighten L2 output has nine coefficients for each colour channel, corresponding to the spherical basis functions 1, x, y, z, 3y2 - 1, xy, yz, xz, x2 - z2. In the notation of Ramamoorthi & Hanrahan, the output order is L00, L11, L10, L1-1, L20, L21, L2-1, L2-2, L22. To sample the Enlighten output to give irradiance for a particular surface normal direction, you need to compute a nine-component dot product of these coefficients with the basis functions, evaluated for the normal direction (x, y, z).

One way of performing this evaluation is to rewrite the dot product as nt M n, where n is the extended normal vector (1, x, y, z) and M is a 4x4 symmetric matrix:

( L00 - L20, ½L11, ½L10, ½L1-1 )
( ½L11, L22, ½L21, ½L2-2 )
( ½L10, ½L21, 3L20, ½L2-1 )
( ½L1-1, ½L2-2, ½L2-1, -L22 )

The weights in this matrix are different to the weights of the original paper by Ramamoorthi & Hanrahan. The output of Enlighten is optimized for the custom weights listed here and your shading result will not look as good if you use the weights of the original paper.

The evaluation shader code then looks like this:

float3 RamaL2(float3 normal)
{
    float3 output;
    float4 n = float4(1.0f, normalize(normal.xyz));  // extend the normal vector with 1.0 in the ambient channel

    output.r = dot(n, mul(g_L2MatrixR, n));          // compute the Red output via matrix and dot product with the Red L2 coefficients
    output.g = dot(n, mul(g_L2MatrixG, n));          // compute the Green output via matrix and dot product with the Green L2 coefficients
    output.b = dot(n, mul(g_L2MatrixB, n));          // compute the Blue output via matrix and dot product with the Blue L2 coefficients

    return max(float3(0.0f, 0.0f, 0.0f), output);    // clamp the result to 0.0
}

Environment visibility SH evaluation

The Environment visibility SH data optionally generated by the Enlighten precompute is normalised so that the same "RamaL2" SH evaluation function as shown above works as expected, generating visibility results in the range [0,1]. This is the case irrespective of whether you use L1 or L2 probes. In other words, the environment visibility SH data for L1 is always pre-scaled by [1,2,2,2], the same scaling factors that the L1 subset of the L2 SH data would have if you used L2 probes.