Tobias Alexander Franke

Triple Product Integrals

The Grand Picture

Most introductions and implementations of Precomputed Radiance Transfer will deal fairly well with the easiest use case: The double product integral for diffuse reflections. Beyond this scope however, few show how proper rotation of frequency-space encoded lighting works, and even fewer dive into the problem of view-dependency in PRT. Both of these are related, as they rely on the ability to transform one set of coefficents into another: Rotating a vector creates another vector, and view dependent reflection transforms incident light represented as coefficients into coefficients of reflected light. From linear algebra, we know how to deal with such a scenario already: To transform one vector into another we need a matrix, so in terms of a lighting scenario we need to talk about Matrix Radiance Transfer and the Triple Product Integral.

Function transforms

In the last post on function transforms I took a quick look at the convolution theorem, which one can roughly describe as the ability to shortcut an integration over the product of two functions, i.e., a convolution, with a dot product in the frequency domain. Typically, the convolution theorem is introduced with the Fourier basis, but other function bases can be used as well.

\begin{equation} \int_S f(s) g(s) ds = \sum_i f_i g_i \end{equation}

This holds true for any function basis that is orthonormal. To test whether a set of functions forms an orthonormal basis, one needs to do two things: Make sure that any two basis functions \Phi_i(s) and \Phi_j(s) integrate to 0 if i \neq j , and to 1 if i = j .

