粒子实例化(rendering/particles + ParticleRunSplitter)

基本信息

属性 值
包 com.gtnewhorizons.angelica.rendering.particles(11 个)+ rendering.ParticleRunSplitter(1 个)
本条目覆盖 12 个文件
总行数 396 + 231 + 135 + 80 + 68 + 54 + 50 + 46 + 15 + 12 + 8 + 67 = 1162
上游依赖 org.embeddedt.embeddium.impl.*、net.coderbot.iris.*、GLSM ffp.ParticleQuadMesh
第三方粒子 mod Biomes O’ Plenty(1 个描述符)
# 文件 行数 可见性 角色
1 ParticleInstancer.java 396 public final 总控:分层、分组、实例属性写入、instanced draw
2 ParticleCaptureTessellator.java 231 public final Tessellator 子类,拦截原版粒子的顶点写入
3 VanillaParticleDescriptors.java 135 public final 19 个原版粒子的描述符
4 ParticleDescriptorRegistry.java 80 public final 类 → 描述符的解析与缓存
5 BopParticleDescriptors.java 68 public final Biomes O’ Plenty 的 1 个描述符
6 ParticleRenderState.java 54 public final GL 状态快照(7 字段)
7 ParticleQuadDecoder.java 50 public final 从捕获的顶点反解出 quad 参数
8 ParticleQuads.java 46 public final 原版 quad 计算 + ABGR 打包
9 ParticleParams.java 15 public final 10 字段的输出结构体
10 ParticleCulling.java 12 public final 单点视锥可见性
11 ParticleDescriptor.java 8 public interface 函数式接口(1 方法)
12 rendering/ParticleRunSplitter.java 67 public final 半透明粒子换 pass 分割

核心思路:两条路径

ParticleInstancer.renderParticle(:119-127)是唯一入口,按描述符类型分两条路:

descriptor != CAPTURE  →  直写(direct)    描述符直接算出 quad 参数
descriptor == CAPTURE  →  捕获再反解(captured)

ParticleDescriptor(ParticleDescriptor.java:7)只有 1 个方法,返回 boolean:

boolean describe(EntityFX fx, float partialTicks, ParticleParams out, ParticleRenderState state);

false = 放弃这个粒子(不画)。true = 已填好 out 与 state。

计数器(5 个,long)

计数器 getter 含义
direct getDirect() :82 走描述符直写成功
captured getCaptured() :86 走捕获 + 反解成功
spilled getSpilled() :90 捕获时溢出(写超过 4 顶点)
undecodable getUndecodable() :94 捕获成功但反解失败
draws getDraws()(:~86) 实际 instanced draw 次数

direct + captured 是「被批处理的粒子」,spilled + undecodable 是「回落到原版路径的粒子」,加上 descriptor.describe 返回 false 的一批,三者之和才是本层粒子总数。

ParticleDescriptorRegistry:类 → 描述符

两个预置描述符(:18-22)

常量 行为
CAPTURE (fx, pt, out, state) -> false —— 恒返回 false,标记「走捕获路径」
VANILLA 调 ParticleQuads.vanillaQuad(fx, pt, fx.particleScale, out) 后返回 true

⚠️ CAPTURE 返回 false 但它的语义不是「失败」而是「选择捕获路径」。renderActive(:137)用 descriptor != CAPTURE 做引用比较来分流,不是看返回值。这是最容易读错的一处:调用方不能靠 describe 的返回值判断走哪条路。

反射查找 renderParticle(:72-79)

private static final String[] RENDER_PARTICLE_NAMES = { "renderParticle", "func_70539_a" };   // :16

⚠️ func_70539_a 是 SRG 名(未解析)。源码内的语义佐证:它被用作查找名的备选,cls.getMethod(name, Tessellator.class, float.class × 6)(:75)—— 签名 (Tessellator, float, float, float, float, float, float),返回 void(源码未取返回值直接 return)。即「1.7.10 粒子类的渲染方法」。这是 SRG 名,不是可读名,不要替换。

resolve(:60-70)的三段逻辑:

情况 返回 行
BY_NAME 命中 该描述符 :61-62
找不到 renderParticle,或声明者不是 EntityFX CAPTURE + LOGGER.warn :65-67
声明者是 EntityFX(即没覆写) VANILLA :69

