GPU Gems 2 · Chapter 37. Octree Textures on the GPU

用存放在 3D texture 中的八叉树替代复杂网格的 UV 参数化,并把查找、过滤、绘制与表面模拟连成一条 GPU 管线。

学习目标

  • 能解释为什么复杂网格不适合强行摊成 2D UV,并画出八叉树叶节点与表面相交区域的关系
  • 能修改 Octree Texture Lab 的深度、表面覆盖率和画笔半径,预测 lookup steps、LOD 与局部更新量的变化
  • 能回答:什么时候保留八叉树纹理,什么时候把它转换为带过滤的标准 2D 纹理

先别急着把模型摊平

想象你要给一只皱褶很多的模型贴纸。把它压成一张平面纸,贴纸会被拉伸,还必须在某些地方剪开;而且模型表面的大部分空间其实并没有贴到东西。

本章解决的问题是:能不能让每个表面点直接在包围模型的空间里找到自己的颜色?如果没有这套方法,复杂模型会被迫维护接缝密集的平面坐标,过滤和表面模拟也容易把“空白区域”当成真实数据。

不摊平网格,也能在表面存纹理传统 2D 参数化摊平后会有 seam / distortion顶点需要额外携带 (u, v)改变存储坐标octree texture只在表面附近的体素叶节点存颜色用 mesh 的 (x, y, z) 直接查找

1. 从 UV 平面转向体积中的表面采样

传统纹理把网格顶点映射到 (u, v) 平面;复杂模型的弯折、孔洞和接缝会让这个映射变得难以维护。octree texture 反过来把纹理放进包围物体的立方体,只在体积与表面相交的区域保存颜色,因此不需要为每个顶点设计一套摊平规则。

这不是把整个 256³ 体积都填满:八叉树会保留空区域为叶节点,只继续细分碰到表面的节点。结果是采样网格仍然规则,表面颜色不会因为 UV 拉伸而变形。GpuGems2Ch37ParameterizationCompareDiagram 展示了“摊平”与“按三维位置查找”的差别。

2. N³-tree:把空间分成可追踪的层级

八叉树的根节点是一个立方体;每次细分都把它变成 2 × 2 × 2 个孩子。孩子要么继续成为内部节点,要么成为叶节点。叶节点保存颜色,空节点直接标记为空;只有包住表面的节点才继续向下展开。

每深入一层,每个方向的分辨率乘 2,所以深度 D 的完整网格分辨率是 2^D。将每条边切成更大的 N 会减少树深,但表面附近会留下更多没有用的格子;这正是存储效率与查找深度之间的取舍。

octree 是 N³-tree 在 N = 2 时的特例root cube2 × 2 × 2 children绿色:表面相交,继续细分空区域:直接成为 leaf每层分辨率 × N深度与分支因子N = 2:树深,贴合表面N > 2:树浅,空格更多分辨率 N^D:深度 D 决定例如 256³ 需要 8 层八叉树

3. 把指针改成纹理中的索引

GPU 版本把 CPU 树中的指针换成 3D texture 里的索引。每个节点对应一个 N × N × N 的 indirection grid;一个 cell 的 RGB 可以是下一级 grid 的位置,也可以直接是叶节点颜色,alpha 则告诉 shader 应该继续查找还是停下。

fragment 在栅格化时已经获得未变换的表面坐标 M。它从根索引开始,根据 M 在当前节点内的相对位置计算 cell 坐标,再读一次 indirection pool。如果 alpha 表明它是 child,就把 RGB 解码成下一级索引;如果 alpha 表明它是 leaf,就返回颜色。老硬件不能保证动态循环,因此应以最大深度的固定循环为底线,支持动态分支时再提前退出。

fragment program:用一次次 indirection 走到叶节点mesh fragmentM(x,y,z)indirection poolRGB = child indexA = leaf / pointerrootI₀ = (0, 0, 0)grid 1grid 2leafalpha 表明“停”不做任意地址写入:每个 fragment 都在自己的位置读取并输出颜色硬件不支持动态分支时,用最大深度的固定循环兜底
vec4 lookupOctree(vec3 M, sampler3D indirectionPool) {
  vec3 localPoint = M;
  vec3 gridIndex = vec3(0.0);
  vec4 cell = vec4(0.0);
  for (int depth = 0; depth < MAX_DEPTH; depth++) {
    vec3 P = (localPoint + gridIndex) * INV_POOL_SIZE;
    cell = texture(indirectionPool, P);
    if (cell.a > 0.9) return cell;
    if (cell.a < 0.1) discard;
    gridIndex = floor(cell.rgb * 255.0 + 0.5);
    localPoint *= N;
  }
  return cell;
}

这段伪 GLSL 把“指针树”改写成一串 dependent lookup:gridIndex 不是 CPU 地址,而是下一轮查找要使用的纹理索引。discard 对应空 cell;固定的 MAX_DEPTH 让同一段代码可以在没有动态分支的设备上运行。

4. 过滤与 LOD:让结果在远处仍然稳定

八叉树查找得到的最近叶颜色还不等于高质量纹理。三维线性插值需要八个相邻样本;如果建树时只收录恰好穿过表面的点,缺少的邻居会让背景色渗入。解决办法是在建树时扩大用于相交测试的盒子,把过滤所需的邻居一起纳入。

过滤不是“免费”:三维插值需要八个邻居当前叶节点···?··?·缺邻居 → 背景色 bleed / aliasing建树时加 halo8-sample filter让所有需要的样本都成为叶节点牺牲一点存储,换稳定的插值结果