\begin{equation}\label{dirac_delta} \int_S \Phi_i(s) \cdot \Phi_j(s) ds = \delta_{ij} = \left\{ \begin{array}{c} 1, i = j \\ 0, i \neq j \end{array} \right. \end{equation}

This works completely analogous to a vector basis.

The Triple Product Integral

So lets assume we have two functions f(s) and g(s) and we want to compute the function product of both as a new function e(s) .

\begin{equation}\label{e-fun} e(s) = f(s) g(s) \end{equation}

Assume further that we have projected f(s) and g(s) into the frequency domain of the function basis \Phi_i(s) . This means that we have the coefficients for both at hand.

\begin{eqnarray} f(s) & = & \sum_j f_j \Phi_j(s) \label{f-fun} \\ g(s) & = & \sum_k g_k \Phi_k(s) \label{g-fun} \end{eqnarray}

It may be possible to do the same operation in Equation \ref{e-fun} - multiplying two functions together - in the frequency space of the basis \Phi that we chose. How would we be able to determine the coefficients e_i of e(s) ? We can start the usual way by integrating it with the basis function.

\begin{equation} e_i = \int_S e(s) \Phi_i(s) ds \end{equation}

But since we already have the coefficients for both f(s) and g(s) , we can first replace e(s) by Equation \ref{e-fun} and then replace further with both Equation \ref{f-fun} and \ref{g-fun} .

\begin{eqnarray} e_i & = & \int_S \Phi_i(s) e(s) ds \\ & = & \int_S \Phi_i(s) \left[ f(s) g(s) \right] ds \\ & = & \int_S \Phi_i(s) \left[ \left( \sum_j f_j \Phi_j(s) \right) \left( \sum_k g_k \Phi_k(s) \right) \right] ds \end{eqnarray}

We can rearrange some things: All the terms that are not dependent on s can be moved out of the integral.

\begin{eqnarray} e_i & = & \int_S \Phi_i(s) e(s) ds \\ & = & \int_S \Phi_i(s) \left[ f(s) g(s) \right] ds \\ & = & \int_S \Phi_i(s) \left[ \left( \sum_j f_j \Phi_j(s) \right) \left( \sum_k g_k \Phi_k(s) \right) \right] ds \\ & = & \sum_j \sum_k f_j g_k \int_S \Phi_i(s) \Phi_j(s) \Phi_k(s) ds \\ & = & \sum_j \sum_k f_j g_k C_{ijk} \label{f-tripling-coeff} \end{eqnarray}

Now that is an interesting looking integral! Three basis functions in one, and at first sight the whole process looks quite familiar too. It is almost identical to what we did to derive the convolution theorem, now we only need to get rid of this integral and we’re done.

The term \int_S \Phi_i(s) \Phi_j(s) \Phi_k(s) ds is called the triple product integral for more or less obvious reasons, and C_{ijk} is a tripling coefficient. Its smaller sibling, the double product integral, is reducable to a Kroenecker Delta if the basis functions \Phi_i(s) form an orthonormal basis. But for C_{ijk} , things are not so easy. In fact, there is no general analytical solution for any arbitrary function basis.

Computing tripling coefficients

Luckily, for the two most common bases used for PRT, Haar-Wavelets and Spherical Harmonics, an analytical formula to compute their tripling coefficents exists.

Tripling coefficients of Spherical Harmonics can be expressed through a relationship with Clebsch-Gordan coefficients, which in turn can be expressed by Wigner 3j symbols. Below is an older implementation of mine, but as far as I’m aware the GNU Scientific Library contains one as well.

template<typename T>
inline T wigner_3j(int j1, int j2, int j3, 
                   int m1, int m2, int m3)
    assert(std::abs(m1) <= j1 && 
    	"wigner_3j: m1 is out of bounds");
    assert(std::abs(m2) <= j2 && 
    	"wigner_3j: m2 is out of bounds");
    assert(std::abs(m3) <= j3 && 
    	"wigner_3j: m3 is out of bounds");
    if (!triangle_inequality(j1, j2, j3)) return 0;
    if (m1+m2+m3 != 0) return 0;
    if (std::abs(m1) > j1) return 0;
    if (std::abs(m2) > j2) return 0;
    if (std::abs(m3) > j3) return 0;
    int t1 = j2 - m1 - j3;
    int t2 = j1 + m2 - j3;
    int t3 = j1 + j2 - j3;
    int t4 = j1 - m1;
    int t5 = j2 + m2;
    int tmin = std::max(0, std::max(t1, t2));
    int tmax = std::min(t3, std::min(t4, t5));
    if (tmin > tmax)
        return 0;
    T wigner = 0;
    auto f = factorial<T>;
    for (int t = tmin; t <= tmax; ++t)
        wigner = wigner + static_cast<T>(std::pow(-1.0, t))/
            (f<T>(t)    * f<T>(t-t1) * f<T>(t-t2) *
             f<T>(t3-t) * f<T>(t4-t) * f<T>(t5-t));
    wigner = wigner * static_cast<T>(std::pow(-1.0, j1-j2-m3)) * 
    		f<T>(j1+j2-j3) * f<T>(j1-j2+j3) * 
    		f<T>(-j1+j2+j3) / f<T>(j1+j2+j3+1) * 
    		f<T>(j1+m1) * f<T>(j1-m1) * 
    		f<T>(j2+m2) * f<T>(j2-m2) * 
    		f<T>(j3+m3) * f<T>(j3-m3)
    return wigner;

With the tripling coefficients at hand, we can create a function product of aribtrary functions encoded in the same frequency domain. A major downside of regular PRT is that the BRDF, visibility and Lambert factor are all represented simply as one transfer function, which therefore gets encoded as one transfer vector. The Lambert factor however is a very low-frequency signal, whilst visibility and BRDF may contain high-frequency peaks. Packing them all together means choosing either to produce a lot of waste coefficients or compromising on the quality of the representation. Ng et al. get around this by decoupling visibility and the BRDF in the transfer function again with a triple product composition.

Another use case presented in Precomputed Shadow Fields for Dynamic Scenes wraps an object in a shell which, at several sample points, has coefficients encoding a visibility function from the point on that shell. If the shell collides with another PRT-object, coefficients of both objects visibility can be combined to compute dynamic shadowing between the two, getting rid of some of the rigidness requirements that PRT imposes on the scene. This paper is naturally enhanced in Precomputed Radiance Transfer Field for Rendering Interreflections in Dynamic Scenes, where the authors not just encode visibility, but indirect diffuse transfer between objects as well.

Transforming coefficients

Returning to Equation \ref{f-tripling-coeff} we can easily construct e_i in another way.

\begin{eqnarray} e_i & = & \sum_j \sum_k f_j g_k C_{ijk} \\ e_i & = & \sum_j f_j T_{ij} \end{eqnarray}

Instead of using the tripling coefficient tensor C_{ijk} and g_k , we can also use a matrix T_{ij} (one way to construct it would be to multiply C_{ijk} and g_k ). This matrix will transform coefficients f_j into coefficients e_i , which represent the function e(s) in the basis \Phi that we chose.

The concept of a matrix to transform a coefficient vector is sometimes mentioned in PRT tutorials for signal rotation. Rather than having an environment map m(s) , rotate it with \mathbf{R}(m)(s) and then resample and recompute the Spherical Harmonic coefficients for it, we can likewise rotate the coefficents m_i with a special frequency-space rotation matrix \mathbf{R^{\star}} . In essence, we would produce a new set of coefficients that simply match those of the rotated environment map in image space.

\begin{eqnarray} m(s) & = & \sum_i m_i \Phi_i(s) ds \\ \mathbf{R}(m)(s) & = & \sum_i \sum_j (m_j \cdot R^{\star}_{ij}) \Phi_i(s) ds \\ \end{eqnarray}

Here is an old implementation of mine of one such matrix to do the job. The input is a regular 3x3 rotation matrix, but the result is a matrix that needs to be as big as the coefficient vector you want to rotate. In case of 4-band Spherical Harmonic vector for instance, the result will be a 16x16 Spherical Harmonic rotation matrix.

Matrix Radiance Transfer

In the diffuse case of PRT, we only need to compute a single number/color, because diffuse surfaces reflect the same intensity in all directions. The double product integral fits the job, because the convolution acts like a blur-filter that adds up all the light hitting the surface and reflects a single value. But for non-diffuse surfaces like metals this outgoing radiance varies in different directions, so we need more than just one number; we need a function that represents all light reflected into all different directions. We need a coefficient vector of the reflected light that we generate from the incident light coefficient vector.

For diffuse PRT, we turn a transfer function of just the incidenct light direction into the transfer coefficient vector. Now however we must turn a transfer function of an incident and outgoing direction into a transfer matrix. But how to construct the matrix for a function that has two variables? We can do so by imagining that we have a special set of transfer coefficients for each outgoing direction \mathbf{\omega_o} , rather than just a single set for all of them. Where before we would turn a transfer function into a set of coefficients, we now basically convert it into a set of functions T_m(\mathbf{\omega_o}) which return the m -th coefficient of the reflected light for an outgoing direction \mathbf{\omega_o} . To do this, we first integrate T along all incident directions \mathbf{\omega_i} .

\begin{equation} T_m(\mathbf{\omega_o}) = \int_{\Omega} T(\mathbf{\omega_i}, \mathbf{\omega_o}) \Phi_m(\mathbf{\omega_i}) d{\mathbf{\omega_i}} \end{equation}

We then integrate each resulting function T_m(\mathbf{\omega_o}) , this time over all outgoing directions \mathbf{\omega_o} .

\begin{equation} T_{mn} = \int_{\Omega} T_m(\mathbf{\omega_o}) \Phi_n(\mathbf{\omega_o}) d{\mathbf{\omega_o}} \end{equation}

In the previous article, the double integral product was used to compute the outgoing reflected light from the incident light coefficients l_n and the surface transfer coefficients t_n . We transferred incident light into single value (or, if you want to think about it this way, into function which equals a constant value), which is perfect for diffuse reflection. Now with Matrix Radiance Transfer a matrix T_{mn} instead represents the transfer of incident to exit light.

\begin{eqnarray} L(\mathbf{\omega_o}) & = & L_e(\mathbf{\omega_o}) + \int_\Omega L(\mathbf{\omega_i}) \cdot f( \mathbf{\omega_i}, \mathbf{\omega_o}) \cdot \langle \mathbf{n}, \mathbf{\omega_i} \rangle d\mathbf{\omega_i} \\ & = & L_e(\mathbf{\omega_o}) + \int_\Omega L(\mathbf{\omega_i}) \cdot T( \mathbf{\omega_i}, \mathbf{\omega_o}) d\mathbf{\omega_i} \\ & = & L_e(\mathbf{\omega_o}) + \sum_n \sum_m \left( l_m T_{mn} \right) \Phi_n(\mathbf{\omega_o}) \\ & = & L_e(\mathbf{\omega_o}) + \langle \langle \mathbf{l}, \mathbf{T} \rangle, \mathbf{\Phi}(\mathbf{\omega_o}) \rangle \\ \end{eqnarray}

Where we used to have a dot product of two vectors, we now produce a reflection coefficent vector from incident light, and then reconstruct the light reflected into a specific direction \mathbf{\omega_o} by multiplying it with the basis \Phi .


Like regular vectors in 3D, coefficient vectors can likewise be transformed with matrices. This is useful when a function - encoded as such a vector - should be transformed into a different function, such as unoccluded to occluded light, normal to rotated environment, incident to exit radiance or when simply decoupling one transfer function into several parts and then multiplying them back together again.


  1. Wikipedia, Clebsch-Gordon Coefficients as Wigner-3j expression
  2. Free Software Foundation, GNU Scientific Library
  3. Ng et al., Triple Product Wavelet Integrals for All-Frequency Relighting
  4. Zhou et al., Precomputed Shadow Fields for Dynamic Scenes
  5. Pan et al., Precomputed Radiance Transfer Field for Rendering Interreflections in Dynamic Scenes
  6. Tobias Alexander Franke, SH rotation matrix implementation
  7. Lehtinen and Kautz, Matrix Radiance Transfer
 Notes FunctionTransform MRT PRT RadianceField