This is the documentation for Enlighten.

Directional irradiance


Another way to supplement the plain irradiance output is to ask Enlighten to compute "directional irradiance", which is the same irradiance texture plus either one or three extra "directional textures". A directional texture contains the principal direction of the incoming light for each output pixel, scaled in such a way that the vector direction has length 0 if the incoming light is completely ambient, and length 1 if it is completely concentrated from one direction. This information about the distribution of light can be used to adjust the sharpness of an indirect specular highlight.

Lighting model

When employing the directional irradiance data, there are two normals to consider:

  1. The "radiosity normal" — the polygon surface normal used in the precompute, disregarding any normal maps applied in the runtime. This is what the irradiance from Enlighten's light maps is computed from.
  2. The "surface normal" — used for the actual shading of pixels, and would typically be fetched from a normal map.

To take both the irradiance information from the radiosity normal and the varying surface normal into account, Enlighten combines two terms:

  1. Diffuse bounce from the surface, approximated with just the surface albedo colour. This is motivated by the fact that, for normal directions that deviate from the radiosity normal, integrating the rendering equation over a hemisphere would cause the irradiance to be in the direction of the surface itself, giving a simple approximation of an extra bounce.
  2. Lambert-like directional term.

For the directional lighting model, Enlighten employs the same lighting model as is used for L1 probe lighting. Enlighten interpolates between the "totally directional" and "totally ambient" case depending on how well the radiosity normal aligns with the principal direction of the incoming light. If they are perfectly aligned, Enlighten considers the lighting totally directional. If they differ by more than 90 degrees, Enlighten considers the lighting totally ambient. We call this term S. To make sure that the directional term evaluates to 1 whenever the surface normal is perfectly aligned with the original "radiosity normal", Enlighten also evaluates the lighting model using the radiosity normal. We call this term R. Dividing S by R thus ensures this directional model is aligned with the default irradiance model Enlighten provides.

A half-Lambert term H is computed as (1/2)*(1-cos(theta)), where theta is the angle between the radiosity normal and the shading normal. This determines the contribution of the bounce term versus the directional term. The final result is then computed as:

I * (H * A + (1-H) * S / R)

Where I is the irradiance, and A the surface albedo colour.

The directional term is computed as follows:

//--------------------------------------------------------------------------------------------------
float DirectionalInfluence(float3 direction, float3 radiosityNormal, float3 normal)
{
    // bias direction by adding in the off-axis tangential part again (to double its influence)
    float3 tangent = direction - radiosityNormal*dot(radiosityNormal, direction);
    direction = normalize(direction + tangent);

    // ----- relighting normal
    // compute dot product with computed centres of distributions
    float radNormDotVI = dot(radiosityNormal, direction);
    float lambda = 1.0f + min(0.0f, radNormDotVI);
    float q = 0.5f*(1.0f + radNormDotVI);
    float q2 = q*q;
    float q3 = q2*q;
    float q4 = q2*q2;

    float radNormVal = lambda * ((10.0f / 3.0f) * (2.f * q3 - q4) - 1.0f) + 1.0f;

    // compute dot product with computed centres of distributions
    float shadingNormDotVI = dot(normal, direction);
    q = 0.5f*(1.0f + shadingNormDotVI);
    q2 = q*q;
    q3 = q2*q;
    q4 = q2*q2;

    float shadingNormVal = lambda * ((10.0f / 3.0f) * (2.f * q3 - q4) - 1.0f) + 1.0f;

    float SdivR = shadingNormVal/radNormVal;
 
    return SdivR;
}

Enlighten can compute directional irradiance for each output pixel using the luminance of the incoming light, in which case a single directional texture is generated and should be applied in shader code as follows:

