多机位输出契约¶
最终主画面 WYSIWYG 输出¶
import lib.kasuga.rendering.output.FrameOutputMode;
import lib.kasuga.rendering.output.mc.MinecraftFrameOutputs;
var output = MinecraftFrameOutputs.open(FrameOutputMode.MIRROR, frame -> {
var texture = frame.resource();
consumeTexture(texture); // 调用方实现,在渲染线程同步消费。
});
output.close(); // 在渲染线程释放。
MIRROR 同时交付 FBO 和显示窗口;OFFSCREEN_ONLY 成功交付后跳过本帧
屏幕 blit,游戏循环和窗口事件仍继续。关闭所有独占输出后恢复正常显示;
没有独占输出成功时回退到原生窗口输出。
默认 view minecraft:main 在 Minecraft.runTick 的最终
RenderTarget.blitToScreen 处截获,世界、后处理、HUD、GUI、加载界面和
调试面板均已绘制。它复用原生最终 blit,提供最终 WYSIWYG 画面,无需
为了捕获再渲染一遍世界。额外机位通过自己的 world/Iris composition 交付。
一次调用创建相机及生命周期¶
所有创建、更新、暂停、恢复、关闭操作都在渲染线程进行。其他线程使用
Minecraft.getInstance().execute(...) 调度;首次创建需要世界已加载。
import lib.kasuga.rendering.output.WorldCameraView;
import lib.kasuga.rendering.output.camera.CameraRenderSettings;
import lib.kasuga.rendering.output.mc.MinecraftCameras;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
var settings = new CameraRenderSettings(
12,
CameraRenderSettings.Quality.FANCY,
new CameraRenderSettings.Shader(
Path.of("/absolute/path/to/shader-pack.zip"),
Map.of("SHADOW_QUALITY", "1")), // 由所选光影包定义的选项名。
List.of(Path.of("/absolute/path/to/resource-pack.zip")),
2, // mipmap 等级 0..4
true // ambient occlusion
);
var camera = MinecraftCameras.create("capture:wide", () ->
new WorldCameraView(100, 80, 100, 45, 15, 0, 70, 1280, 720),
settings, frame -> consumeTexture(frame.resource()));
camera.pause();
camera.updatePose(new WorldCameraView(110, 82, 100, 90, 10, 0, 50, 1920, 1080));
camera.resume();
camera.close(); // 同时关闭 producer 和它拥有的输出。
消费者是示例占位函数。无光影使用 CameraRenderSettings.Shader.disabled()。
省略 settings 的重载使用视距 8、Fancy、无光影、基础 vanilla/mod 材质、
mipmap 0、AO 开启。pose 可以传固定记录或每帧一次采样的 provider。
WorldCameraView 参数为 x/y/z 世界绝对坐标、yaw/pitch/roll、垂直 FOV、宽/高。
角度单位为度,yaw/pitch 遵循 MC。数值必须有限,FOV 为 (0, 180),尺寸
必须为正且不超过 GL 最大纹理尺寸。新尺寸自动替换渲染和输出 FBO。
view ID 必须非空、唯一,不能使用 minecraft:main。
CameraHandle.state() 为 READY、PAUSED、FAILED、CLOSED;failure() 保存
故障。READY 表示注册可用,初次资源异步加载时可能还没有交付第一帧。
provider、资源加载、绘制或消费者失败会移除该机位并释放资源,保留异常。
close() 幂等;暂停停止输出并撤销服务端订阅,恢复后重新订阅。
资源配置在创建时固定,更换包、质量或视距时关闭后重新创建。位置、FOV、
尺寸通过 updatePose 动态修改。低层也可分别调用 MinecraftWorldViews.register
和 MinecraftFrameOutputs.open(viewId, ...),或给同一 view 添加多个消费者;
低层 producer/消费者需分别关闭。无消费者或未启用的机位不重渲染、不采样。
同进程隔离与渲染时序¶
flowchart LR
A[宿主帧 / 共享动画 token] --> B[机位 A world / resource / Iris scope]
B --> C[A 世界与 Iris 最终合成]
C --> D[A 消费者]
D --> E[机位 B 独立 scope 与输出]
E --> F[恢复主视角并渲染]
F --> G[主后处理 / HUD / GUI]
G --> H[最终主画面输出]
| 配置或资源 | 隔离方式 |
|---|---|
| 位置、朝向、FOV、尺寸 | 每机位 Camera、投影和颜色/深度 FBO |
| 区块与光照 | 每机位 ClientLevel、ClientChunkCache、light engine |
| 地形与实体绘制 | 每机位 LevelRenderer、RenderBuffers、可变实体/方块实体 dispatcher |
| 渲染距离 | 每机位 2..32 chunks,独立服务端订阅与客户端视距 |
| Iris 光影 | 每机位 ShaderPack、PipelineManager、材质设置、uniform 时间、捕获及 shadow 状态 |
| 材质与模型 | 每 pack stack 独立 resource manager、TextureManager、ModelManager、atlas |
| 质量 | 每机位 Fast/Fancy、叶片类型、AO、mipmap |
| 雾与云 | 每机位雾历史;Iris/Sodium 云格式标志按 CloudRenderer 实例保存 |
同一 pack stack/mipmap 的机位共享载入的模型/atlas,以引用计数释放;可变
renderer/dispatcher 各自持有。资源包依列表顺序叠加,后面的优先。基础资源
保留 vanilla/mod resources,不继承主视角选中的用户/服务端包,需要的包
明确写入 resourcePacks。目录和 zip 都支持。连接下载的玩家 skin/cape/elytra,
以及共享 light texture、字体 glyph、particle atlas 和没有文件资源的模组生成纹理
为借用资源,不随机位删除。机位自有 atlas 优先,不会被主视角 atlas 替换。
Iris 入口在 scope 内路由到各自实例,部分 static 状态通过切换和恢复隔离。 区块构建任务创建时捕获机位资源、材质和质量,在 worker 执行时恢复上下文。 原版 rebuild task 还持有资源 lease,取消或 CPU 构建结束后才归还,避免关闭 机位时释放仍被异步任务读取的模型/材质。原版 ViewArea 按实际机位坐标定位。 这要求同一 context 顺序渲染,不支持多线程同时绘制机位。
宿主 tick 和 RenderFrameEvent.Pre/Post 每帧仍只执行一次,模型动画以共享
frame token 去重;可见标记每机位绘制前清理。scope 内 MC 世界、renderer、
buffers、目标、camera、资源 getter 指向当前机位,world stage 收到机位视锥
与矩阵。自定义时间历史可用 WorldRenderPipelineContext.viewId() 分区。
退出 scope 恢复主状态以及 GL read/draw FBO、viewport、投影和绘制/雾状态。 回调内关闭延迟释放至本次交付结束。世界切换销毁旧 session,下次按当前 世界重建;MC 关闭统一释放。vanilla 在途 builder pack 在归还时释放,Sodium 停止其 terrain workers 后释放原生缓冲、sky VBO、pipeline 和 FBO。
远处区块与实体¶
服务端也需安装包含该功能的 KasugaLib;单人集成服务器同样使用协议。 不支持 channel 的服务器会让机位明确失败,不假装加载了远处世界。
服务端按 camera UUID、epoch、维度和中心管理订阅,分批申请 FULL/non-ticking 区块 tickets,不在主线程阻塞等待生成。每订阅每 tick 最多申请 8 个 ticket、 发送 4 个已完成且 light-ready 的快照;方块/光照变化触发重发。客户端把 区块、heightmap、光照及方块实体送进各自 ClientLevel。移动丢弃范围外缓存 与过期 epoch 数据,并清理 light/Sodium tracker。暂停、关闭、登出、维度 变化和 heartbeat 超时撤销服务端 tickets。
实体每 5 ticks 同步 spawn、位置、头朝向、完整 metadata、装备、属性、乘客
和已有 leash 到机位世界,副本独立插值,不消费原版 packDirty,保持主玩家
实体追踪。该订阅不是新玩家连接:远处能加载/拍摄,但 non-ticking tickets
不扩展 AI/红石的服务端模拟范围。
输出规约与边界¶
OutputFrame<FrameTexture> 提供 view ID、每 view 的帧号、尺寸和颜色纹理。
颜色为 RGBA8、不透明 alpha,OpenGL 左下角原点,无深度附件。router 按
view 拥有颜色目标;同一帧的所有订阅收到同一个 OutputFrame 和资源对象,
只借用读取,不可写入、resize 或删除。下一帧覆盖,resize 或最后一个订阅
关闭删除旧对象;回调内取消订阅时,释放推迟到本次交付结束。回调外保留画面
必须当场复制到自己的 GPU/CPU storage,DEBUG 预览也是先复制后合成。
消费者在渲染线程,不应等待编码/网络;可以自行实现 PBO/fence 读回和有界
worker 队列,裸纹理 ID 不能交给没有当前 GL context 的异步线程。
上表内资源已隔离,但并非复制完整客户端:
- 额外机位输出 world stages 和 Iris 最终合成,不包含 HUD/GUI、手部、发光
轮廓及主视角的
GameRenderer.postEffect。完整主画面走最终输出入口。 - 质量支持 Fast/Fancy、AO、mipmap,没有全部 options(Fabulous、粒子预设、 独立云开关等)。主 light texture、字体、particle engine 和原版 core shader loader 共用;修改字体、粒子或 core shaders 的包不能认定完全隔离,远处 机位也没有独立的服务器粒子事件流。
- 未完整复刻 resource-pack overlays metadata、第三方 reload listener、模组 实体 pairing payload/attachment、效果与音频协议。
- 第三方代码若直接读 static 状态、缓存主世界或在 world stage 推进逻辑,仍 需适配;带时间历史的效果应按 view ID 管理。
- 多机位高视距会增加生成、网络、CPU/GPU/显存负载。公开服务器的权限、 订阅数量和资源预算由上层策略约束。
不显示窗口画面的 FBO 数据流如何调用¶
无窗口启动后,同样使用下面的 FBO API。MinecraftCameras.create 自带离屏
消费者;MinecraftFrameOutputs.open 订阅最终主画面。启动器保留一个主画面
OFFSCREEN_ONLY 输出,包括加载界面,因此关闭调用方的输出也不会显示 MC
窗口。启动器和调用方的主画面订阅共享一个捕获目标,不额外复制一帧。
正常桌面启动时,主画面订阅可选 OFFSCREEN_ONLY,关闭后恢复显示。
GPU 内直接传递¶
var camera = MinecraftCameras.create("export:camera", () ->
new WorldCameraView(100, 80, 100, 0, 20, 0, 70, 1280, 720), frame -> {
FrameTexture texture = frame.resource();
// 在这个回调和当前 GL context 中,使用 texture.textureId() 采样/合成,
// 或从 texture.framebufferId() blit 到调用方持有的 FBO。
});
同步只读消费者直接共享上面的句柄,不需要先复制。比如一个机位同时 订阅两个预览窗口和一个 GPU 合成器,router 只创建一份输出颜色纹理、 每帧只执行一次最终捕获;窗口呈现到各自屏幕仍各需一次 blit。关闭其中 一个消费者不会释放其他消费者正在用的画面,消费者异常也只取消其订阅。 捕获本身失败则取消该 view 的所有订阅,其他机位继续工作。
纹理 ID 是 GL 对象句柄,不是可被 CPU 解引用的指针,仅在本进程相应 GL
context/share group 有意义。即使只读,CPU 编码器仍需回读。若后续帧或异步
消费者还需要它,必须在回调里复制到自有 FBO,并在 GL/render 线程管理复制目标和 fence,不能
把借用的 ID 直接放入后台队列。DEBUG 预览的 OutputFramebuffer.capture
加 glBlitFramebuffer 是一份实际的 GPU 复制示例,见 modelling 模块的 DualCameraDebug。
CPU 字节流:最小可用录制示例¶
提供 RgbaReadback.copy(FrameTexture):在回调中返回调用方拥有的紧密排列
RGBA8 byte[],已翻转为左上角原点,无 stride padding。它恢复 read FBO、
read buffer、pixel-pack buffer 和 pack 参数;返回后 byte[] 可交给无 GL
context 的线程。该实现是同步 glReadPixels,会等待 GPU,适合功能验证,
高吞吐录制应替换为 PBO/fence 多槽读回。
以下片段固定 1280×720,把一个机位写为可编码的原始 RGBA 文件;创建/关闭 camera 在渲染线程,磁盘写入在单独线程。调用方应先创建输出目录:
import lib.kasuga.rendering.output.WorldCameraView;
import lib.kasuga.rendering.output.gl.RgbaReadback;
import lib.kasuga.rendering.output.mc.MinecraftCameras;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
var queue = new ArrayBlockingQueue<byte[]>(2);
var running = new AtomicBoolean(true);
var writer = Executors.newSingleThreadExecutor();
// 保留 future;结束后检查 get(),使磁盘错误可见。
var writeResult = writer.submit(() -> {
try (var stream = Files.newOutputStream(Path.of("capture.rgba"))) {
while (running.get() || !queue.isEmpty()) {
byte[] rgba = queue.poll(100, TimeUnit.MILLISECONDS);
if (rgba != null) stream.write(rgba);
}
}
return null;
});
var camera = MinecraftCameras.create("export:camera", () ->
new WorldCameraView(100, 80, 100, 0, 20, 0, 70, 1280, 720), frame -> {
if (writeResult.isDone()) {
try { writeResult.get(); }
catch (Exception failure) { throw new IllegalStateException("Output writer failed", failure); }
throw new IllegalStateException("Output writer stopped");
}
if (queue.remainingCapacity() == 0) return; // 有界队列满时丢帧,不阻塞宿主。
queue.offer(RgbaReadback.copy(frame.resource()));
});
// 录制结束:在渲染线程关闭 camera,然后通知 writer 排空剩余数据。
try { camera.close(); }
finally { running.set(false); writer.shutdown(); }
// 在后台控制线程等待 writeResult.get(),不要在渲染线程等待磁盘。
输出文件顺序为逐帧、逐行 RGBA,单帧大小 1280 * 720 * 4 bytes。可用 FFmpeg
转换为视频:
ffmpeg -f rawvideo -pixel_format rgba -video_size 1280x720 -framerate 60 \
-i capture.rgba -c:v libx264 -pix_fmt yuv420p capture.mp4
这里的 60 是对文件帧序列的播放解释,并非保证 MC 按 60 FPS 采样;队列满 会丢帧,游戏也按真实时间 tick。需要准确时长/固定时间步的离线渲染时,还 需要调度时间、按时间戳补帧或离线推进,以及编码器背压策略。连续流期间 分辨率必须固定,resize 后需重新配置编码器/开始新片段。
只想导出最终主画面时把上述 create 换为:
var output = MinecraftFrameOutputs.open(FrameOutputMode.OFFSCREEN_ONLY, frame -> {
byte[] rgba = RgbaReadback.copy(frame.resource());
// 按相同有界队列规约交付;尺寸使用 frame.width()/frame.height()。
});
// 结束时 output.close()。
流向为 world/Iris → 每 view 一次最终 blit → 共享输出 FBO → 同步只读回调 → 自有 GPU 副本或
RGBA 字节 → 有界后台队列 → 编码/磁盘/网络。额外机位不含 HUD,主视角含
完整最终画面。公开 API 的纹理输出和读回已实现;示例视频编码属于调用方。
只读消费者共享完成帧¶
输出 router 已改为按 view 捕获和持有 storage。同一帧所有订阅收到同一个
OutputFrame<FrameTexture>,最后一个订阅关闭后才释放共享目标;回调中关闭
或替换订阅不会提前删除借用纹理。首次注册选择目标 factory,首次有效输出
才分配 GPU storage。不同 view 仍各自拥有输出目标。
| 同一 view 的 N 个同步只读订阅 | 修改前 | 修改后 |
|---|---|---|
| 输出颜色纹理 / FBO | N 份 | 1 份 |
| 每帧最终捕获 | N 次 | 1 次 |
| 每帧捕获的 alpha 初始化操作 | 2N 次 clear | 2 次 clear |
| 消费者调用 | N 次 | N 次 |
RGBA8 颜色 storage 的名义大小是 4 × width × height 字节;1080p 约
7.91 MiB、4K 约 31.64 MiB。上表不计 driver metadata、世界渲染目标、窗口
呈现或消费者自己的复制/编码 storage。MIRROR 主画面仍需要正常屏幕 blit,
每个辅助窗口也仍需要呈现;共享捕获并不省掉这些消费者的工作。
此修改没有加入 CPU 零拷贝或异步读回。GPU 纹理 ID 不是 CPU 地址,
RgbaReadback.copy 仍执行同步 GPU 回读、Java 数组复制和 Y 翻转。只读 GPU
消费者直接借用句柄即可;跨帧、异步消费需自有 storage 和同步机制。
本轮 JVM 检查 633 用例,0 failures/errors,1 skipped,独立 GL 10 组
通过,renderingReleaseCheck 通过。新增 GL 断言实测三个订阅只分配一个
输出目标、每帧只捕获一次,消费者失败后仍可读取正确 RGB/alpha,resize
和最后订阅关闭释放旧 GL 对象。真实 MC 菜单检查通过共享帧身份、完整 GUI
逐像素一致、resize、回调内替换订阅、最后订阅释放和消费者故障回退。
这些结果验证捕获次数和资源所有权,没有测量独立 GPU 毫秒耗时;前文 JFR
数据为修改前的整帧结果,不能据此声称本次共享捕获的具体 FPS 提升。
共享捕获版本还重新通过 macOS MC + Iris/Sodium 的多机位窗口回归
(FRAME_WINDOWS_SMOKE_PASS,独立关闭与 resize)和隐藏窗口启动回归
(HEADLESS_CLIENT_SMOKE_PASS,主画面加两机位输出)。本轮没有重跑 Linux
无显示服务测试;前文 Linux 记录属于此前的启动器验证。
本文是离屏与多机位主题的契约篇。启动、验证与性能实操见 离屏渲染实操,原理见 离屏输出与多机位原理。