这份文档面向两类人:
- 第一次接触这个项目的开发者。
- 对 shader、后处理、渲染管线几乎没有基础,但希望做出自定义
RenderEntity、Bloom、Glow 效果的人。
如果你现在的目标是下面这几种之一,这份文档就是给你准备的:
- 做一个自定义的渲染实体,让服务端生成、客户端显示。
- 做一个会发光的球、法阵、能量体、束流、黑洞、光晕。
- 让效果既能跟随世界位置,又能在帧尾做 bloom / glow。
- 排查“为什么效果不显示”“为什么被水 / 粒子 / 云影响”“为什么 Iris 下不一样”。
这份文档不会假设你已经懂 GLSL。 相反,它会先告诉你“整个系统怎么想”,再告诉你“具体怎么写”。
在当前版本里,RenderEntity 不是“一个自己既同步又渲染的黑盒对象”。
它被拆成了几层职责明确的部件:
RenderEntity负责服务端权威状态、编解码、同步、客户端镜像回写。RenderEntityRenderer负责真正的渲染生命周期。RenderEntityInstance负责在客户端持有“实体镜像 + renderer + 运行时状态”。ClientRenderEntityManager负责每帧调度 world pass 和 frame-post。- glow / bloom provider 与公共 renderer 负责把常见的发光效果转成统一的帧尾任务。
你可以把它想象成:
RenderEntity是“网络同步的状态对象”。RenderEntityRenderer是“这个状态对象该怎么画”。RenderEntityInstance是“客户端正在运行的这一个实例”。
一条 RenderEntity 的完整链路可以概括成下面 7 步:
- 服务端创建实体,设置字段,然后调用
spawn(world, pos)。 ServerRenderEntityManager.tick()判断哪些玩家能看到它。- 服务端向可见玩家发送
CREATE / TOGGLE / REMOVE包。 - 客户端收到包后,用 codec 解码出实体镜像。
- 客户端把这个镜像包进
RenderEntityInstance。 - 每帧 world pass 阶段,runtime 调用
renderLocal(...)。 - 每帧 frame-post 阶段,runtime 调用
collectFrameEffects(...),并自动收集ScreenGlow/PersistentBloom/WorldLight这类内建 provider。
这 7 步里最容易搞混的是第 6 步和第 7 步。
renderLocal(...)表示在世界渲染阶段直接画东西。collectFrameEffects(...)表示在世界画完之后,把一个“帧尾效果任务”提交出去。
这两者的差别,决定了你做出来的是:
- 一个真正画进世界的几何体。
- 还是一个帧尾叠加的 glow / bloom / 屏幕特效。
如果你完全没有 shader 基础,先看这一节。
顶点着色器可以理解成:
“把模型的每个顶点,从它自己的局部坐标,变成最终屏幕上的位置。”
它通常负责三件事:
- 接收模型顶点。
- 用矩阵把顶点从局部空间变到观察空间。
- 把结果传给 GPU 后续阶段。
对于一个球体来说,顶点着色器不会决定它亮不亮,它主要决定:
- 这个球在世界哪里。
- 它有多大。
- 它当前在相机面前是什么姿态。
片元着色器可以理解成:
“对于屏幕上的每一个像素,算出它应该是什么颜色、什么透明度。”
这才是 glow / bloom 外观真正形成的地方。
比如:
- 中心更亮还是边缘更亮。
- 是纯色还是带噪声流动。
- halo 是往外散还是往中心堆。
- alpha 多大,发光强度多大。
world pass 就是“跟普通世界几何一起画”的那一段。
如果你在 renderLocal(...) 里画一个球,那它更像一个真正存在于世界中的模型:
- 会参与常规的深度测试。
- 会和其他世界内对象一起按当前阶段被绘制。
- 适合实体本体、束流、模型、网格。
frame-post 可以理解成:
“世界主体已经画完了,我现在拿着这一整帧的结果,再做额外处理。”
这类效果适合:
- glow
- bloom
- 屏幕空间 halo
- 基于场景颜色 / 深度的合成效果
它的优点是:
- 更适合做发光和后处理。
- 不需要把一切都塞进 world pass。
它的代价是:
- 你要更清楚自己依赖的是场景颜色、深度,还是只依赖最终帧。
- 某些透明物体和光影环境下,表现会和 world pass 不完全一样。
Bloom 的核心思路不是“把物体画亮”,而是:
- 先提取高亮区域。
- 对高亮区域做模糊。
- 再把模糊结果叠回原场景。
所以 Bloom 更像“外溢的光”。
它和“物体本体发光”不是一回事。
Glow 是一个更泛的词,通常表示“看起来在发光”。
在这个项目里,常见有三类:
- 直接绘制的发光球体
例如
PostGlowSphereRenderer。 - 屏幕空间 glow
例如
ScreenGlowProvider。 - 帧尾 blur + composite 的 bloom
例如
PersistentBloomContextProvider。
它们看起来都像“发光”,但实现原理不同,环境兼容性也不同。
这是最重要的一张表。 零基础开发时,不要一上来就自己写 shader。 先判断你到底要哪一类效果。
| 需求 | 建议入口 | 适合原因 | 代价 |
|---|---|---|---|
| 画一个真正存在于世界里的几何体 | renderLocal(...) |
逻辑直接,和普通模型思路接近 | 你要自己处理 shader / buffer / 渲染状态 |
| 画一个带本体、带外扩发光的光球 | collectFrameEffects(...) + PostGlowSphereRenderer |
已有公共实现,可直接复用,Iris 下也更稳 | 它是“发光球体”方案,不是通用 bloom API |
| 只想做帧尾模糊后的稳定外辉光 | PersistentBloomContextProvider |
适合能量核心、法阵、远处亮源 | 依赖场景颜色 / 深度,当前 Iris safe backend 下不会执行 |
| 只想给一个点状亮源加屏幕 halo | ScreenGlowContextProvider 或 ScreenGlowProvider |
适合小型光源、屏幕空间 aura | 同样依赖场景颜色 / 深度 |
如果你是第一次做:
- 想做“看得见球体本体的光球”,优先用
PostGlowSphereRenderer。 - 想做“只有外层 bloom,没有明确几何本体”,用
PersistentBloomContextProvider。 - 想做“纯世界模型”,从
renderLocal(...)开始。
这一节先讲最适合入门的方案:
- 继承
AutoRenderEntity - 自己实现
RenderEntityRenderer<T> - 使用
@CooAutoRegister - 用
@CodecField自动处理编解码和镜像回写
这样做的好处是:
- 你不用手写 codec。
- 你不用额外再拆一个 renderer 类。
- 代码集中,学习成本最低。
@CooAutoRegister
class MyGlowOrbEntity(
world: Level? = null,
pos: Vec3 = Vec3.ZERO
) : AutoRenderEntity(world, pos),
RenderEntityRenderer<MyGlowOrbEntity> {
constructor() : this(null, Vec3.ZERO)
companion object {
val ID: ResourceLocation = ResourceLocation.fromNamespaceAndPath(
"yourmod",
"my_glow_orb"
)
}
@field:CodecField
var radius: Float = 4.0f
@field:CodecField
var intensity: Float = 6.0f
@field:CodecField
var haloIntensity: Float = 3.0f
@field:CodecField
var glowColor: Vector3f = Vector3f(0.6f, 0.9f, 1.2f)
override fun getRenderID(): ResourceLocation = ID
}这段代码做了什么:
@CooAutoRegister让扫描器自动发现这个类型,并为 codec 注册做准备。AutoRenderEntity自动帮你生成 codec,并在客户端镜像更新时自动回写@CodecField字段。RenderEntityRenderer<MyGlowOrbEntity>表示这个实体自己就是自己的 renderer。- 无参构造 给反射和解码用。
ID是这个类型在客户端注册和网络包中的稳定标识。@CodecField表示这些字段需要被同步。
@CodecField 自动做了两件事:
- 把字段纳入编解码。
- 在客户端收到新镜像后,把字段从临时对象复制回当前实体。
但是它没有自动做一件非常重要的事情:
“当你在服务端修改字段时,自动触发同步。”
也就是说,下面这段代码虽然能改值,但不一定会立刻同步到客户端:
entity.intensity = 10.0f如果这个字段变化后你希望客户端马上看到,你还要自己调用:
entity.markDirty()或者:
entity.requestSync()这点非常重要。
很多人以为 @CodecField 等于“自动同步”,其实它只负责“字段被编码”和“字段能回写”,不负责“字段变化时主动发包”。
你必须在服务端世界中生成它。
最简单的方式:
val entity = MyGlowOrbEntity()
entity.radius = 5.0f
entity.intensity = 7.5f
entity.glowColor = Vector3f(1.2f, 0.7f, 0.3f)
entity.spawn(serverLevel, Vec3(x, y, z))spawn(world, pos) 底层会做的事很简单:
- 检查
world是否是ServerLevel - 写入世界和位置
- 把实体交给
ServerRenderEntityManager
之后它会由服务端 manager 负责:
- 判断玩家是否在可视范围内
- 发送
CREATE - 在字段变化时发送
TOGGLE - 删除时发送
REMOVE
RenderEntity 有一个很关键的字段:
entity.renderRange = 256.0如果玩家距离实体超过这个范围,即使你的 renderer 完全正确,客户端也根本拿不到实体镜像。
所以遇到“效果不存在”的第一批排查项应该包括:
- 是否真的在服务端生成了
- 玩家是否与实体在同一个世界
- 是否超出
renderRange - 实体是否已经被
canceled
如果你要做的是“一个本体清晰、还能外扩发光的球”, 最适合从这里开始。
原因很简单:
- 你不用自己写 shader。
- 你不用自己管球体网格。
- 这套实现现在已经从
test提炼成公共 renderer 了。 - 它只依赖
FINAL_FRAME_POST,在当前 Vanilla / Iris safe backend 下都更容易稳定工作。
@CooAutoRegister
class MyGlowOrbEntity(
world: Level? = null,
pos: Vec3 = Vec3.ZERO
) : AutoRenderEntity(world, pos),
RenderEntityRenderer<MyGlowOrbEntity> {
constructor() : this(null, Vec3.ZERO)
companion object {
val ID: ResourceLocation = ResourceLocation.fromNamespaceAndPath(
"yourmod",
"my_glow_orb"
)
}
@field:CodecField
var radius: Float = 4.0f
@field:CodecField
var intensity: Float = 6.0f
@field:CodecField
var haloIntensity: Float = 3.2f
@field:CodecField
var haloRadiusScale: Float = 1.8f
@field:CodecField
var fresnelStrength: Float = 1.3f
@field:CodecField
var animationSpeed: Float = 1.0f
@field:CodecField
var overbrightClamp: Float = 6.0f
@field:CodecField
var glowColor: Vector3f = Vector3f(0.6f, 0.9f, 1.2f)
override fun getRenderID(): ResourceLocation = ID
override fun initialize(instance: RenderEntityInstance<MyGlowOrbEntity>) {
PostGlowSphereRenderer.initialize()
}
override fun collectFrameEffects(
input: FrameEffectInput<MyGlowOrbEntity>,
collector: FrameEffectCollector
) {
val entity = input.instance.entity
PostGlowSphereRenderer.submit(
collector = collector,
effectId = ID.toString(),
sourceInstanceId = entity.uuid.toString(),
entity = entity,
frameContext = input.frameContext,
config = PostGlowSphereConfig(
radius = entity.radius,
intensity = entity.intensity,
haloIntensity = entity.haloIntensity,
haloRadiusScale = entity.haloRadiusScale,
fresnelStrength = entity.fresnelStrength,
animationSpeed = entity.animationSpeed,
overbrightClamp = entity.overbrightClamp,
glowColor = Vector3f(entity.glowColor)
)
)
}
}initialize(...):
- 只做一件事,确保共享 glow renderer 已经初始化。
- 它会准备球体网格和公共 shader。
collectFrameEffects(...):
- 不直接画球。
- 它把一次“请在帧尾画一个发光球”的请求提交给 runtime。
PostGlowSphereConfig:
- 描述这个光球的总样式。
- 包括半径、亮度、halo 强度、颜色、动画速度等。
PostGlowSphereRenderer.submit(...):
- 向
FrameEffectCollector提交一个帧尾任务。 - 真正执行时,会在
FINAL_FRAME_POST阶段把球画到主渲染目标上。
适合:
- 能量球
- 光核
- 法阵核心
- 明确能看见“球体本体”的效果
不适合:
- 你只想要“外层模糊辉光”,不想要任何明确几何本体
- 你要做非常规几何而不是球
如果你只是想做柔和外晕,看下一节的 PersistentBloom。
如果你的目标不是“一个发光球”,而是“一个亮源在帧尾向外扩散出柔和模糊的外辉光”, 那么更接近 Bloom 语义的入口是:
PersistentBloomContextProvider
它的基本原理是:
- 收集 bloom 采样数据。
- 在帧尾生成 mask。
- 对 mask 做 blur。
- 再把 blur 结果叠回场景。
@CooAutoRegister
class MyBloomSourceEntity(
world: Level? = null,
pos: Vec3 = Vec3.ZERO
) : AutoRenderEntity(world, pos),
RenderEntityRenderer<MyBloomSourceEntity>,
PersistentBloomContextProvider {
constructor() : this(null, Vec3.ZERO)
companion object {
val ID: ResourceLocation = ResourceLocation.fromNamespaceAndPath(
"yourmod",
"my_bloom_source"
)
}
@field:CodecField
var bloomRadius: Float = 4.0f
@field:CodecField
var bloomIntensity: Float = 1.8f
@field:CodecField
var bloomColor: Vector3f = Vector3f(0.7f, 0.9f, 1.3f)
override fun getRenderID(): ResourceLocation = ID
override fun collectPersistentBlooms(
context: ScreenGlowRenderContext,
output: MutableList<PersistentBloom>
) {
output.add(
PersistentBloom(
position = Vector3f(pos.x.toFloat(), pos.y.toFloat(), pos.z.toFloat()),
color = Vector3f(bloomColor),
radius = bloomRadius,
intensity = bloomIntensity,
softness = 0.58f,
softOcclusionFloor = 0.10f,
haloRadiusScale = 2.6f,
brightnessNormalization = 0.9f,
haloOpacity = 0.45f,
blurSigma = 4.8f,
blurRange = 4.2f
)
)
}
}因为 PersistentBloomContextProvider 是一个内建 provider。
在当前 runtime 里,RenderEntityInstance.collectFrameEffects(...) 会自动检查:
- 这个实体是不是
PersistentBloomContextProvider
如果是,它会自动把它交给 ClientPersistentBloomManager。
所以你不需要自己在 collectFrameEffects(...) 里手动再提交一次。
优点:
- 真正有 blur + composite 的外溢辉光感
- 适合远处亮源、法阵、能量核心
- 本体可以很小,但 halo 仍然稳定
缺点:
- 依赖
SCENE_COLOR_COPY - 依赖
SCENE_DEPTH_READ - 依赖帧尾 blur 管线
这三个条件意味着它不是所有 backend 都能跑。
这一节非常重要。
本质:
- 一个“直接绘制到帧尾”的发光球体 renderer。
- 不是 blur bloom,而是一个带本体、带 halo 的后处理球体。
特点:
- 本体清楚
- 有边缘、有层次
- 依赖少
- 当前 Iris safe backend 下也更有机会稳定运行
本质:
- 一个“先做 mask,再 blur,再 composite”的 bloom 系统。
特点:
- 外晕柔和
- 模糊感强
- 更像真正的 bloom
- 依赖场景颜色和深度
本质:
- 一个更轻量的屏幕空间 glow 系统。
特点:
- 更适合小型光源
- 主要从屏幕空间观感出发
- 不适合替代实体本体
- 你想“看见球体本体”,用
PostGlowSphereRenderer - 你想“只要外层辉光”,用
PersistentBloom - 你想“给一个点源补屏幕 halo”,用
ScreenGlow
如果你不是要 glow / bloom,而是真正自己画几何体, 那就把主要逻辑放进:
renderLocal(...)
最小骨架长这样:
override fun renderLocal(input: LocalRenderInput<MyEntity>) {
myShader.useOnContext {
setMatrix4("projMat", input.projMatrix)
setMatrix4("viewMat", input.viewMatrix)
setMatrix4("transMat", input.modelMatrix)
myBuffer.draw()
}
}它的含义是:
input.modelMatrix是当前实体在世界中的模型矩阵input.viewMatrix是当前相机观察矩阵input.projMatrix是投影矩阵
如果你把一个对象放在 renderLocal(...) 里画,
它更像“世界里的真实模型”。
如果你把一个对象放在 collectFrameEffects(...) 里提交,
它更像“帧尾叠加的后处理特效”。
这是当前最省事的路径。
自动注册会帮你做的事情:
- 扫描到这个
RenderEntity类型 - 把 codec 注册到
ClientRenderEntityRegistry
但是它不会自动帮你注册独立 renderer。
如果你的实体自己实现了:
RenderEntityRenderer<MyEntity>那么客户端在解码后会直接把这个实体当 renderer 使用。
这意味着:
- codec 自动注册后就够了
- 不需要再额外写一个
rendererFactory
那你就需要显式注册 renderer:
ClientRenderEntityRegistry.register(
id = MyEntity.ID,
codec = MyEntity().getCodec()
)
ClientRenderEntityRegistry.registerRenderer(MyEntity.ID) {
MyEntityRenderer()
}如果 codec 注册了但 renderer 没注册,而实体本身又不实现 RenderEntityRenderer,
客户端会直接抛异常,而不是静默失败。
这一节是全文最容易救命的一节。
很多“效果不对”的问题,不是参数错了,而是环境条件根本变了。
先看最基础的三件事:
- 玩家和实体是不是在同一个世界
- 玩家是否在
renderRange内 - 实体是不是已经
canceled
只要这三者有一个不满足,客户端根本不一定会持有这个实体。
你的 glow / bloom 最终能不能正确被地形、方块、实体遮住,取决于你走哪条路径。
- 依赖当前 world pass 的深度测试
- 更接近普通世界几何
- 在帧尾绘制
- 仍然启用了 depth test
- 关闭了 depth write
这意味着它通常比纯屏幕空间 glow 更容易保持“不会直接穿墙”, 同时又不会把深度缓冲本身污染掉。
- 不是直接画几何
- 它们是读场景深度,再做 mask / composite
因此它们的遮挡正确性强依赖“这一帧的深度纹理到底记录了什么”。
这类对象最容易让人误判为“API 有 bug”。
根本原因是:
- 透明对象不一定像实心方块那样稳定写入深度
- 后处理效果读到的 scene depth,不一定完整代表所有透明层
- 不同 backend、不同渲染顺序、不同光影环境,对透明层处理会不同
这会带来几类典型现象:
- glow 看起来穿过了水
- 粒子和光晕叠加关系不稳定
- 云雾区域里的 glow 对比度突然变差
这不是说系统完全没法处理透明对象, 而是你要接受一个事实:
“纯后处理效果对透明物体的遮挡,天然比直接 world geometry 更脆弱。”
如果你的效果必须尽量稳定地与世界前景交互:
- 优先考虑
renderLocal(...)或PostGlowSphereRenderer - 不要一开始就只依赖
PersistentBloom或ScreenGlow
这是当前项目里最关键的环境差异之一。
SCENE_COLOR_COPYSCENE_DEPTH_READSAFE_WORLD_COMPOSITEFINAL_FRAME_POSTEARLY_WORLD_HOOK
SAFE_WORLD_COMPOSITEFINAL_FRAME_POST
你应该立刻注意到:
当前 IrisSafeRenderBackend 没有:
SCENE_COLOR_COPYSCENE_DEPTH_READ
这会直接影响两类效果:
ScreenGlowPersistentBloom
因为这两个 manager 的任务提交都声明了必需能力:
FINAL_FRAME_POSTSCENE_COLOR_COPYSCENE_DEPTH_READ
所以在当前实现下:
PostGlowSphereRenderer这类只要求FINAL_FRAME_POST的效果,更适合跨 Vanilla / Iris 使用。PersistentBloom和ScreenGlow在 Vanilla safe backend 下更完整,在 Iris safe backend 下当前不会执行。
这不是参数问题,而是 backend capability 问题。
即使你的效果“已经渲染出来了”,它在不同光影环境里仍然可能看起来完全不同。
原因包括:
- tone mapping
- 曝光
- 色彩曲线
- 雾和体积光
- 后续光影包自己的 bloom / glow / color grading
这会导致:
- 同一个颜色,在某个光影里发白
- 同一个强度,在某个环境里看起来发灰
- 边缘 halo 明明存在,但因为对比度下降而像是消失了
因此你调 glow 时不要只盯着:
intensity
还要一起看:
overbrightClamp- 颜色值是否高于
1.0 - halo 和 core 的占比
- 当前光影是否自己也在做额外 bloom
世界效果最终都会被投影到屏幕上。
这意味着:
- 距离越远,物体屏幕尺寸越小
- FOV 越大,物体看起来越小
- 分辨率变化会影响屏幕空间效果的表现
对三类效果的影响不一样:
- 远了就是变小
- 这是正常现象
- 它本身不依赖老的 distance-adaptive screen glow 方案
- 但视觉上仍然会因为投影尺寸变化而显得更集中或更分散
- 因为它们直接与屏幕空间分析强相关
- 距离、FOV、屏幕尺寸对它们更敏感
这类问题的典型现象是:
- 第一次生成有效
- 后来改了颜色 / 半径 / 强度,客户端却没变化
常见原因只有几个:
- 字段没标
@CodecField - 字段值改了但没
markDirty() - 自定义 codec 没把字段编码进去
- 自己手写了
loadProfileFromEntity(...)但没正确回写字段
如果你用的是 AutoRenderEntity,第 3 和第 4 个问题通常会少很多,
但第 2 个问题依然非常常见。
先按这个顺序排查:
- 服务端是否真的
spawn(...) - 玩家是否在
renderRange内 getRenderID()是否稳定且正确- codec 是否注册
- 如果不是“实体自带 renderer”,renderer 是否注册
- 当前 backend 是否支持你需要的 capability
先看你是不是用了:
PersistentBloomContextProviderScreenGlowContextProviderScreenGlowProvider
如果是,先确认你是否依赖:
SCENE_COLOR_COPYSCENE_DEPTH_READ
当前 IrisSafeRenderBackend 不提供这两个能力。
这通常是“halo 分布形状”问题,不是“球半径不够大”。
处理方向应该是:
- 增大外层 halo 的
radiusScale - 增大
haloIntensityScale - 开启或调大
haloCenterSuppress - 调大
haloCenterSuppressRadius
而不是一味继续放大核心层半径。
常见原因:
intensity太大haloIntensity太大overbrightClamp太高- 多个加色发光体叠加
- 光影包本身还有额外 bloom
优先确认:
- 你用的是
PostGlowSphereRenderer还是纯屏幕空间 glow - 场景里是不是透明层对象
- 当前深度纹理是不是完整包含了你期望的前景
如果你完全从 0 开始,不要一上来就自己写 GLSL。 建议按这个顺序学习:
- 先用
AutoRenderEntity + @CodecField + @CooAutoRegister跑通一个最小实体。 - 再用
PostGlowSphereRenderer做一个能看见本体的光球。 - 之后再尝试
PersistentBloomContextProvider,理解真正的 bloom 语义。 - 最后再去写自己的 world pass mesh 和自定义 shader。
最稳妥的入门顺序就是:
- 先学同步
- 再学 renderer 生命周期
- 再学帧尾效果
- 最后学 shader 细节
如果你想按“从简单到复杂”的顺序看仓库实现,建议看这些文件:
common/src/main/kotlin/cn/coostack/cooparticlesapi/renderer/RenderEntity.kt看同步基类和spawn(...)common/src/main/kotlin/cn/coostack/cooparticlesapi/renderer/AutoRenderEntity.kt看@CodecField自动编解码common/src/main/kotlin/cn/coostack/cooparticlesapi/renderer/runtime/RenderEntityRenderer.kt看 renderer 生命周期接口common/src/main/kotlin/cn/coostack/cooparticlesapi/renderer/runtime/RenderEntityInstance.kt看客户端实例如何调度 world pass / frame-post / providercommon/src/main/kotlin/cn/coostack/cooparticlesapi/renderer/client/ClientRenderEntityManager.kt看每帧调度入口common/src/main/kotlin/cn/coostack/cooparticlesapi/renderer/glow/PostGlowSphereRenderer.kt看复用型发光球 renderercommon/src/main/kotlin/cn/coostack/cooparticlesapi/renderer/glow/PersistentBloomContextProvider.kt看 bloom provider 接口common/src/main/kotlin/cn/coostack/cooparticlesapi/renderer/client/ClientPersistentBloomManager.kt看 blur + composite 的具体执行链路common/src/main/kotlin/cn/coostack/cooparticlesapi/test/options/renderer/TestRendererEntity.kt看最小 post-glow 样板common/src/main/kotlin/cn/coostack/cooparticlesapi/test/options/renderer/TestPersistentGlowSphereEntity.kt看可配置的发光球样板
你可以把当前系统记成一句话:
“RenderEntity 负责同步,RenderEntityRenderer 负责画;world pass 画实体本体,frame-post 做 glow / bloom;如果你是新手,先从 AutoRenderEntity + PostGlowSphereRenderer 开始。”