GPU 视锥剔除(rendering/culling)

基本信息

属性 值
包 com.gtnewhorizons.angelica.rendering.culling
本条目覆盖 6 个文件(目录共 7 个,GpuCulling.java 已被 AngelicaModulesConfig 提及)
总行数 674 + 166 + 122 + 77 + 46 + 28 = 1113
GL 特性要求 compute shader(GL43 SSBO + GL40 indirect draw)
计算着色器资源 angelica:culling/chunk_cull.csh(GpuDrivenChunkCuller.java:22)
# 文件 行数 可见性 角色
1 GpuTerrainCuller.java 674 public final 编排:遍历区块、把 section 元数据打包、上传 SSBO、派发 compute、切 indirect
2 SectionMetaBuffer.java 166 public CPU 侧 section 元数据表(slot 分配 + 查找)
3 GpuDrivenChunkCuller.java 122 public compute program 加载 / SSBO 绑定 / 派发
4 FrustumExtractor.java 77 public final 手写 std140 UBO 布局(视锥平面 + 控制字)
5 GpuCulledMultiDrawBatch.java 46 public final 继承 embeddium MultiDrawBatch,把绘制转成 GPU 间接绘制
6 RenderPassIndex.java 28 public final 按 pass 名字分配稳定索引

⚠️ 本目录大量 import org.embeddedt.embeddium.impl.*(14 个类)。这是内嵌 Embeddium/Sodium 上游,不是 Angelica 自己的代码 —— 本目录是 Angelica 对上游渲染 API 的适配与扩展层。读本目录时必须区分:*Impl 的行为由上游决定,本目录只负责喂数据。

关键常量(源码实值)

GpuDrivenChunkCuller(:21-33)

常量 值 行 说明
VISIBLE_ENTRY_BYTES 8 :24 每可见 section 一个 uvec2(源码注释)
INDIRECT_COMMAND_BYTES 20 :25 count, instCount, firstIdx, baseVtx, baseInst(源码注释)
FACINGS_PER_SECTION 7 :26 包私有
LOCAL_SIZE_X 64 :27 compute workgroup 宽度
INITIAL_META_SECTIONS 4096 :28 私有
VISIBLE_SSBO_BINDING 0 :30
META_SSBO_BINDING 1 :31
INDIRECT_SSBO_BINDING 2 :32
FRUSTUM_UBO_BINDING 1 :33

⚠️ FACINGS_PER_SECTION = 7 的「7 从哪来」在本仓库不可判定。:598 另用 embeddium 的 ModelQuadFacing.COUNT 作循环上界,而 ModelQuadFacing 在本仓库没有源码(org/embeddedt/embeddium/impl/ 只在 src/test 下有测试替身)。因此 ModelQuadFacing.COUNT 标为未解析,FACINGS_PER_SECTION = 7 与它是否相等本仓库无法验证。不要断言「7 = 6 面 + 1」。

⚠️ FRUSTUM_UBO_BINDING = 1 与 META_SSBO_BINDING = 1 数值相同。二者是不同 binding point(UBO vs SSBO),共用 index 1 在 GL 里合法,但极易误读。

INDIRECT_COMMAND_BYTES = 20 恰好是标准 DrawElementsIndirectCommand 结构体大小(5 个 4 字节字段),与 GL 规范一致,不是自定格式。

FrustumExtractor(:10-17)

常量 值 行
UBO_SIZE_BYTES 160 :10
PLANE_COUNT 6 :11
PLANE_STRIDE 16 :13
CONTROL_OFFSET 96 :14
CAMERA_WORLD_OFFSET 112 :15
BATCH_OFFSET 128 :16
PYR_OFFSET 144 :17

布局完全自洽,可验算:

0    .. 96   6 个视锥平面 × PLANE_STRIDE(16) = 96 字节
96   .. 112  CONTROL      (4 × int)
112  .. 128  CAMERA_WORLD (3 × float + 1 填充)
128  .. 144  BATCH        (1 × int + 12 填充)
144  .. 160  PYR          (PYR_OFFSET + 8, +12 两处写 float)
= 160 = UBO_SIZE_BYTES

