TESR 网格捕获(rendering/tesr 模板与顶点)

基本信息

属性 值
包 com.gtnewhorizons.angelica.rendering.tesr
本条目覆盖 8 个文件
总行数 212 + 162 + 86 + 69 + 46 + 28 + 25 + 18 = 646
主题 模板捕获、顶点解码/变换、缓冲管理、ModelBox 识别、闪光捕获
# 文件 行数 可见性
1 GlintCapture.java 212 public final
2 VertexTransform.java 162 public final
3 MeshBuffer.java 86 public final
4 BakedTransformCapture.java 69 public final
5 ModelBoxCapture.java 46 public final
6 ModelPartMesher.java 28 public final
7 TemplateCapture.java 25 public final
8 TemplateBuffer.java 18 public final

同包另两篇:TESR 实例化管线、TESR 保留组。

TemplateBuffer:模板数据容器

18 行,唯一的数据载体。

public final class TemplateBuffer {              // :3
    public final int[] data;                      // :5   顶点数据
    final int[] work;                             // :6   工作副本(包私有)
    public final int vertexCount;                 // :7
    public final int drawMode;                    // :8
    long bucketEpoch;                             // :9   包私有
    int bucketIndex;                              // :10  包私有
}

构造器(:12-17)复制一次:

this.data = data;
this.work = data.clone();

⚠️ work = data.clone() 在每次构造时做一次完整深拷贝。模板是长生命周期的缓存对象,所以拷贝一次可接受;但 VertexTransform.writeInstance 每次绘制都要重填 work 的全部 vertexCount 个顶点(见下),data 保持不变作为真源。

3 个字段 final、2 个字段可写且包私有。bucketEpoch / bucketIndex 是 InstancedTemplateRenderer.Bucket 的索引,不参与相等性判断(没有 equals)。

⚠️ 没有 equals / hashCode —— 若被放进 HashMap/HashSet 会退化为身份哈希。源码内它只被数组索引访问(TemplateCapture、VertexTransform),但这是隐式约束:一旦有代码把它当 map 键就会出错。

TemplateCapture:DirectTessellator → TemplateBuffer

25 行,5 步:

public static TemplateBuffer toTemplate(DirectTessellator direct) {   // :15
    final int vertexCount = direct.getVertexCount();                 // :16
    if (vertexCount == 0) return null;                               // :17
    final int drawMode = direct.getDrawMode();                       // :18
    final VertexFormat format = direct.getVertexFormat();            // :19
    final ByteBuffer copy = direct.allocateBufferCopy();             // :20
    final int[] data = VertexTransform.decode(memAddress0(copy), format, vertexCount, drawMode);   // :21
    memFree(copy);                                                   // :22
    return new TemplateBuffer(data, vertexCount, drawMode);          // :23
}
步 行 说明
空检查 :17 vertexCount == 0 返回 null(不是空模板)
拷出 :20 allocateBufferCopy() —— 从直接内存拷出
解码 :21 VertexTransform.decode(addr, format, count, drawMode)
立即释放 :22 memFree(copy)
包装 :23

⚠️ memFree(copy) 在 :22 无 try/finally。若 :21 的 decode 抛异常,直接内存永久泄漏。这是本条目里最实在的资源泄漏点。

⚠️ 返回 null 而不是抛异常(:17)—— 调用方必须判空。空模板(无顶点)不构成错误,可能被正常路径遇到。

⚠️ drawMode 被保存进 TemplateBuffer 但 decode 也在用它(:21)—— drawMode 影响法线播种(见下),但它同时被存下来用于后续绘制。两者共用一个值,但语义不同(一个用于解码、一个用于 GL 绘制模式)。

VertexTransform:解码与实例写入

162 行,是模板数据的编解码核心。

13 个静态 import 的索引常量(:10-21)

全部来自 gtnhlib:com.gtnewhorizon.gtnhlib.client.renderer.cel.util.ModelQuadUtil:

