Offscreen Output and Multi-Camera Rendering¶
This page maintains the interface, startup and operation reference; for isolation, data flow and synchronization principles see the explanation page. The Player, Free and Fixed categories and their creation entry points are in the camera API. The configuration of the standalone-world camera views below applies to Free/Fixed; Player shares the vanilla final main frame and the player's current configuration. The performance and client acceptance sections below keep the historical records from that time; they do not mean this documentation migration re-ran the corresponding tests.
This page corresponds to Minecraft 1.21.1 / NeoForge 21.1.203; the compatibility path actually verified Sodium 0.6.13 and Iris 1.8.12. All camera views render in order inside one client process and the main OpenGL context; multi-window uses auxiliary contexts with a shared texture to display finished frames. Camera views each own world caches, a terrain renderer, camera, projection, color/depth targets and an Iris pipeline, while the main view still takes the vanilla rendering path.
Offscreen output delivers a GPU FBO and the game tick keeps running. The headless startup entry points support a hidden desktop context and an EGL pbuffer context on Linux without X11/Wayland. A full Minecraft client and an OpenGL driver are still required; there is currently no fixed-timestep offline video exporter and no built-in video encoder, so a caller can send frames to its own compositing, encoding and transport modules.
Dual-Camera DEBUG Startup¶
./gradlew :modules:modelling:runClient -PkasugaDebugDualCamera=true
# Optional: enter an existing singleplayer world; omit it to enter normally from the menu.
./gradlew :modules:modelling:runClient \
-PkasugaDebugDualCamera=true \
-PkasugaQuickPlayWorld=world-view-test \
-PkasugaClientDirectory=/absolute/path/to/test-client
The equivalent JVM switch is -Dkasuga.debugDualCamera=true. It is off by default and creates no DEBUG
camera view or preview target. On entering a world it creates debug:first-person and debug:third-person,
with first person on the left and third person on the right, and the window title states the left-right order. The two have separate
sessions, FOV follows the user setting, size follows the window, render distance 8 chunks, Fancy, shader pack off.
Third person sits 4 blocks back along the view direction and checks for wall occlusion; first person hides the player's own entity.
The DEBUG preview is the world image and contains no first-person hand or HUD. Opening a menu/inventory pauses the side-by-side compositing and displays the original screen normally. Leaving the world releases the camera views and they are rebuilt automatically the next time you enter. The main view still runs, and DEBUG compositing happens before the final screen output; while it is active, subscribing to the main view also captures the side-by-side image.
The automated check is for an isolated world copy only:
./gradlew :modules:modelling:runClient \
-PkasugaDebugDualCamera=true \
-PkasugaDebugDualCameraCaptureFrames=180 \
-PkasugaQuickPlayWorld=world-view-test \
-PkasugaClientDirectory=/absolute/path/to/test-client
After previewing 180 frames it writes debug/dual-camera.png, prints DUAL_CAMERA_DEBUG_PASS
and closes the client. The plain DEBUG switch keeps running and does not exit automatically.
Multi-Window¶
./gradlew :modules:modelling:runClient -PkasugaDebugMultiWindow=true
After entering a world, first and third person each get their own window and the MC main window keeps displaying. It works with
kasugaQuickPlayWorld / kasugaClientDirectory; for testing,
-PkasugaDebugDualCameraCaptureFrames=120 prints MULTI_WINDOW_DEBUG_PASS and exits after both paths have presented 120 frames. Off by default. DEBUG temporarily disables pause-on-lost-focus and
restores the previous setting afterwards; an ordinary API caller can set mc.options.pauseOnLostFocus itself.
import lib.kasuga.rendering.output.gl.FramePreviewWindow;
import lib.kasuga.rendering.output.mc.MinecraftFrameWindows;
// Owns both the camera and the window: closing either this view's native window releases the 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();
// Owns only the window and a subscription: closing this window does not close the existing camera.
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();
All operations are called on the MC render thread. The camera size follows the window framebuffer/DPI; while minimized the last valid size is kept. Close events are handled every frame, and a paused camera view can also be closed. A window that only subscribed keeps the last frame after the source camera view stops publishing, and the caller is responsible for closing it. Exiting MC releases the windows together and only then releases the main context.
An auxiliary window uses a GL context with a shared texture, its own read FBO and a bidirectional GPU fence.
Presenting only copies the finished frame and adds no world rendering; a camera view captures once per frame and multiple
subscribers share the read-only output FBO. Each auxiliary window still has its own context-local read FBO,
which is only the read entry point onto the shared texture and does not allocate a separate output color texture.
The main context may overwrite the next frame only after the texture read completes; no CPU glFinish is used. Every present
restores MC's current context / GL capabilities. Standalone windows can be stretched, and the image keeps its aspect ratio
with black bars. GLFW initialization, the global error callback and termination are still managed by MC.
This is not multiple clients: player input, ticks and the main window event loop still belong to one MC. Linux Null/EGL mode provides no native window, and calling the multi-window API reports an explicit error.
Headless MC Launcher¶
The repository provides scripts/run-headless-client.sh and the Gradle runClientHeadless entry point.
By default it uses the standalone modules/modelling/run/headless-client directory and does not reuse the everyday client.
A single-player Quick Play world must already exist under that directory's saves/; an existing directory can also be specified:
./scripts/run-headless-client.sh \
-PkasugaClientDirectory=/absolute/path/to/headless-client \
-PkasugaQuickPlayWorld=world-view-test \
-PkasugaHeadlessFps=60
# Or connect straight to a server; the client session's authentication requirements still apply.
./scripts/run-headless-client.sh -PkasugaHeadlessServer=localhost:25565
# Gradle direct entry point for a desktop machine.
./gradlew :modules:modelling:runClientHeadless -PkasugaHeadlessBackend=hidden \
-PkasugaQuickPlayWorld=world-view-test
This is the startup entry point for this repository's NeoForge development client; it keeps the Gradle mod/classpath configuration and does not handle account login or video encoding. Without a world/server specified it starts into an offscreen menu for a caller's client extension to control; it does not create a new world automatically.
| Mode | GL context | Display service |
|---|---|---|
hidden, default on macOS/Windows |
a GLFW window hidden from creation | needs a desktop display environment |
egl, default on Linux |
GLFW Null platform + EGL pbuffer | needs no X11/Wayland/Xvfb |
Before startup, prepareHeadlessClient sets earlyWindowControl = false in the chosen client directory's
config/fml.toml, so that NeoForge does not display a splash before the game mixins take effect.
This setting is kept in that directory. MC does not go fullscreen, the context is not displayed, vsync is not enabled and
pause-on-lost-focus is disabled; the render frame rate is controlled separately by kasugaHeadlessFps (default 60). The first-time accessibility
introduction screen is skipped, while game ticks, world loading, resource packs and the regular loading screens still run.
Linux needs Java 21, a C compiler, CMake, patch, curl and an EGL driver that supports desktop OpenGL.
For example, a Debian/Ubuntu Mesa environment can install libegl1,
libgl1-mesa-dri, build-essential, cmake, patch, curl. On first launch the script
automatically downloads the pinned GLFW 3.4 source, verifies its SHA-256, applies a small in-repository pbuffer patch and
compiles a shared library with X11/Wayland disabled. The build output lands in
modules/modelling/build/headless-glfw/cmake/src/libglfw.so and is reused on later launches.
# Linux: the script unsets DISPLAY / WAYLAND_DISPLAY and defaults EGL_PLATFORM=surfaceless.
./scripts/run-headless-client.sh -PkasugaQuickPlayWorld=world-view-test
# Pre-build separately, or deploy a precompiled library for the same architecture.
./scripts/build-headless-glfw.sh
KASUGA_HEADLESS_GLFW_LIBRARY=/opt/kasuga/libglfw.so \
./scripts/run-headless-client.sh -PkasugaHeadlessServer=localhost:25565
# Force software rendering; only for functional verification on a machine with no GPU.
LIBGL_ALWAYS_SOFTWARE=1 ALSOFT_DRIVERS=null \
./scripts/run-headless-client.sh -PkasugaQuickPlayWorld=world-view-test
The upstream GLFW 3.4 plain EGL path only selects a window surface; the patch selects a pbuffer config and calls eglCreatePbufferSurface on the Null platform only. Substituting the upstream GLFW library for
this one is not equivalent. If you start Gradle's egl mode directly, you must unset the display environment variables yourself,
set EGL_PLATFORM=surfaceless and pass -PkasugaHeadlessGlfwLibrary=/absolute/libglfw.so.
The JVM equivalents are -Dkasuga.headless=egl and -Dorg.lwjgl.glfw.libname=..., and
NeoForge's early window configuration must still be turned off before startup.
No display service still means a GL driver is required. Hardware EGL depends on driver support and render node permissions; multi-camera shader packs and large resource packs still consume the corresponding CPU/GPU memory. A hidden window or a pbuffer does not eliminate world rendering cost, and software rendering does not stand for production hardware performance.
Verification¶
The real client uses a world copy and an isolated directory to check shader/shadow sessions, pixel content of different material atlases, chunks and entities beyond 1024 blocks, restoration of metadata defaults, main RGB consistency, fog and state restoration, resize, pause/resume, callback closure and failure release.
./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
# The path with Iris/Sodium not installed.
./gradlew :modules:modelling:runClient \
-PkasugaRendererMods=false -PkasugaTestWorldViews=true \
-PkasugaQuickPlayWorld=world-view-test \
-PkasugaClientDirectory=/absolute/path/to/vanilla-test-client
The client artifacts are debug/world-views/report.json and PNGs, with the success marker
WORLD_VIEWS_SMOKE_PASS. The standalone GL regression covers final blit RGB/orientation/alpha, resize,
abnormal state recovery and resource release, and needs no full MC launch.
JFR Testing and Performance Optimization¶
./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
The real MC runs 0, 1, 2 and 4 additional camera views in turn, then re-measures 0 at the end; each group warms up for 8 seconds and JFR
profile records for 12 seconds. The additional camera views are 1280×720, 4 chunks, Fancy, no shader pack, and the main view
uses the test shader pack, vsync is off and an empty consumer measures delivery. The test exits by itself; the results are
the per-group JFR files and report.json in debug/camera-performance/; do not enable DEBUG
or another benchmark at the same time. Each run overwrites that directory, so copy the records before comparing.
2026-10-02, Apple M3 Max, main window 1708×960, first round of comparison in the real client:
| Metric for 4 additional camera views | Before optimization | After optimization |
|---|---|---|
| Host frame mean CPU render | 2.913 ms | 2.953 ms |
| Host frame P95 | 3.305 ms | 3.278 ms |
| Host frame P99 | 3.870 ms | 3.500 ms |
| Sum of mean camera pass durations | 1.262 ms | 0.945 ms |
| Render thread allocation rate | 207.31 MB/s | 156.70 MB/s |
| Cloud path allocation samples | 131 | 12 |
Two optimizations were implemented from the JFR data:
- The Iris/Sodium
hadShadersOnflag used to be global, so with the main view's shader pack on and the camera views' off it flipped back and forth, which repeatedly invalidated the cloud geometry cache. It is now kept per CloudRenderer instance and switched/restored during drawing; real-client tests covered the actual Mixin path. - Gaussian sampling for fog/sky color produced a temporary Vec3 on every loop iteration. The camera views use the same kernel, sampling and addition order, accumulate in scalars and create the final Vec3 at the end. Comparing 200 fixed random positive/negative and fractional coordinates against vanilla, the double bits of all three components are identical, and the sampling order of 216 samples matches too. The main view keeps the vanilla path.
The observed allocation rate dropped by about 24% and the sum of mean camera pass durations by about 25%, while the whole-frame mean stayed flat, so there are no grounds for claiming an FPS increase. The first 0-camera-view mean was 2.900 ms and the closing re-measurement 1.601 ms, which shows clear warm-up/world activity/environment variance. The two rounds are not a statistical A/B with the world state locked, and they cover neither complex commercial shader packs, large entity scenes nor other GPUs.
Host frame timing uses RenderFrameEvent.Pre/Post and does not wait for the GPU to finish, so it does not represent full
swap/present, encoding or end-to-end latency. kasuga.CameraRenderPass records world + Iris
final composition and excludes the output blit/consumers; kasuga.CameraChunkSnapshot records
server snapshot build/encoding. The allocation rate uses the jdk.ThreadAllocationStatistics delta divided by time;
do not accumulate ObjectAllocationSample.weight, because the first sample may carry allocations from before recording started.
The number of allocation samples is a pointer for locating hotspots, not an exact byte count.
The raw records are in ../modules/modelling/build/camera-performance-baseline and
modules/modelling/build/camera-performance-optimized/, and are local build artifacts.
The remaining hotspots are mostly biome/noise reads, terrain/entity frustum filtering and entity vertex building. Later work
should re-measure in complex scenes; the full entity state sent every 5 ticks could be deduplicated further by value, but the sync
semantics for returning to default values must be preserved.
Player Motion Synchronization Fix¶
A camera view subscription sends entity state every 5 ticks, whereas the vanilla teleport handler interpolates over 3 ticks, which produces a two-tick stutter. Distant camera view entities were changed to interpolate over 5 ticks. For entities the main client already tracks, the camera view copies their position, previous-tick position, rotation and limb animation history into their own copy before each render; a player's client-side predicted movement no longer takes a round trip through the server subscription. The separate Entity/ClientLevel is kept, and FOV/render resource isolation is unaffected.
A real-client regression has the player move/turn/change walking animation continuously, and at partial tick 0, 0.25, 0.75 and 1 of 120 output frames checks that the copy's position interpolation, rotation history and animation phase agree:
./gradlew :modules:modelling:runClient -PkasugaTestCameraMotion=true \
-PkasugaQuickPlayWorld=world-view-test \
-PkasugaClientDirectory=/absolute/path/to/test-client
This test deliberately modifies the player position inside the isolated world and restores it at the end. The success marker is
CAMERA_MOTION_SMOKE_PASS, and the report is debug/camera-motion.json.
Final Code Re-Measurement¶
After the player motion fix, vanilla distant terrain positioning and the RGBA readback interface were added, the same JFR scenario was run again (empty consumer, excluding the synchronous CPU readback cost):
| Additional camera views | Mean CPU render | P95 | P99 |
|---|---|---|---|
| 0, first | 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, closing re-measurement | 1.253 ms | 1.479 ms | 1.657 ms |
With 4 camera views the render thread allocation rate is 138.14 MB/s, the sum of mean camera pass durations is 1.040 ms
and the cloud path allocation samples are 14. Against the original 207.31 MB/s record, the observed
allocation rate dropped by about 33%, while whole-frame time is still at a similar level; the warm-up and unlocked-world limitations above still
apply. "1 camera view being faster than the first 0-camera-view run" must not be read as additional camera views speeding anything up.
The records and analysis are kept in ../modules/modelling/build/camera-performance-final.
Verification summary: 625 JVM cases, 0 failures/errors, 1 skipped; 9 standalone GL check groups passed, including RGBA orientation, alpha and PBO/pack state restoration; the vanilla and Iris client multi-camera checks, the dual-camera DEBUG actual output, the 120-frame player motion regression and the final JFR test passed. The integrated server's chunk/entity subscription path was actually run; a standalone dedicated server and arbitrary third-party shader pack/resource pack combinations are outside this verification's coverage.
Verification of Multi-Window and Headless Startup, and Additional Optimizations¶
After the new features were completed, renderingReleaseCheck passed; the JVM checks are 627 cases,
0 failures/errors, 1 skipped. The standalone GL regression is 10 groups, adding shared window texture
presenting, bidirectional fences, current context/capabilities restoration, resize and independent window closing,
plus Sodium-type RGBA native blit keeping RGB and forcing output alpha=1.
| Actual check | Result |
|---|---|
| macOS MC + Iris/Sodium, multi-camera windows and mirroring an existing camera view | passed; closing a paused camera view, DPI resize, other camera views continuing after one window closes |
| Multi-window first/third person DEBUG startup | MULTI_WINDOW_DEBUG_PASS |
macOS run-headless-client.sh hidden mode |
HEADLESS_CLIENT_SMOKE_PASS, main frame plus two camera view PNGs |
| Debian 12 aarch64, no DISPLAY / WAYLAND_DISPLAY, GLFW Null + EGL | HEADLESS_CLIENT_SMOKE_PASS, main frame plus two camera view PNGs |
The Linux verification used Mesa 22.3.6's llvmpipe / OpenGL 4.5 and did not start Xvfb. It checked that the native window was never visible, that the context came back to its place, that there was no GL error, and that RGBA was non-empty with an opaque alpha. The main frame PNG includes the hand/HUD, and the camera view PNGs are their respective world views; JFR also recorded 80 render passes per camera view. What ran was the JVM / classpath exported from that NeoForge client configuration; the whole Gradle project was not rebuilt inside the VM. This proves that the client rendering path can run without a display service; it does not prove NVIDIA/AMD hardware EGL, Windows or all third-party shader pack combinations.
The test VM temporarily used 12 GiB of memory and a 6.5 GiB Java heap and loaded this machine's large test models,
including three 16384×8192 atlases; this is not a minimum runtime memory requirement. The artifacts are kept in the ignored
modules/modelling/build/headless-linux-smoke/debug and headless-linux-jfr.
Two startup/background performance issues were fixed as well:
- GLFW Null's
glfwWaitEventsTimeoutis an empty function, so MC's frame limiting loop busy-waits. EGL mode was changed toLockSupport.parkNanos, and early wake-ups are still handled by the original frame deadline loop. For the same 60 FPS offscreen menu, 4 seconds of JFR each: before the optimization, 26 of 74 render thread execution samples were inlimitDisplayFPS; after, 0 of 50. This is short-duration CPU sampling evidence and cannot be taken as a world rendering FPS gain or an exact CPU percentage. - The PBR cache key used to allocate a whole-image
int[]and an equally large byte buffer for scratch, adding8 × width × heightbytes of scratch. It now chunks by 4096 pixels with 32 KiB of scratch, and the SHA-256 descriptor, size and big-endian ARGB order are unchanged. Tests cover chunk boundaries, different raster formats and subimages, and old on-disk caches stay valid. The actual memory demand of the source image, the bake result and the GPU atlas is still there.
The earlier 0/1/2/4 camera view JFR tables do not include the new window presenting cost or the synchronous CPU readback cost, so their durations cannot be applied directly to multi-window or software EGL deployments.
This is the hands-on page for the offscreen and multi-camera topic. For the output and camera contracts see multi-camera output contracts, and for the principles see offscreen output and multi-camera principles.