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