掉落物品实例化(rendering/items)
基本信息
| 属性 | 值 |
|---|---|
| 包 | com.gtnewhorizons.angelica.rendering.items |
| 本条目覆盖 | 2 个文件(目录共 5 个;HeldItemGlint、BlockRenderListManager、ItemRenderListManager 已被其它条目提及) |
| 总行数 | 355 + 113 = 468 |
| 依赖 | AngelicaTesrMeshCache(见 Celeritas)、GlintCapture(见 TESR 网格捕获)、AngelicaConfig |
| # | 文件 | 行数 | 可见性 |
|---|---|---|---|
| 1 | DroppedItemInstancer.java |
355 | public final(全静态) |
| 2 | ItemPropCache.java |
113 | public final class(泛型) |
ItemPropCache:泛型的 LRU + 过期缓存
113 行,public final class ItemPropCache<V>。这是本条目理解 DroppedItemInstancer 的前提,先读它。
一个可注入的过期时钟(:75-78)
private static int elapsedTicks() {
return Minecraft.getMinecraft().thePlayer.ticksExisted; // :77
}
⚠️ elapsedTicks() 读 Minecraft.getMinecraft().thePlayer.ticksExisted —— thePlayer 在主菜单 / 加载界面时为 null → NPE。⚠️ ItemPropCache 的所有 get / insert / clear 都调 elapsedTicks()(:35、:47、:64),在无玩家时全部 NPE。这是本条目最实在的崩溃点。
⚠️ ticksExisted 是玩家的 tick 计数,不是世界时间 —— 维度切换 / 死亡重生时不重置,但换存档会。不是单调时间源(在「重生」时可能回退?ticksExisted 在同一玩家对象上单调,换世界则为新玩家对象的 0)。
⚠️ elapsedTicks() 是 static 但返回 per-player 的值 —— 若 ItemPropCache 有多个实例(icons 是唯一的,见下),共享同一时钟。
5 个字段(:8-12)
| 字段 | 类型 | 行 |
|---|---|---|
EXPIRY_TICKS |
1_200 |
:6(private static final) |
cache |
Object2ObjectLinkedOpenHashMap<ItemProp, Slot<V>> |
:8(初值 64) |
key |
ItemProp |
:9 |
capacity |
IntSupplier |
:10 |
factory |
Supplier<V> |
:11 |
disposer |
Consumer<V> |
:12 |
smallestExpiry |
int |
:13 |
⚠️ capacity 是 IntSupplier(不是 int)(:10)—— 容量可在运行期变化。DroppedItemInstancer 传的是 () -> AngelicaConfig.itemRendererCacheSize(:30)—— 即用户配置。改配置立即生效(下一次 insert 时 capacity.getAsInt() 读到新值)。
⚠️ key 是复用的可变键对象(:9)—— 每次 get / insert 都 key.set(...) 覆写。⚠️ get 用 cache.getAndMoveToLast(key)(:32)—— 哈希查找用当前 key 的值,查找本身不存入 key。⚠️ insert 在满时做 oldest.set(key); cache.put(oldest, slot)(:48-50)—— 把最旧的 ItemProp 对象改成新值再放回,这是「复用键对象避免分配」的手法,也是 get 之后 key 若被外部持有会出错的隐患(本条目未见外部持有)。
Slot 与 ItemProp:两个私有嵌套类(:79-...)
private static final class Slot<V> { // :79
private V value; // :80
private int expiry; // :81
}
@NoArgsConstructor @Data // :84-85 Lombok!
static final class ItemProp { // :86
private float minU, minV, maxU, maxV; // :87-90 (推断)
...
}
⚠️ ItemProp 用 Lombok 的 @Data + @NoArgsConstructor(:84-85)—— Lombok 是本仓的编译期依赖。⚠️ @Data 生成 equals / hashCode / toString / getter / setter。⚠️ ItemProp 是包私有(static final class 无 public) 但被 DroppedItemInstancer 用(:41-42 new ItemPropCache.ItemProp())—— 同包。
⚠️ @Data 生成的 equals / hashCode 基于全部 float 字段 —— 浮点精确相等。key.set(minU, ...) 若每次算出略微不同的 float(如经过不同计算路径),缓存永远 miss。⚠️ get 的 7 个参数(:29 minU, minV, maxU, maxV, widthSubdivisions, heightSubdivisions, thickness)有 5 个是 float、2 个是 int —— 混合类型,float 相等的严格性是缓存命中率的关键。
get 的三个早退与过期回收(:29-43)
key.set(minU, minV, maxU, maxV, widthSubdivisions, heightSubdivisions, thickness); // :30
if (cache.isEmpty()) return null; // :31
final Slot<V> slot = cache.getAndMoveToLast(key); // :32
if (slot == null) return null; // :33
final int time = elapsedTicks(); // :35
slot.expiry = time + EXPIRY_TICKS; // :36
if (time > smallestExpiry && time > (smallestExpiry = cache.get(cache.firstKey()).expiry + 20)) { // :37
dispose(cache.removeFirst()); // :38
if (!cache.isEmpty()) { smallestExpiry = cache.get(cache.firstKey()).expiry + 20; } // :39-41
}
return slot.value; // :42
5 个要点:
| # | 行 | 语义 |
|---|---|---|
| 1 | :31 |
空表 → null(不调 elapsedTicks(),避免了无玩家时的 NPE) |
| 2 | :32 |
getAndMoveToLast —— LRU 命中即移到末尾 |
| 3 | :36 |
每次命中都续期 EXPIRY_TICKS = 1200 tick = 60 秒(20 TPS) |
| 4 | :37 |
每次 get 最多回收 1 个条目(不是循环回收) |
| 5 | :38、:70 |
dispose 释放 |
⚠️ 第 4 点是最重要的设计取舍:get 里只回收一个,若缓存里堆积了大量过期条目,需要多次 get 调用才能清空。⚠️ 若某个过期条目长期排在 firstKey 但 time <= smallestExpiry,回收不会触发 —— smallestExpiry 是第一个条目的 expiry + 20(:37、:40),即只有「最早的条目确实过期超过 20 tick」才回收。若最早的条目刚被续期(:36),后续过期条目不会被回收(因为 smallestExpiry 会被抬高到第一个条目的新 expiry)。⚠️ 这是「按 insertion/访问顺序的近似 LRU + 时间双条件」的组合,回收时机依赖访问模式。
⚠️ + 20 的魔数(:37、:40)—— 20 tick = 1 秒。无注释说明「为什么是 20」。
⚠️ 返回 null 而非抛异常(:31、:33、:70)—— cache miss 是正常路径。
insert 的两分支(:45-60)
public V insert() {
final Slot<V> slot;
if (cache.size() >= capacity.getAsInt()) { // :47
final ItemProp oldest = cache.firstKey(); // :48
slot = cache.removeFirst(); // :49
oldest.set(key); // :50
cache.put(oldest, slot); // :51
} else {
slot = new Slot<>(); // :53
slot.value = factory.get(); // :54
cache.put(new ItemProp(key), slot); // :55
}
slot.expiry = elapsedTicks() + EXPIRY_TICKS; // :58
return slot.value; // :59
}
⚠️ 满时把最旧的 Slot 对象(含已建的 value)复用,只换键(:48-51)—— 不 dispose 旧的 value。⚠️ 这是有意的(value 是 IconMesh 等可复用对象),但与 get 的 dispose(cache.removeFirst())(:38)行为相反 —— 一个 dispose 一个不 dispose。
⚠️ ⚠️ 这是本条目最可疑的一处:insert 满时复用最旧槽位的 value 但不释放(若 disposer 非 null,它被跳过)。⚠️ DroppedItemInstancer 传的 disposer 是 null(:30 ..., IconMesh::new, null)—— 所以本场景无影响。但 API 契约不一致:clear()(:62-68)会 dispose 所有 value,而 insert 的驱逐不 dispose。若将来传入非 null disposer,会泄漏。
⚠️ 未满时才 new Slot + factory.get()(:53-54)—— 未驱逐的槽位会正确 dispose 吗:clear() 会(:63-65),get 的过期回收会(:38),insert 的驱逐不会。三处行为不一致。
⚠️ capacity.getAsInt() <= 0 时(配置为 0),cache.size() >= 0 恒真 → 永远走驱逐分支 → cache.firstKey() 在空表上抛 NoSuchElementException。⚠️ 容量 0 未被防御。(clear() 后 cache 空,若 insert 被调用则崩。)
clear 与 dispose(:62-73)
public void clear() { // :62
for (Slot<V> slot : cache.values()) { dispose(slot); } // :63-65
cache.clear(); // :66
smallestExpiry = 0; // :67
}
private void dispose(Slot<V> slot) { // :70
if (disposer != null && slot.value != null) { // :71
disposer.accept(slot.value); // :72
}
}
⚠️ clear() 遍历时调 dispose,若 disposer 回调里再调 clear() 或改 cache 会 CME(本条目未见此用法)。⚠️ dispose 的两重 null 检查(:71)—— disposer 与 slot.value 都可能 null(new Slot<>() 后 value 未 set 的路径,:53-54 是先 new 再 set,中间无窗口)。
DroppedItemInstancer:主控(全静态)
355 行,public final,私有构造器(:69 private DroppedItemInstancer() {})—— 纯静态类。
20 个静态字段(:28-69)
| 字段 | 值 | 行 |
|---|---|---|
CAPTURE_BYTES |
64 * 1024 = 64 KiB |
:28 |
icons |
new ItemPropCache<>(() -> AngelicaConfig.itemRendererCacheSize, IconMesh::new, **null**) |
:30 |
blocks |
Object2ObjectOpenHashMap<BlockMeta, BlockMesh> |
:31 |
blockKey |
new BlockMeta() |
:32 |
iconBackend |
new AngelicaTesrMeshCache.GtnhMeshBackend() |
:33 |
ICON_ARGS |
Object[8] |
:34 |
GLINT_ARGS |
Object[8] |
:35 |
BLOCK_ARGS |
Object[4] |
:36 |
blockBackend |
static BakedTransformCapture(非 final,可为 null) |
:37 |
basePart |
static boolean |
:38 |
glintCapture |
static GlintCapture |
:39 |
glintSeen |
static boolean |
:40 |
glintProperties |
new ItemPropCache.ItemProp() |
:41 |
currentGlintProperties |
new ItemPropCache.ItemProp() |
:42 |
glintTemplate |
static TemplateBuffer |
:43 |
glintTemplateWidth / glintTemplateHeight |
static int × 2 |
:44 |
instanced / glintInstanced / fallback |
static long × 3 |
:46-48 |
bails |
long[BailReason.VALUES.length] |
:67 |
⚠️ 11 个静态可变字段全部非 volatile(blockBackend、basePart、glintSeen、glintTemplate、glintTemplateWidth/Height、instanced、glintInstanced、fallback、bails)—— 渲染单线程假设。⚠️ 统计计数器 instanced / fallback / bails 被 statXxx() public 方法读取(:71-85)—— 若从 Tracy 线程读(TracyFramePlots 之类),无 happens-before。
⚠️ ICON_ARGS / GLINT_ARGS 各 8 个 Object,BLOCK_ARGS 只有 4 个(:34-36)—— 8 个 Object 参数的调用是反射调用(Method.invoke 装箱)。⚠️ Object[] 复用避免每次装箱 —— 但反射调用会写入返回值到 Object[0]。⚠️ 3 个数组都是 static final 且非 volatile —— 若 blockBackend 跨线程用,反射参数数组会竞争。本条目未读调用点(:90-355),不下结论。
BailReason:7 个降级原因(:50-57)
public enum BailReason {
INELIGIBLE("items.bail.ineligible"), // :51
NO_MATERIAL("items.bail.material"), // :52
ISBRH("items.bail.isbrh"), // :53
BLOCK_STATE("items.bail.blockState"), // :54
NOT_ALLOWED("items.bail.notAllowed"), // :55
TEMPLATE("items.bail.template"), // :56
QUEUE("items.bail.queue"); // :57
public static final BailReason[] VALUES = values(); // :59
public final String plotName; // :60
}
| # | 原因 | plot 名 | 语义(据名) |
|---|---|---|---|
| 1 | INELIGIBLE |
items.bail.ineligible |
不满足实例化条件 |
| 2 | NO_MATERIAL |
items.bail.material |
找不到材质 |
| 3 | ISBRH |
items.bail.isbrh |
该方块有 ISBRH(走自定义渲染) |
| 4 | BLOCK_STATE |
items.bail.blockState |
方块状态不支持 |
| 5 | NOT_ALLOWED |
items.bail.notAllowed |
明确禁止(配置) |
| 6 | TEMPLATE |
items.bail.template |
模板构建失败 |
| 7 | QUEUE |
items.bail.queue |
入队失败 |
⚠️ 7 个 plotName 全是 items.bail.* 前缀 —— 不是 . 结尾(对比 BailClassCounts 的 tesr.bailCls.mat.,见 FPS / 性能剖析)。⚠️ 两套 BailReason 的 plot 命名风格不同(items.bail.camelCase vs tesr.bailCls.snake_lowercase)。这是两套独立的降级统计体系(DroppedItemInstancer 的 7 个 + BailClassCounts 的 3 个实例)。
⚠️ VALUES = values()(:59)是枚举数组的克隆 —— 无防御性拷贝的隐患:VALUES 是 public static final 但数组内容可被外部改写。⚠️ 正确写法是 values().clone() 或私有。⚠️ bails 数组长度用 VALUES.length(:67) —— 若 VALUES 被外部改短,bails 仍按构造时长度分配,但 statBail 用 reason.ordinal() 索引(:84)—— ordinal 可能越界。这是真实的可被外部触发的数组越界。
⚠️ bail 方法是私有的(:88-90)—— 外部 mod 无法记录自己的降级原因。
4 个统计 getter(:71-85)
| 方法 | 行 | 返回 |
|---|---|---|
statInstanced() |
:71-73 |
instanced |
statGlintInstanced() |
:75-77 |
glintInstanced |
statFallback() |
:79-81 |
fallback |
statBail(reason) |
:83-85 |
bails[reason.ordinal()] |
⚠️ 3 个是全局累计计数(long,无重置方法) —— 进程生命周期累计。⚠️ 本条目未找到重置入口(clear() 在 IconMesh 侧还是本类侧,:90-355 未读)。无重置则 Tracy 曲线是单调的累计值(对比 AngelicaRenderQueue.recordFrameStats 是每帧的,见 Tessellator 条目)。
⚠️ ⚠️ statBail(reason) 接受任意 BailReason,用 reason.ordinal() 直接索引,无范围检查(:84)—— 传 null NPE。BailReason 只有 7 个值,调用方传不出越界的 ordinal(除非 VALUES 被篡改,见上)。实际风险低。
icons 的 disposer 传 null(:30)
⚠️ 这是本类与 ItemPropCache 的契约点:disposer = null 意味着**IconMesh 从不被释放**(clear() 时也不释放)。⚠️ 而 cache.put(new ItemProp(key), slot) 的驱逐路径也不释放(见 ItemPropCache.insert 分析)。⚠️ IconMesh 若持有 GL 资源(VBO 等),会泄漏。⚠️ IconMesh 的定义不在本条目覆盖的 2 个文件里(IconMesh 可能是 AngelicaTesrMeshCache 内的类,见 Celeritas)—— 是否持有 GL 资源本条目不可判定。
已知问题 / 风险
ItemPropCache.elapsedTicks()读Minecraft.getMinecraft().thePlayer.ticksExisted(:77)—— 主菜单 / 加载界面thePlayer为 null 时 NPE。get(:35)、insert(:58)、clear(无)都调它。这是最实在的崩溃点。ItemPropCache.insert的驱逐路径不调disposer(:48-51),而get的过期回收(:38)与clear()(:63-65)都调 —— 三处行为不一致。当前disposer = null无影响,但传非 null disposer 时会泄漏。ItemPropCache的capacity <= 0未防御(:47)—— 空表上firstKey()抛NoSuchElementException。ItemPropCache.get每次最多回收 1 个过期条目(:37-38),堆积的过期条目需多次调用才能清空;且smallestExpiry由第一个条目决定(:37、:40),第一个条目刚被续期会阻止后续回收。ItemPropCache的+ 20魔数无注释(:37、:40)。ItemPropCache.key是复用的可变键对象(:9),insert满时oldest.set(key)改写旧键(:50)—— 任何外部持有ItemProp的代码会看到值被改。ItemProp用 Lombok@Data生成 float 的精确equals/hashCode(:85-86)—— 浮点微小差异导致缓存永远 miss。BailReason.VALUES = values()是public无克隆的数组(:59)—— 外部可改写,进而使bails索引越界。DroppedItemInstancer的 11 个静态可变字段全部非 volatile(:37-67),但statXxx()是 public —— 跨线程读统计无 happens-before。icons的disposer = null(:30)+ItemPropCache三处不一致的 dispose →IconMesh可能永不释放。是否持有 GL 资源本条目未确认。3 个统计计数器是进程级累计且无重置入口可见(:71-81)—— Tracy 上是单调曲线,不是每帧值。ICON_ARGS/GLINT_ARGS/BLOCK_ARGS三个static final Object[]复用给反射调用(:34-36)—— 跨线程竞争会写坏参数。调用点本条目未读。DroppedItemInstancer.java355 行中本条目只读到第 90 行 —— 约 265 行(75%)未读。主渲染流程、glintCapture的使用、iconBackend的调用、blockBackend的创建时机本条目不可判定。这是本条目最大的缺口。ItemPropCache.ItemProp的 7 个字段只读到前 4 个(:87-90的minU/minV/maxU/maxV),widthSubdivisions/heightSubdivisions/thickness三个字段的声明行未读(@Data生成的方法名未核实)。
相关条目
- TESR 网格捕获 -
GlintCapture/TemplateBuffer/BakedTransformCapture的定义 - Celeritas(内嵌地形渲染引擎) -
AngelicaTesrMeshCache/GtnhMeshBackend/IconMesh的来源 - FPS / 性能剖析 -
BailClassCounts是另一套降级统计 - 护甲 / 闪光 / 玩家反射 -
GlintClock的闪光矩阵来源 - Iris / 着色器兼容桥 -
ShaderGlint是另一套闪光机制 - 配置与模块开关 -
AngelicaConfig.itemRendererCacheSize的配置项 - TESR 实例化管线 -
ModelPartBatcher的调用方