float3 DirectionalIrradiance(float2 uv, float3 normal, float3 albedo)
{  
    // Sample the irradiance and directionality textures.
    float3 irradiance = tex2D(g_IrradianceSampler, uv).xyz;
    float3 direction = tex2D(g_DirectionalSampler, uv).xyz * 2.0f - 1.0f;
    float3 radiosityNormal = tex2D(g_RadiosityNormalSampler, uv).xyz * 2.0f - 1.0f;

	float cosTheta = dot(radiosityNormal, normal);
	float H = 0.5f - cosTheta * 0.5f;
	return irradiance * max(0, H * albedo + (1 - H) * DirectionalInfluence(direction, radiosityNormal, normal));
}

Alternatively, the principal direction of incoming light for each pixel can be computed per-colour channel. This is known as colour-separated directional irradiance, and results in three directional textures — one per colour component. The shader code for calculating directional irradiance using these textures is similar to that shown in the previous example:

float3 SeparatedDirectionalIrradiance(float2 uv, float3 normal, float3 albedo)
{  
    // Sample the irradiance and directionality textures.
    // We can ignore the w component of the irradiance texture.
    float3 irradiance = tex2D(g_IrradianceSampler, uv).xyz;
    float4 radiosityNormal = tex2D(g_RadiosityNormalSampler, uv).xyz * 2.0f - 1.0f;
    float3 directionR = tex2D(g_DirectionalSampler, uv).xyz * 2.0f - 1.0f;
	float3 directionG = tex2D(g_DirectionalSamplerG, uv).xyz * 2.0f - 1.0f;
	float3 directionB = tex2D(g_DirectionalSamplerB, uv).xyz * 2.0f - 1.0f;

    float cosTheta = dot(radiosityNormal, normal);
    float H = 0.5f - cosTheta * 0.5f;
    float3 directionalInfluence = float3(
		DirectionalInfluence(directionR, radiosityNormal, normal),
		DirectionalInfluence(directionG, radiosityNormal, normal),
		DirectionalInfluence(directionB, radiosityNormal, normal)
    );
    return irradiance * max(float3(0, 0, 0), H * albedo + (1 - H) * directionalInfluence);
}

In these examples, the directional textures use unsigned byte format and the value is scaled to the range [0,1] to [-1,1] in the shader. This is the equivalent of SNORM texture format.

Indirect specular

Given a principal direction for the lighting environment, it is possible to heuristically invent a specular highlight. There are many ways to do this; this page presents the method used in our sample framework. You may find that alternative formulations work better for your application, particularly if you are able to incorporate additional information about the lighting environment.

The approach shown here works well for low specular powers. For sharper reflections, further information about the lighting environment is required. Enlighten provides support for this through cube maps. See the Low level cubemap API page for more information.

float3 CalcDISpecular(float2 uv, float3 normal, float3 reflectVec, float specular_power, float3 irradiance)
{
    // Get the direction of maximum intensity, rescaled to [-1,1]. This is
    // the same as the direction decode for the directional irradiance.
    // We don't need the rebalancing w component.
    float3 principalDir;

    if (g_UseColourSeparatedDirectionalIrradiance)
    {
        // average the per-colour channel directions
	    principalDir = ((tex2D(g_DirectionalSamplerR, uv) +
			             tex2D(g_DirectionalSamplerG, uv) +
			             tex2D(g_DirectionalSamplerB, uv)).xyz / 3.0f * 2.0f - 1.0f);
    }
    else
    {
        principalDir = tex2D(g_DirectionalSampler, uv).xyz * 2.0f - 1.0f;
    }

    // Squish component in normal direction.
    // It is possible for the direction to below the horizon. This
    // correction ensures we do not see any undesirable backlighting.
    principalDir += normal * max(0.0f, -dot(principalDir, normal));

    // Extract the light distribution factor from the length.
    float fac = length(principalDir);

    // Compute a typical phong highlight in the principal direction.
    // We use the light distribution factor to modulate both the power
    // and scale. This is just a heuristic and alternative formulations
    // are possible.
    float rDotL    = max(dot(reflectVec, normalize(principalDir)), 0.0f);
    float specular = pow(rDotL, specular_power * fac) * (fac * fac);

    // The final result is then the irradiance result modulated by the
    // specular term.
    return specular * irradiance;
}