Spherical Harmonics pop up in certain fields of graphics, as they are useful for encoding spherical functions. In this document I describe various notes and tidbits about them.
Note: Skip to Fourier basis section if you already know what a basis function is.
A vector space is (roughly) a set whose elements can be added together or multiplied by scalars (ie. scaled). These 2 operations must satisfy some sensible axioms, such as a associativity and commutativity of addition. Here is a full list of axioms:
Some common vector spaces you may be familiar with are
A basis for a given vector space, is a set vectors with the special property that any element of the vector space can be written in a unique way, as a linear combination of elements of the basis. Here are some examples of bases for
Note that something like
We call a basis "orthogonal" if all of the vectors in the basis are mutually orthogonal. We additionally call the basis "orthonormal" if all of the vectors are normalized. Looking at the 3 examples listed above, the first is orthonormal, the second is only orthogonal, and the third is neither orthogonal nor orthonormal.
We typically think of elements of vector spaces, vectors, as lists of numbers. However, this is limiting a definition. In fact, if we loosen this definition, we can use functions as our vectors, forming a function space. The addition and scaling operators are defined as such:
Where
The elements of a basis for a function space is then called a basis function. A simple example of a function space is a the space of single-indeterminate polynomials. One possible basis for this vector space is the monomial basis:
This is clearly a valid basis as every polynomial can be written as a linear combination of the elements:
Where
Spherical Harmonics are an analogue of the Fourier basis on the surface of a sphere. To understand this, we need to go over a few more concepts.
The Fourier series is a neat kind of infinite series which can be used to approximate periodic functions (functions that repeat their values at regular intervals, called the period). Assume we have a periodic function
Where
Note that this is just one of infinitely many possible Fourier bases, since scaling a basis yields another basis. Common among all Fourier bases is the constant term and the alternating cosine and sine terms.
Note: You may have seen different definitions of Fourier series than what I have shown. In fact, Fourier series have several equivalent definitions, including the sine-cosine definition, the exponential definition, and amplitude-phase definition. The other definitions are not relevant to the topic at hand.
One particular kind of Fourier basis is formed by the so-called "Circular Harmonics" (CH). This basis is a scaled version of what we have seen before, given by:
These functions form a basis for periodic functions with period
As mentioned just above, 2 operations of particular importance are projection and reconstruction. They are both fairly straight forward to define. If we want to project a function
Where
A pragmatic way to think about Circular Harmonics are as a kind of compression - a way to store a
Next I'll show a concrete example of the aforementioned projection and reconstruction operations using some Python code.
First, let's define a function which we will project into CH basis and then reconstruct. I've chosen a saw wave:
def f(x):
x = np.mod(x, 2*np.pi)
return x/(2*np.pi)
Next, let's define a function that gives us the n'th basis function:
import numpy as np
# n is the index of the basis function, x is the input to the function
def fourier_basis(n, x):
multiplier = np.ceil(n / 2)
if n == 0:
return 1 / np.sqrt(2 * np.pi)
elif n % 2 == 1:
return np.cos(multiplier * x) / np.sqrt(np.pi)
else:
return np.sin(multiplier * x) / np.sqrt(np.pi)
Next, we need a way to calculate integrals. I've implemented a simple Riemann sum:
# function is the function to integrate
# a and b are the bounds of the integral
# n is the number of discrete steps to take
def riemann(function,a,b,n):
sumval = 0
h = (b-a)/n
for i in range(0,n-1):
current_x = a+i*h
sumval = sumval + function(current_x) * h
return sumval
With those 2 building blocks, we can define a function that calculates coefficient for the n'th basis function:
# f is the function to project into the CH basis
# n is the index of the basis function
# calculates the integral of the n'th basis function multiplied by f
def fourier_project(n, f):
return riemann(lambda x: f(x) * fourier_basis(n, x), 0, 2*np.pi, 1000)
We can then define a function that performs reconstruction. We'll use 30 basis functions for this example:
# f is the function to project into CH basis, then reconstruct
# x is the is the input to the function
def fourier_reconstruct(f, x):
sum = 0
for n in range(30):
sum += fourier_project(n, f) * fourier_basis(n, x)
return sum
Finally, we can compare the actual and reconstructed functions:
from matplotlib import pyplot as plt
x = np.linspace(-np.pi*2, np.pi*2, 1000)
fig, ax = plt.subplots()
ax.plot(x, f(x), label='f(x)')
ax.plot(x, fourier_reconstruct(f, x), label='f_reconstructed(x)')
ax.legend()
plt.show()
Looks pretty good to me!
I mentioned earlier that Spherical Harmonics (SH) are an analogue of the Fourier basis on the surface of a sphere. Phrased differently, they are analogous to the Circular Harmonics, but with an extra dimension of space. Just like the CH basis, the SH basis is orthonormal.
Unlike with Circular Harmonics, the basis functions for the SH basis come in levels. Each level contains
By convention, we number each basis function starting at a negative index, and going to a positive index of equal absolute value. We typically write each basis function as
While the SH basis functions are typically written in literature using Spherical Coordinates, we are more often working with 3 dimensional unit vectors in graphics. These 2 representations are equivalent with the following transformation:
Using this more convenient representation, the first 3 levels of SH basis functions are given by:
Order | Basis functions |
---|---|
A few notable observations to be made here:
Note: What I've shown thus far are known as the "real" (as in real numbers) Spherical Harmonics. Spherical Harmonics have a different expansion which uses complex instead of real numbers, and is a bit more complex. Since we are only usually working with real-valued functions in graphics programming, this expansion is less useful to us, so I've omitted it.
Projection and reconstruction with the SH basis is completely analogous to case of circular harmonics. For projection:
Where
Reconstruction is again just an inner product:
Where
Note: The reason why I use Spherical Coordinates in the above description, despite having just explained that we usually prefer to use unit vectors, is primarily to make the integral more tenable. The space of unit vectors doesn't map intuitively to a pleasant domain of integration. Alternatively, we could write the double integral as a single integral in Solid Angle domain, but integrals in solid angle domain are not easy to calculate directly, so we usually translate to spherical coordinates before calculating anyways.
Spherical Harmonics have a nice property that lets us efficiently calculate the integral of the product of 2 spherical functions. Consider the following such integral:
Where
The integral looks a bit daunting at first, but if we happen to have both
Where
One can also calculate convolutions of spherical harmonics without too much effort, using the following convolution theorem:
Here,
Two quantities of particular importance when rendering scenes with global illumination (GI) are radiance and irradiance. I'll briefly summarize what these quantities mean below:
Quantity | Unit | Meaning |
---|---|---|
Radiant flux | Watts (Joule/Second), denoted | Energy received or emitted per unit time. |
Irradiance | Watts per square meter, denoted | Radiant flux received by a surface per unit area. |
Radiance | Watts per steradian per square meter, denoted | Radiant flux received or emitted per unit Solid angle, per unit projected area. |
Radiance is the quantity typically associated with a single ray of light, while irradiance is the quantity associated with a point on a surface. You can think of radiance as roughly being being the energy carried by a ray of light, and irradiance as the total energy incident on a point on a surface from all directions.
We can calculate the irradiance
Here
Note: The cosine term is necessary due to how projected area varies with the angle between the surface normal and the normal of the area being projected. Roughly speaking, we want to weigh light coming from shallow angles less, as that light is spread over a larger area. For some context, see the images in Cosine weighted sampling > Why.
One technique for lighting scenes with GI is light probes. These are positions in space with which we associate a quantity of light. When it is time to shade a surface point, we can grab the few nearest probes to said point, interpolate between them, and use them to get an estimate of the indirect light contribution. That begs the question - what exactly do we store in each light probe? Spherical Harmonics turn out to be a great a fit, for a few main reasons:
Recall that the radiometric quantity associated with light incident on a surface point is irradiance. It should then be no surprise that the quantity we should project into the SH basis and store in each light probe is also irradiance. To compute the coefficients for each probe, we can use path tracing.
Path tracing involves shooting a bunch of light rays from a point, bouncing them around the scene, and attenuating their contribution whenever they hit a surface. Note however that the radiometric quantity associated with a ray of light is radiance, not irradiance. I'll ignore this mismatch in desired quantities for now, and first explain how to project radiance into the L2 SH basis. Below is some pseudo-C#-code to do this. Note that I'm not going to bother with color - we'll just be using monochromatic lighting for simplicity.
// Given a probe position, returns the SH coefficients for radiance
// incoming from all directions to that position, using monte carlo
// integration.
float[] ProjectRadianceIntoSH(Vector3 probePosition)
{
float[] radianceSH = new float[9];
for (int i = 0; i < TotalSamplesPerProbe; i++)
{
// Trace a light path going in a random direction to get radiance
// associated with the direction
Vector3 rayDirection = GetRandomDirection();
float radiance = TracePath(probePosition, rayDirection);
for (int n = 0; n < 9; n++)
{
// SHBasis(n, d) evaluates the n'th SH basis function
// given a direction vector d.
radianceSH[n] += radiance * SHBasis(n, rayDirection);
}
}
// Monte carlo normalization
float reciprocalSampleCount = 1.0f / TotalSamplesPerProbe;
float reciprocalUniformSphereDensity = 4.0f * Math.PI;
return radianceSH * reciprocalSampleCount * reciprocalUniformSphereDensity;
}
For convenience, I've used a flat index for indexing SH basis functions, rather than an explicit level
Now we are faced the issue I outlined earlier: If we want to actually shade a surface with this, we need irradiance rather than radiance, which means that we essentially need to calculate the integral described in A brief primer on radiometry:
Ramamoorthi and Hanrahan describe how to do just this in their paper On the relationship between radiance and irradiance: determining the illumination from images of a convex Lambertian object. I'm not going to go over all the details of their derivations, but focus on the conclusions in broad strokes - you can read the paper for more info.
Ramamoorthi defines an operator for the clamped cosine term called
It's important to note here that we are only indexing the SH coefficients of
Ramamoorthi then goes on to derive an analytical expression for the coefficients
Let's now revisit the irradiance integral with these definitions. We'll start by rewriting it a tiny bit:
Note that we have dropped the surface position
Given the nice property I described in the section Spherical Harmonics > Integral of product, one may now be tempted calculate irradiance integral like so:
However, this would be incorrect. The reason is a mismatch in coordinate systems. Incoming radiance,
The effect that this term has when multiplied onto the summand is to transform the input to the radiance function
Ramamoorthi shows that the rotation term can be rewritten to:
Thus the expression for irradiance becomes:
In the follow up paper An Efficient Representation for Irradiance Environment Maps, Ramamoorthi further simplifies this expression by defining a new set of coefficients
Which lets us finally express irradiance as:
Next, we rewrite irradiance in terms of SH coefficients, using what we know from the sections on projection:
It then becomes evident from the last 2 equations that:
Phrased in plain English, if we have radiance in SH, given by coefficients
Note: You may notice that the above expression looks quite similar to the expression in the Spherical Harmonics > Convolution section. This is because the transformation from radiance to irradiance just that - a convolution of radiance with the clamped cosine term.
Since we already have an analytical expression for
The
We can put this all together to write some pseudocode that convolves our SH coefficients for radiance incoming from a given direction, to irradiance given the normal vector of a surface point:
// Given 9 SH coefficients (L2 SH) encoding directional radiance,
// return 9 SH coefficients encoding directional irradiance
float[] ConvolveRadianceToIrradianceSH(float[] radianceSH)
{
float[] irradianceSH = new float[9];
// L0
float aHat0 = 3.14159;
irradianceSH[0] = aHat0 * radianceSH[0];
// L1
float aHat1 = 2.09439;
irradianceSH[1] = aHat1 * radianceSH[1];
irradianceSH[2] = aHat1 * radianceSH[2];
irradianceSH[3] = aHat1 * radianceSH[3];
// L2
float aHat2 = 0.78539;
irradianceSH[4] = aHat2 * radianceSH[4];
irradianceSH[5] = aHat2 * radianceSH[5];
irradianceSH[6] = aHat2 * radianceSH[6];
irradianceSH[7] = aHat2 * radianceSH[7];
irradianceSH[8] = aHat2 * radianceSH[8];
return irradianceSH;
}
As well as a function to shade a surface given a surface normal, surface albedo and a set of SH coefficients corresponding to the a light probe:
float ShadeSH(float[] irradianceSH, float surfaceAlbedo, Vector3 surfaceNormal)
{
float outputColor = 0;
for (int n = 0; n < 9; n++)
{
outputColor += irradianceSH[n] * SHBasis(n, surfaceNormal);
}
return outputColor * surfaceAlbedo;
}
Note that I am still using monochromatic lighting for simplicity, so the surface albedo is just a single scalar.
With this, we can finally write pseudocode to shade a point using a light probe:
// First, we "bake" a probe at a given position:
Vector3 probePosition = ...;
float[] radianceSH = ProjectRadianceIntoSH(probePosition);
float[] irradianceSH = ConvolveRadianceToIrradiance(radianceSH);
// Next, we use it to shade a point with a given normal:
Vector3 surfaceNormal = ...;
float surfaceAlbedo = ...;
float colorAtSurface = ShadeSH(irradianceSH, surfaceAlbedo, surfaceNormal);
In reality, instead of using a single probe, we would bake multiple probes, yielding several sets of SH coefficients, and then interpolate the few nearest ones to get a set of interpolated SH coefficients for irradiance. This is just a dumbed down example.
fC0 * fLight[0] - fC3 * fLight[6]
- fC1 * fLight[3] * x
- fC1 * fLight[1] * y
+ fC1 * fLight[2] * z
+ fC2 * fLight[4] * xy
- fC2 * fLight[5] * yz
+ 3.0 * fC3 * fLight[6] * z^2
- fC2 * fLight[7] * zx
+ fC4 * fLight[8] * (x^2 - y^2)
(1/(2*sqrt(pi))) * f_0
- (sqrt(3)/(3*sqrt(pi))) * f_3 * x
- (sqrt(3)/(3*sqrt(pi))) * f_1 * y
+ (sqrt(3)/(3*sqrt(pi))) * f_2 * z
+ (sqrt(15)/(8*sqrt(pi))) * f_4 * xy
- (sqrt(15)/(8*sqrt(pi))) * f_5 * yz
- (sqrt(5)/(16*sqrt(pi))) * f_6
+ 3.0 * (sqrt(5)/(16*sqrt(pi))) * f_6 * z^2
- (sqrt(15)/(8*sqrt(pi))) * f_7 * zx
+ (sqrt(5)/(16*sqrt(pi))*0.5) * f_8 * (x^2 - y^2)