Tessellator / 线程世界访问 / 渲染队列

基本信息

属性 值
包 com.gtnewhorizons.angelica.rendering
本条目覆盖 7 个文件
总行数 215 + 120 + 61 + 83 + 81 + 30 + 25 = 615
主题 顶点状态旁路、工作线程世界访问防护、渲染崩溃复位、掉落实体元数据覆写、实体叠加层延迟、跨线程渲染任务队列

这 7 个文件是 Angelica 渲染热路径上的旁路设施 —— 它们不改变渲染结果,只在原版渲染流程旁边收集额外状态、做越界防护、或把工作挪出主线程。

# 文件 行数 可见性 类别
1 DeferredEntityOverlay.java 215 public 延迟绘制
2 FallingBlockRendering.java 61 public final Iris 属性覆写
3 WorkerWorldAccess.java 120 public final 越界诊断
4 FallingBlockMetaAccess.java 83 public final IBlockAccess 装饰器
5 AngelicaRenderQueue.java 81 public 跨线程任务队列
6 RenderThreadContext.java 30 public ThreadLocal<WorldSlice>
7 StateAwareTessellator.java 25 public interface 对外 API

StateAwareTessellator:本条目里唯一的对外 API

它明确声明自己不是 API

/// Despite not in the `api` package, this interface is used by GT5uNH and GTNHLib, possibly others. Be careful what you
/// change!          // :3-4
@SuppressWarnings("unused")
public interface StateAwareTessellator {   // :6

这是本仓少见的、由作者自己写下的兼容性警告。 该接口在 rendering/ 包(不在 api/),但已被 GT5uNH 和 GTNHLib 使用。改签名即破坏外部 mod。

见 API 层 的对照 —— 那里是「正式 API 包」,这里是「事实上被外部依赖的接口」。

位标志与访问器

成员 值 / 签名 行
RENDERED_WITH_VANILLA_AO 0x1 :9
NO_DIRECTIONAL_SHADING 0x2 :10
angelica$setAppliedAo(boolean) :12
angelica$setNoDirectionalShading(boolean) :13
angelica$setCeleritasMeshing(boolean) :18
angelica$getVertexStates() int[] :20
angelica$getShaderOverrideBlockIds() int[] :22
angelica$setShaderOverrideBlockId(short) :24

两个常量是位掩码(可叠加进同一个 int):第 0 位「顶点来自带 enableAO 标志的 RenderBlocks 调用」,第 1 位「无方向光照」。

angelica$ 前缀是 Angelica 对 mixin accessor 统一采用的命名约定 —— 明确标识「这是注入方法,不是原版 API」。

angelica$setCeleritasMeshing(true) 会开启逐顶点 AO 状态收集到 angelica$getVertexStates()(注释 :15-18)。这是有代价的 —— 打开就多一个 int 数组写。celeritas 地形网格化时需要,实体渲染时不需要。

实现方与消费方

角色 文件
实现(mixin) mixins/early/rendering/MixinTessellator.java
设置 AO/方向光照 mixins/early/rendering/MixinRenderBlocks.java
Celeritas 侧 rendering/celeritas/AngelicaChunkBuildContext.java、rendering/celeritas/AngelicaChunkBuilderMeshingTask.java
内嵌 Iris 侧 net/coderbot/iris/Iris.java

⚠️ net/coderbot/iris/Iris.java 也实现/消费这个接口 —— 这再次说明 StateAwareTessellator 是跨 Angelica 与内嵌 Iris 的共享契约,改动需两边同步。

WorkerWorldAccess:工作线程越界诊断

存在理由

celeritas 把区块网格化搬到工作线程。ISBRH(ISimpleBlockRenderingHandler)实现常常假定自己在主线程、直接摸 World。一旦越界访问:

