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)三段逻辑:
- 已登记 → 直接返回(
:19-20) - 未登记且
nextIndex >= MAX_PASSES→ 抛IllegalStateException - 否则分配
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 集合在运行时动态变化(资源包重载)的话,旧名字的索引不会释放。
已知问题 / 风险
ModelQuadFacing不在本仓库。ModelQuadFacing.COUNT/ModelQuadFacing.ALL标为未解析。FACINGS_PER_SECTION = 7(GpuDrivenChunkCuller)与FACING_COUNT = 7(SectionMetaBuffer)是否与上游一致本仓库无法验证。- 两份同值常量无编译期关联:
FACINGS_PER_SECTION(私有/包私有)与FACING_COUNT(public)。不同步修改会静默错位。 FRUSTUM_UBO_BINDING = 1与META_SSBO_BINDING = 1数值撞车,语义完全不同,极易误改。patchBypassFrustum与writeStd140在同一字节上竞争(CONTROL_OFFSET + 8),调用顺序敏感且无约束。FrustumExtractor.writePlane对零法向量无防护,退化的 MVP 会写入NaN/±Inf且不报错。appendSection静默截断facingMask到 8 位(:213),而outputBase溢出有显式检查 —— 同一函数里两种错误处理强度不一致。PassState.stampint 溢出的补救会误判世代 1 的残留值(:79-82),理论缺陷。PassState.releaseRanges()在 region 遍历中调用会导致 region 静默丢失(:100-103),无日志。GpuCulledMultiDrawBatch.appendDrawCommand/mergeIntoLastCommand抛UnsupportedOperationException(:26、:31),是上游 API 升级时的首要破坏点,且失败点在渲染循环内。ensureReady()失败静默跳过剔除(dispatchPreparedPasses内),而 UBO 未设置会LOG.warn—— 两种失败的可诊断性不一致。- **
GPU SSBO 无显式删除路径**。visibleSsboGlId/indirectSsboGlId随实例走,activeInstance` 是静态单例。 SectionMetaBuffer的 32 字节 post 区间(OFFSET_POSTS = 16到OFFSET_FIRST_INDEX_BASE = 48)布局在本仓库不可判定 —— 写入发生在上游 sink 内。SectionMetaBuffer.INITIAL_CAPACITY = 16384个 section(× 64 字节 = 1 MiB)无配置项。
相关条目
- AngelicaModulesConfig - GPU 剔除的开关字段
- Celeritas(内嵌地形渲染引擎) -
AngelicaRenderPassConfiguration、TerrainDrawStats、AngelicaChunkRenderer是本目录的调用方 - Iris(内嵌) -
net.coderbot.iris.pipeline.ShadowRenderer(GpuTerrainCuller.java:11) - Subprojects(内嵌子项目) - GLSM 的
GLStateManager/BackendManager来源 - Tessellator / 线程世界访问 / 渲染队列 - 同属
rendering/ - FPS / 性能剖析 - 剔除耗时的观测点