1. 从标题拆解:3D Gaussian Splatting 的渲染到底难在哪
3D Gaussian Splatting(后面我统一简称 3DGS)这两年在三维重建和新视角合成圈子里热度一直没下来过。它用一堆三维高斯椭球去表达场景,每个高斯自带位置、协方差、不透明度和球谐函数系数,训练完之后直接光栅化就能出图,速度比传统 NeRF 那套体渲染快了一个数量级。但真正把 3DGS 跑起来的人都知道,训练只是前半场,渲染才是决定你能不能把它用起来的关键环节。
这个标题里的“(分块)渲染”,说的就是 3DGS 渲染管线里最核心的那一步——基于屏幕空间分块(tile-based)的并行光栅化。它解决的问题很具体:一个场景动辄几十万到几百万个高斯,如果每个像素都去遍历所有高斯,计算量直接爆炸。分块渲染的思路是把屏幕切成一个个小格子(通常是 16×16 像素),先把高斯按覆盖范围分配到对应的 tile 上,再让每个 tile 独立并行处理自己那一小撮高斯,最后合成出整张图。
这套东西适合谁来啃?如果你只是想在网页上看看别人做好的 3DGS 模型,那没必要深究;但如果你想自己训练模型、改渲染器、做实时应用,或者想把 3DGS 塞进自己的引擎里,那分块渲染这套机制你必须吃透。它牵扯到 CUDA 核函数设计、球谐函数求值、深度排序、alpha 混合等一连串细节,任何一个环节没搞明白,出来的图要么糊、要么闪、要么直接崩。
我下面会按“整体设计思路 → 核心细节 → 实操实现 → 问题排查”这条线,把分块渲染从头到尾讲一遍。涉及 CUDA 的地方我会给出可参考的代码结构和参数,涉及球谐函数的地方我会把公式拆开讲清楚为什么这么算。你不需要是图形学老手,但最好有一点 CUDA 和线性代数的基础,不然读起来会有点吃力。
2. 整体设计与思路拆解
2.1 为什么不用逐像素遍历,而要用分块
先说清楚一个前提:3DGS 的渲染本质上是一种基于图元(primitive)的光栅化,而不是 NeRF 那种基于射线的体渲染。每个高斯椭球投影到屏幕上之后,会变成一个二维的椭圆(近似处理成椭圆,实际是投影后的协方差矩阵决定的形状)。渲染的任务就是把这些椭圆按深度顺序混合起来,得到每个像素的颜色。
最朴素的做法是:对屏幕上每一个像素,遍历所有高斯,判断这个像素是否落在某个高斯的影响范围内,如果在就累加颜色。这个复杂度是 O(像素数 × 高斯数),一张 1080p 的图有 200 多万像素,场景有 100 万个高斯,那就是 2×10¹² 次判断,根本跑不动。
分块渲染把这个复杂度降下来的逻辑是这样的:一个高斯投影到屏幕上,覆盖的像素面积通常很小,可能就几十到几百个像素。也就是说,绝大多数像素和高斯之间根本没有交集。分块的做法就是先把这种“空间上的稀疏性”利用起来——把屏幕切成 tile,每个 tile 只处理真正覆盖到它的那些高斯。
这里有个关键数字:tile 大小选 16×16。为什么是 16 而不是 8 或 32?这是权衡的结果。tile 太小,tile 数量多,每个 tile 的调度开销和边界重复计算就上去了;tile 太大,单个 tile 内的高斯数量多,共享内存放不下,而且负载不均衡会更严重。16×16 在主流 GPU 上刚好能让一个 tile 的高斯列表塞进共享内存,同时线程块(block)的线程数(256)和 tile 像素数(256)能对上,一个线程负责一个像素,映射非常干净。
2.2 分块渲染的四个阶段
整个分块渲染管线我习惯拆成四个阶段来看,这样排查问题的时候能快速定位是哪一步出了岔子:
- 投影与视锥剔除:把每个三维高斯从世界坐标投影到屏幕坐标,算出它在屏幕上的椭圆参数(中心、协方差逆矩阵),同时剔除掉不在视锥内的高斯。
- 分块与排序:根据每个高斯覆盖的 tile 范围,生成 (tile_id, gaussian_id) 的键值对,然后按 tile_id 为主键、深度为次键做全局排序。
- 球谐函数求值:根据视角方向计算每个高斯在该视角下的颜色,这一步涉及球谐函数系数的加权求和。
- Alpha 混合:在每个 tile 内,按深度从前往后遍历高斯,做 alpha 混合,累加出最终像素颜色。
这四个阶段里,第一阶段和第三阶段是逐高斯的,第二阶段是全局的,第四阶段是逐 tile 的。理解这个划分,后面看代码就不会迷路。
2.3 方案选型背后的取舍
有人会问,为什么不用硬件光栅化管线,非要自己写 CUDA?原因在于 3DGS 的高斯不是三角形,它的形状是各向异性的椭圆,而且需要按深度做有序的 alpha 混合。硬件光栅化管线对三角形优化得很好,但对这种任意形状的图元和有序混合支持很差。自己写 CUDA 虽然麻烦,但能完全控制数据流和内存访问模式,性能反而更高。
另一个取舍是排序放在 GPU 上还是 CPU 上。早期一些实现把排序放 CPU,结果数据传输成了瓶颈。现在主流做法是用 CUDA 的cub::DeviceRadixSort在 GPU 上做基数排序,键值对直接留在显存里,避免来回拷贝。基数排序对整数键非常高效,而我们的主键 tile_id 正好是整数,次键深度可以量化成整数,完美契合。
3. 核心细节解析与实操要点
3.1 三维高斯投影到屏幕的数学过程
这一步是整个渲染的基础,算错了后面全白搭。一个三维高斯由均值 μ(3维)和协方差矩阵 Σ(3×3)定义。投影到屏幕分两步:先做视图变换,再做透视投影。
视图变换后,高斯在世界空间的位置变成相机空间位置,协方差矩阵的变换是 Σ' = W Σ Wᵀ,其中 W 是视图变换的旋转部分(3×3)。注意这里只取旋转,不平移,因为协方差描述的是形状和朝向,不受平移影响。
透视投影这一步比较绕。严格来说,透视投影是非线性的,高斯投影后不再是高斯。但 3DGS 用了一个局部仿射近似(local affine approximation),在均值点附近做一阶泰勒展开,把投影近似成仿射变换。具体做法是构造雅可比矩阵 J,然后屏幕空间的协方差就是 Σ_screen = J W Σ Wᵀ Jᵀ。
得到屏幕空间协方差后,取它的左上 2×2 子矩阵(因为屏幕是二维的),记为 Σ_2d。这个 2×2 矩阵的逆就是椭圆方程的二次型矩阵。椭圆的影响范围通常取 3σ 半径,也就是要算 Σ_2d 的特征值,取最大特征值的平方根乘以 3 作为椭圆的半径。
注意:这里有个容易踩的坑。Σ_2d 必须是正定的,否则开方会出 NaN。实际中因为浮点误差,偶尔会出现非正定的情况,需要在代码里加一个小的正则项(比如在对角线上加 1e-6),或者直接跳过这个高斯。
3.2 球谐函数求值的实现细节
球谐函数(Spherical Harmonics,SH)用来表达高斯的视角相关颜色。3DGS 默认用 3 阶 SH,也就是每个颜色通道有 16 个系数(1 个直流项 + 3 个一阶 + 5 个二阶 + 7 个三阶)。三个通道加起来就是 48 个系数。
求值的时候,输入是视角方向(从高斯指向相机的单位向量),输出是 RGB 颜色。公式是:
color = Σᵢ cᵢ · Yᵢ(dir)
其中 cᵢ 是第 i 个 SH 系数,Yᵢ 是第 i 个基函数。基函数的计算有现成的递推公式,但直接展开写死更快,因为阶数固定。
实操中要注意两点。第一,视角方向要在世界空间计算,不要用相机空间,否则颜色会随相机旋转而错误变化。第二,直流项(0 阶)要加 0.5 再乘系数,这是 3DGS 的一个约定,目的是让基础颜色落在合理范围。具体来说,最终颜色 = 0.5 + SH 求值结果,然后 clamp 到 [0,1]。
我见过有人把 SH 求值放在 alpha 混合阶段做,结果每个像素重复计算同一个高斯的颜色,浪费了大量算力。正确做法是在分块之前,对每个高斯算一次颜色,存到一个缓冲区里,混合阶段直接读。
3.3 分块键值对的生成与排序
这是分块渲染里最体现工程功底的一步。对每个高斯,算出它覆盖的 tile 范围(tile_min_x, tile_max_x, tile_min_y, tile_max_y),然后为每个覆盖的 tile 生成一个键值对。
键的设计很讲究。如果只用 tile_id 做键,那同一个 tile 内的高斯顺序是乱的,alpha 混合需要按深度排序,所以键里必须包含深度信息。常见做法是把键拼成一个 64 位整数:高 32 位放 tile_id,低 32 位放量化后的深度。这样一次基数排序就能同时按 tile 和深度排好。
深度怎么量化?把浮点深度映射到 32 位整数。因为基数排序是从低位到高位逐字节排的,所以低 32 位放深度、高 32 位放 tile_id,排完之后就是先按 tile 分组、组内按深度有序。这里要注意深度的方向:我们希望近的在前,所以量化的时候要么取负,要么用 (max_depth - depth) 来映射。
提示:tile_id 的计算是 tile_id = tile_y * num_tiles_x + tile_x。num_tiles_x = ceil(screen_width / 16)。这个计算要在核函数里做,注意用整数运算,别用浮点,否则会有精度问题导致 tile 边界错位。
3.4 Alpha 混合的前向遍历
混合阶段,每个 tile 分配一个线程块,256 个线程对应 256 个像素。线程块先从全局内存把该 tile 的高斯列表加载到共享内存,然后每个线程遍历这个列表,对属于自己的像素做混合。
混合公式是标准的 front-to-back alpha compositing:
C = Σᵢ cᵢ · αᵢ · Πⱼ<ᵢ (1 - αⱼ)
其中 αᵢ 是第 i 个高斯在该像素处的不透明度,等于高斯的不透明度乘以高斯分布在像素中心的值。这个值通过 Σ_2d 的逆矩阵算出来:α = opacity · exp(-0.5 · dᵀ Σ_2d⁻¹ d),d 是像素中心到高斯中心的偏移。
遍历过程中,如果累积的透射率(1 - 已累积的 alpha)小于某个阈值(比如 1e-4),就可以提前终止,因为后面的高斯贡献已经可以忽略。这个提前终止能省不少算力,尤其是高斯重叠严重的时候。
4. 实操过程与核心环节实现
4.1 环境准备与依赖确认
先把环境搭起来。3DGS 的官方实现依赖 CUDA 和 PyTorch,CUDA 版本要和 PyTorch 编译时用的版本对上,否则会报各种奇怪的链接错误。我一般用 conda 建一个独立环境,避免污染系统 Python。
conda create -n gsplat python=3.10 conda activate gsplat pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install plyfile tqdm装完之后验证一下 CUDA 是否可用:
import torch print(torch.cuda.is_available()) print(torch.version.cuda) print(torch.cuda.get_device_name(0))如果torch.cuda.is_available()返回 False,八成是驱动版本和 CUDA 版本不匹配。用nvidia-smi看一下驱动支持的 CUDA 版本,然后装对应版本的 PyTorch。这一步别偷懒,环境不对后面编译自定义 CUDA 扩展会一直失败。
4.2 投影核函数的实现
投影核函数负责把每个高斯从三维投影到屏幕,输出屏幕中心、2D 协方差逆矩阵、颜色、深度等。我给出核心结构:
__global__ void project_gaussians( const float3* means3d, // 高斯中心 const float* cov3d, // 3D 协方差(6个元素,上三角) const float* shs, // 球谐系数 const float* opacities, // 不透明度 const float* viewmatrix, // 视图矩阵 const float* projmatrix, // 投影矩阵 int num_gaussians, float2* out_means2d, // 输出:屏幕中心 float4* out_conic, // 输出:2D 协方差逆矩阵 float3* out_colors, // 输出:SH 求值后的颜色 float* out_depths) // 输出:深度 { int idx = blockIdx.x * blockDim.x + threadIdx.x; if (idx >= num_gaussians) return; // 1. 视图变换 float3 p_view = transform_point(viewmatrix, means3d[idx]); // 2. 视锥剔除:相机空间 z 太小的直接跳过 if (p_view.z < 0.2f) return; // 3. 投影到屏幕 float4 p_hom = transform_point4(projmatrix, p_view); float2 p_screen = make_float2( (p_hom.x / p_hom.w * 0.5f + 0.5f) * screen_width, (p_hom.y / p_hom.w * 0.5f + 0.5f) * screen_height ); // 4. 计算 3D 协方差在相机空间的变换 float3x3 cov_cam = compute_cov_cam(cov3d[idx], viewmatrix); // 5. 构造雅可比矩阵,做局部仿射近似 float3x3 J = compute_jacobian(p_view, projmatrix); float3x3 cov_screen = J * cov_cam * transpose(J); // 6. 取 2x2 子矩阵并求逆 float3 cov2d = make_float3(cov_screen[0][0], cov_screen[0][1], cov_screen[1][1]); float det = cov2d.x * cov2d.z - cov2d.y * cov2d.y; if (det <= 0.0f) return; // 非正定,跳过 float3 conic = make_float3(cov2d.z / det, -cov2d.y / det, cov2d.x / det); // 7. 球谐求值 float3 dir = normalize(means3d[idx] - camera_pos); float3 color = eval_sh(shs + idx * 48, dir); // 8. 写输出 out_means2d[idx] = p_screen; out_conic[idx] = make_float4(conic.x, conic.y, conic.z, 0.0f); out_colors[idx] = color; out_depths[idx] = p_view.z; }这里有几个细节值得展开。视锥剔除的阈值 0.2 不是随便定的,太小会让近处的高斯投影出巨大的椭圆,拖慢渲染;太大又会裁掉本该可见的高斯。0.2 是官方实现里的经验值,实测下来比较稳。
雅可比矩阵的构造是透视投影近似的核心。对于标准透视投影,雅可比矩阵大致是:
J = [[focal_x / z, 0, -focal_x * x / z²], [0, focal_y / z, -focal_y * y / z²], [0, 0, 0]]第三行全零是因为屏幕是二维的,z 方向的投影不影响屏幕坐标。这个矩阵乘上相机空间协方差,再乘转置,就得到屏幕空间协方差。
4.3 分块键值对生成与排序
投影完成后,每个高斯有了屏幕中心、2D 协方差逆矩阵、颜色、深度。接下来生成分块键值对:
__global__ void compute_tiles( const float2* means2d, const float4* conic, const float* depths, int num_gaussians, int num_tiles_x, int num_tiles_y, uint64_t* tile_keys, // 输出:排序键 uint32_t* tile_gaussian_ids, // 输出:高斯索引 int* tile_counts) // 输出:每个 tile 的高斯数 { int idx = blockIdx.x * blockDim.x + threadIdx.x; if (idx >= num_gaussians) return; float2 center = means2d[idx]; float3 conic_val = make_float3(conic[idx].x, conic[idx].y, conic[idx].z); // 计算椭圆半径:取 2D 协方差最大特征值的 3 倍 float mid = 0.5f * (conic_val.x + conic_val.z); float det = conic_val.x * conic_val.z - conic_val.y * conic_val.y; float lambda1 = mid + sqrtf(fmaxf(0.1f, mid * mid - det)); float lambda2 = mid - sqrtf(fmaxf(0.1f, mid * mid - det)); float radius = 3.0f * sqrtf(fmaxf(lambda1, lambda2)); // 计算覆盖的 tile 范围 int tile_min_x = max(0, (int)((center.x - radius) / TILE_SIZE)); int tile_max_x = min(num_tiles_x - 1, (int)((center.x + radius) / TILE_SIZE)); int tile_min_y = max(0, (int)((center.y - radius) / TILE_SIZE)); int tile_max_y = min(num_tiles_y - 1, (int)((center.y + radius) / TILE_SIZE)); // 为每个覆盖的 tile 生成键值对 for (int ty = tile_min_y; ty <= tile_max_y; ty++) { for (int tx = tile_min_x; tx <= tile_max_x; tx++) { int tile_id = ty * num_tiles_x + tx; // 键:高 32 位 tile_id,低 32 位深度(取负使近的在前) uint64_t key = ((uint64_t)tile_id << 32) | (uint32_t)(__float_as_uint(-depths[idx])); int slot = atomicAdd(&tile_counts[tile_id], 1); // 这里需要预先分配好每个 tile 的存储空间 tile_keys[tile_id * MAX_GAUSSIANS_PER_TILE + slot] = key; tile_gaussian_ids[tile_id * MAX_GAUSSIANS_PER_TILE + slot] = idx; } } }这段代码里有个工程上的难点:每个 tile 的高斯数量事先不知道,需要动态分配。常见做法是先跑一遍统计每个 tile 的高斯数,前缀和求出总数量,再跑一遍填充。或者用一个足够大的缓冲区,配合原子操作分配槽位。前者更省内存,后者实现更简单。
排序用cub::DeviceRadixSort::SortPairs,对 (key, value) 对排序。因为键的高 32 位是 tile_id,排完之后同一个 tile 的高斯就聚在一起了,而且组内按深度有序。
注意:
__float_as_uint(-depths[idx])这个技巧利用了 IEEE 754 浮点数的性质——对于正数,浮点数的位表示和它的大小顺序一致。取负之后,越近的深度(值越小)取负后越大,位表示也越大,排序后自然排在前面。但要注意处理负深度和 NaN,实际代码里要加保护。
4.4 混合核函数的实现
混合阶段,每个 tile 一个线程块,256 个线程对应 256 个像素:
__global__ void render_tiles( const uint32_t* tile_gaussian_ids, const int* tile_offsets, // 每个 tile 在排序后数组中的起始位置 const float2* means2d, const float4* conic, const float3* colors, const float* opacities, int num_tiles_x, int image_width, int image_height, float3* out_image) { // 当前线程块负责的 tile int tile_id = blockIdx.x; int tile_x = tile_id % num_tiles_x; int tile_y = tile_id / num_tiles_x; // 当前线程负责的像素 int pixel_x = tile_x * TILE_SIZE + threadIdx.x % TILE_SIZE; int pixel_y = tile_y * TILE_SIZE + threadIdx.x / TILE_SIZE; if (pixel_x >= image_width || pixel_y >= image_height) return; // 加载该 tile 的高斯列表到共享内存 __shared__ int shared_ids[MAX_GAUSSIANS_PER_TILE]; int start = tile_offsets[tile_id]; int count = tile_offsets[tile_id + 1] - start; for (int i = threadIdx.x; i < count; i += blockDim.x) { shared_ids[i] = tile_gaussian_ids[start + i]; } __syncthreads(); // 逐高斯混合 float3 color = make_float3(0.0f, 0.0f, 0.0f); float transmittance = 1.0f; float2 pixel = make_float2(pixel_x + 0.5f, pixel_y + 0.5f); for (int i = 0; i < count; i++) { int gid = shared_ids[i]; float2 delta = make_float2(pixel.x - means2d[gid].x, pixel.y - means2d[gid].y); float3 conic_val = make_float3(conic[gid].x, conic[gid].y, conic[gid].z); // 高斯分布在像素中心的值 float power = -0.5f * (conic_val.x * delta.x * delta.x + conic_val.z * delta.y * delta.y) - conic_val.y * delta.x * delta.y; if (power > 0.0f) continue; // 椭圆外 float alpha = fminf(0.99f, opacities[gid] * __expf(power)); if (alpha < 1.0f / 255.0f) continue; color.x += colors[gid].x * alpha * transmittance; color.y += colors[gid].y * alpha * transmittance; color.z += colors[gid].z * alpha * transmittance; transmittance *= (1.0f - alpha); if (transmittance < 1e-4f) break; // 提前终止 } out_image[pixel_y * image_width + pixel_x] = color; }这段代码有几个性能关键点。第一,共享内存加载高斯列表,避免每个线程重复读全局内存。第二,__expf是快速指数函数,精度略低但速度快很多,渲染场景下完全够用。第三,提前终止阈值 1e-4 是经验值,再小收益不明显,再大会出现可见的截断。
5. 常见问题与排查技巧实录
5.1 渲染结果全黑或全白
这是最常见的问题,通常出在几个地方。全黑的话,先检查投影核函数里的视锥剔除是不是太激进,把高斯全裁掉了。可以在核函数里加个计数器,统计通过剔除的高斯数量,如果接近零,那就是剔除逻辑有问题。
全白的话,多半是 alpha 混合的累积出了问题。检查transmittance的初始值是不是 1.0,以及alpha有没有被 clamp 到合理范围。还有一种可能是颜色缓冲区没清零,或者输出的时候写错了索引。
5.2 图像出现明显的块状接缝
这是分块渲染特有的问题,说明 tile 边界处的高斯分配有遗漏。检查 tile 范围的计算,特别是tile_max_x和tile_max_y的取整方式。用(int)((center.x + radius) / TILE_SIZE)的时候,如果center.x + radius刚好落在 tile 边界上,可能会漏掉右边那个 tile。稳妥的做法是加一个小的 epsilon,或者用ceil代替截断。
另一个可能的原因是椭圆半径算小了。3σ 是理论值,但浮点误差可能让边缘像素的高斯贡献被截断。可以把半径系数从 3.0 调到 3.5,代价是稍微多算一点,但接缝会消失。
5.3 渲染速度慢得离谱
先看 GPU 利用率。如果利用率很低,说明瓶颈在数据搬运或者核函数启动开销。检查是不是每个 tile 都启动了一个线程块,tile 数量太多的话,启动开销会累积。可以考虑把多个 tile 合并到一个线程块里处理。
如果 GPU 利用率很高但速度还是慢,那多半是高斯数量太多。检查有没有做视锥剔除和 alpha 提前终止。另外,球谐函数的阶数也可以降,从 3 阶降到 2 阶甚至 1 阶,颜色质量会下降但速度提升明显。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决思路 |
|---|---|---|---|
| 全黑 | 视锥剔除过激 | 统计通过剔除的高斯数 | 放宽 z 阈值,检查投影矩阵 |
| 全白 | alpha 累积错误 | 打印 transmittance 变化 | 检查初始值和 clamp 范围 |
| 块状接缝 | tile 边界遗漏 | 可视化 tile 覆盖范围 | 加 epsilon,调大椭圆半径 |
| 颜色闪烁 | SH 求值方向错误 | 固定视角看颜色是否稳定 | 确保方向在世界空间计算 |
| 速度慢 | 高斯过多或未提前终止 | 看 GPU 利用率和核函数耗时 | 降 SH 阶数,调终止阈值 |
| NaN 像素 | 协方差非正定 | 检查 det 是否小于零 | 加正则项,跳过非法高斯 |
5.5 几个我踩过的坑
第一个坑是共享内存溢出。我一开始把MAX_GAUSSIANS_PER_TILE设成 1024,结果某些 tile 的高斯数超过这个值,直接写越界,渲染出来的图随机出现彩色噪点。后来改成动态分配,每个 tile 按实际数量分配,问题才解决。如果你用固定大小,一定要留足余量,或者加边界检查。
第二个坑是排序键的深度量化。我一开始直接把浮点深度的位表示当键,结果负深度(相机后面的点)的位表示是负数,排序后跑到最前面,导致渲染出错。后来改成先取负再转位表示,并且加了视锥剔除保证深度为正,才稳定下来。
第三个坑是球谐函数的直流项。我忘了加 0.5 的偏移,结果渲染出来的图整体偏暗,颜色也不对。这个 0.5 是 3DGS 训练时的约定,渲染时必须对应上,否则颜色会系统性偏移。
6. 性能调优与扩展思路
6.1 共享内存与寄存器优化
混合核函数是性能热点,优化空间最大。共享内存方面,高斯列表的加载可以用向量化读取,一次读 4 个 int,减少内存事务。寄存器方面,conic和colors这些频繁访问的数据可以提前加载到寄存器,避免重复读共享内存。
还有一个技巧是把 tile 内的像素分组。256 个线程处理 256 个像素,但相邻像素的高斯贡献高度相关。可以让一个线程处理 2×2 的像素块,这样高斯列表只需要遍历一次,四个像素共享计算。代价是寄存器压力增大,需要权衡。
6.2 多分辨率渲染
如果目标平台性能有限,可以考虑多分辨率渲染。先在一个低分辨率下渲染出大致结果,再对边缘区域做高分辨率细化。3DGS 的高斯本身就有尺度信息,可以根据高斯在屏幕上的投影大小决定渲染精度,小高斯用低精度,大高斯用高精度。
6.3 与其他渲染管线的结合
3DGS 渲染出来的结果可以和传统光栅化管线结合。比如场景里的静态背景用网格渲染,动态物体用 3DGS 渲染,最后合成。这样既能利用 3DGS 的逼真度,又能利用传统管线的高效性。结合的关键是深度缓冲区的共享,3DGS 渲染时要读写同一个深度缓冲,保证遮挡关系正确。
6.4 球谐函数的替代方案
球谐函数虽然表达能力强,但 48 个系数 per 高斯,存储和计算开销都不小。如果场景的视角相关效果不强,可以降阶甚至直接用 RGB 颜色。另一种方案是用球面高斯(Spherical Gaussian)代替球谐,用更少的参数表达类似的效果,求值也更快。这个方向目前还在研究中,但已经有了一些可用的实现。
7. 我个人的一些实操体会
分块渲染这套东西,看代码和真正跑起来是两回事。我建议你先把官方实现跑通,用一个小场景(比如 Mip-NeRF 360 里的 garden)训练一个模型,然后逐步改渲染器,每次只改一个地方,改完立刻可视化对比。这样出了问题能快速定位,不会一锅乱炖。
调试的时候,可视化中间结果是关键。把投影后的椭圆画出来,把 tile 覆盖范围画出来,把每个 tile 的高斯数量统计出来,这些图能帮你快速发现异常。我习惯用 Python 写一个小脚本,把 CUDA 核函数的输出 dump 成图片,比在终端里打印数字直观得多。
最后说一个容易被忽略的点:数值稳定性。3DGS 的渲染涉及大量的浮点运算,尤其是协方差矩阵的求逆和指数函数。在极端情况下(比如高斯非常扁,或者深度非常大),浮点误差会被放大,导致 NaN 或者颜色异常。我的做法是在关键步骤加保护,比如求逆之前检查行列式,指数函数之前 clamp 输入范围。这些保护会稍微增加开销,但能避免整个渲染崩掉,非常值得。
如果你想把 3DGS 用到实际项目里,分块渲染是绕不过去的一关。它不像训练那样有现成的框架帮你兜底,很多细节需要自己抠。但一旦吃透,你会发现这套机制的设计非常优雅,很多思路(比如空间稀疏性利用、分块并行、有序混合)在其他渲染场景里也能复用。