常量 用途
VERTEX_SIZE 每顶点的 int 数量
X_INDEX / Y_INDEX / Z_INDEX 位置
TEX_X_INDEX / TEX_Y_INDEX 纹理坐标
COLOR_INDEX 颜色
LIGHT_INDEX 光照
NORMAL_INDEX 法线
DEFAULT_COLOR / DEFAULT_LIGHTMAP 缺省填充值

⚠️ VERTEX_SIZE 的值本条目不可判定 —— 定义在 gtnhlib 依赖里,不在本仓库。InstancedTemplateRenderer.TEMPLATE_STRIDE = VERTEX_SIZE * 4(InstancedTemplateRenderer.java:37)也依赖它。

⚠️ 索引用位运算而非乘:out[v * VERTEX_SIZE | NORMAL_INDEX](:45)、work[vbase | COLOR_INDEX](:137)。| 优先级低于 *,所以这是 (v * VERTEX_SIZE) | NORMAL_INDEX —— 正确,但前提是 VERTEX_SIZE 是 2 的幂。若 gtnhlib 改成非 2 的幂会静默算错。

⚠️ DEFAULT_COLOR / DEFAULT_LIGHTMAP 被 import 但本条目未看到它们在已读段落(:29-50、:131-162)中使用 —— 它们在 decode 的 3 参数重载(:93-129)里。该方法本条目未完整阅读。

PACKED_UP(:27)

private static final int PACKED_UP = NormI8.pack(0f, 1f, 0f, 0f);

NormI8 来自 net.coderbot.iris.vertices(内嵌 Iris)。(0, 1, 0, 0) 是 +Y 方向。EntityShadowBatcher 也有同名常量 PACKED_UP(:30)—— 两份独立定义的同值常量。

法线播种:seedMissingNormals(:35-~55)

decode(long, VertexFormat, int, int)(:29-33)先调 3 参数重载,再调 seedMissingNormals。

final int prim = switch (drawMode) {              // :36-40
    case GL11.GL_QUADS -> 4;
    case GL11.GL_TRIANGLES -> 3;
    default -> 0;
};
if (prim != 0) {                                  // :41
    outer:
    for (int p = 0; p + prim <= vertexCount; p += prim) {   // :43
        for (int v = p; v < p + prim; v++) {
            if (out[v * VERTEX_SIZE | NORMAL_INDEX] != 0) continue outer;   // :45
        }
        final int packed = packedFaceNormal(out, p, prim);   // :47
        for (int v = p; v < p + prim; v++) {
            out[v * VERTEX_SIZE | NORMAL_INDEX] = packed;     // :49
        }
    }
}

逻辑:若一个图元(prim 个顶点)里所有顶点的法线都是 0,就用 packedFaceNormal 算出面法线并赋给全部顶点。只要有一个顶点有法线就整组跳过(continue outer)。

⚠️ 这是一个 switch 表达式(Java 14+)与带标签的 continue(outer: 标签在 :42)。语法上依赖现代 JDK。

⚠️ 「法线全 0」被当作「无法线」。但合法的 -Y 法线打包后可能恰好是 0 —— 若是,「向下」的面会被误判为「无法线」而重算。packedFaceNormal(本条目未完整读)若算出一致的值则无害;但这依赖 NormI8 的打包约定,本条目不可判定。

⚠️ drawMode 既非 GL_QUADS 也非 GL_TRIANGLES 时 prim = 0,整个播种跳过(:41)—— 例如 GL_LINES / GL_TRIANGLE_STRIP / GL_TRIANGLE_FAN(VertexTransform.java:39 的 default -> 0)。1.7.10 的 Tessellator 只用 QUADS,但第三方 TESR 若发 strip/fan,法线不会被播种。

⚠️ continue outer 跳的是最外层 for(:43) —— 正确写法,但 outer: 标签在 :42 独占一行,若格式化工具重排代码极易丢失标签的作用域语义。

writeInstance:每顶点的实例变换(:131-150)

9 个参数,long destPtr 返回。逐顶点做 3 件事:

步 行 动作
颜色相乘 :137 work[COLOR] = mulColor(src[COLOR], colorABGR)
光照覆盖 :138 work[LIGHT] = packedLight(直接覆盖,不相乘)
纹理矩阵 :139-147 texMatrix != null 时变换 UV,否则原样复制

