离屏输出与多机位渲染¶
本页维护接口、启动和操作参考;隔离、数据流与同步原理见 原理文档。 Player、Free、Fixed 分类与创建入口见 相机 API。下文独立世界机位的配置适用于 Free/Fixed; Player 共享原生最终主画面和玩家当前配置。 下文性能和客户端验收段落保留当时的历史记录,不表示此次文档迁移重新进行了对应测试。
本文对应 Minecraft 1.21.1 / NeoForge 21.1.203;兼容路径实际验证了 Sodium 0.6.13 和 Iris 1.8.12。所有机位在同一个客户端进程、主 OpenGL context 内顺序渲染;多窗口使用共享纹理的辅助 context 展示已完成的画面。机位分别拥有世界缓存、地形渲染器、相机、投影、颜色/深度目标 和 Iris pipeline,主视角仍走原生渲染路径。
离屏输出交付 GPU FBO,游戏 tick 继续运行。无窗口启动入口支持桌面隐藏 context,以及 Linux 无 X11/Wayland 的 EGL pbuffer context。仍需运行完整 Minecraft 客户端及 OpenGL 驱动;目前没有固定时间步离线视频导出器或内置 视频编码器,调用方可以把画面送往自己的合成、编码和传输模块。
双机位 DEBUG 启动¶
./gradlew :modules:modelling:runClient -PkasugaDebugDualCamera=true
# 可选:进入指定的已有单人世界;省略则从菜单正常进入。
./gradlew :modules:modelling:runClient \
-PkasugaDebugDualCamera=true \
-PkasugaQuickPlayWorld=world-view-test \
-PkasugaClientDirectory=/absolute/path/to/test-client
等价的 JVM 开关为 -Dkasuga.debugDualCamera=true。默认关闭,不创建 DEBUG
机位或预览目标。进入世界后创建 debug:first-person 和 debug:third-person,
左侧为第一人称,右侧为第三人称,窗口标题标明左右顺序。两者各有独立
session,FOV 跟随用户设置,尺寸跟随窗口,视距 8 chunks、Fancy、光影关闭。
第三人称沿视线向后 4 blocks,并检查墙体遮挡;第一人称隐藏玩家自身实体。
DEBUG 预览为世界画面,不含第一人称手部或 HUD。打开菜单/背包时暂停并排 合成,正常显示原有界面。退出世界释放机位,下次进入自动重建。主视角仍 执行,DEBUG 合成发生在最终屏幕输出前;此时订阅主视角捕获的也是并排画面。
自动检查只用于隔离的世界副本:
./gradlew :modules:modelling:runClient \
-PkasugaDebugDualCamera=true \
-PkasugaDebugDualCameraCaptureFrames=180 \
-PkasugaQuickPlayWorld=world-view-test \
-PkasugaClientDirectory=/absolute/path/to/test-client
预览 180 帧后写入 debug/dual-camera.png、打印 DUAL_CAMERA_DEBUG_PASS
并关闭客户端。普通 DEBUG 开关持续运行,不自动退出。
多窗口¶
./gradlew :modules:modelling:runClient -PkasugaDebugMultiWindow=true
进入世界后,第一和第三人称各有一个独立窗口,MC 主窗口继续显示。可配合
kasugaQuickPlayWorld / kasugaClientDirectory;测试用
-PkasugaDebugDualCameraCaptureFrames=120 在两路均呈现 120 帧后打印
MULTI_WINDOW_DEBUG_PASS 并退出。默认不开启。DEBUG 会临时禁用失焦暂停,
结束后恢复原设置;普通 API 调用方可自行设置 mc.options.pauseOnLostFocus。
import lib.kasuga.rendering.output.gl.FramePreviewWindow;
import lib.kasuga.rendering.output.mc.MinecraftFrameWindows;
// 同时拥有相机和窗口:关闭任一该机位的原生窗口会释放这个 camera。
var windowCamera = MinecraftFrameWindows.createCamera("monitor:wide", () ->
new WorldCameraView(100, 80, 100, 0, 20, 0, 70, 640, 360), settings,
new FramePreviewWindow.Options("Wide camera", 640, 360));
windowCamera.pause();
windowCamera.resume();
windowCamera.resize(1280, 720);
windowCamera.close();
// 只拥有窗口和订阅:关闭这个窗口不会关闭现有相机。
var mirror = MinecraftFrameWindows.open("capture:wide",
new FramePreviewWindow.Options("Existing camera", 640, 360));
var mainMonitor = MinecraftFrameWindows.open(MinecraftFrameOutputs.MAIN_VIEW,
new FramePreviewWindow.Options("Final MC + GUI", 640, 360));
mirror.close();
mainMonitor.close();
所有操作在 MC 渲染线程调用。相机尺寸随窗口 framebuffer/DPI 改变;最小化时 保留最近有效尺寸。关闭事件每帧处理,暂停机位也可关闭。单独订阅的窗口在 源机位停止发布后保留最后画面,调用方负责关闭它。退出 MC 会统一释放窗口, 之后才释放主 context。
辅助窗口使用共享纹理的 GL context、自己的 read FBO 和双向 GPU fence。
呈现只复制完成的画面,不增加世界渲染;同一机位每帧只捕获一次,多个
订阅共享只读输出 FBO。每个辅助窗口仍有自己的 context-local read FBO,
它只是挂接共享纹理的读取入口,不额外分配一份输出颜色纹理。
纹理读取完成后主 context 才能覆盖下一帧;不使用 CPU glFinish。每次呈现
恢复 MC 的 current context / GL capabilities。独立窗口可拉伸,画面保持比例
并留黑边。GLFW 初始化、全局 error callback 和终止仍由 MC 管理。
这不是多个客户端:玩家输入、tick、主窗口事件循环仍属于同一个 MC。 Linux Null/EGL 模式不提供原生窗口,调用多窗口 API 会明确报错。
无窗口 MC 启动器¶
仓库提供 scripts/run-headless-client.sh 和 Gradle runClientHeadless。
默认使用独立 modules/modelling/run/headless-client 目录,不复用日常客户端。
单人 Quick Play 的世界必须已存在于该目录的 saves/,也可指定已有目录:
./scripts/run-headless-client.sh \
-PkasugaClientDirectory=/absolute/path/to/headless-client \
-PkasugaQuickPlayWorld=world-view-test \
-PkasugaHeadlessFps=60
# 或直接连接服务器,需符合该客户端会话的认证条件。
./scripts/run-headless-client.sh -PkasugaHeadlessServer=localhost:25565
# 桌面机器的 Gradle 直接入口。
./gradlew :modules:modelling:runClientHeadless -PkasugaHeadlessBackend=hidden \
-PkasugaQuickPlayWorld=world-view-test
这是本仓库 NeoForge 开发客户端的启动入口,保留 Gradle 的 mod/classpath 配置, 不负责账号登录或视频编码。未指定世界/服务器时启动到离屏菜单,供调用方的 客户端扩展控制;不是自动创建新世界。
| 模式 | GL context | 显示服务 |
|---|---|---|
hidden,macOS/Windows 默认 |
从创建起隐藏的 GLFW 窗口 | 需要桌面显示环境 |
egl,Linux 默认 |
GLFW Null 平台 + EGL pbuffer | 不需要 X11/Wayland/Xvfb |
启动前 prepareHeadlessClient 在所选客户端目录的 config/fml.toml 设置
earlyWindowControl = false,避免 NeoForge 在 game mixin 生效前显示 splash。
该设置会保留在此目录。MC 不进入全屏、context 不显示、不启用 vsync,禁用
失焦暂停;渲染帧率单独由 kasugaHeadlessFps(默认 60)控制。首次辅助功能
介绍页被跳过,游戏 tick、世界加载、资源包和常规加载界面仍运行。
Linux 需要 Java 21、C 编译器、CMake、patch、curl,以及支持桌面 OpenGL
的 EGL 驱动。例如 Debian/Ubuntu 的 Mesa 环境可安装 libegl1、
libgl1-mesa-dri、build-essential、cmake、patch、curl。第一次启动脚本
自动下载固定 GLFW 3.4 源码、校验 SHA-256、应用仓库内的小型 pbuffer patch,
编译关闭 X11/Wayland 的共享库。构建产物位于
modules/modelling/build/headless-glfw/cmake/src/libglfw.so,后续启动复用。
# Linux:脚本会 unset DISPLAY / WAYLAND_DISPLAY,并默认 EGL_PLATFORM=surfaceless。
./scripts/run-headless-client.sh -PkasugaQuickPlayWorld=world-view-test
# 预先单独构建,或部署预编译的同架构库。
./scripts/build-headless-glfw.sh
KASUGA_HEADLESS_GLFW_LIBRARY=/opt/kasuga/libglfw.so \
./scripts/run-headless-client.sh -PkasugaHeadlessServer=localhost:25565
# 显式使用软件渲染,仅用于无 GPU 环境的功能验证。
LIBGL_ALWAYS_SOFTWARE=1 ALSOFT_DRIVERS=null \
./scripts/run-headless-client.sh -PkasugaQuickPlayWorld=world-view-test
底层 GLFW 3.4 的普通 EGL 路径只选择 window surface;patch 仅在 Null 平台
选择 pbuffer config 并调用 eglCreatePbufferSurface。使用原版 GLFW 库代替
此库不等价。若直接启动 Gradle 的 egl 模式,须自行 unset 显示环境变量、
设置 EGL_PLATFORM=surfaceless 并传 -PkasugaHeadlessGlfwLibrary=/absolute/libglfw.so。
JVM 等价配置为 -Dkasuga.headless=egl 与 -Dorg.lwjgl.glfw.libname=...,
NeoForge 的早期窗口配置仍须在启动前关闭。
没有显示服务也仍需 GL 驱动。硬件 EGL 取决于驱动支持和 render node 权限; 多机位光影/大材质包仍消耗对应 CPU/GPU 内存。隐藏窗口或 pbuffer 都不会 消除世界渲染成本,软件渲染也不代表生产硬件性能。
验证¶
真实客户端用世界副本和隔离目录,检查 shader/shadow session、不同材质 atlas 像素、1024 blocks 外的区块与实体、metadata 默认值恢复、主 RGB 一致、 雾与状态恢复、resize、暂停/恢复、回调关闭及故障释放。
./gradlew :modules:modelling:modelUnitTest :modules:modelling:renderGlTest
./gradlew :modules:modelling:runClient \
-PkasugaTestWorldViews=true -PkasugaTestCameraIris=true \
-PkasugaQuickPlayWorld=world-view-test \
-PkasugaClientDirectory=/absolute/path/to/test-client
# 不安装 Iris/Sodium 的路径。
./gradlew :modules:modelling:runClient \
-PkasugaRendererMods=false -PkasugaTestWorldViews=true \
-PkasugaQuickPlayWorld=world-view-test \
-PkasugaClientDirectory=/absolute/path/to/vanilla-test-client
客户端产物为 debug/world-views/report.json 和 PNG,成功标志
WORLD_VIEWS_SMOKE_PASS。独立 GL 回归覆盖最终 blit RGB/方向/alpha、resize、
异常状态恢复和资源释放,无需启动完整 MC。
JFR 测试与性能优化¶
./gradlew :modules:modelling:runClient \
-PkasugaTestCameraPerformance=true \
-PkasugaQuickPlayWorld=world-view-test \
-PkasugaClientDirectory=/absolute/path/to/performance-test-client
python3 scripts/analyze-camera-jfr.py \
/path/to/cameras-4.jfr --output /path/to/analysis.json
真实 MC 依次跑 0、1、2、4 个额外机位,最后复测 0;每组预热 8 秒,JFR
profile 记录 12 秒。额外机位 1280×720、4 chunks、Fancy、无光影,主视角
使用测试光影,关闭 vsync,空消费者统计交付。测试自动退出,结果为
debug/camera-performance/ 中各组 JFR 和 report.json;不要同时开启 DEBUG
或其他基准。每次运行会覆盖该目录,比较前先复制记录。
2026-10-02,Apple M3 Max,主窗口 1708×960,真实客户端初轮对照:
| 4 个额外机位指标 | 优化前 | 优化后 |
|---|---|---|
| 宿主帧 CPU 渲染均值 | 2.913 ms | 2.953 ms |
| 宿主帧 P95 | 3.305 ms | 3.278 ms |
| 宿主帧 P99 | 3.870 ms | 3.500 ms |
| camera pass 平均耗时之和 | 1.262 ms | 0.945 ms |
| 渲染线程分配率 | 207.31 MB/s | 156.70 MB/s |
| 云层路径 allocation samples | 131 | 12 |
根据 JFR 实施两项优化:
- Iris/Sodium
hadShadersOn原为全局标志,主视角开光影、机位关光影时反复 切换,导致云几何缓存频繁失效。改为 CloudRenderer 每实例保留,在绘制中 切换/恢复,真实客户端测试覆盖了实际 Mixin 路径。 - 雾/天空色 Gaussian 采样每次循环产生临时 Vec3。机位采用相同 kernel、 采样和加法顺序,以标量累积后创建最终 Vec3。200 个固定随机正负/小数 坐标对比原版,三个分量 double bits 完全一致,216 次取样顺序也相同。 主视角保持原版路径。
观察到分配率降低约 24%,camera pass 均值之和降低约 25%,整帧均值持平, 不能据此宣称 FPS 提升。初次 0 机位均值 2.900 ms、结束复测 1.601 ms,显示 预热/世界活动/环境波动明显。两轮不是锁定世界状态的统计 A/B,也未覆盖 复杂商用光影、大实体场景或其他 GPU。
宿主帧计时为 RenderFrameEvent.Pre/Post,未等待 GPU 完成,不代表完整
swap/present、编码或端到端延迟。kasuga.CameraRenderPass 记录 world + Iris
final composition,不含输出 blit/消费者;kasuga.CameraChunkSnapshot 记录
服务器快照构建/编码。分配率用 jdk.ThreadAllocationStatistics 差值/时间;
不要累计 ObjectAllocationSample.weight,首次样本可能带录制前分配。
allocation sample 数量是定位依据,不是精确字节数。
原始记录在 ../modules/modelling/build/camera-performance-baseline 和
modules/modelling/build/camera-performance-optimized/,属于本地构建产物。
剩余热点主要是 biome/noise 读取、地形/实体视锥筛选和实体顶点构建。后续
应在复杂场景复测;实体每 5 ticks 的完整状态可进一步按值去重,但要保留
回到默认值时的同步语义。
玩家运动同步修正¶
机位订阅每 5 ticks 发送实体状态,而原版 teleport handler 用 3 ticks 插值, 会出现两 ticks 停顿。远处机位实体改为按 5 ticks 插值。对于主客户端已经 追踪的实体,机位在每次渲染前复制其位置、上一 tick 位置、旋转及 limb animation 历史到独立副本;玩家的客户端预测移动不再绕服务端订阅一圈。 保留独立 Entity/ClientLevel,FOV/渲染资源隔离不受影响。
真实客户端回归让玩家连续移动/转向/改变步行动画,在 120 个输出帧的 partial tick 0、0.25、0.75、1 检查副本位置插值、旋转历史与动画相位一致:
./gradlew :modules:modelling:runClient -PkasugaTestCameraMotion=true \
-PkasugaQuickPlayWorld=world-view-test \
-PkasugaClientDirectory=/absolute/path/to/test-client
此测试主动修改隔离世界内的玩家位置,结束时恢复。成功标志
CAMERA_MOTION_SMOKE_PASS,报告为 debug/camera-motion.json。
最终代码复测¶
加入玩家运动修正、原版远处地形定位与 RGBA 读回接口后,再跑相同的 JFR 场景(空消费者,不包含同步 CPU 读回成本):
| 额外机位数 | CPU 渲染均值 | P95 | P99 |
|---|---|---|---|
| 0,初次 | 2.198 ms | 4.053 ms | 5.440 ms |
| 1 | 2.033 ms | 2.543 ms | 2.852 ms |
| 2 | 2.125 ms | 2.534 ms | 2.952 ms |
| 4 | 2.846 ms | 3.267 ms | 3.878 ms |
| 0,结束复测 | 1.253 ms | 1.479 ms | 1.657 ms |
4 机位渲染线程分配率为 138.14 MB/s,camera pass 均值之和为 1.040 ms,
云层路径 allocation samples 为 14。相对最初 207.31 MB/s 的记录,观察到约
33% 的分配率下降,整帧耗时仍属接近的水平;上述预热与非锁定世界限制依然
适用。不能把“1 机位比初次 0 机位快”解释为额外机位有加速作用。
记录及分析保留于 ../modules/modelling/build/camera-performance-final。
验证汇总:625 个 JVM 用例,0 failures/errors,1 skipped;9 组独立 GL 检查 通过,包含 RGBA 方向、alpha、PBO/pack 状态恢复;原版和 Iris 客户端多机位 检查、双机位 DEBUG 实际输出、120 帧玩家运动回归以及最终 JFR 测试通过。 集成服务器的区块/实体订阅路径实际运行过,独立 dedicated server 与任意 第三方光影/材质组合不在这次验证覆盖范围内。
多窗口与无窗口启动的验证及补充优化¶
新增功能完成后,renderingReleaseCheck 通过;JVM 检查为 627 用例,
0 failures/errors,1 skipped。独立 GL 回归为 10 组,新增共享窗口纹理
呈现、双向 fence、current context/capabilities 恢复、resize、窗口独立关闭,
以及 Sodium 类型的 RGBA native blit 保持 RGB、强制输出 alpha=1。
| 实际检查 | 结果 |
|---|---|
| macOS MC + Iris/Sodium,多机位窗口及现有机位镜像 | 通过;暂停机位关闭、DPI resize、一个窗口关闭后其他机位继续 |
| 多窗口第一/第三人称 DEBUG 启动 | MULTI_WINDOW_DEBUG_PASS |
macOS run-headless-client.sh 隐藏模式 |
HEADLESS_CLIENT_SMOKE_PASS,主画面及两机位 PNG |
| Debian 12 aarch64,无 DISPLAY / WAYLAND_DISPLAY,GLFW Null + EGL | HEADLESS_CLIENT_SMOKE_PASS,主画面及两机位 PNG |
Linux 验证使用 Mesa 22.3.6 的 llvmpipe / OpenGL 4.5,没有启动 Xvfb。 检查原生窗口始终不可见、context 归位、没有 GL error、RGBA 非空且 alpha 不透明。主画面 PNG 包含手部/HUD,机位 PNG 为各自的世界视图;JFR 也记录了 两机位各 80 次 render pass。运行的是该 NeoForge 客户端配置导出的 JVM / classpath,VM 内未重新构建整个 Gradle 项目。此项证明无显示服务的客户端 渲染路径可运行,不证明 NVIDIA/AMD 硬件 EGL、Windows 或所有第三方光影组合。
测试 VM 临时使用 12 GiB 内存、6.5 GiB Java 堆,载入了本机大型测试模型,
包含三张 16384×8192 atlas;这不是最小运行内存要求。产物保存在忽略的
modules/modelling/build/headless-linux-smoke/debug 和 headless-linux-jfr。
额外修正了两个启动/后台性能问题:
- GLFW Null 的
glfwWaitEventsTimeout是空函数,MC 的限帧循环会忙等。 EGL 模式改为LockSupport.parkNanos,仍由原来的 frame deadline 循环 处理提前唤醒。相同 60 FPS 离屏菜单各录制 4 秒 JFR:优化前 74 个渲染线程 execution samples 中有 26 个在limitDisplayFPS,优化后 50 个样本中为 0。 这是短时间 CPU 采样证据,不能当成世界渲染 FPS 提升或精确 CPU 百分比。 - PBR 缓存 key 原来临时分配整图
int[]和等大的 byte buffer,增加8 × width × height字节 scratch。现在按 4096 像素分块,scratch 为 32 KiB,SHA-256 的 descriptor、尺寸、大端 ARGB 顺序不变。测试覆盖 chunk 边界、不同 raster 格式和 subimage,旧磁盘缓存保持有效。原始图像、烘焙 结果、GPU atlas 的实际内存需求仍存在。
此前 0/1/2/4 机位 JFR 表不包含新增窗口呈现或同步 CPU 读回成本,不能将其 耗时直接套用到多窗口或软件 EGL 部署。
本文是离屏与多机位主题的实操篇。输出与相机的契约见 多机位输出契约,原理见 离屏输出与多机位原理。