掉落物品实例化(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 资源本条目不可判定。

已知问题 / 风险

  1. ItemPropCache.elapsedTicks() 读 Minecraft.getMinecraft().thePlayer.ticksExisted(:77)—— 主菜单 / 加载界面 thePlayer 为 null 时 NPE。 get(:35)、insert(:58)、clear(无)都调它。这是最实在的崩溃点。
  2. ItemPropCache.insert 的驱逐路径不调 disposer(:48-51),而 get 的过期回收(:38)与 clear()(:63-65)都调 —— 三处行为不一致。当前 disposer = null 无影响,但传非 null disposer 时会泄漏。
  3. ItemPropCache 的 capacity <= 0 未防御(:47)—— 空表上 firstKey() 抛 NoSuchElementException。
  4. ItemPropCache.get 每次最多回收 1 个过期条目(:37-38),堆积的过期条目需多次调用才能清空;且 smallestExpiry 由第一个条目决定(:37、:40),第一个条目刚被续期会阻止后续回收。
  5. ItemPropCache 的 + 20 魔数无注释(:37、:40)。
  6. ItemPropCache.key 是复用的可变键对象(:9),insert 满时 oldest.set(key) 改写旧键(:50)—— 任何外部持有 ItemProp 的代码会看到值被改。
  7. ItemProp 用 Lombok @Data 生成 float 的精确 equals/hashCode(:85-86)—— 浮点微小差异导致缓存永远 miss。
  8. BailReason.VALUES = values() 是 public 无克隆的数组(:59)—— 外部可改写,进而使 bails 索引越界。
  9. DroppedItemInstancer 的 11 个静态可变字段全部非 volatile(:37-67),但 statXxx() 是 public —— 跨线程读统计无 happens-before。
  10. icons 的 disposer = null(:30)+ ItemPropCache 三处不一致的 dispose → IconMesh 可能永不释放。是否持有 GL 资源本条目未确认。
  11. 3 个统计计数器是进程级累计且无重置入口可见(:71-81)—— Tracy 上是单调曲线,不是每帧值。
  12. ICON_ARGS / GLINT_ARGS / BLOCK_ARGS 三个 static final Object[] 复用给反射调用(:34-36)—— 跨线程竞争会写坏参数。调用点本条目未读。
  13. DroppedItemInstancer.java 355 行中本条目只读到第 90 行 —— 约 265 行(75%)未读。主渲染流程、glintCapture 的使用、iconBackend 的调用、blockBackend 的创建时机本条目不可判定。这是本条目最大的缺口。
  14. ItemPropCache.ItemProp 的 7 个字段只读到前 4 个(:87-90 的 minU/minV/maxU/maxV),widthSubdivisions / heightSubdivisions / thickness 三个字段的声明行未读(@Data 生成的方法名未核实)。

相关条目