  • 读越界 → 读到别的区块的数据,网格化结果错乱但不崩
  • 写越界 → 污染真实世界数据(不可逆)

这个类把两种情况都变成可上报的日志。它不拦截、不阻断,只诊断。

两个入口(:35-43)

方法 行 级别 文案
readOutsideSlice(worldMethod, x, y, z, renderingBlock) :35-38 LOGGER.warn read outside the chunk slice
blockedWrite(worldMethod, x, y, z, renderingBlock) :40-43 LOGGER.error ignored, it would corrupt world data

⚠️ blockedWrite 的文案说 “ignored”(已忽略),但这个类里没有任何实际拦截逻辑 —— 它只打日志。真正的拦截在别处(celeritas 的 WorldSlice 层)。文案承诺的东西不在这个文件里,容易误读为「已处理」。

两条日志都传 new Throwable()(:37、:42)带完整调用栈。

每 renderType 只报一次

LoggedRenderTypeErrors(:96-119)是内部私有类,按 block.getRenderType() 去重:

boolean shouldLog(Block block) {
    final int renderType = block == null ? -1 : block.getRenderType();
    final int slot = renderType < 0 ? 0 : renderType + 1;
    final boolean[] current = this.logged;
    return (slot >= current.length || !current[slot]) && markLogged(slot);
}
项 值 行
初始容量 INITIAL_SLOTS = 64 :26
扩容策略 max(slot + 1, length * 2) :114
线程安全 logged 字段 volatile;markLogged synchronized :98、:107

READS 与 WRITES 是两个独立实例(:30-31),所以同一个 renderType 可以各报一次读、一次写。

⚠️ markLogged 是 synchronized 但 shouldLog 先做了无锁的 !current[slot] 预判。预判可以重复通过(两个线程同时通过),但 markLogged 只有一个返回 true,去重语义仍然正确。logged 用 volatile 引用 + 整体替换数组(copy-on-grow)保证可见性。这是正确的无锁读 + 锁写模式。

⚠️ block == null 时 renderType = -1 → slot = 0,与 renderType 为 -1 的方块共用去重槽。实际 1.7.10 的 renderType 从 0 开始,故 slot = renderType + 1 恒 ≥ 1,slot 0 只被 null 用,不冲突。

报告 URL 与 ISBRH 溯源

项 值 行
REPORT_URL https://github.com/GTNewHorizons/angelica/issues :25
StringBuilder 初始容量 224 :46

describe(:45-58)拼出的信息包含:调用的 World 方法名、越界坐标、方块注册名、ISBRH 实现类全名、以及提供该 ISBRH 的 modId。

modIdOf(:77-87)的实现方式值得单独说:它取 clazz.getProtectionDomain().getCodeSource().getLocation(),转成 File jar 路径,再遍历 Loader.instance().getModList() 逐个比对 mod.getSource()。

⚠️ 这是 O(mod 数量) 的线性扫描,且每次都做(虽然被 shouldLog 的去重压住了 —— 每 renderType 只做一次)。且 jarFileUrl(:89-94)手工处理 jar:file:...!/ 协议的 !/ 切分。

三个 catch (Throwable ignored)(:64、:72、:85)吞掉一切异常 —— 溯源失败不影响诊断主流程。设计合理,但也意味着溯源字段静默缺失。

RenderThreadContext:Worker 的 ThreadLocal

30 行,一个 ThreadLocal<WorldSlice>:

private static final ThreadLocal<WorldSlice> currentWorldSlice = new ThreadLocal<>();   // :6
方法 行 语义
set(WorldSlice) :8-10 绑定
clear() :12-14 remove() 而非 set(null) —— 避免 WorldSlice 泄漏在 map 里
get() :16-18 无条件取
workerSlice() :20-23 主线程返回 null,工作线程才返回 slice
hasWorldSlice() :25-27 get() != null

workerSlice() 是最常被调用的方法(MixinWorldClient_WorkerAccess.java:30),它用 TessellatorManager.isOnMainThread() 判线程 —— 主线程永远拿 null,这样主线程路径会走原版的 World 而不是 slice,语义正确。

⚠️ clear() 没有 finally 保护。工作线程若在 set() 之后抛异常,ThreadLocal 里残留 WorldSlice,该 slice 及其引用的区块数据被线程池线程长期持有。线程池线程不死,泄漏就不释放。ConcurrentTileEntityMapTest 里有多处显式 RenderThreadContext.clear()(:28、:76、:85),说明调用方是知道这个风险的 —— 但生产路径的清理保证无法从本文件确认。

使用方(全部在 src/mixin/java):

使用者 用途
mixins/early/celeritas/terrain/MixinChunk.java:75 get() 取 slice
mixins/early/celeritas/terrain/MixinChunk.java:86 hasWorldSlice() 快速判
mixins/early/celeritas/terrain/MixinChunk.java:106 hasWorldSlice() 分支
mixins/early/celeritas/terrain/MixinWorldClient_WorkerAccess.java:30 workerSlice()

FallingBlockMetaAccess:元数据装饰器

implements IBlockAccess(:10),把「这一格的实际方块 metadata」覆写进一个委托的 IBlockAccess。

唯一的覆写(:31-37):

@Override
public int getBlockMetadata(int x, int y, int z) {
    if (x == this.x && y == this.y && z == this.z) return this.metadata;
    return delegate.getBlockMetadata(x, y, z);
}

其余 8 个方法全是直通委托:getBlock、getTileEntity、getLightBrightnessForSkyBlocks、isBlockProvidingPowerTo、isAirBlock、getBiomeGenForCoords、getHeight、extendedLevelsInChunkCache、isSideSolid(:39-82)。

set(...) 返回 this(:18-25)以便链式,clear() 只把 delegate 置 null(:27-29)—— x/y/z/metadata 不清,且 clear() 后任何非 metadata 的调用都会 NPE(delegate 为 null)。

⚠️ 单例复用。FallingBlockRendering 持有 private static final FallingBlockMetaAccess META_ACCESS(FallingBlockRendering.java:19)并在 metaAccess(...)(:24-26)里 set(...) 后返回。这是全局可变单例 —— 若两个掉落实体嵌套渲染(FallenBlock 渲染时触发其它渲染),内层 set 会覆盖外层的 delegate/坐标/元数据。源码里没有重入保护。

⚠️ delegate 字段非 volatile 且无同步。若渲染跨线程,字段可见性无保证。

用途:1.7.10 的 FallingBlock 实体携带 metadata 但世界里的方块可能已经变了。掉落实体渲染需要看到它自己携带的那个 metadata,而不是当前世界里的。所以覆写单点读。

FallingBlockRendering:Iris 顶点属性覆写

方法 行 动作
setEntityAttribute(Block, int) :28-33 记 recordBlockEntityAttribute(block, metadata),着色器启用时发 glVertexAttrib2s(MC_ENTITY, blockMaterialId, 0)
resetEntityAttribute() :35-40 记 (null, 0),发 glVertexAttrib2s(MC_ENTITY, -1, -1)
isActive() :50-52 active && shadersActive()
shadersActive() :54-56 TessellatorManager.isOnMainThread() && IrisApi.getInstance().isShaderPackInUse()
skipDirectionalShading() :58-60 isActive() && BlockRenderingSettings.INSTANCE.shouldDisableDirectionalShading()
metaAccess(world, x, y, z, metadata) :24-26 返回全局单例 META_ACCESS.set(...)

active 是 public static boolean(:17)—— 可写字段,isActive() 读它。

blockMaterialId(:42-48)向 内嵌 Iris 的 BlockRenderingSettings.INSTANCE.getBlockMetaMatches() 查 Block → Int2IntMap 两级映射,再 BlockMaterialMapping.resolveId(metaMap, metadata)。任一环为 null 返回 -1。

⚠️ setEntityAttribute 与 resetEntityAttribute 不成对时会残留属性。set 用 (materialId, 0),reset 用 (-1, -1)。若 set 之后抛异常没走到 reset,MC_ENTITY 顶点属性会一直是那个方块的 id,影响后续所有实体渲染。runUnrecorded 的 lambda 里没有 try/finally。

依赖来源:net.coderbot.iris.* 4 个类 + net.irisshaders.iris.api.v0.IrisApi 1 个 —— 全部是内嵌 Iris 上游,本文件是 Angelica 原创的适配层。

DeferredEntityOverlay:把实体叠加层推迟到全部实体之后

问题(源码注释 :18-26)

Defers entity overlay rendering (auras, armor effects, etc.) to after all entities have been drawn. This lets us fix z-fighting between overlay’s own coplanar faces by disabling the depth mask, and ensures the overlay composites correctly on top of all opaque geometry. Used by both the charged creeper aura and the Wither armor overlay. The deferred render replays shouldRenderPass on the original renderer instance so that any mod mixins targeting that method still fire during the deferred pass.

两个用途:充能爬行者光球、凋灵护甲叠加层。

生命周期:标记 → 捕获 → 回放

markOverlayPass(fn, renderer, entity, partialTick)   // shouldRenderPass HEAD 注入
        ↓
deferRender(limbSwing, ..., scale)                   // doRender 里 renderPassModel.render() 的 WrapOperation
        ↓
renderAll()                                          // 全部实体画完后
方法 行 作用
markOverlayPass :60-67 置 overlayPassActive = true,存 4 个 pending 字段
clearStaleOverlayFlag :69-71 置 overlayPassActive = false
deferRender :77-94 置 overlayPassActive = false,填 DeferredEntry,glGetFloat(GL_MODELVIEW_MATRIX) 存 16 float,入队,清 3 个 pending 引用
clear() :114-118 recycle() + 清两个标志
renderAll() :120-134 遍历回放,try/finally 保证 GbufferPrograms.endEntities()

⚠️ markOverlayPass 的注释(:55-59)明确说明存在「陈旧标记」问题:若 shouldRenderPass 返回 ≤ 0 则 render() 根本没被调用,pending* 不会被消费。这正是 clearStaleOverlayFlag() 存在的原因 —— 每个 shouldRenderPass 注入点都必须配一个清理调用,否则下一次 markOverlayPass 会带着上一次的 pending 上下文。这是跨文件隐式契约:markOverlayPass 与 clearStaleOverlayFlag 必须成对。

renderAll 的三段 Iris 状态管理

GbufferPrograms.beginEntities();                                 // :123
try {
    for (...) {
        CapturedRenderingState.INSTANCE.setCurrentEntityAndItem(EntityIdHelper.getEntityId(entry.entity), 0);  // :126
        renderOverlay(entry);
    }
} finally {
    CapturedRenderingState.INSTANCE.setCurrentEntityAndItem(-1, 0);   // :130
    GbufferPrograms.endEntities();                                   // :131
}

finally 是正确写法 —— renderOverlay 抛异常不会把 Iris 的 entities 作用域泄漏出去。复位值 -1 与 CapturedRenderingState 的「无实体」约定一致。

⚠️ 但 recycle()(:133)在 try/finally 之外,不在 finally 里。若循环中抛异常,recycle() 不执行,deferred 里的 DeferredEntry 不会被归还到 pool,下一帧 acquireEntry() 会全部 new(:96-102)。不是泄漏(deferred 会被下次 clear() 清),但池永久性失效。这与 RenderRecovery 里 13 步无 finally 的问题同类。

renderOverlay 的状态保存/恢复(:136-184)

try/finally 包裹,finally 里恢复 3 件事:

# 行 恢复动作 条件
1 :178-179 glDepthMask(true) 仅当本帧真的关过(depthMaskDisabled)
2 :180 glEnable(GL_CULL_FACE) 同上
3 :182 glPopMatrix() 无条件

glEnable(GL_CULL_FACE) 与 glDepthMask(true) 绑定在同一个 if 里(:178-181)。它们在 :159/:163 确实是成对设置与清除的,所以逻辑正确 —— 但这是耦合而非独立追踪。若将来只关 depthMask 不关 culling,这里会错误地重开 culling。

回放的两段(:154-175):

段 行 内容
pass 1 :155-172 replaying = true → 调 fn.invoke(entity, 1, partialTick) → 若 result > 0 则关 depthMask + 关 culling + setLivingAnimations + render(...)
pass 2 :175 fn.invoke(entity, 2, partialTick)

⚠️ replaying 标志在 pass 1 前置 true、pass 2 后才在 finally 里置 false(:155、:177)。它用于防止回放时再次 deferRender(注释 :45-46)。但 pass 2 在 replaying == true 期间执行,若 pass 2 里 mixin 又触发 deferRender,会被 replaying 拦下。这个标志的正确性依赖两个 mixin 注入点都检查它 —— MixinRenderCreeper_AuraDepth、MixinRenderWither_ArmorCentering、MixinRendererLivingEntity_DeferredEntityOverlay 三个 mixin 都要检查,本文件无法确认。

对象池

字段 行 说明
INITIAL_CAPACITY :35 4
deferred :36 活跃队列,ArrayList<>(4)
pool :37 空闲池,ArrayList<>(4)
MATRIX_BUF :39 static final FloatBuffer,16 float

acquireEntry()(:96-102)从 pool 尾部 remove(size-1)(后进先出,利于缓存局部性)。recycle()(:104-112)只清 3 个引用字段(shouldRenderPass / renderer / entity),不清 6 个 float 和 matrix 数组 —— 因为 matrix 是 final float[16](:187)无法替换,而 6 个 float 会被下次 set 覆写。

⚠️ 但 recycle() 不清 matrix。若条目被归还后 deferred 里仍可读到 matrix(不该发生,recycle 里 deferred.clear()),或若 deferRender 在 acquireEntry 后抛异常(glGetFloat 之前),该条目带着上次的矩阵内容入队。

AngelicaRenderQueue:跨线程渲染任务队列

81 行。一个 ConcurrentLinkedQueue<Runnable> + 主线程执行器。

项 值 行
TASKS ConcurrentLinkedQueue<Runnable> :13
lastFrameTasksRan volatile int :16
lastFrameTimeNs volatile long :17
lastFrameLongestTaskNs volatile long :18
WAIT_TIME TimeUnit.MILLISECONDS.toNanos(20) = 20 ms :72

主线程直通(:42-49)

private static final Executor EXECUTOR = (runnable) -> {
    if (GLStateManager.isMainThread()) { runnable.run(); }
    else { TASKS.add(runnable); LockSupport.unpark(GLStateManager.getMainThread()); }
};

主线程提交立即同步执行,不进队列。这保证主线程调用 executor().execute(...) 的语义与 Runnable::run 一致。

⚠️ 主线程直通路径没有 try/catch,任务异常会直接冒泡出 Executor.execute(该方法签名不声明受检异常,受检异常只能在 lambda 里包成 unchecked)。

managedBlock(:74-80)

public static void managedBlock(BooleanSupplier isDone) {
    while (!isDone.getAsBoolean()) {
        if (AngelicaRenderQueue.processTasks(1) == 0) LockSupport.parkNanos(WAIT_TIME);
    }
}

在等待异步任务完成的同时泵渲染队列,避免死锁。每轮只跑 1 个任务(processTasks(1)),保证公平。

⚠️ parkNanos(20ms) 的粒度很粗。若 isDone 在两次 park 之间变为 true,最多浪费 20 ms。且 parkNanos 不检查中断(LockSupport.park 返回后不抛异常),循环条件只靠 isDone —— 没有中断退出路径。若 isDone 永远为 false(例如 Future 因异常永不完成),此方法永久占用调用线程。

唯一的生产调用点在内嵌 Sodium 里:net/coderbot/…/me/jellysquid/mods/sodium/common/util/collections/FutureDequeDrain.java:35 AngelicaRenderQueue.managedBlock(future::isDone)。⚠️ 这说明 Angelica 的类被内嵌上游 Sodium 直接调用 —— 改 managedBlock 签名会破坏 Sodium。

帧统计

方法 行 语义
getQueueDepth() :20-22 TASKS.size() —— ConcurrentLinkedQueue.size() 是 O(n),且是弱一致的
recordFrameStats(tasksRan, timeNs, longestTaskNs) :36-40 由消费方写入
processTasks(int max) :60-70 最多跑 max 个,返回实际跑了几个

getQueueDepth() 在热路径(Tracy 绘图)被调用:profiling/TracyFramePlots.java:272、:280。每帧对无界 ConcurrentLinkedQueue 做 O(n) 遍历,队列长了会成为可观开销。这是真实缺陷。

调用点:

位置 调用
mixins/early/celeritas/terrain/MixinRenderGlobal.java:310 processTasks(1)
mixins/early/celeritas/terrain/MixinRenderGlobal.java:322 recordFrameStats(...)
profiling/TracyFramePlots.java:272-274、:280-281 4 个 getter 绘图
me/jellysquid/mods/sodium/.../FutureDequeDrain.java:35 managedBlock

已知问题 / 风险

