Skip to main content

Custom Effects

A custom effect has three independent pieces, kept separate so dedicated servers never load rendering classes:

  1. A common, immutable WorldEffect description with a shared EffectType.
  2. An optional PacketCodec<RegistryByteBuf, E> registered with SyncedEffectRegistry (only needed for server sync).
  3. A client EffectBatchRenderer<E> registered with EffectRendererRegistry.

Register custom effect pieces during your mod's common/client initializers.


1. The description

public record RuneBurst(Vec3d center, float radius, ColorRgba color) implements WorldEffect {
public static final EffectType<RuneBurst> TYPE =
new EffectType<>(Identifier.of("my_mod", "rune_burst"), RuneBurst.class);

@Override public EffectType<RuneBurst> type() { return TYPE; }
@Override public double cullingRadius() { return radius; }
}

Optionally override maxRenderDistance() (default 128) and lodPolicy(). The dispatcher removes sub-pixel effects using projected radius; expensive renderers can use the selected LOD level to reduce work. Return EffectLodPolicy.NONE to opt out or provide custom distance bands.

2. The codec (for server sync)

In the common initializer:

SyncedEffectRegistry.register(RuneBurst.TYPE, RUNE_BURST_PACKET_CODEC);

3. The renderer

In the client initializer:

EffectRendererRegistry.register(RuneBurst.TYPE, new RuneBurstRenderer());
public interface EffectBatchRenderer<E extends WorldEffect> {
default void prepare() {} // create reusable GPU resources (render thread)
void render(RenderStage stage, WorldRenderContext context, List<E> effects);
default Set<RenderStage> stages() { ... } // default BEFORE_TRANSLUCENT
default Set<RenderStage> shaderPackStages() { ... } // default END_MAIN
default void close() {} // release owned GPU resources
}

The renderer receives all visible instances of its type in one call, so it can upload one vertex buffer and issue batched draws.

GPU resource lifecycle

Create GPU objects lazily in prepare() on the render thread — never in constructors or static initializers, where the GPU device may not exist yet. Release everything in close(); Nebulon triggers teardown when the game renderer closes.


Render stages

StageWhen
AFTER_ENTITIESAfter entity rendering
BEFORE_TRANSLUCENTBefore vanilla translucency (the default)
END_MAINEnd of world rendering

A renderer can return several stages for multi-pass shaders.

With an Iris shader pack active, the dispatcher consults shaderPackStages() instead, which defaults to END_MAIN — and Nebulon defers that logical stage until immediately after world rendering returns. That window is after the pack's final composite (so the pack cannot erase custom effects) but before vanilla clears world depth for the hand (so block and first-person occlusion stay correct). A renderer intentionally mapped into an Iris program should override shaderPackStages() to return stages() so it remains inside Iris's world pipeline. See Materials & shaders.


Batching strategies

Proven approaches used by the built-in renderers:

  • One combined vertex buffer for variable geometry — the arc-chain renderer.
  • Reusable proxy geometry with parameterized draws — the fog renderer.
  • DynamicUniformBlock<T> for typed per-draw std140 data.
  • StaticMesh for immutable GPU geometry (normal or instanced indexed draws).
  • InstanceBuffer<T> for bounded std140/gl_InstanceID batching (experimental — see Advanced rendering); the pooled-particle renderer is the in-tree reference.

The low-level renderer always keeps RenderPass, RenderPipeline, GPU buffers, samplers, framebuffers, and Fabric's WorldRenderContext accessible. The convenience API is never a ceiling.


Synchronizing a custom effect

Once the codec is registered, the server API works exactly as for built-ins:

EffectHandle<RuneBurst> handle = NebulonNetworking.syncedEffects()
.spawn(serverWorld, new RuneBurst(center, 3.0f, color), 20 * 10);

Nothing under the common packages may reference client-only classes — that separation is what lets a dedicated server sync effects it never renders.