⚠️ 第 2 段会 WARN:"Could not locate renderParticle on particle class {}, using the capture path"(:66)。若某个 mod 大量注册自定义粒子而没有标准 renderParticle 签名,每种类都会打一条 WARN —— 第三方粒子包安装后日志爆炸是预期行为,不是 bug。

第 3 段的判据 render.getDeclaringClass() == EntityFX.class(:69)—— 用「谁声明了这个方法」区分「原版粒子」与「自定义粒子」,比逐个登记可靠。

两级缓存

结构 类型 行
BY_NAME Object2ObjectOpenHashMap<String, ParticleDescriptor> :24
BY_CLASS Reference2ObjectOpenHashMap<Class<?>, ParticleDescriptor> :25
lastClass / lastDescriptor 单槽 1 元素缓存 :27-28

forClass(:42-52)先查单槽(cls == lastClass),再查 BY_CLASS,未命中才 resolve 并 put。

⚠️ BY_CLASS 是 Reference2ObjectOpenHashMap(弱引用键) —— 粒子类被卸载后条目可回收。但 lastClass / lastDescriptor 是强引用且只存 1 个,会钉住最后查的那个类不被回收。影响极小(只 1 个类)但确实存在。

⚠️ BY_CLASS 与两个静态 map 都无同步。register(:37-40)会 clearCache()(:39),若与 forClass 并发,会出现「查到 null → resolve → put」竞态。实际都在渲染线程,风险低。

静态初始化顺序

static {                       // :30-33
    VanillaParticleDescriptors.registerAll();
    BopParticleDescriptors.registerAll();
}

类加载即注册,无显式 init 调用。这意味着 registerAll 里任何异常都会变成 ExceptionInInitializerError,此后该类永久不可用(除非重启)。VanillaParticleDescriptors 的 registerAll(:110-130)是 19 次 register,每次都 clearCache() —— 19 次全清。

VanillaParticleDescriptors:19 个原版粒子

三个私有函数式接口(:26-36)

接口 方法
BaseScale float of(EntityFX fx) —— 从粒子读基础缩放
ScaleFactor float factor(int age, int maxAge, float partialTicks) —— 按生命周期调制缩放

BaseScaled record(:91-99)把两者组合:

fx.particleScale = base.of(fx) * factor.factor(fx.particleAge, fx.particleMaxAge, partialTicks);

⚠️ 它直接写 fx.particleScale —— 修改原版粒子对象的字段。若同一粒子对象被重复 describe(例如 clearCache 后重查),缩放会被反复乘上 factor,指数衰减。renderActive(:137-146)每粒子只调一次,但 BaseScaled 本身无幂等保护。

4 个 ScaleFactor 常量(:36-54)

常量 公式 行
RAMP ParticleQuads::ramp —— 方法引用 :36
FLAME f = (age + pt) / maxAge; return 1.0F - f * f * 0.5F :38-42
LAVA f = ...; return 1.0F - f * f :44-47
PORTAL f = 1 - f; f *= f; return 1.0F - f :49-54

PORTAL 用了两行而非一个表达式(:51-52),是为了让 f = 1 - f 的中间值可读。语义与 1 - (1-f)² 等价。

registerAll 的 19 条(:110-130)

A. registerScaled 组(10 个),全部是 base × factor:

# 粒子类 基础缩放来源 factor
1 EntityFlameFX flameScale FLAME
2 EntityLavaFX lavaParticleScale LAVA
3 EntityPortalFX portalParticleScale PORTAL
4 EntitySmokeFX smokeParticleScale RAMP
5 EntityCloudFX field_70569_a(未解析) RAMP
6 EntityCritFX initialParticleScale RAMP
7 EntityHeartFX particleScaleOverTime RAMP
8 EntityNoteFX noteParticleScale RAMP
9 EntityReddustFX reddustParticleScale RAMP
10 EntitySnowShovelFX snowDigParticleScale RAMP

⚠️ 第 5 条 field_70569_a 是 SRG 名(未解析)。源码内语义佐证:它被包在 BaseScale 的实现里(fx -> ((EntityCloudFX) fx).field_70569_a),而 BaseScale.of 返回 float —— 所以它是 EntityCloudFX 上的一个 float 缩放字段。不要替换成任何猜测的可读名。