每个区块起点都是 16 的倍数 —— 符合 std140 对齐规则,这是硬编码布局能安全工作的前提。

⚠️ CAMERA_WORLD_OFFSET 只写 3 个 float(+0/+4/+8,:65-67),第 4 个 float(+12)从不写;BATCH_OFFSET 只写 1 个 int(:42、:46),其余 12 字节从不写。未写的字节是 allocateDirect 的零(:22 ByteBuffer.allocateDirect 保证清零),但复用同一个 buffer 时会保留上次的值。patchBatchEntryBase(:45-47)在复用路径上只覆盖前 4 字节,:42 的 out.putInt(BATCH_OFFSET, 0) 只在 writeStd140 里出现。

SectionMetaBuffer(:13-20)

常量 值 行
INITIAL_CAPACITY 16384 :13
BYTES_PER_SECTION 64 :14
FACING_COUNT 7 :15
POST_COUNT FACING_COUNT + 1 = 8 :16
OFFSET_SLICE_MASK 12 :18
OFFSET_POSTS 16 :19
OFFSET_FIRST_INDEX_BASE 48 :20

SectionMetaBuffer.FACING_COUNT = 7 与 GpuDrivenChunkCuller.FACINGS_PER_SECTION = 7 是两份独立定义的同值常量。二者没有任何编译期关联(一个在本类、一个在上游适配类),不同步修改不会报错,只会静默错位。

POST_COUNT = FACING_COUNT + 1(:16)—— 7 个面 + 1 个额外的「全部/未剔除」条目。这是「7 里的第 8 槽」的来源,OFFSET_POSTS = 16 处放 8 个 post 的数据(7 × 2 字节 用 14,OFFSET_POSTS + 14 处 2 字节是 padding,正好填到 28)。

⚠️ OFFSET_FIRST_INDEX_BASE = 48 与 POST_COUNT 的关系未在源码中体现。48 - 16 = 32 字节留给 8 个 post(每 post 4 字节),但源码里看不到这个 4 字节步长的写入代码。该区间的实际布局在本仓库不可判定(写入发生在 uploadSectionMetaSink 指向的上游 sink 里)。

RenderPassIndex(:8-11)

常量 值 行
MAX_PASSES 8 :8

FrustumExtractor:手写视锥平面提取

6 个平面的来源

writeStd140(:25-43)从 MVP 矩阵直接推出 6 个 Gribb-Hartmann 半空间:

平面 行 公式
left :31 r3 + r0
right :32 r3 - r0
bottom :33 r3 + r1
top :34 r3 - r1
near :35 r3 + r2
far :36 r3 - r2

其中 r0 = 第 0 行 (m00, m10, m20, m30),r1 = 第 1 行,r2 = 第 2 行,r3 = 第 3 行(:26-29)。

⚠️ 注意 JOML 的行列约定:r0x = mvp.m00()、r0y = mvp.m10()、r0z = mvp.m20()、r0w = mvp.m30() —— 这是取第 0 列(GLSL 记法)但源码变量名叫 r0x,容易误读成「第 0 行」。变量名的 r 指 row,但取的是 column。

writePlane(:70-76)把 4 分量除以 sqrt(a²+b²+c²)(只算 xyz 的长度,不含 d)做归一化:

final float invLen = 1.0f / (float) Math.sqrt(a * a + b * b + c * c);

d 分量也乘同一个 invLen(:75),这是正确的(等比缩放整个方程)。

⚠️ 法向量为全零时 invLen = Infinity,写入的是 NaN/±Inf。退化 MVP(如正交投影 + 零缩放)会触发。源码无防护。

patch 系列

方法 行 写什么
patchBatchEntryBase(entryBase, out) :45-47 BATCH_OFFSET 的 int
patchPrimitiveRatio(vPerPrim, ePerPrim, out) :50-53 PYR_OFFSET + 8 与 +12 两个 float
patchControl(visibleCount, indexPointerMask, out) :55-58 CONTROL_OFFSET + 0 与 +4
patchBypassFrustum(bypass, out) :60-62 CONTROL_OFFSET + 8(bypass ? 1 : 0)
patchCameraWorld(x, y, z, out) :64-68 CAMERA_WORLD_OFFSET + 0/+4/+8

