渲染与视觉扩展
模组的所有自绘入口都是中立的:只面对 RenderCanvas 与 RenderContext,不接触任何平台图形 API(宿主内部以具体实现接入,颜色表示与转换由宿主处理)。
6.1 渲染扩展点的注册方式
三类渲染扩展点全部走 ctx.registries()(ModRegistries),统一使用 Registry#register(RegistryKey, 内容实例):
ctx.registries().renderFilters()——渲染滤镜;ctx.registries().backgroundRenderers()——背景渲染器;ctx.registries().hitEffectStyles()——打击特效样式 provider。
不再有 per-模组的 registerXxx(registry) 回调(GameplayMod 上已无这类方法)。注册必须写在注册窗口内(onPreInit):宿主在全部模组的 onPreInit 结束后立即冻结注册表,此后再注册抛 RegistryFrozenException;同一 ResourceLocation 重复注册抛 IllegalArgumentException。
| 扩展点 | 注册方式 | 执行顺序规则 | 返回 null 的语义 |
|---|---|---|---|
| 渲染滤镜 | ctx.registries().renderFilters() | 按 zOrder() 升序执行(数字小的先执行、越靠下),层号相同按注册顺序 | 不适用(apply(RenderCanvas, FilterFrame) 无返回值) |
| 背景渲染器 | ctx.registries().backgroundRenderers() | 按 zOrder() 升序执行(数字小的先绘制、越靠下),层号相同按注册顺序 | 不适用(render(RenderCanvas, FilterFrame) 无返回值) |
| 打击特效样式 | ctx.registries().hitEffectStyles() | 按注册顺序遍历 provider,所有返回非 null 的样式同时生效、依次绘制 | 表示该 provider 不参与本次打击;全部 provider 均返回 null 时使用游戏默认文字特效 |
补充说明:
- 所在层是固定的:渲染滤镜落在
FULLSCREEN(第 6 层,最顶层,绘制在游玩内容之上),背景渲染器落在BACKGROUND(第 0 层,最底层,绘制在游玩内容之下)。zOrder()只决定同一类回调内部的相对叠放,不改变所在层。 zOrder()默认返回0,取值范围为任意整数(可为负数或大数字,如-99表示该类最底、99表示最顶),不受RenderLayer枚举 0-6 编号范围的限制。- 滤镜与背景渲染器都拿到
FilterFrame作为帧只读视图:width()/height()(像素)、musicTimeMs()、progress()(0-1)、canvas()(唯一绘制入口)、context()(完整游玩上下文快照,含 combo / accuracy / misses / lanes 等)、layer()(滤镜为FULLSCREEN、背景渲染器为BACKGROUND)、zOrder()(透传当次回调声明的层号)。宿主保证单帧内各回调状态互不污染(每个回调前后自动 save/restore)。
6.2 绘制入口 RenderCanvas
void fillRect(double x, double y, double w, double h);
void drawRect(double x, double y, double w, double h);
void drawLine(double x1, double y1, double x2, double y2);
void fillTriangle(double x1, double y1, double x2, double y2, double x3, double y3);
void setColor(Color color);
void setAlpha(double alpha);
void clip(double x, double y, double w, double h);
void save();
void restore();
void translate(double dx, double dy);
void scale(double sx, double sy);
void rotate(double angle, double pivotX, double pivotY);
void drawText(String text, double x, double y, TextStyle style);
TextMetrics measureText(String text, TextStyle style);
void drawImage(RenderImage image, double x, double y, double w, double h);关键约定:
fillRect为实心填充,支持半透明(受setAlpha(double)影响);drawRect绘制 1px 描边矩形(不填充内部);drawLine绘制 1px 直线;fillTriangle填充实心三角形。setColor(Color)同时作用于填充与描边;文本颜色由TextStyle单独指定,不受setColor影响。setAlpha(double)(0-1)作用于后续所有绘制。translate/scale/rotate(angle, pivotX, pivotY)修改坐标系;rotate的角度单位是弧度,轴点为当前局部坐标(正值 = 屏幕顺时针,Y 轴向下)。drawText(text, x, y, style)的x语义由TextStyle#alignment()决定(LEFT左缘 /CENTER水平中心 /RIGHT右缘);measureText只测量不绘制,返回TextMetrics(width()/height(),不含基线信息)。drawImage(image, x, y, w, h)把图像拉伸绘制到目标矩形,不支持子区域;要画图集的一部分请用RenderContext#drawTexture(...)(源坐标为归一化[0,1],u1 > u0、>)。save()与restore()必须成对使用(保存 / 恢复颜色、透明度、裁剪、变换等全部绘制状态)。clip(x, y, w, h)要包在save()/restore()里:在其间设置的裁剪区在该对 save/restore 结束后自动失效。注意clip是局部空间语义,按调用时的当前变换求交(“先裁剪、后变换”),因此在translate之后调用clip(0, 0, w, h)裁的是平移后的局部矩形,而不是世界坐标;反向换算世界坐标是错误用法。
6.3 RenderContext 提供什么
一次渲染回调的上下文,模组从渲染事件里拿到它(event.context()):
int width();
int height();
double partialTick();
RenderLayer layer();
MatrixStack matrices();
RenderCanvas canvas();
void drawTexture(RenderImage image, float x, float y, float w, float h,
float u0, float , float u1, float );
AdvancedRenderAccess advanced();- 生命周期:本对象只在本次回调期间有效。回调返回后宿主可能复用其底层资源(画布 / 矩阵栈),因此不得把
ctx、canvas()、matrices()存起来跨帧或跨回调使用,也不得在回调之外调用它们的绘制方法。 - 坐标系:
width()×height()是本层的逻辑尺寸,绘制使用逻辑坐标(左上角为原点)。宿主在进入插槽前已把画布变换设置为“逻辑空间”,模组无需自行换算分辨率。 canvas()的状态(颜色 / 透明度 / 裁剪 / 变换)由宿主在进入插槽前重置为默认;模组应自行save()/restore()配对——宿主会在插槽结束后强制恢复,但插槽内部的状态污染会影响同层其它模组。matrices()返回MatrixStack(push()/pop()/translate(dx, dy)/scale(sx, sy)/rotate(angle, pivotX, pivotY))。它与canvas().save()/restore()共享同一份底层状态:push()等价于canvas().save(),pop()等价于canvas().restore(),二者可以混用,但成对关系必须守恒。栈深超限时push()安全降级(不压栈、限流告警),配对的pop()同样为空操作。- 3D 变换原语(z 通道):
MatrixStack另提供translate3D(dx, dy, dz)/scale3D(sx, sy, sz)/rotateX(angle, pivotY, pivotZ)/rotateY(angle, pivotX, pivotZ),与 2D 方法同栈、可交错书写,把自绘内容放入 3D 世界空间。口径: - z 单位 = 像素,
+z指向屏幕内(远离观察者);z=0 为内容基准平面(与轨道/音符共面),硬边界|z| ≤ 2000(推荐|z| ≤ 500以规避远处深度精度问题)。角度单位是弧度(与rotate同口径,注意区别于GameStateAccess相机接口的度口径)。 - 世界层作用域:
BACKGROUND/WORLD/NOTES/JUDGE_FX四层(含index < 4的自定义层)内 z 进入深度场——不同 z 的内容按深度遮挡(与书写顺序无关),轨道倾斜(track_tilt)时随透视相机产生视差。 - 层外安全退化:
HUD/OVERLAY/FULLSCREEN(含index ≥ 4的自定义层)永久正交、无深度测试,3D 原语退化为 2D(z 不影响像素,rotateX/rotateY只表现为 2D 压扁),不报错不告警。 - 纯视觉:3D 变换不参与判定,不影响输入映射与判定线几何。
- 2D 快路径:
dz==0(且此前无 3D 变换)逐位等价于translate、sz==1逐位等价于scale、0° 旋转逐位退化——不写 3D 的代码零额外开销。 partialTick()是帧插值参数([0,1])。固定步长帧模式下由宿主计算,vsync/unlimited模式恒为1.0;宿主已登记说明:本引擎为“每帧一次逻辑更新 + 同帧渲染”,固定步长下该值恒接近0,当前没有实际的插值需求,依赖它做平滑的模组应先按常量设计。advanced()给出裸图形 API 出口AdvancedRenderAccess(available()/isRenderThread()/runOnRenderThread(Runnable)/nativeContextId()):破坏可移植性、不承诺跨版本稳定,GL 调用只能在渲染线程执行,非必要不用。
6.4 七个 RenderLayer
RenderLayer 是引擎内置渲染阶段枚举,自底向上共 7 层,每层对应固定数字编号(index())。编号越大越靠上。宿主在每一层的前后各投递一次 RenderEvent.Pre / RenderEvent.Post(共 7 层,每帧 14 次),模组据此把自绘内容插到引擎各层之间。
| 枚举常量 | index() | 用途 |
|---|---|---|
BACKGROUND | 0 | 背景层——模组背景渲染器(曲绘 / 纯色等),位于游玩内容之下 |
WORLD | 1 | 轨道容器层——模组轨道样式、Z 分层轨道、HOLD 长条装饰;模组在此绘制“轨道之下、背景之上”的内容(如自定义轨道底板) |
NOTES | 2 | 轨道与音符层——真实轨道 / 音符、假轨与假音符、屏障指示 |
JUDGE_FX | 3 | 判定特效层——打击特效(含模组的 HitEffectStyle) |
HUD | 4 | 原版 HUD 层——combo、快捷键框、进度条、分数 / 准度 / 血条、模组 HUD 覆盖 |
OVERLAY | 5 | 覆盖渲染层——altHud、暂停对话框与花屏、模组 renderOverlay、入场黑幕 / 读秒遮罩 |
FULLSCREEN | 6 | 全屏覆盖层(最顶层)——后处理滤镜(模组的 RenderFilter) |
RenderEvent.Pre在该层内容绘制之前投递(模组内容画在该层之下),RenderEvent.Post在之后投递(模组内容画在该层之上)。- 比
RenderEvent的NOTES层更细的插槽是TrackRenderEvent.Pre/TrackRenderEvent.Post:模组可以精确地画在“轨道与音符之下 / 之上”。它只在游玩场景投递(轨道渲染器也被资源预览等场景复用,那些场景不投递本事件),监听器不得假设“每帧必到”。 - 渲染事件不可取消(渲染是纯通知),
setResult无效。
6.5 示例:注册一个简单渲染滤镜(描边 / 边框)
package com.example.bordermod;
import com.seecmd.mod.api.GameplayMod;
import com.seecmd.mod.api.ModContext;
import com.seecmd.mod.api.ModInfo;
import com.seecmd.mod.api.registry.RegistryKey;
import com.seecmd.mod.api.render.FilterFrame;
import com.seecmd.mod.api.render.RenderCanvas;
import com.seecmd.mod.api.render.RenderFilter;
import java.awt.Color;
import java.util.List;
/**
* 渲染滤镜示例:在所有游玩内容之上叠加一圈边框。
*
* <p>滤镜固定落在 {@link com.seecmd.mod.api.render.RenderLayer#FULLSCREEN}(第 6 层,最顶层),
* zOrder 只决定同一类回调(其它滤镜)之间的相对叠放。
*/
public final class BorderExampleMod implements GameplayMod {
private static final String MOD_ID = "example_border";
@Override
public ModInfo info() {
return ModInfo.of(MOD_ID, "Border Example", "1.0.0", List.of("YourName"),
"演示注册一个在游玩内容之上叠加边框的渲染滤镜");
}
@Override
public void onPreInit(ModContext ctx) {
// 注册窗口内注册:重复注册同一 location 会立刻失败,
// 注册表冻结后(onInit 及之后)再注册会抛 RegistryFrozenException。
ctx.registries().renderFilters().register(
RegistryKey.of(MOD_ID, "border", RenderFilter.class),
new BorderFilter());
}
/** 4px 彩色边框滤镜。 */
private static final class BorderFilter implements RenderFilter {
private static final int BORDER = 4;
@Override
public int zOrder() {
return 10; // 数字越大越靠上
}
@Override
public void apply(RenderCanvas canvas, FilterFrame frame) {
double w = frame.width();
double h = frame.height();
// 自绘前先 save():宿主会在回调结束后强制恢复状态,
// 但这不影响插槽内部的状态污染,配对自己收尾更稳妥。
canvas.save();
canvas.setAlpha(1.0);
canvas.setColor(new Color(255, 0, 0, 180)); // 上
canvas.fillRect(0, 0, w, BORDER);
canvas.setColor(new Color(0, 0, 255, 180)); // 下
canvas.fillRect(0, h - BORDER, w, BORDER);
canvas.setColor(new Color(0, 255, 0, 180)); // 左
canvas.fillRect(0, 0, BORDER, h);
canvas.setColor(new Color(255, 255, 0, 180)); // 右
canvas.fillRect(w - BORDER, 0, BORDER, h);
// 内层半透明描边:裁剪必须包在一对 save/restore 之间,结束后裁剪自动失效。
canvas.save();
canvas.clip(20, 20, w - 40, h - 40);
canvas.setAlpha(0.5);
canvas.setColor(Color.WHITE);
canvas.drawRect(20, 20, w - 40, h - 40);
canvas.restore();
canvas.restore();
}
}
}6.6 HUD 自绘的位置
模组若要隐藏并自绘原版 HUD 组件(分数 / 连击 / 准度 / HP 条),应提供 GameplayHud 能力(经 GameplayMod#createGameplayHud() 返回,或经 ModContext#capabilities().register(GameplayHud.class, ...) 注册):
- 可见性声明:调用
setProgressVisible(false)/setComboVisible(false)/setScoreVisible(false)/setAccuracyVisible(false)声明隐藏原版组件。对应的isXxxVisible()是“声明”而非“查询真实状态”:声明隐藏后 getter 返回false,宿主据此跳过原版绘制并进入覆盖渲染。 - 覆盖渲染:宿主在覆盖阶段调用
renderOverlay(RenderCanvas canvas, HudContext hudCtx),模组在此自绘。数值经宿主每帧调用updateValues(HudValues)注入,模组在renderOverlay中经values()读取,两者配对使用(不要自行缓存来自其它渠道的数值)。 - 只附加、不隐藏:默认
wantsOverlayWithoutHiding()返回false,即只有隐藏了至少一个原版组件时宿主才调用renderOverlay;返回true表示模组希望在原版 HUD 之上附加绘制内容(如提示 HUD、装饰层)而保留全部原版组件。 - 多模组叠加:所有返回非 null 的实例按启用顺序收集,可见性声明取“任一隐藏即隐藏”(最严格),覆盖渲染依次调用、可互相叠加。
- 另有一种更轻量的做法是不接管 HUD 能力,而是注册一个渲染滤镜,在
FULLSCREEN层自绘(如本文 6.5 与 5.5 的示例);它不改变原版 HUD 的绘制,只是叠加内容。需要在特定层绘制时,也可以改为订阅对应层的RenderEvent。
GameplayHud、HudContext、HudValues 的完整签名与语义见「能力接口」与「只读视图」相关章节。
6.7 场景实体:挂到世界层级 + 查询世界矩阵自绘(查询面)
模组可以把自有实体挂进宿主的世界层级(轨道区 / 某条轨道 / 自己的另一个实体之下),
每帧写入本地位姿,再查询世界矩阵来摆放自绘内容——这就是「模组可见层级 API」
(2026-10-16,统一世界二期,查询面)。入口全部在 GameStateAccess:
createEntity / mountToTrackArea / mountToTrack / mountToEntity / entityPose /
entityWorldMatrix / entityVisible / setEntityVisible / entityAlpha / setEntityAlpha
/ destroyEntity;类型为 render.SceneEntity(不透明句柄)与 render.EntityPose
(本 tick 位姿写入面)。
最小流程(三步):
// ① 创建 + 挂载(挂载窗口 = 非 tick 上下文,如 GameStartEvent 回调)
ctx.eventBus().subscribe(GameStartEvent.class, e -> {
GameStateAccess game = ctx.capabilities().get(GameStateAccess.class);
if (game == null) return; // 能力仅在游玩中存在,取用前判空
this.marker = game.createEntity("marker"); // 实体归本模组所有(未挂载)
game.mountToTrack(this.marker, 1); // 挂到轨道 1(也可 mountToTrackArea)
});
// ② 每 tick 写本地位姿(GameplayTickEvent 回调内——唯一合法窗口;未写 = 恒等、帧间不累积)
ctx.eventBus().subscribe(GameplayTickEvent.Post.class, e -> {
GameStateAccess game = ctx.capabilities().get(GameStateAccess.class);
if (game == null || this.marker == null) return;
com.seecmd.mod.api.render.EntityPose pose = game.entityPose(this.marker);
if (pose == null) return; // 窗口外 / 句柄失效 → null(限频告警)
pose.setOffsetX(120); // 标量平移(实体本地坐标,可写)
pose.translate(0, 40); // 标量叠加(等价 offsetY += 40)
pose.rotateZ(e.musicTimeMs() * 0.09, 0, 0); // op 追加(保序;禁随机/禁墙钟——只用 τ)
});
// ③ 查询世界矩阵 → 经既有渲染扩展点自绘(查询面:宿主不替你渲染实体,画什么、怎么画由你决定)
// 在你的 RenderEvent / RenderCanvas 回调里:
double[] m = game.entityWorldMatrix(this.marker); // 列主序 double[16](防御性拷贝,可留存)
// 世界坐标 = m 作用于本地点:wx = m[0]*x + m[4]*y + m[12],wy = m[1]*x + m[5]*y + m[13]必须遵守的契约:
- 挂载窗口:
mountTo*/destroyEntity只在非 tick 上下文(GameStartEvent、 场景生命周期内)合法;tick 分发期间调用被拒(限频告警,不抛异常)。 - 每 tick 清空:
entityPose写入的本地位姿每 tick 开始被宿主清空——未写 = 恒等、 帧间不累积,需要持续位姿就在GameplayTickEvent回调内每帧重写(确定性契约: 禁随机、禁墙钟,时间一律取事件的musicTimeMs())。 - 所有权:实体归创建方模组独占——挂载/销毁/可见性/alpha 只能操作自己的实体;
mountToEntity的父子都必须属于自己。改他人实体被拒绝(限频告警)。 - 生命周期:实体随游玩场景存续——游玩结束(场景退出)宿主自动级联销毁全部模组 实体,句柄失效(查询返回
null/false/0);不要跨局缓存句柄。 - 查询面语义:实体 = 纯变换节点(组,无渲染内容)——宿主不会按树渲染你的实体; 世界矩阵/继承量(
entityVisible= 父∧本、entityAlpha= 父链连乘)是查询产物, 自绘内容由你经既有渲染扩展点(RenderEvent/RenderCanvas/ 渲染滤镜)完成。 - 纯视觉:整套 API 只影响渲染,判定引擎零消费。
完整签名与契约见 API 参考 的「SceneEntity 与 EntityPose(render 包)」与
GameStateAccess「模组可见层级」段。