
As part of my RenderMan for real-time research, I have for some time, been working on a solution for generating spherical harmonic coefficients from arbitrarily complex lighting environments for use in real-time. While not completely finished with this, I have some promising preliminary results.
First, some background. Spherical Harmonic lighting is a method for computing complex low-frequency lighting solutions in real-time by pre-calculating a series of SH coefficients which when used in a linear combination of SH basis functions, can produce an approximation of the original lighting. For more details on this process, I would direct you to Robin Green’s Spherical Harmonic Lighting: The Gritty Details. I drew much of my code from this paper.
For my first pass, I chose to only approximate the lighting function, as opposed to both the lighting and light-transfer functions. I implemented two RSL shaders and a DSO Shadeop. The “baking” shader, applied to a single Point in the RenderMan scene, creates a series of points using stratified sampling over a unit sphere, and samples the environment’s light at each point.
The “Bake” shader:
surface shbake(float samples = 10000; float sqrt_samples = 100; string path = "shbake.sh") {
// Create uniformly distributted samples across the
// sphere using jittered stratification
float i = 0;
float a = 0;
float b = 0;
float oneoverN = 1.0 / sqrt_samples;
for (a = 0; a < sqrt_samples; a = a + 1) {
for (b = 0; b < sqrt_samples; b = b + 1) {
// Generate unbiased distribution of spherical coords
float x = (a + random()) * oneoverN;
float y = (b + random()) * oneoverN;
float theta = 2.0 * acos(sqrt(1.0 - x));
float phi = 2.0 * PI * y;
point sphericalPos = point(theta, phi, 1);
point cartPos = point(sin(theta) * cos(phi), sin(theta) * sin(phi), cos(theta));
normal n = normalize(normal cartPos);
Ci = diffuse(n);
shbake(path, samples, sphericalPos, Ci);
}
}
}
Once all of the samples have been loaded via the shbake function exposed by the DSO, the sampled lighting function is projected onto SH coefficients. To produce the coefficient for a particular SH polynomial, integrate the produce of the polynomial at the sample position with the value of the light at the sample position over a hemisphere. Numerically, this is done with monte-carlo integration:
Generating the SH coefficients:
void ProjectSH(SHResult* result) {
const double weight = 4.0 * PI;
// Loop over the samples
for (int i = 0; i < gSampleCount; i++) {
for (int n = 0; n < gNumSHCoeffs; n++) {
result[n].RedCoeff += gLightSamples[i].Color[0] * gLightSamples[i].SFCoeffs[n];
result[n].GreenCoeff += gLightSamples[i].Color[1] * gLightSamples[i].SFCoeffs[n];
result[n].BlueCoeff += gLightSamples[i].Color[2] * gLightSamples[i].SFCoeffs[n];
}
}
// Divide each coeff by the weight and number of samples
double factor = weight / gSampleCount;
for (int i = 0; i < gNumSHCoeffs; i++) {
result[i].RedCoeff *= factor;
result[i].GreenCoeff *= factor;
result[i].BlueCoeff *= factor;
}
}
Note that the monte-carlo “weight” scaler is 4 Pi. Because of the even distribution of sample points, each point has the same probability of appearing: one over the area of the sphere, 4 Pi.
Once the coefficients have been generated, they are written to file. At this point, I decided to write another shadeop to test the SH coefficients inside RenderMan as opposed to putting together a real-time application to utilize them. Real-time use is of course the eventual goal, but in order to verify the results of the projection, RenderMan works just fine. The “beauty” pass used to test the output consists of a shader which can be applied to any geometry, and a DSO shadeop which returns a color given a sampling vector.
Here’s the “beauty” shader:
surface shcolor(string path="shbake.sh") {
// Sample from our SH file
color shColor = shcolor(path, vtransform("world", normalize(N)));
Ci = shColor;
}
And here a snippet from the shcolor shadeop:
extern "C" SHADEOP(shcolor) {
// Pull out the arguments and such...
// ....
// Convert the point to spherical coordinates and normalize
double sphericalPos[3];
double r = sqrt(position[0] * position[0] + position[1] * position[1] + position[2] * position[2]);
double t = atan(position[1] / position[0]);
double p = acos(position[2] / r);
sphericalPos[0] = t; sphericalPos[1] = p; sphericalPos[2] = r;
// Compute the color
int total = 0;
for (int l = 0; l < BANDS; l++) {
// For each coeff in the band
for (int m = -l; m <= l; m++) {
((float*)argv[0])[0] += gCoeffs[total] * SH(l, m, sphericalPos[0], sphericalPos[1]);
total++;
((float*)argv[0])[1] += gCoeffs[total] * SH(l, m, sphericalPos[0], sphericalPos[1]);
total++;
((float*)argv[0])[2] += gCoeffs[total] * SH(l, m, sphericalPos[0], sphericalPos[1]);
total++;
}
}
return 0;
}
The bit which actually does the lighting calculation does so by summing each SH polynomial evaluated at the given point multiplied by the appropriate SH coefficient. While I have verified that the results coming out of the SH projection step work correctly by checking against a known closed-form lighting function, the resulting approximation generated by the shcolor function does not appear to be correct.
Here are the samples collected, displayed in an interactive view which is now a temporary part of the DSO code:
And here is the resulting approximation:
I am guessing this has either something to do with how the data is being sampled (conversion from Cartesian coordinates to spherical might be the culprit) or how the actual color is being generated (summing the SH polynomials and their coefficients).
I’m going to keep working on this one. Stay tuned for more.


Post a Comment