B. 直接 register 组(9 个):

粒子类 描述符 行
EntitySpellParticleFX ParticleDescriptorRegistry.VANILLA :112
EntityDiggingFX DIGGING :113
EntityBlockDustFX DIGGING :114
EntityBreakingFX DIGGING :115
EntityFireworkOverlayFX FIREWORK_OVERLAY :116
EntityCrit2FX EMPTY :117
EntityHugeExplodeFX EMPTY :118
EntityFireworkSparkFX FIREWORK_SPARK :119

⚠️ EntityCrit2FX 与 EntityHugeExplodeFX 登记为 EMPTY(:56,恒返回 false)—— 它们被显式「关掉」。EntityHugeExplodeFX(巨型爆炸)是全屏方块级渲染,不是 billboard quad,实例化 quad 画不对;EntityCrit2FX 同理。这不是遗漏,是刻意的排除。但因为 EMPTY 返回 false,调用方会把它算进「被放弃」而不是「失败」,日志里看不出区别。

⚠️ registerScaled 组只有 10 个,但 registerAll 的 A 组第 11 个 EntitySpellParticleFX 走的是 VANILLA(:112)—— 它没有 RAMP 缩放调制,与其它 10 个行为不一致。

DIGGING:3 种方块破坏粒子共用

DIGGING(:58-75)被 EntityDiggingFX / EntityBlockDustFX / EntityBreakingFX 共用。

有 particleIcon 时(:62-68)用 getInterpolatedU/V 做亚纹理插值:

out.u0 = fx.particleIcon.getInterpolatedU(jitterU / 4.0F * 16.0F);
out.u1 = fx.particleIcon.getInterpolatedU((jitterU + 1.0F) / 4.0F * 16.0F);

⚠️ jitterU 已经被 fx.particleTextureJitterX 除以 4 了吗 —— 看 DIGGING 的第 61-62 行,jitterU = fx.particleTextureJitterX(原始值,未除 4),然后 jitterU / 4.0F * 16.0F。即 jitterU * 4.0F。源码里 / 4.0F * 16.0F 写成两步而不是 * 4.0F,是刻意保留原版除法结构的痕迹,数值等价但不要化简。

无 particleIcon 时(:69-72):

out.u0 = (fx.particleTextureIndexX + jitterU / 4.0F) / 16.0F;
out.u1 = out.u0 + 0.015609375F;

⚠️ 0.015609375F 是 1/64 减去一个 epsilon 的魔数。1/64 = 0.015625。差值 0.015625 - 0.015609375 = 0.000015625 = 2⁻¹⁶。这是半 texel 收缩(避免相邻粒子纹理渗色)。源码无注释说明,不要「修正」成 0.015625F。

对比 ParticleQuads.iconUv(:25-26)里用的是 0.0624375F(1/16 - 同样的 2⁻¹⁶ 偏移)。两个魔数不是同一个:0.0624375F 用于 1/16 粒度的标准粒子,0.015609375F 用于 1/64 粒度的破坏粒子。同一份 0.0624375 语义被写成了两个不同字面量。

FIREWORK_OVERLAY:写回原版字段

FIREWORK_OVERLAY(:98-108)修改 fx.particleAlpha:

final float t = fx.particleAge + partialTicks - 1.0F;
fx.particleAlpha = 0.6F - t * 0.25F * 0.5F;

⚠️ 这是一个单调递减的公式,没有 clamp。t 随 particleAge 增长,particleAlpha 会变成负数并持续下降。ParticleQuads.packColor(:43-45)的 Math.clamp((int)(a*255), 0, 255) 会把负值夹到 0,所以最终颜色不会出错,但 fx.particleAlpha 本身已被污染。renderActive 之后若该粒子对象被别处使用(例如 HUD 或 TESR),会读到负 alpha。

UV 是硬编码的(:102-105):u0 = 0.5F、v0 = 0.375F、u1 = 0.25F、v1 = 0.125F —— 烟花 overlay 贴图的 2×2 图集的中心格。源码写成 0.25F + 0.25F 与 0.125F + 0.25F 的加法形式(不是 0.5F / 0.375F),是对原版偏移式写法的直译。

FIREWORK_SPARK:含未解析 SRG 名

