球谐函数光照渲染

在静态光照探针运用中,主要依靠球谐函数
球谐函数定义:基函数
在数学中,一个基函数是一个函数空间中的一个基地,就像欧拉空间的一个坐标轴一样。
球谐函数是将谐函数限制在球坐标下的单位球面的一组基函数,

球面空间上的球谐函数可以看作球体的球壳,本质上是二维函数,可视化如下:

绿色表示球谐函数的值为正值,而红色表示球谐函数的值为负值;矢径越大球谐函数值的绝对值越大,反之矢径越小球谐函数值的绝对值越小。
而在实际的使用中,一般只取前几阶的球谐函数来近似球面上的函数。
例如,前三阶的球谐基函数如下:

其中,r = 1 r=1r=1,更多公式请参考:wiki-Table_of_spherical_harmonics。
球谐函数系数求解方法:投影
可以采用积分的方式求解

使用蒙特卡洛积分来近似求解球谐系数的原因主要与球面积分的复杂性和蒙特卡洛方法的特性有关。以下是具体原因的分析:


详细推导过程可参阅下面的链接
【论文复现】Spherical Harmonic Lighting:The Gritty Details
https://zhuanlan.zhihu.com/p/359856625
【论文复现】An Efficient Representation for Irradiance Environment Maps
https://zhuanlan.zhihu.com/p/363600898
上述描述基于天空盒场景下,
-
天空盒(Cubemap):
- 天空盒是一个立方体贴图(Cubemap),用于模拟场景中的环境光照(例如天空、远处的环境光)。
- 它由6个面组成,每个面是一个二维纹理,表示从某个点向外看时的环境光信息。
- 在渲染中,天空盒通常用来提供入射光的方向和强度(
Li),用于计算物体表面的光照。
-
**公式2的上下文:
- 原文提到“公式2已经分析了”,这通常指的是光照积分公式(例如渲染方程的一部分)。
- 渲染方程中,物体表面某点的出射光(Radiance)可以通过积分入射光(
Li)和材质属性(如BRDF)计算。 - 这里提到的“与具体着色点无关”意味着,这个积分过程不依赖于物体表面某点的具体位置或法线,而只与天空盒的整体光照分布有关。
-
积分的目的:
- 积分的目标是对整个天空盒的入射光进行采样,计算某个效果(例如环境光照贡献)。
p.color表示天空盒上某个像素的颜色(即入射光强,Radiance)。Yi(normalise(p.position))可能是一个球谐函数(Spherical Harmonics, SH)的基函数,用于将光照信息投影到某个基底上。dw是微分立体角(differential solid angle),表示积分时每个像素对应的角度范围。
伪代码解释
伪代码如下:
for(pixel &p : Cubemap)
Li += p.color * Yi(normalise(p.position)) * dw;
这是在对天空盒(Cubemap)的所有像素进行遍历,计算某个光照积分的结果。逐行解释:
-
for(pixel &p : Cubemap):- 遍历天空盒(Cubemap)中的每个像素
p。 - 每个像素
p包含颜色信息(p.color)和位置信息(p.position)。 p.position是该像素在立方体贴图上的三维方向向量(指向天空盒的某个点)。
- 遍历天空盒(Cubemap)中的每个像素
-
Li += p.color * Yi(normalise(p.position)) * dw:Li:表示累积的入射光(Radiance)或某个光照贡献的结果。p.color:天空盒上像素p的颜色,表示该方向的入射光强度。Yi(normalise(p.position)):normalise(p.position):将像素的位置向量归一化,得到一个单位方向向量(因为球谐函数需要单位向量作为输入)。Yi是一个球谐基函数(Spherical Harmonics Basis Function),用于将光照信息分解到球谐基底上。i表示某个特定的球谐阶(order)和分量。- 球谐函数常用于环境光照的压缩和快速计算,例如在预计算辐射传输(Precomputed Radiance Transfer, PRT)中。
dw:微分立体角,表示每个像素在球面上的微小角度范围,决定了该像素对积分的贡献权重。- 这一行的操作是将每个像素的贡献(颜色 × 球谐基函数值 × 立体角)累加到
Li上。
-
整体含义:
- 这段代码的目的是对整个天空盒进行光照积分,计算某个特定球谐分量(由
Yi表示)的光照贡献。 - 由于原文提到“与具体着色点无关”,说明这个积分的结果是针对整个天空盒的全局光照分布,而不是针对某个特定物体表面的点。
- 结果
Li可能用于后续的渲染计算,例如环境光照、漫反射光照或镜面反射的近似。
- 这段代码的目的是对整个天空盒进行光照积分,计算某个特定球谐分量(由
为什么“与具体着色点无关”?
- 在渲染方程中,着色点(物体表面的点)的光照贡献通常依赖于其法线方向和材质属性。
- 但是,这里提到的积分只涉及天空盒的入射光(
p.color)和方向(p.position),没有引入着色点的法线或材质信息。 - 这意味着积分的结果是一个全局的量,适用于所有着色点(例如,用于计算环境光的球谐系数)。
- 这种方法常见于预计算光照(Precomputed Lighting),例如将天空盒光照投影到球谐基底上,生成一组系数,供实时渲染使用。
总结
这段伪代码的目的是对天空盒的所有像素进行光照积分,计算入射光(Li)在某个球谐基函数(Yi)上的投影,用于环境光照的预计算。积分结果与具体着色点无关,因此可以一次性对整个天空盒计算,生成全局光照信息。希望这个解释能帮你理解!如果有进一步问题,请告诉我。
球谐函数的应用:光照计算
当球谐函数的阶数越高,还原的效果越好。
球谐函数的结果是比较粗糙的,只能模拟低频信号
因此可考虑运用球谐函数进行环境光照,BRDF中漫反射的渲染。

初始代码思路
考虑先从天空盒入手,将天空盒的颜色信息看作初始环境光照。
后续可考虑从环境烘焙天空盒。实现连续场景的球谐函数环境光渲染。
输入天空盒图片信息
天空盒需要6张图片
```cpp
Harmonics::Harmonics(std::array<std::string, 6> vImageFilenames)
{
// 循环处理6张图像
for (int i = 0; i < 6; i++)
{
// 读取图像文件
cv::Mat img = cv::imread(vImageFilenames[i]);
// 检查图像是否成功读取
if (!img.data)
// 如果读取失败,抛出运行时异常
throw std::runtime_error("read image failed: " + vImageFilenames[i]);
// 将图像转换为32位浮点型三通道格式,并将像素值归一化到[0,1]范围
img.convertTo(m_Images[i], CV_32FC3, 1.0 / 255.0);
}
}
```cpp
cv::Mat Harmonics::RenderCubemap(int width, int height)
{
// 创建6个面的图像数组,每个面都是32位三通道浮点型图像
std::array<cv::Mat, 6> imgs;
for (int k = 0; k < 6; k++)
{
// 为每个面创建指定大小的图像
imgs[k] = cv::Mat(height, width, CV_32FC3);
// 遍历每个像素
for (int i = 0; i < height; i++)
{
for (int j = 0; j < width; j++)
{
// 计算UV坐标(范围0-1)
float u = (float)j / (width - 1);
float v = 1.f - (float)i / (height - 1);
// 将UV坐标转换为3D方向向量
glm::vec3 pos = CubeUV2XYZ({ k, u, v });
// 计算该方向的颜色值
glm::vec3 color = Render(pos);
// 将颜色值存储到图像中(注意OpenCV使用BGR顺序)
imgs[k].at<cv::Vec3f>(i, j) = { color.b, color.g, color.r };
}
}
}
// 定义6个面在十字形布局中的x坐标
int xarr[6] = { 2 * width, 0, width, width, width, 3 * width };
// 定义6个面在十字形布局中的y坐标
int yarr[6] = { height, height, 0, 2 * height, height, height };
// 创建最终的十字形布局图像(3行4列)
cv::Mat expandimg(3 * height, 4 * width, CV_32FC3);
// 将6个面复制到十字形布局的对应位置
for (int i = 0; i < 6; i++)
{
// 定义每个面在最终图像中的位置和大小
cv::Rect region(xarr[i], yarr[i], width, height);
cv::Mat temp;
// 确保图像大小正确
cv::resize(imgs[i], temp, cv::Size(width, height));
// 将处理后的面复制到最终图像的指定位置
temp.copyTo(expandimg(region));
}
return expandimg;
}
```
计算球谐函数
将基函数的形式带入计算即可,注意设置好基函数的阶数,带入位置计算时,需要先将法向量归一化,之后带入分量计算。
可以将计算分成两个函数,一个在预计算阶段,使用更高阶数,高精度系数。另一个在渲染阶段,使用低阶数和简化系数。
其中,有关光照的使用高阶计算,渲染阶段基于位置计算时使用低阶计算。
功能分离:
RenderBasis 专注于渲染时的快速计算。
Basis 支持更高阶计算,用于复杂光照模拟。
vector<float> Harmonics::Basis(const glm::vec3& pos)
{
// 初始化结果向量,大小为m_Degree
vector<float> Y(m_Degree);
// 归一化输入位置向量得到单位法向量
glm::vec3 normal = glm::normalize(pos);
// 提取法向量的x、y、z分量
float x = normal.x;
float y = normal.y;
float z = normal.z;
// 计算0阶球谐函数
if (m_Degree >= 0)
{
Y[0] = 1.f / 2.f * sqrt(1.f / PI);
}
// 计算1阶球谐函数
if (m_Degree >= 1)
{
Y[1] = sqrt(3.f / (4.f * PI)) * z; // Y_1^-1
Y[2] = sqrt(3.f / (4.f * PI)) * y; // Y_1^0
Y[3] = sqrt(3.f / (4.f * PI)) * x; // Y_1^1
}
// 计算2阶球谐函数
if (m_Degree >= 2)
{
Y[4] = 1.f / 2.f * sqrt(15.f / PI) * x * z; // Y_2^-2
Y[5] = 1.f / 2.f * sqrt(15.f / PI) * z * y; // Y_2^-1
Y[6] = 1.f / 4.f * sqrt(5.f / PI) * (-x * x - z * z + 2 * y*y); // Y_2^0
Y[7] = 1.f / 2.f * sqrt(15.f / PI) * y * x; // Y_2^1
Y[8] = 1.f / 4.f * sqrt(15.f / PI) * (x * x - z * z); // Y_2^2
}
// 计算3阶球谐函数
if (m_Degree >= 3)
{
Y[9] = 1.f / 4.f * sqrt(35.f / (2.f * PI)) * (3 * x * x - z * z) * z; // Y_3^-3
Y[10] = 1.f / 2.f * sqrt(105.f / PI) * x * z * y; // Y_3^-2
Y[11] = 1.f / 4.f * sqrt(21.f / (2.f * PI)) * z * (4 * y * y - x * x - z * z); // Y_3^-1
Y[12] = 1.f / 4.f * sqrt(7.f / PI)*y*(2 * y*y - 3 * x*x - 3 * z * z); // Y_3^0
Y[13] = 1.f / 4.f * sqrt(21.f / (2.f * PI)) * x * (4 * y * y - x * x - z * z);
Y[14] = 1.f / 4.f * sqrt(105.f / PI) * (x * x - z * z) * y;
Y[15] = 1.f / 4.f * sqrt(35.f / (2 * PI)) * (x * x - 3 * z * z) * x;
}
return Y;
}
```cpp
vector<float> Harmonics::RenderBasis(const glm::vec3& pos)
{
vector<float> Y(m_Degree);
glm::vec3 normal = glm::normalize(pos);
float x = normal.x;
float y = normal.y;
float z = normal.z;
if (m_Degree >= 0)
{
Y[0] = 1.f / 2.f * sqrt(1.f / PI);
}
if (m_Degree >= 1)
{
Y[1] = 2.0 / 3.0 *sqrt(3.f / (4.f * PI)) * z;
Y[2] = 2.0 / 3.0 *sqrt(3.f / (4.f * PI)) * y;
Y[3] = 2.0 / 3.0 *sqrt(3.f / (4.f * PI)) * x;
}
if (m_Degree >= 2)
{
Y[4] = 1.0 / 4.0 *1.f / 2.f * sqrt(15.f / PI) * x * z;
Y[5] = 1.0 / 4.0 *1.f / 2.f * sqrt(15.f / PI) * z * y;
Y[6] = 1.0 / 4.0 *1.f / 4.f * sqrt(5.f / PI) * (-x * x - z * z + 2 * y*y);
Y[7] = 1.0 / 4.0 *1.f / 2.f * sqrt(15.f / PI) * y * x;
Y[8] = 1.0 / 4.0 *1.f / 4.f * sqrt(15.f / PI) * (x * x - z * z);
}
return Y;
}
```
计算球谐函数系数
- 参考以下思路
![image]()
```cpp
void Harmonics::Evaluate()
{
// 初始化系数向量,大小为m_Degree,每个元素初始化为(0,0,0)
m_Coefs = vector<glm::vec3>(m_Degree, glm::vec3());
// 获取图像的宽度和高度
int w = m_Images[0].cols;
int h = m_Images[0].rows;
// 遍历6个面的贴图
for (int k = 0; k < 6; k++)
{
cv::Mat img = m_Images[k];
// 遍历图像的每个像素
for (int j = 0; j < w; j++)
{
for (int i = 0; i < h; i++)
{
// 计算像素中心坐标
float px = (float)i + 0.5;
float py = (float)j + 0.5;
// 将像素坐标转换为[-1,1]范围的纹理坐标
float u = 2.0 * (px / (float)w) - 1.0;
float v = 2.0 * (py / (float)h) - 1.0;
// 计算微分面积
float d_x = 1.0 / (float)w;
float x0 = u - d_x;
float y0 = v - d_x;
float x1 = u + d_x;
float y1 = v + d_x;
float d_a = surfaceArea(x0, y0) - surfaceArea(x0, y1) - surfaceArea(x1, y0) + surfaceArea(x1, y1);
// 计算球面坐标
u = (float)j / (img.cols - 1);
v = 1.0f - (float)i / (img.rows - 1);
// 将立方体贴图坐标转换为3D笛卡尔坐标
glm::vec3 p = CubeUV2XYZ({ k, u, v });
// 获取像素颜色值
auto c = img.at<cv::Vec3f>(i, j);
// 将颜色值转换为线性空间并乘以微分面积
glm::vec3 color = { c[2] * d_a, c[1] * d_a,c[0] * d_a };
// 计算球谐基函数值
vector<float> Y = Basis(p);
// 累加球谐系数
for (int i = 0; i < m_Degree; i++)
{
m_Coefs[i] = m_Coefs[i] + Y[i] * color;
}
}
}
}
}
渲染函数
```cpp
glm::vec3 Harmonics::Render(const glm::vec3& pos)
{
// 计算球谐基函数在给定位置的值
vector<float> Y = RenderBasis(pos);
glm::vec3 color;
// 遍历所有球谐系数,累加计算最终颜色
for (int i = 0; i < m_Degree; i++)
{
// 将基函数值与对应系数相乘并累加到颜色值中
color = color + Y[i] * m_Coefs[i];
}
return color;
}
```
光照探针烘焙
在天空盒场景中应用光照探针与球谐函数(SH)的实现
在仅包含天空盒(Skybox)的场景中应用光照探针(Light Probes)与球谐函数(Spherical Harmonics, SH)来实现全局光照(Global Illumination, GI),主要是为动态物体提供逼真的漫反射光照。天空盒场景的特殊性在于没有复杂的几何体,只有天空盒的全局环境光(通常通过HDR CubeMap提供)。因此,探针的主要作用是从天空盒捕获光照并投影到SH,用于动态物体(如角色、粒子)的光照计算。以下是详细步骤,结合Vulkan实现(参考之前的vkHarmonicsVirtualLights项目)和其他引擎(如Unity、UE5)的实践,特别考虑天空盒场景的简单性。
1. 理解天空盒场景的特点
场景特性:
天空盒是一个高分辨率CubeMap(e.g., 1024x1024或更高,HDR格式),提供全局环境光照(如天空、云、山脉)。
没有局部几何体,因此光照探针的CubeMap主要捕获天空盒光照,无间接反射(如墙壁反弹光)。
光照在空间中均匀(无遮挡),探针数量和密度需求较低。
目标:为动态物体(如移动的球体)提供基于SH的漫反射光照,捕获天空盒的低频光照信息。
SH选择:3阶SH(9系数/通道,RGB共27浮点数)足够表示漫反射光照,内存占用低(~108字节/探针)。
注:如果使用rgb存储SH系数,需要三张纹理
2. 步骤流程
步骤 1:布置光照探针(Probe Placement)
目的:在场景中放置探针,捕获天空盒的光照。由于天空盒光照均匀,探针数量可较少,分布简单。
数量:
8-16个探针(2x2x2网格),支持三线性插值。
示例:10米立方体场景,建议2米间隔,生成4x4x4=64个探针。
放置方式:
规则网格:最适合天空盒场景,因光照无显著局部变化。
位置:基于场景AABB,均匀分布。
避免嵌入:天空盒场景无几何体,无需射线检测。
Vulkan实现(C++,生成规则网格):
#include <glm/glm.hpp>
#include <vector>
std::vector<glm::vec3> GenerateProbePositions(glm::vec3 minBounds, glm::vec3 maxBounds, glm::ivec3 resolution) {
std::vector<glm::vec3> probes;
glm::vec3 spacing = (maxBounds - minBounds) / glm::vec3(resolution - glm::ivec3(1));
for (int x = 0; x < resolution.x; ++x) {
for (int y = 0; y < resolution.y; ++y) {
for (int z = 0; z < resolution.z; ++z) {
glm::vec3 pos = minBounds + glm::vec3(x, y, z) * spacing;
probes.push_back(pos);
}
}
}
return probes;
}
// 使用:10x10x10米场景,4x4x4网格
auto probes = GenerateProbePositions(glm::vec3(-5, 0, -5), glm::vec3(5, 10, 5), glm::ivec3(4, 4, 4));
天空盒场景特点:由于光照均匀,可用较粗网格(如2-3米间隔),无需自适应放置。
步骤 2:烘焙低分辨率CubeMap(CubeMap Baking)
目的:为每个探针捕获天空盒的光照,生成低分辨率CubeMap(16x16或32x32每面),用于SH投影。
过程:
创建CubeMap:
使用Vulkan的VkImage创建6层纹理(VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT)。
格式:HDR(如VK_FORMAT_R32G32B32A32_SFLOAT),分辨率低(16x16或32x32),因为SH仅需低频光照。
渲染CubeMap:
在每个探针位置设置虚拟相机,渲染6个方向(±X, ±Y, ±Z)。
视图矩阵:每个面90° FOV,指向对应方向。
渲染内容:直接采样天空盒CubeMap(无需场景几何体)。
在Vulkan中使用Offscreen Render Pass,只渲染天空盒。
可选的优化:
天空盒场景无间接光,渲染简单,仅需采样天空盒纹理。
使用重要性采样(Importance Sampling)减少噪声。
Vulkan实现(C++伪代码):
#include <vulkan/vulkan.hpp>
#include <glm/glm.hpp>
VkImage CreateCubeMap(VmaAllocator allocator) {
VkImageCreateInfo imageInfo = {
.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO,
.imageType = VK_IMAGE_TYPE_2D,
.format = VK_FORMAT_R32G32B32A32_SFLOAT,
.extent = {32, 32, 1},
.arrayLayers = 6,
.flags = VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT,
.usage = VK_IMAGE_USAGE_STORAGE_BIT | VK_IMAGE_USAGE_SAMPLED_BIT
};
VkImage cubeMap;
VmaAllocation allocation;
vmaCreateImage(allocator, &imageInfo, ..., &cubeMap, &allocation, ...);
return cubeMap;
}
void RenderCubeMap(VkCommandBuffer cmd, VkImage cubeMap, glm::vec3 probePos, VkImage skybox) {
for (int face = 0; face < 6; ++face) {
glm::mat4 view = GetCubeFaceViewMatrix(probePos, face); // 自定义:±X/Y/Z视图
glm::mat4 proj = glm::perspective(glm::radians(90.0f), 1.0f, 0.1f, 1000.0f);
// 设置Render Pass,绑定skybox纹理
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, skyboxPipeline);
vkCmdBindDescriptorSets(cmd, ..., skyboxDescriptorSet, ...);
// 渲染到cubeMap的face层
VkRenderingInfo renderingInfo = { /* 设置cubeMap layer */ };
vkCmdBeginRendering(cmd, &renderingInfo);
vkCmdDraw(cmd, 3, 1, 0, 0); // 渲染全屏三角形,采样skybox
vkCmdEndRendering(cmd);
}
}
天空盒场景简化:
无几何体,渲染仅采样天空盒CubeMap(texture(skybox, dir))。
每个探针的CubeMap可能相同(因天空盒光照均匀),但仍需渲染以支持位置变化(未来扩展几何体)。
步骤 3:投影到球谐函数(SH Projection)