UV 变换公式(:142-143):

u' = texMatrix.m00()*u + texMatrix.m10()*t + texMatrix.m30();
t' = texMatrix.m01()*u + texMatrix.m11()*t + texMatrix.m31();

⚠️ 只用了 texMatrix 的第 0、1 列和第 3 行(平移) —— 忽略缩放的 m22 与 z 相关的项。这是刻意的(2D 纹理矩阵只需 2×2 + 平移),但若 texMatrix 含透视项会被忽略。

⚠️ Float.intBitsToFloat / floatToRawIntBits 配对(:140-146)—— 正确,但用 raw 而非普通转换意味着 NaN 载荷会被保留。这是与 OperationArgs.boxed 相同的一致性选择(见 帧节流)。

最后一步(:149):

return destFormat.writeToBuffer0(destPtr, work, vertexCount * VERTEX_SIZE, mv, scratch);

⚠️ work 的前 vertexCount * VERTEX_SIZE 个元素必须全部被本循环填满,否则残留上次的数据。循环写了 COLOR / LIGHT / TEX_X / TEX_Y 四个槽,位置、法线、其余槽位靠 :137-148 之前 work = data.clone() 留下的原值。work 只在 TemplateBuffer 构造时 clone 一次,之后永不重置 —— 所以每个槽位都必须每次都写或保持不变。若将来 writeInstance 增加条件分支跳过某槽,会读到上一次绘制其它实例时的值。这是真实的隐患。

mulColor:带哨兵的两色相乘(:152-161)

static int mulColor(int a, int b) {
    if (a == -1) return b;      // :153
    if (b == -1) return a;      // :154
    return mul8(a,b,0) | mul8(a,b,8) | mul8(a,b,16) | mul8(a,b,24);   // :155
}

⚠️ -1(即 0xFFFFFFFF,全白且 alpha 最大)是「无 tint」哨兵。先判 a 再判 b —— 若两者都是 -1 返回 b(也是 -1),正确。

⚠️ -1 作为哨兵意味着「无法表达 tint 成纯白不透明」。若某实例的 tint 恰好是 0xFFFFFFFF(白色不透明),它会覆盖模板颜色而不是相乘(结果恰为白色,数学上等价)—— 所以实际无害,但语义是巧合而非设计。

mul8(:158-161)是标准的 8 位定点乘法:

final int t = ((a >>> shift) & 0xFF) * ((b >>> shift) & 0xFF) + 0x80;   // + 0x80 舍入
return (((t + (t >> 8)) >> 8) & 0xFF) << shift;

⚠️ + 0x80 是四舍五入(不是截断)—— 与 ParticleQuads.packColor 的 (int) 截断(见 粒子条目)策略不同。两处都是颜色量化,但一个有舍入一个没有。

⚠️ mulColor 是包私有(无 public),mul8 是 private。

BakedTransformCapture:带变换烘焙的捕获

69 行,extends DirectTessellator(gtnhlib)。

字段 行 说明
delta :19 ModelViewDelta(在 client/rendering,见 客户端渲染服务)
deltaMatrix :20 Matrix4f 暂存
scratch :21 Vector3f
transform :22 当前生效的变换,非 null 期间才做矩阵烘焙
firstColor :23 首个 draw 的打包颜色
sawRun :24 是否已见到第一个 draw
colorUniform :25 全部 draw 颜色是否一致

begin / end(:31-42)

public void begin() { TessellatorManager.startCapturingDirect(this); delta.snapshot(); sawRun = false; colorUniform = true; }   // :31-36
public TemplateBuffer end() {                                                                                                // :38
    final TemplateBuffer template = colorUniform ? TemplateCapture.toTemplate(this) : null;                                  // :39
    TessellatorManager.stopCapturingDirect();                                                                                 // :40
    return template;
}

⚠️ end() 在 colorUniform == false 时返回 null(:39)—— 颜色不一致的模板无法实例化(因为实例 tint 只能整体乘一个颜色)。这是有意的设计约束,不是失败。