patchPrimitiveRatio 的注释(:49)说明了它用 float 存小整数的原因:

Small exact integers, so the float round trip through the UBO is lossless.

即「值足够小,float 往返无损」—— 因为该字段在着色器侧被声明为 float。

⚠️ patchBypassFrustum 写在 CONTROL_OFFSET + 8,而 writeStd140 的 :40 会写 0 覆盖它。若先 writeStd140 再 patchBypassFrustum(true),顺序正确;反之会被静默清掉。调用顺序敏感,源码无约束。

GpuTerrainCuller:编排与四个 PassState

四个 PassState 槽位(:106-109)

private final PassState primaryPass  = new PassState();
private final PassState secondPass   = new PassState();
private final PassState sortedPass   = new PassState();
private PassState buildPass = primaryPass;
private PassState current   = primaryPass;
槽 用途 触发条件
primaryPass 主地形 pass beginRenderPass 默认(:335-341)
secondPass 第二个 pass beginCombinedPasses / startSecondPass(:174-184),对应 AngelicaRenderPassConfiguration.CUTOUT_MIPPED_PASS(:322)
sortedPass 半透明 pass startSortedPass(:351-357),对应 AngelicaRenderPassConfiguration.TRANSLUCENT_PASS(:323)
buildPass 当前正在填充的那个 随 pass 切换指向上面三者之一

⚠️ PassState.regionRanges 初值是 EMPTY_RANGES = new long[0](:58、:69),只有 putRange 首次触发才扩容(:85-93),且下限 max(..., 64)(:87)。releaseRanges()(:100-103)会把数组换回静态空数组 —— 若在遍历 region 时被调用,getRange 全部返回 -1L,region 静默丢失。

PassState 的 stamp 世代机制

void reset(int mask, int base) {   // :72-83
    ...
    stamp++;
    if (stamp == 0) { Arrays.fill(regionStamps, 0); stamp = 1; }
}

stamp 是世代号,用于避免每帧清空 regionStamps 数组。getRange(:95-98):

if (regionId >= regionStamps.length || regionStamps[regionId] != stamp) return -1L;

⚠️ stamp 是 int,会溢出。溢出回绕时 :79-82 用 Arrays.fill(regionStamps, 0) 补救并置 stamp = 1。若此时某个 region 的真实世代恰好是 1,会被误判为「本世代有值」而读到上上轮的残留 range。 实际触发需要 2³² 次 pass reset,理论缺陷。

appendSection 的位打包(:205-216)

if ((outputBase & ~0x00FFFFFF) != 0) throw new IllegalStateException("packed outputBase overflow: ...");
visibleStaging.putInt(off + 0, slot);
visibleStaging.putInt(off + 4, (outputBase << 8) | (facingMask & 0xFF));

每个可见 section 写 8 字节 = 2 个 int,与 VISIBLE_ENTRY_BYTES = 8 一致:

int 低位 高位
off + 0 slot(32 位全用) —
off + 4 facingMask & 0xFF(8 位) outputBase << 8(24 位)

⚠️ outputBase 被限制到 24 位(0x00FFFFFF),超出即抛 IllegalStateException。这是显式的容量上限检查,有诊断信息(含实际值与上限),是本目录里错误处理做得最好的地方。

⚠️ facingMask 被截到 8 位。ModelQuadFacing.ALL 若超出 8 位,高位被静默丢弃且无日志。

recordRegion 的 range 打包(:217-222)

buildPass.putRange(region.getId(), (((long) drawStart) << 32) | (drawCount & 0xFFFFFFFFL));

drawStart 用高 32 位,drawCount 用低 32 位,各不损失精度(都是非负 int)。maxElementCount 取全程最大值(:220)。