Vulkan实现参考上面的流程
天空盒场景特点:由于光照均匀,SH系数可能在所有探针中近似(但仍需投影以支持未来扩展)。
步骤 4:数据存储(Storage)
目的:存储SH系数,供运行时插值。
方法:
使用Vulkan VkBuffer(Storage Buffer)存储所有探针的SH系数(vec3 shCoeffs[probeCount * 9])。
优化:FP16压缩,减少内存(~108字节/探针)。
示例:64探针,内存约64 * 108 = 6.9KB。
步骤 5:运行时插值与光照重建(Interpolation and Evaluation)

Vulkan实现(Fragment Shader):
glslCollapseWrapCopy#version 460
in vec3 worldPos, worldNormal;
layout(set = 1, binding = 0) buffer ProbeData { vec3 shCoeffs[]; };
uniform vec3 gridMin, gridMax, gridRes;
vec3[9] TrilinearInterpolate(vec3 pos) {
vec3 normPos = (pos - gridMin) / (gridMax - gridMin);
ivec3 idx = ivec3(floor(normPos * (gridRes - 1.0)));
vec3 frac = fract(normPos * (gridRes - 1.0));
// 插值8个邻居SH系数(简化)
vec3[9] interpolated; // 实现三线性插值
return interpolated;
}
vec3 EvaluateSH(vec3 normal, vec3[9] coeffs) {
vec3 result = vec3(0);
result += 0.282095 * coeffs[0]; // l=0,m=0
result += 0.488603 * normal.y * coeffs[1]; // l=1,m=-1
// 其他7项(参考Sloan论文)
return result;
}
void main() {
vec3[9] sh = TrilinearInterpolate(worldPos);
vec3 ambient = EvaluateSH(normalize(worldNormal), sh);
outColor = vec4(ambient, 1.0);
}
天空盒场景简化:光照均匀,插值可能退化为单一SH系数(因探针间差异小),但仍需网格支持位置变化。
3. 天空盒场景的特殊优化
减少探针数量:由于光照均匀,16-64个探针足够(e.g., 4x4x4网格)。
简化CubeMap:所有探针的CubeMap可能相同(因无几何体遮挡),可考虑只渲染一个CubeMap并复用,但仍需为每个探针存储SH系数(支持未来扩展)。
直接使用天空盒SH(可选):
如果动态物体无需局部光照变化,可直接对天空盒CubeMap进行SH投影,生成单一SH系数组,省略探针网格。
实现:将天空盒CubeMap传入SH投影Compute Shader,生成9系数/通道。
缺点:丢失空间变化,适合极简场景。
示例GLSL(直接投影天空盒):
glslCollapseWrapCopylayout(set = 0, binding = 0) uniform samplerCube skybox;
layout(set = 0, binding = 1) buffer SHBuffer { vec3 coeffs[9]; };
// 同前述SH投影代码,替换cubeMap为skybox
4. 在Vulkan项目中的实现(参考vkHarmonicsVirtualLights)
推荐的开源项目 vkHarmonicsVirtualLights(https://github.com/SiTronXD/vkHarmonicsVirtualLights)
5. 结合Unity/UE5的参考
Unity:
添加LightProbeGroup,设置少量探针(16-64个)。
Skybox通过Lighting > Environment > Skybox Material设置,烘焙时自动采样。
触发Lightmapping.Bake(),生成SH系数。
UE5:
使用SkyLight(Capture Environment),结合Lightmass Importance Volume(少量采样点)。
调整Volumetric Lightmap Detail Cell Size(e.g., 200单位,减少探针)。
烘焙生成Volumetric Lightmap。


浙公网安备 33010602011771号