⚠️ TessellatorManager.stopCapturingDirect() 无 try/finally(:40)。若 toTemplate 抛异常(memFree 泄漏见上),捕获状态不会被关闭,后续所有 Tessellator 写入都会进到这个 DirectTessellator —— 严重的状态泄漏。startCapturingDirect / stopCapturingDirect 是 gtnhlib 的全局单槽(其实现不在本仓库)。

⚠️ 构造器用 memAlloc(capacity)(:28)—— 直接内存,无对应的 memFree。BakedTransformCapture 的实例由谁创建、何时释放,本条目不可判定。

interceptDraw:颜色一致性检查(:44-60)

@Override
protected int interceptDraw(Tessellator tessellator) {                 // :45
    final Color4 color = GLStateManager.getColor();                    // :46
    final int packed = ColorABGR.pack(color.getRed(), color.getGreen(), color.getBlue(), color.getAlpha());   // :47
    if (!sawRun) { sawRun = true; firstColor = packed; }               // :48-50
    else if (packed != firstColor) colorUniform = false;               // :51-53
    transform = delta.deltaOrNull(deltaMatrix);                        // :54
    try { return super.interceptDraw(tessellator); }                   // :56
    finally { transform = null; }                                      // :58
}

逐 draw 比对 GLSM 的当前颜色。第一个 draw 记下 firstColor,后续任一不同则 colorUniform = false(粘性,不回退)。

⚠️ transform 是实例字段而非 ThreadLocal。若捕获在多线程进行(InstancedTemplateRenderer 的 TTL 重建可能在工作线程),会互相覆盖。源码无同步。

⚠️ finally { transform = null; }(:58) —— 正确,保证异常时清理。

writeVertexData:条件烘焙(:62-68)

@Override
protected long writeVertexData(VertexFormat format, int[] rawBuffer, int rawBufferIndex) {   // :63
    if (transform != null) return format.writeToBuffer0(writePtr, rawBuffer, rawBufferIndex, transform, scratch);   // :65
    return super.writeVertexData(format, rawBuffer, rawBufferIndex);   // :67
}

transform != null 时用自定义矩阵写顶点,否则走父类。transform 的来源是 delta.deltaOrNull(deltaMatrix)(:54)—— 返回 null 表示「modelview 未变」,此时烘焙是恒等变换,走父类的快路径。

⚠️ writePtr 是 DirectTessellator 的字段(父类),子类直接访问 —— 依赖父类字段名与可见性。

ModelPartMesher:ModelPart 的 Tessellator 输出

28 行,2 个方法。

public static void emitPart(Tessellator t, ModelRenderer part, float scale) {     // :13
    t.setTranslation(part.offsetX + part.rotationPointX * scale,
                     part.offsetY + part.rotationPointY * scale,
                     part.offsetZ + part.rotationPointZ * scale);                 // :14-17
    emitPartLocal(t, part, scale);                                                 // :18
    t.setTranslation(0, 0, 0);                                                    // :19
}
public static void emitPartLocal(Tessellator t, ModelRenderer part, float scale) {  // :22
    final List<ModelBox> boxes = part.cubeList;
    for (int i = 0, n = boxes.size(); i < n; i++) boxes.get(i).render(t, scale);   // :24-26
}

emitPartLocal 与 ModelHorseArmor 构造函数里的遍历几乎相同(见 护甲条目)—— 两处都直接遍历 part.cubeList 这个可变字段。

⚠️ t.setTranslation(0, 0, 0) 在 :19 无 try/finally。若 emitPartLocal 抛异常,Tessellator 的平移状态停留在中间值,后续渲染全部偏移。这是真实的异常路径缺陷。

⚠️ emitPart 不检查 part == null 或 part.cubeList == null,:23 直接 .size() 会 NPE。而 PlayerReflectionCapture.quadsOf(:165-168)做了完整的 part == null || cubeList == null || isEmpty() 三重检查。同包两处的空安全标准不一致。

