Advanced Rendering
This page covers the client rendering toolbox: capability queries, scene snapshots, off-screen targets, ordered post passes, the compositor, dependency-aware render graphs, GPU instancing, and ShaderToy-style shaders.
Capabilities
Sodium and Iris are optional for consumers; Nebulon does not mix into their internals. Query advanced features explicitly instead of assuming them:
CapabilitySupport depth = NebulonRenderSystem.capability(RenderCapability.SCENE_DEPTH_SAMPLING);
if (depth.available()) {
// depth-aware path
} else {
// proxy-volume fallback; depth.explanation() helps diagnostics
}
RenderEnvironment environment = NebulonRenderSystem.environment();
boolean shaderPackActive = environment.irisShaderPackActive();
RenderCapability values: WORLD_EFFECTS, DYNAMIC_LIGHTS, CUSTOM_PIPELINES, OFFSCREEN_TARGETS, DEPTH_TARGETS, IRIS_PIPELINE_MAPPING, SCENE_COLOR_SAMPLING, SCENE_DEPTH_SAMPLING, POST_PROCESSING.
Compute shaders remain explicitly unavailable.
Scene attachments
Nebulon captures the active main color and world depth into renderer-owned textures during its world stages. Copies are lazy and shared: native lights, fog, particles, and custom passes in the same stage reuse one snapshot instead of each copying depth.
SceneAttachments scene = NebulonRenderSystem.sceneAttachments();
GpuTextureView color = scene.color();
GpuTextureView depth = scene.depth();
The views are frame-scoped. Post passes get them via PostPassContext.scene() and graph passes via RenderGraphContext.scene().
With Iris, the END_MAIN snapshot contains the shader pack's final composited framebuffer and preserved world depth — Iris-owned internal attachments are never retained or written.
Built-in depth consumers: particle softness(...) fading, fog and snow-storm ray-march clipping, and the native light pass.
Managed render targets
ManagedRenderTarget is a lazy, resize-aware Minecraft framebuffer. Own it from an EffectBatchRenderer, call prepare on the render thread, and close it from the renderer's close():
private final ManagedRenderTarget glow = new ManagedRenderTarget(
RenderTargetDescriptor.scaled("my_mod glow", 0.5f, true)); // half-res, with depth
Framebuffer framebuffer = glow.prepare(viewportWidth, viewportHeight);
GpuTextureView color = glow.colorView();
GpuTextureView depth = glow.depthView();
Shared targets can be declared once and retrieved by identifier:
NebulonRenderTargets.register(GLOW_ID,
RenderTargetDescriptor.scaled("my_mod glow", 0.5f, true));
ManagedRenderTarget glow = NebulonRenderTargets.get(GLOW_ID);
Ordered post passes
For a single independent callback, register a pass with a world stage and integer priority. The context exposes the main framebuffer, the compatibility environment, and lazy access to named Nebulon targets:
PostPassHandle pass = NebulonPostProcessing.register(
Identifier.of("my_mod", "bloom_extract"),
RenderStage.END_MAIN,
100,
context -> {
Framebuffer target = context.target(GLOW_ID).framebuffer();
// record the extraction pass
});
pass.close();
Use a render graph instead when passes share resources or have dependencies.
The compositor
NebulonCompositor runs ordered fullscreen passes through two full-resolution ping-pong targets, then copies only the final result back to the main framebuffer — a pass never samples the texture it is writing:
CompositorPassHandle handle = NebulonCompositor.register(
Identifier.of("my_mod", "color_grade"),
RenderStage.END_MAIN,
100,
new FullscreenCompositorPass(
Identifier.of("my_mod", "color_grade_pipeline"),
Identifier.of("my_mod", "core/color_grade")));
FullscreenCompositorPass exposes the InputColor, SceneColor, and SceneDepth samplers. For custom target sizes or several intermediate buffers (half-res bloom, multi-scale blur, outlines, depth-aware decals), prefer a render graph.
Render graphs
RenderGraph adds resource declarations, inferred dependencies, cycle validation, and target ownership on top of the ordered registry.
Targets
GraphTarget bloom = GraphTarget.transientTarget(
Identifier.of("my_mod", "bloom_half"),
RenderTargetDescriptor.scaled("my_mod bloom", 0.5f, false));
- Transient targets are borrowed from a descriptor-keyed pool for one execution and reused by later frames/graphs.
- Persistent targets belong to their graph and retain contents until it closes.
- All targets resize lazily with the main viewport.
Passes and dependencies
Identifier extractId = Identifier.of("my_mod", "extract_bloom");
RenderGraph graph = RenderGraph.builder(
Identifier.of("my_mod", "rune_bloom"), RenderStage.END_MAIN)
.priority(100)
.pass(extractId, pass -> pass
.writes(bloom)
.execute(context -> {
Framebuffer output = context.target(bloom).framebuffer();
// extract bright pixels
}))
.pass(Identifier.of("my_mod", "composite_bloom"), pass -> pass
.reads(bloom)
.after(extractId)
.execute(context -> {
GpuTextureView bloomColor = context.target(bloom).colorView();
// composite into context.main()
}))
.build();
RenderGraphHandle handle = NebulonRenderGraphs.register(graph);
handle.close(); // unregisters and releases persistent resources
A writer automatically precedes readers of the same target; after(passId) expresses a dependency without a shared target. Construction rejects duplicate pass IDs, missing dependencies, multiple writers, cycles, and undeclared target access — context.target(...) verifies the executing pass declared the target in reads/writes, turning hidden dependencies into immediate errors.
RenderGraphContext.scene() exposes the frame's Nebulon-owned color/depth snapshots. Nebulon releases all registered graphs and pooled targets when the game renderer closes.
GPU instancing (experimental)
Minecraft exposes instance counts but not portable per-instance vertex attribute divisors, so InstanceBuffer<T> uploads a fixed-stride std140 array that shaders index with gl_InstanceID. It batches automatically at the declared capacity and stays inside Minecraft's GPU abstraction.
record RuneInstance(Matrix4f transform, Vector4f color) {}
InstanceBuffer<RuneInstance> instances = new InstanceBuffer<>(
"my_mod rune instances", 80, 256,
(out, rune) -> out.putMat4f(rune.transform()).putVec4(rune.color()));
instances.drawBatches(visibleRunes, batch -> {
try (RenderPass pass = openRunePass()) {
pass.setPipeline(material.pipeline());
RenderSystem.bindDefaultUniforms(pass);
batch.bind(pass, "Instances");
runeMesh.draw(pass, batch.count());
}
});
The matching shader declares an Instances std140 block with the same fixed capacity and reads instances[gl_InstanceID]. StaticMesh.upload(...) uploads reusable geometry once and supports normal or instanced indexed draws.
The batch callback must record its draw before returning — the backing ring rotates immediately afterwards. The API is experimental until a backend conformance suite covers vanilla, Sodium, active Iris packs, and Apple's OpenGL translation layer.
DynamicUniformBlock<T> provides typed per-draw std140 uploads (prepare(), beginFrame(), upload(value) returning a GpuBufferSlice, close()), and MeshPrimitives.quad(...) / MeshPrimitives.uvSphere(...) generate common proxy geometry.
ShaderToy-style shaders
For single-pass ShaderToy ports:
- Add
.shaderToyInputs()to the material. - Import
<nebulon:shadertoy.glsl>in the shader. - Upload
ShaderToyUniformsthrough its typed dynamic buffer (ShaderToyUniforms.createBuffer(...)).
This supplies iResolution, iTime, iTimeDelta, iFrameRate, iFrame, iMouse, iDate, and iSampleRate. Declare iChannel0–iChannel3 samplers on the material and in GLSL when the original uses texture channels. Use MeshPrimitives for proxy geometry.
Multi-buffer ShaderToy projects require explicit render stages/framebuffers — they are not silently treated as single-pass. Always verify the original shader and asset licenses before shipping a port.