mipmap 的 finest level 是原始颜色叶节点;更粗的一层把同一父节点的孩子颜色合并成平均值。shader 根据屏幕导数选择一个合适的深度:近处深入树保留细节,远处在较浅层从 LOD pool 取平均颜色,避免单个叶样本造成 aliasing。

LOD pool:沿树向上合并颜色indirection treelevel 0 · leaveslevel 1 · parentslevel 2 · rootstop at chosen depthLOD poolparent average color近处:读深层,保细节远处:读浅层,防 aliasing导数决定过滤级别

5. 用同一棵树支持绘制与表面模拟

建树时先把模型缩放进 unit box,再从根开始检查每个子盒是否和多边形相交;到达目标深度就创建颜色叶,否则继续创建 child,未相交的格子直接标记为空。绘制时,画笔是沿表面移动的小球,只局部更新被覆盖的叶节点,并通过 3D texture 的局部上传减少带宽。

3D brush 只改动被触碰的叶节点Pcenter / Pradiusalpha 随距离衰减,颜色做加权混合partial updateRGBA 3D texture cells只上传亮起的 cells
void createNode(int depth, Polygons polygons, Box box) {
  for (Child child : childrenOf(box)) {
    Box childBox = computeSubBox(child, box);
    if (depth + 1 == paintingDepth) {
      setChildColor(child, white);
    } else if (intersects(childBox, polygons)) {
      createChild(child);
      createNode(depth + 1, polygons, childBox);
    } else {
      setChildAsEmpty(child);
    }
  }
}

这段建树逻辑的关键不是递归本身,而是“只为相交区域创建路径”。绘制时可以把 setChildColor 换成基于画笔距离的颜色混合,并只调用局部 texture 更新;显示复杂场景时,则可以在细节可见时使用八叉树,在远处切换到过滤好的 2D 表示。

第二种应用把八叉树当作表面地址层,而不是直接存液体密度。每个叶节点保存 density map 的索引,neighbor textures 把一个叶节点映射到空间邻居,再从 density map 读取邻居值,下一轮 GPU cellular automaton 只更新 2D 状态纹理。表面通常只需要约 9 个邻居,不必为完整 3D 的 26 个方向都固定一张纹理。

八叉树做地址层,density map 做状态层N1N2N3N4leaf L → index Ineighbor lookup2D texturesneighbor texture N(I)density map [N(I)]表面平均约 9 个邻居,不必固定 26 张纹理

三步验收:从坐标到可用的表面数据

分步1 / 3

第一步:建立只贴近表面的层级

把网格放进 unit box,从 root cube 开始做相交测试;空区域变成 leaf,表面附近的 child 才继续细分。用 N³-tree 的深度控制分辨率,用 Octree Texture Lab 观察表面覆盖率改变后占用 cell 的估计。

octree 是 N³-tree 在 N = 2 时的特例root cube2 × 2 × 2 children绿色:表面相交,继续细分空区域:直接成为 leaf每层分辨率 × N深度与分支因子N = 2:树深,贴合表面N > 2:树浅,空格更多分辨率 N^D:深度 D 决定例如 256³ 需要 8 层八叉树
不摊平网格,也能在表面存纹理传统 2D 参数化摊平后会有 seam / distortion顶点需要额外携带 (u, v)改变存储坐标octree texture只在表面附近的体素叶节点存颜色用 mesh 的 (x, y, z) 直接查找

先猜一猜:在下面的实验里把 tree depth 调大、把 surface coverage 调小,再从 lookup 切换到 liquid,哪些指标会分别变化?动手试时一次只改一个控件,观察 lookup steps、selected LOD、occupied cells 和 neighbor reads。

从 M 走到叶颜色indirection poolRGBA alpha → depth 2
max grid resolution32³
occupied cells (teaching estimate)342
lookup steps5
filter samples1
neighbor reads
selected LOD2

每个 fragment 沿树走到 leaf,再取回颜色。

本章小结

  • octree texture 用表面三维坐标替代复杂 UV 参数化。
  • N³-tree 只细分相交区域,深度决定分辨率与查找成本。
  • indirection pool 用 RGB 索引和 alpha 标志实现 GPU 树查找。
  • 过滤需要邻居补全,LOD pool 用父节点平均色抑制远处 aliasing。
  • 同一棵树既能支撑 3D 绘制,也能给表面模拟提供邻居地址。

练习

问题 1|画出一次 lookup。 给定深度为 3 的八叉树,fragment 的 M 落在根节点的第二个 child;该 child 的 alpha 表示仍是内部节点,RGB 解码出下一级索引。请写出 shader 接下来至少要保存的两个状态,并说明何时返回颜色。

问题 2|修改 Demo 代码。 为 Octree Texture Lab 增加一个 standard-2d 模式:把当前 LOD 的颜色画成一张平面图,并增加一个“边缘外推”开关。你要防止什么过滤伪影?

问题 3|做取舍。 一个近景角色经常被重新绘制,另一个远处物体只占屏幕很小区域。哪个更适合保留 octree texture,哪个更适合转换为标准 2D 纹理?请给出两个证据。

名词解释

本章出现的专业名词,用大白话再讲一遍。

octree texture
N³-tree
indirection pool
dependent texture lookup
LOD pool

资料与写作方式声明

本章以GPU Gems 2 · Chapter 37. Octree Textures on the GPU权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…