// :102
if (((EntityFireworkSparkFX) fx).field_92048_ay && age >= maxAge / 3 && (age + maxAge) / 3 % 2 != 0) return false;

⚠️ field_92048_ay 是 SRG 名(未解析)。源码内语义佐证:它出现在 && 的第一个操作数位置,与两个 int 比较式合取 —— 所以它是 EntityFireworkSparkFX 上的一个 boolean 字段。配合后两个条件(age >= maxAge / 3 且 (age + maxAge) / 3 % 2 != 0)构成烟花闪烁的间歇效果。不要替换成任何猜测的可读名。

⚠️ 该字段只被读、从未被写,且 FIREWORK_SPARK 不像 BaseScaled 那样写 fx.particleScale —— 它是 4 个「不缩放」描述符之一。

ParticleQuads:原版 quad 与 ABGR 打包

vanillaQuad(:9-14)

out.half = 0.1F * scale;

⚠️ 0.1F 是 1.7.10 EntityFX.render 的原版粒子半边长魔数(源码无注释)。它决定了所有原版粒子的尺寸基准。

center(:16-20)

out.centerX = (float) (fx.prevPosX + (fx.posX - fx.prevPosX) * partialTicks - EntityFX.interpPosX);

EntityFX.interpPosX 是静态字段(1.7.10 的渲染器偏移)。三个轴各减一次。⚠️ interpPosX/Y/Z 是 static 且可变 —— 并发渲染会互相污染,但实际都在渲染线程。

iconUv 的 UV 翻转(:22-37)

out.u0 = maxU;  out.v0 = maxV;
out.u1 = minU;  out.v1 = minV;

⚠️ u0/v0 存 max、u1/v1 存 min —— UV 是翻转的。这与常规「u0 = min」的约定相反。ParticleQuadDecoder.decode(:44-47)的赋值 out.u0 = verts[3]; out.v0 = verts[4]; out.u1 = verts[13]; out.v1 = verts[14]; 同样是「先读的进 u0/v0,后读的进 u1/v1」—— 两条路径的 UV 语义必须一致,改一处必须改两处。

无 particleIcon 时用 16×16 网格索引(:23-26):minU = particleTextureIndexX / 16.0F,maxU = minU + 0.0624375F。

ramp(:39-41)

return Math.clamp((age + partialTicks) / maxAge * 32.0F, 0.0F, 1.0F);

⚠️ maxAge == 0 时除零 → 0/0 = NaN → Math.clamp(NaN, 0, 1) 的结果依 JDK 实现(Math.clamp 对 NaN 返回 NaN)。源码无防护。 粒子 maxAge 为 0 极罕见(意味着 0 帧生命周期)但非零可能性。

Math.clamp 是 Java 21+ API —— 但本项目编译目标是 Java 17/1.7.10 兼容?⚠️ 本条目未核实 Math.clamp 的可用性来源(可能是 JDK 21 编译 + 运行时回退,或 gtnhlib 提供了 polyfill)。这是需要独立确认的点,不臆断。

packColor(:43-45)

return (clamp((int)(a*255),0,255) << 24) | (clamp((int)(b*255),0,255) << 16) | (clamp((int)(g*255),0,255) << 8) | clamp((int)(r*255),0,255);

布局是 ABGR(alpha 在最高位,red 在最低位)—— 字段名 colorABGR 已明示。每个分量都 Math.clamp 到 [0, 255]。

⚠️ (int) 截断而非 Math.round:0.5F → 0,0.9F → 0。这是截断不是四舍五入,比 Math.round 暗一档。1.7.10 原版 Tessellator 的颜色处理也是截断,行为一致。

ParticleCaptureTessellator:拦截原版顶点

extends net.minecraft.client.renderer.Tessellator(:8),继承原版类并覆写顶点写入,是整条捕获路径的基础。

常量/字段 值 行
MAX_VERTICES 4 :10
verts float[MAX_VERTICES * 5] = 20 float :12
captured 捕获到的顶点数 :15
spilled 是否溢出 :16

begin 复制 11 个 Tessellator 字段(:24-43)

this.brightness、hasBrightness、color、hasColor、hasTexture、hasNormals、isColorDisabled、xOffset、yOffset、zOffset 逐个手动复制。这不是调用 super.begin() —— 是绕过原版实现直接设字段。