prepareRegion(:224-233)反向解包,读不到时(packed < 0L)把 currentDrawStart / currentDrawCount 清零 —— 「本 region 本 pass 无内容」与「起点 0 长度 0」在下游表现相同,这是安全的。

缓冲区容量:三个不同的倍增下限

缓冲 扩容策略 行
GPU SSBO(ensureGpuBuffers) max(maxVisible, max(gpuCapacity * 2, 64)) :294(源文件)
CPU staging(ensureStagingCapacity) max(needed, max(stagingCapacity * 2, 64)) :301(源文件)
PassState.regionRanges max(regionId + 1, max(len * 2, 64)) :87

三处都用了 64 作为初始下限,但 GPU SSBO 的下限容量是条目数,staging 的下限是 64 * ENTRY_BYTES 字节。ensureStagingCapacity(:301-315)在扩容时 memFree 旧块(:308),ensureGpuBuffers 只重发 glBufferData(GPU 侧由驱动回收)。

dispatchPreparedPasses 的两道提前返回(:251-256 等)

if (!computeActiveThisPass || totalAppendedEntries == 0) return;
if (!needsDispatch(primaryPass) && (!secondPrepared || !needsDispatch(secondPass))
    && (!translucentReady || !needsDispatch(sortedPass))) return;
if (!culler.ensureReady()) return;
if (frustumUboBytes == null) { LOG.warn("GpuTerrainCuller: frustum UBO not set before dispatch; skipping cull"); return; }

第三行是 LOG.warn —— 这是本目录唯一的诊断输出。第四个提前返回(ensureReady() 失败)完全静默。

dispatchPreparedPasses 的 try/finally 包住 BackendManager.RENDER_BACKEND.beginComputeDispatchBatch() / endComputeDispatchBatch()(:266-273)—— 写对了。dispatchPass(:281-288)对每个 pass 做:patch UBO → culler.uploadFrustum → culler.dispatch → r.dispatched = true。

⚠️ r.dispatched = true 设在最后(:287)。若 culler.dispatch 抛异常,dispatched 保持 false,endPass 不会清它,下一帧 needsDispatch 仍为 true 会重试 —— 这实际是好事(幂等重试),但也意味着异常时同一 pass 会被反复 dispatch。

endPass 清理(:239-249)

if (indirectBufferBound) { GLStateManager.glBindBuffer(GL40.GL_DRAW_INDIRECT_BUFFER, 0); indirectBufferBound = false; }
currentDrawStart = 0; currentDrawCount = 0;
if (current == secondPass) secondPrepared = false;
else if (current == primaryPass) primaryReady = false;
else if (current == sortedPass) translucentReady = false;

只解绑 indirect buffer,不删除 SSBO(visibleSsboGlId / indirectSsboGlId 是实例字段,随 GpuTerrainCuller 实例生命周期)。activeInstance(:119)静态单例的实例泄漏时这些 GL 对象永不删除。

GpuCulledMultiDrawBatch:把上游 API 变成间接绘制

46 行,extends org.embeddedt.embeddium.impl.gl.device.MultiDrawBatch。

5 个 override 里 3 个是故意抛异常或空实现:

方法 行 行为
appendDrawCommand(baseVertex, elementCount, elementPointer) :24-27 throw new UnsupportedOperationException("GPU-culled batches build their commands on the GPU")
mergeIntoLastCommand(additionalElementCount) :29-32 同上抛
upload(CommandList) :34-36 空实现(命令在 GPU 上生成,无 CPU 侧数据可上传)
delete() :38-40 空实现(不持有 CPU 侧资源)
execute(commandList, tessellation, primitiveType) :42-45 culler.drawRegionRange(commandList, tessellation, primitiveType, drawStart, size)

⚠️ appendDrawCommand / mergeIntoLastCommand 抛的是 UnsupportedOperationException(unchecked)。若上游 BatchAssembler 在某条路径上仍会调它们,会在渲染循环里炸。上游版本升级时这是首要的破坏点 —— 因为这两个方法是上游 API 的一部分,实现方无法预知调用时序。