  1. StateAwareTessellator 事实上是公开 API(源码 :3-4 自述被 GT5uNH + GTNHLib 使用),但既不在 api/ 包也无版本化,且 @SuppressWarnings("unused")(:5)表明编译器认为它没人用 —— IDE 会把它标灰并建议删除。这是最容易造成误删的接口。
  2. FallingBlockMetaAccess 是全局可变单例(FallingBlockRendering.java:19)且无重入保护,嵌套渲染会互相覆盖。clear() 后 delegate 为 null 但坐标字段残留,任何非 metadata 调用 NPE。
  3. FallingBlockRendering.setEntityAttribute / resetEntityAttribute 必须成对,异常路径无 try/finally,MC_ENTITY 顶点属性会残留。
  4. AngelicaRenderQueue.getQueueDepth() 是 O(n),且被 Tracy 每帧调用(TracyFramePlots.java:272、:280)。
  5. AngelicaRenderQueue.managedBlock 无中断退出路径(:74-80),isDone 永不成立即永久占用线程。
  6. RenderThreadContext.clear() 无 finally 保护,工作线程异常会残留 WorldSlice 引用直到线程池销毁。
  7. DeferredEntityOverlay.recycle() 不在 finally 里(:133),回放抛异常后对象池永久失效(每帧 new)。
  8. markOverlayPass / clearStaleOverlayFlag 必须成对调用,本文件无法验证 3 个注入点是否都做到了。
  9. WorkerWorldAccess.blockedWrite 的日志文案说 “ignored” 但本文件无拦截逻辑,实际拦截在 celeritas 的 WorldSlice 层 —— 单看本文件会误判。
  10. WorkerWorldAccess.modIdOf 每次遍历 Loader.getModList()(:82),靠 shouldLog 的按 renderType 去重压住成本。
  11. PlayerReflectionCapture / AngelicaRenderQueue 都被内嵌上游直接调用,不是 Angelica 单方面能改的契约。

相关条目