⚠️ 每复制一个字段都是一条与原版 Tessellator 的耦合。1.7.10 的 Tessellator 若新增字段而这里漏复制,会用默认零值而非继承来的值。src/mixin/java 下没有 MixinTessellator 对本类生效(那属于 MixinTessellator 作用于原版类),所以这里没有任何兜底。

⚠️ color 的初值(:35):runTessellator.hasColor ? runTessellator.color : 0xFFFFFFFF —— 无颜色时回退到不透明白,不是 0。

propagateColor(:55-59)

if (spilled || closed || !hasColor || run == null) return;
run.color = color; run.hasColor = true;

把捕获期间的最终颜色写回运行中的 Tessellator —— 因为原版粒子通过 Tessellator.setColor 改颜色,而捕获路径不真正提交,必须手动回写。⚠️ spilled 或 closed 时不回写 —— 此时颜色停留在捕获前的值,下一个粒子会继承错误颜色。

ParticleQuadDecoder:顶点反解

50 行,把捕获的 20 个 float 反解回 ParticleParams。这是本子系统里最容易读错的一段。

前置检查(:10、:16)

检查 行 失败含义
count != 4 → return false :10 不是单 quad
denom <= 1.0e-12f → return false :16 旋转矩阵退化,面积为零

denom(:15)是合成后的方向向量 s = (sx, sy, sz) 的长度平方。

反解公式(:18-24)

cx = (verts[0] + verts[10]) * 0.5f;    // 顶点 0 与顶点 2 的中点 = quad 中心
dx = verts[10] - verts[0];              // 对角线
half = (dx*sx + dy*sy + dz*sz) / (2.0f * denom);

half 是投影到法线方向的对角线半长 —— 即 quad 的「半径」。

⚠️ verts[0..2] 与 verts[10..12] 相差 10 个 float = 2 个顶点(每顶点 5 float)。顶点 0 与顶点 2 是对角,这是四边形索引约定。改 ParticleQuadMesh 的顶点顺序会让反解全错且不报错(只会在后面的 tol 校验里返回 false)。

容差校验(:26-34)

final float tol = 1.0e-5f * (1.0f + Math.abs(half) + scale);

tol 是相对容差,随 quad 大小放大。逐顶点、逐分量比对(Math.abs(实际 - 预测) > tol 就失败)。比对用 ParticleQuadMesh.cornerA(i) / cornerB(i) / offsetX/Y/Z —— 这些符号定义在 GLSM 的 ffp.ParticleQuadMesh 里(com.gtnewhorizons.angelica.glsm.ffp),不在本包。

⚠️ ParticleQuadMesh 不在本条目覆盖范围(在 GLSM 子项目,见 Subprojects)。VERTEX_COUNT、cornerA、cornerB、offsetX/Y/Z 的定义本条目不可判定,只能确认它们被本类按「4 顶点、5 float/顶点」的约定使用。

UV 交叉校验(:36-38)

if (verts[3] != verts[8] || verts[9] != verts[14] || verts[18] != verts[13] || verts[19] != verts[4]) return false;

⚠️ 这是 4 个 != 的精确浮点比较,不是容差比较。捕获路径写入的 UV 来自同一个源、同一次计算,因此位相同;反之若原版粒子用了不同的 UV 写入方式(如逐顶点插值),这里会因微小浮点差异而失败。

若解码失败,调用方回落到原版路径:CAPTURE.replayInto(runTessellator) + undecodable++(ParticleInstancer.java:163-166)—— 正确,不丢粒子。

ParticleRenderState:GL 状态快照

54 行,7 个 public 字段 + 3 个方法。

字段 类型 来源(sample() :19-28)
texture int GLStateManager.getBoundTextureForServerState()
blend boolean GLStateManager.isEffectiveBlendEnabled()
blendSrc / blendDst int scratch 的 getSrcRgb() / getDstRgb()
blendSrcAlpha / blendDstAlpha int scratch 的 getSrcAlpha() / getDstAlpha()
depthMask boolean GLStateManager.isEffectiveDepthMaskEnabled()
方法 行 语义
sample() :19-28 从 GLSM 读当前有效状态
set(other) :30-38 逐字段复制
matches(other) :40-42 7 字段全等(&& 串联的长表达式)
apply() :44-53 写回 GLSM:glBindTexture → blend 分支 → glDepthMask