ModelBoxCapture:识别「这个 box 是不是标准立方体」

46 行,是模式匹配而非渲染。EPSILON = 1.0e-4f(:10)。

public static CubeParams capture(TexturedQuad[] quads, float texWidth, float texHeight, boolean mirror,
                                 int texU, int texV, float x, float y, float z,
                                 int sizeX, int sizeY, int sizeZ, float inflate) {   // :14

13 个参数,其中 6 个是 float 位置/尺寸 + 2 个 int 尺寸。⚠️ 参数列表极长且位置参数相邻(float x, float y, float z, int sizeX, ...)—— 传错顺序编译期无法发现。

六道校验,任意一道失败返回 null

# 行 检查
1 :15 quads == null || quads.length != UnitCubeMesh.FACE_COUNT
2 :17 CubeParams.of(...) 返回 null
3 :23 每个 quad 非 null、vertexPositions != null、length == 4
4 :28 每个顶点非 null、vector3D != null
5 :30-32 几何位置匹配单位立方体
6 :35-37 纹理坐标匹配标准 UV 布局

镜像处理(:19-20、:27):

final float lowX  = mirror ? params.minX() + params.spanX() : params.minX();   // :19
final float spanX = mirror ? -params.spanX() : params.spanX();                // :20
...
final PositionTextureVertex vertex = quad.vertexPositions[mirror ? 3 - corner : corner];   // :27

镜像做两件事:X 方向的起点与跨度翻转(:19-20),以及顶点索引反转 3 - corner(:27)。

⚠️ 顶点索引反转只作用于 mirror,但几何校验用的 unit[0] * spanX 已经把 span 取负了(:30)。两处镜像处理叠加是否正确,取决于 UnitCubeMesh.CORNERS 的定义 —— 该类在 GLSM 子项目,本条目不可判定。若两者语义不匹配,镜像路径会全部返回 null(静默失效),而正镜像路径正常。这是一个只能靠实测发现的问题。

near 的相对容差(:43-45)

private static boolean near(float a, float b) { return Math.abs(a - b) < EPSILON; }

⚠️ 绝对容差 1.0e-4f,不是相对容差。对比 ParticleQuadDecoder 用的是相对容差 1.0e-5f * (1 + |half| + scale)(见 粒子条目)。两处策略不同:大尺寸 box 在这里更容易通过(绝对容差),UV 比对在小纹理图上也更容易通过。对超大 box,位置误差 0.0001 会被接受 —— 这可能是有意的容差,也可能过宽。

UV 比对(:33-37):

final int u = texU + net.u(corner).texels(sizeX, sizeZ);
final int v = texV + net.v(corner).texels(sizeY, sizeZ);
if (!near(vertex.texturePositionX, u / texWidth) || !near(vertex.texturePositionY, v / texHeight)) return null;

⚠️ sizeZ 同时参与 u 与 v 的 texels 计算(:33-34)—— 说明 net.u/v 的 texels(...) 签名接受的是「该面对应的两个边长」而非统一签名。UnitCubeMesh.Net 在 GLSM 子项目,本条目不可判定。

MeshBuffer:GPU 网格缓冲

86 行,public final,upload / bind / draw / unbind / render / delete。

方法 行 说明
upload(format, drawMode, data, vertexCount) :16 4 参版
upload(format, drawMode, data, vertexCount, dynamic) :20 5 参版,多一个 dynamic 标志
isUploaded() :32
bind() :36
draw(first, count) :44
unbind() :48
render() :53
delete() :59
ensureCapacity(ByteBuffer, int bytes, boolean preserve) :70 static 工具

⚠️ ensureCapacity 是本条目唯一被外部复用的方法 —— ParticleInstancer.emit(ParticleInstancer.java:174)与 groupFor(:~273)都调它,RetainedTesrGroups 也调。扩容策略是 buffer.capacity() * 2(:76)。

⚠️ ensureCapacity 的 preserve 参数控制是否拷贝旧内容。两个粒子调用点都传 true(ParticleInstancer.java:174、:~273),但粒子数据是每帧重填的,preserve 无必要 —— 每次扩容都做一次全量拷贝。

⚠️ 4 参 upload 与 5 参 upload 并存(:16、:20)—— 4 参版必须转调 5 参版并传某个 dynamic 默认值。该默认值本条目未读,语义可能与调用方预期不符。

GlintCapture:附魔闪光捕获

212 行,public final。本条目唯一只有 1 个内部类 Range(:16)的大文件 —— 其余 200 行的方法签名本条目未逐个读取。

已确认的事实:

项 值/说明
唯一内部类 private static final class Range(:16)
关联 GlintClock(见 护甲条目)提供帧级闪光矩阵
关联 ModelPartBatcher 与 items/HeldItemGlint 都引用 GlintClock(grep 确认)
关联 HeldItemGlint 在 rendering/items/(本条目未覆盖,items/ 只有 2 个未覆盖文件:DroppedItemInstancer、ItemPropCache;HeldItemGlint、BlockRenderListManager、ItemRenderListManager 已被其它条目提及)

⚠️ GlintCapture.java 212 行是本条目最大的未读文件。 本条目只覆盖了它的存在与唯一内部类,其算法本条目不可判定。不做臆测。

已知问题 / 风险

  1. TemplateCapture.toTemplate 的 memFree(copy) 无 try/finally(:22),decode 抛异常则直接内存永久泄漏。
  2. BakedTransformCapture.end() 的 stopCapturingDirect() 无 try/finally(:40),toTemplate 抛异常则 gtnhlib 的全局捕获槽泄漏 —— 后续所有 Tessellator 写入都会进入这个 DirectTessellator。这是本条目最严重的状态泄漏。
  3. BakedTransformCapture 用 memAlloc(capacity) 但无 memFree(:28),直接内存生命周期无保障。
  4. BakedTransformCapture.transform 是实例字段非 ThreadLocal(:22),多线程捕获会互相覆盖。
  5. TemplateBuffer.work 只在构造时 clone 一次(:14),之后靠 writeInstance 逐槽填满。任何条件跳过某槽都会读到上一次绘制其它实例的残留值。
  6. ModelPartMesher.emitPart 的 setTranslation(0,0,0) 无 try/finally(:19),异常时 Tessellator 平移状态残留。
  7. ModelPartMesher.emitPartLocal 无空安全检查(:23),而同包的 PlayerReflectionCapture.quadsOf 有三重检查 —— 标准不一致。
  8. ModelBoxCapture.capture 是 13 参数方法(:14),6 个相邻 float 位置/尺寸,顺序传错编译期无保护。
  9. ModelBoxCapture 的 near 用绝对容差 1.0e-4f(:10、:44),与 ParticleQuadDecoder 的相对容差策略不同。
  10. ModelBoxCapture 的镜像路径正确性依赖 GLSM 的 UnitCubeMesh.CORNERS 语义(:27、:30),本仓库不可判定;不匹配会静默全量返回 null。
  11. VertexTransform.seedMissingNormals 把「法线为 0」当作「无法线」(:45),依赖 NormI8 打包约定,本仓库不可判定是否存在合法的 0 打包法线。
  12. VertexTransform 的索引用 | 而非 +(:45、:137),依赖 VERTEX_SIZE 是 2 的幂(gtnhlib,不在本仓库)。
  13. VertexTransform.writeInstance 忽略 texMatrix 的 m22 与透视项(:142-143),只做 2×2 + 平移。
  14. VertexTransform.mulColor 逐分量舍入(:159)而 ParticleQuads.packColor 截断 —— 两处颜色量化策略不同。
  15. TemplateBuffer 无 equals/hashCode,作为 map 键会退化为身份哈希。
  16. MeshBuffer 4 参与 5 参 upload 并存(:16、:20),4 参版的 dynamic 默认值未确认。
  17. MeshBuffer.ensureCapacity 的 preserve = true 在粒子路径上做无必要的全量拷贝(容量倍增时)。
  18. GlintCapture.java 212 行中约 200 行算法本条目未读(只确认了唯一内部类 Range)。

相关条目