prepare(drawStart, drawCount, maxElementCount)(:18-22)直接写上游的 protected 字段 this.size 与 this.maxElementCount。这依赖上游字段名与可见性 —— 跨包访问上游实现细节,上游改名即编译失败(好过静默错误)。

RenderPassIndex:稳定 pass 索引

28 行,类注释(:6)说得很清楚:> Stable index assignment for TerrainRenderPass instances. Keyed by pass name.

项 值/行
MAX_PASSES 8(:8)
INDICES Object2IntOpenHashMap<String>(fastutil,:10)
defaultReturnValue -1(:11,静态块)
indexOf(pass) synchronized(:17)

indexOf(:17-27)三段逻辑:

  1. 已登记 → 直接返回(:19-20)
  2. 未登记且 nextIndex >= MAX_PASSES → 抛 IllegalStateException
  3. 否则分配 nextIndex++ 并 put(:24-26)

溢出时的异常消息给出了修复指引(:22):

RenderPassIndex: more than 8 distinct TerrainRenderPass names; bump MAX_PASSES and the pass-bit budget in SectionMetaBuffer.sectionKey. Registered: …

⚠️ 这是本目录唯一一处显式指出「改一处不够,还要改另一处」的注释。SectionMetaBuffer.sectionKey(passIndex, x, y, z)(:34)把 passIndex 打包进 64 位 key,pass 位数有预算上限。sectionKey 的具体位分配在私有方法里(:34-47),本条目未展开 —— 它是 private,只能从调用点间接推断。

⚠️ 按 pass 名字(String)而非实例做键。同名但语义不同的 pass 会共用索引。synchronized 保护了 INDICES 与 nextIndex,但 nextIndex 只增不减,进程生命周期内不会回收。pass 集合在运行时动态变化(资源包重载)的话,旧名字的索引不会释放。

已知问题 / 风险

  1. ModelQuadFacing 不在本仓库。ModelQuadFacing.COUNT / ModelQuadFacing.ALL 标为未解析。FACINGS_PER_SECTION = 7(GpuDrivenChunkCuller)与 FACING_COUNT = 7(SectionMetaBuffer)是否与上游一致本仓库无法验证。
  2. 两份同值常量无编译期关联:FACINGS_PER_SECTION(私有/包私有)与 FACING_COUNT(public)。不同步修改会静默错位。
  3. FRUSTUM_UBO_BINDING = 1 与 META_SSBO_BINDING = 1 数值撞车,语义完全不同,极易误改。
  4. patchBypassFrustum 与 writeStd140 在同一字节上竞争(CONTROL_OFFSET + 8),调用顺序敏感且无约束。
  5. FrustumExtractor.writePlane 对零法向量无防护,退化的 MVP 会写入 NaN/±Inf 且不报错。
  6. appendSection 静默截断 facingMask 到 8 位(:213),而 outputBase 溢出有显式检查 —— 同一函数里两种错误处理强度不一致。
  7. PassState.stamp int 溢出的补救会误判世代 1 的残留值(:79-82),理论缺陷。
  8. PassState.releaseRanges() 在 region 遍历中调用会导致 region 静默丢失(:100-103),无日志。
  9. GpuCulledMultiDrawBatch.appendDrawCommand / mergeIntoLastCommand 抛 UnsupportedOperationException(:26、:31),是上游 API 升级时的首要破坏点,且失败点在渲染循环内。
  10. ensureReady() 失败静默跳过剔除(dispatchPreparedPasses 内),而 UBO 未设置会 LOG.warn —— 两种失败的可诊断性不一致。
  11. **GPU SSBO 无显式删除路径**。visibleSsboGlId/indirectSsboGlId 随实例走,activeInstance` 是静态单例。
  12. SectionMetaBuffer 的 32 字节 post 区间(OFFSET_POSTS = 16 到 OFFSET_FIRST_INDEX_BASE = 48)布局在本仓库不可判定 —— 写入发生在上游 sink 内。
  13. SectionMetaBuffer.INITIAL_CAPACITY = 16384 个 section(× 64 字节 = 1 MiB)无配置项。

相关条目