⚠️ matches 是 7 项 && 的单行表达式(:41,一行 200+ 字符)。新增字段时极易忘记加进 matches,结果是状态不同的粒子被合进同一组,用错误的 GL 状态绘制。本类无扩展性保障。 set 同理。

⚠️ apply() 里的 blend 分支(:46-51):blend == true 时 glEnable(GL_BLEND) + glBlendFuncSeparate;false 时 glDisable(GL_BLEND)。glBlendFuncSeparate 在 disable 分支不重置 —— 依赖后续 apply 重新设置。GLSM 会跟踪这个状态,所以是安全的,但跳过 apply 后再 enable 组合状态是未定义路径。

scratch 是 private final BlendState(:9),每个 ParticleRenderState 实例各有一个。Group(ParticleInstancer.java:56)内嵌一个 ParticleRenderState,所以每个 Group 都常驻一个 BlendState。

ParticleInstancer:分层与分组

beginLayer 的三分支(:~217-232)

private static void beginLayer() {
    layerActive = false; layerStarted = true;
    final SodiumGameOptions options = ClientProxy.options();
    if (options == null || !options.advanced.enableDeferredBatching) return;   // 分支 1:不批处理
    deferred = pipeline();
    if (available()) { layerActive = true; groupCount = 0; LAYER_MV.set(GLStateManager.getModelViewMatrix()); }
    else { deferred = null; DeferredDrawBatcher.enter(); }                     // 分支 3:退化到批处理器
}
分支 条件 行为
1 options == null 或 !enableDeferredBatching layerActive = false,每粒子直调原版(:121-125)
2 available() 实例化路径,LAYER_MV 快照 modelview
3 Iris 延迟管线不可用 DeferredDrawBatcher.enter() 退化

⚠️ layerActive = false 但 layerStarted = true(:~218-219)—— 二者是不同含义:layerStarted 表示「本层已开始,不要重复 beginLayer」,layerActive 表示「走实例化路径」。renderParticle(:120)只判 !layerStarted,endLayer(:~244)先判 !layerActive。改任一个都要看另一处。

⚠️ enableDeferredBatching 读的是 Sodium 的配置(me.jellysquid.mods.sodium.client.gui),不是 AngelicaConfig —— 与 FpsReducer 一样走 angelica-options.json。

groupFor:线性查找 + 状态匹配(:~262-274)

for (int i = 0; i < groupCount; i++) {
    final Group g = GROUP_POOL.get(i);
    if (g.translucent == translucent && g.state.matches(state)) return g;
}

O(n) 线性扫描,n = 本层不同 GL 状态组合数。状态维度:7 个字段 + 1 个 translucent 位 = 理论 2⁸ = 256 种。实际远少(典型 4~8 组),但最坏情况线性扫描 256 项 × 每粒子。

groupFor 还会 MeshBuffer.ensureCapacity(g.list, INITIAL_BYTES, true)(:~273)—— 每新建一组就重置到 INITIAL_BYTES,不做容量保留。

INITIAL_BYTES = 256 * STRIDE(:43),STRIDE = ParticleInstancedAttribs.STRIDE(:42,符号在 GLSM,本条目不可判定其值)。

endLayer:两轮 flush + 教科书级异常聚合(:180-238)

这是全仓 RenderFailures 的正确用法样板:

第一部分(:186-199) —— 先刷不透明、再刷半透明:

for (int i = 0; i < groupCount; i++) { if (!group.translucent) flush(group); }
for (int i = 0; i < groupCount; i++) { if (group.translucent) flush(group); }

⚠️ 两轮循环而不是排序 —— Group 的创建顺序取决于粒子出现顺序,不透明/半透明是交错的。两轮扫描保证不透明全部先画。代价是 groupCount 项要扫两遍。

第二部分(:200-237) —— catch (Throwable t) { failure = t; } + finally 里逐项 try/catch 并 RenderFailures.suppress 聚合:

finally 内的步骤 行 失败处理
GLStateManager.popStateTo(restoreDepth) :~228 suppress(failure, t)
GbufferPrograms.setTranslucencyDeclaration(...) :~233 suppress(failure, t)(嵌在内层 try/finally)
ring().postDraw() :~240 suppress(failure, t)
清 group.list、重置 6 个状态字段 :~245-251 无 try/catch(list.clear() 理论上不抛)

最后 :237 RenderFailures.rethrowWrapped(failure)。

⚠️ popStateTo 之后才 setTranslucencyDeclaration(:229-234),且后者嵌在 if (restoreDepth >= 0) 内 —— 若 restoreDepth < 0(pushState 从未成功),Iris 的 translucency 声明不会被复位。这是有意的(没 push 就不该 pop),但意味着 deferred != null && restoreDepth < 0 时 Iris 状态泄漏。

⚠️ 注意 rethrowWrapped 而非 rethrow(:237)—— 这里要的是「聚合后统一抛」,rethrow 的 sneaky-throw 在这里会绕过调用点的 catch 意图。选择是对的。

绘制(:~343-350)

VAOManager.setCurrentVertexFlags(ParticleQuadMesh.VERTEX_FLAGS);
...
GLStateManager.glDrawArraysInstanced(GL11.GL_QUADS, 0, ParticleQuadMesh.VERTEX_COUNT, count);

⚠️ 用 GL_QUADS 而不是 GL_TRIANGLES —— 1.7.10 的 core profile 里 GL_QUADS 在 GL43 已废弃但在兼容性 profile 下仍可用。这是 1.7.10 特有的可行路径,现代版本不可移植。

ParticleRunSplitter:半透明换 pass

67 行,rendering/ 顶层(不在 particles/ 包内)。类注释(:16-18):

Splits a particle run between gbuffers_particles and gbuffers_particles_translucent.

状态机(2 个静态 boolean)

字段 行 语义
runTranslucent :21 当前 run 的半透明性
方法 行 动作
beginRun() :25-28 runTranslucent = false + GbufferPrograms.setTranslucencyDeclaration(Boolean.FALSE)
splitIfNeeded(fx, tessellator, partialTicks) :30-50 核心
currentRunTranslucent() :52-54 getter
isTranslucent(fx) :56-66 判定

isTranslucent 的两条路(:56-66)

if (particle.particleIcon instanceof TextureAtlasSprite sprite) {
    return ((SpriteExtension) sprite).celeritas$getTransparencyLevel() == SpriteTransparencyLevel.TRANSLUCENT;
}
return particle instanceof EntityFireworkSparkFX
    || particle instanceof EntityFireworkOverlayFX
    || particle instanceof EntityFireworkStarterFX
    || particle instanceof EntitySpellParticleFX
    || particle instanceof EntityCloudFX;
路径 条件 依据
贴图路径 particleIcon 是 TextureAtlasSprite SpriteExtension.celeritas$getTransparencyLevel()(Angelica 自己的 mixin 接口,对象池在 Celeritas)
类名兜底 否则 5 个硬编码类的 instanceof

⚠️ 贴图路径依赖 particleIcon instanceof TextureAtlasSprite(:57)。若资源包提供的 particleIcon 不是 TextureAtlasSprite(而是 1.7.10 的 IIcon 其它实现),会静默落到 5 类兜底 —— 而这 5 类之外的粒子会被判为不透明,写进 gbuffers_particles 而不是 gbuffers_particles_translucent,在 Iris 着色器下渲染错误。这是真实缺陷。

⚠️ EntityFireworkStarterFX 出现在兜底列表但 EntityFireworkSparkFX / OverlayFX 同时在 VanillaParticleDescriptors 里有专门描述符 —— 同一批粒子在两个子系统里各有一份知识,可能不一致。

splitIfNeeded 的 5 步(:30-50)

if (translucent == runTranslucent) return;              // 1. 无变化则返回
if (DeferredDrawBatcher.isActive()) DeferredDrawBatcher.exitAndFlush();   // 2. 退出批处理
tessellator.draw();                                     // 3. 提交当前批
runTranslucent = translucent;                           // 4. 切换
GbufferPrograms.setTranslucencyDeclaration(translucent);
tessellator.startDrawingQuads();
tessellator.setBrightness(particle.getBrightnessForRender(partialTicks));
if (batcherWasActive) DeferredDrawBatcher.enter();      // 5. 重入批处理

⚠️ tessellator.draw() 之后没有 startDrawingQuads() 的对应 draw() 保护 —— 若 setTranslucencyDeclaration 抛异常,tessellator 处于「已 draw 未 restart」的中间态。原版 Tessellator.draw() 内部会置 isDrawing = false,下次 startDrawingQuads() 才恢复。异常路径下状态不一致。

⚠️ 亮度只对新 run 的第一个粒子设置(:46),后续同 run 粒子不重设 —— 这与原版 EffectRenderer 行为一致(亮度每粒子不同则需切 run,但同 run 内亮度变化未被处理)。这是潜在缺陷:两个同半透明性的粒子亮度不同,不会触发 split,第二个粒子的亮度会沿用第一个。

batcherWasActive 在 exitAndFlush 前取值(:36)并在重入时使用(:47)—— 顺序正确。

已知问题 / 风险

  1. ParticleDescriptorRegistry.CAPTURE 恒返回 false,但语义是「选择捕获路径」(ParticleDescriptorRegistry.java:18)。调用方用引用比较分流,返回值不可当失败标志。
  2. VanillaParticleDescriptors 含 2 个未解析 SRG 名:EntityCloudFX.field_70569_a(float 缩放)、EntityFireworkSparkFX.field_92048_ay(boolean)。ParticleDescriptorRegistry 另有 func_70539_a(方法查找名)。本机无 MCP 反混淆映射表,不替换。
  3. isTranslucent 的贴图路径有 instanceof 静默降级(ParticleRunSplitter.java:57)—— 非 TextureAtlasSprite 的 particleIcon 会让半透明粒子被误判为不透明。
  4. ParticleRunSplitter.splitIfNeeded 同 run 内不处理亮度变化(:30-50),亮度不同的粒子会共用第一个的亮度。
  5. ParticleRunSplitter.splitIfNeeded 异常路径状态不一致(tessellator.draw() 与 startDrawingQuads() 之间无保护)。
  6. BaseScaled.describe 写 fx.particleScale 无幂等保护(VanillaParticleDescriptors.java:93),重复调用会指数衰减。
  7. FIREWORK_OVERLAY 写 fx.particleAlpha 为可能为负的值且无 clamp(:99),污染原版粒子对象。
  8. DIGGING 与 ParticleQuads 各有一份不同的半 texel 魔数(0.015609375F vs 0.0624375F),同一语义两个字面量,无注释。
  9. ParticleRenderState.matches / set 是 7 字段平铺表达式(:30-42),新增字段时极易遗漏,无扩展性保障。
  10. ParticleQuadDecoder 的 UV 交叉校验用精确 !=(:36-38),微小浮点差异即导致反解失败(虽有 replay 兜底,但会持续增加 undecodable 计数)。
  11. ParticleQuads.ramp 未防 maxAge == 0(:40),除零产生 NaN。
  12. Math.clamp(Java 21+ API)的来源未核实 —— 出现在 ParticleQuads.java:40、:44。本条目不臆断其编译/运行兼容性。
  13. ParticleInstancer.groupFor 是 O(n) 线性扫描(:~263),ParticleRenderState 有 8 个状态维度(7 字段 + translucent)。
  14. endLayer 中 setTranslucencyDeclaration 嵌在 if (restoreDepth >= 0) 内(ParticleInstancer.java:229-234),restoreDepth < 0 时 Iris translucency 声明泄漏。
  15. ParticleCaptureTessellator.begin 手动复制 11 个 Tessellator 字段(:24-43),绕过 super.begin(),无兜底;propagateColor 在 spilled / closed 时不回写颜色(:56),下一粒子可能继承错误颜色。
  16. ParticleDescriptorRegistry 的 lastClass / lastDescriptor 是强引用单槽(:27-28),会钉住最后查询的粒子类;BY_CLASS 虽为弱引用键,但 register 每次都全清(19 次 register = 19 次 clearCache)。
  17. ParticleInstancedAttribs.STRIDE 与 ParticleQuadMesh 的全部符号定义在 GLSM 子项目(com.gtnewhorizons.angelica.glsm.ffp),本条目不可判定其值。
  18. 类加载即注册(ParticleDescriptorRegistry.java:30-33),registerAll 抛异常会变成 ExceptionInInitializerError 且该类永久不可用。

相关条目