Skip to main content

Render Loop

Package: dev.anchorlight.stonelib.render

Per-player visual effects such as particle trails, halos, and wings need drawing every tick or two. A task per player does not scale: a hundred players with effects is a hundred scheduled tasks. RenderLoop is one loop for the whole plugin. Each frame it walks every registered effect, works out who is close enough to see it, and draws it for those players only.

Threading

Player positions cannot be read safely off the main thread, so the loop is two tasks that behave as one:

  1. A very cheap main-thread snapshot that copies every online player's position.
  2. An async render pass that culls and draws against that snapshot.

The snapshot does no per-effect work and no world access, so its cost on the main thread stays flat however many effects are registered. Effects see positions up to one period old.

Setup

SchedulerService scheduler = new SchedulerService(this);
RenderLoop renderLoop = new RenderLoop(this, scheduler, 1L, 48.0);
renderLoop.start();
ParameterMeaning
pluginYour plugin.
schedulerThe SchedulerService that owns the loop's tasks.
periodTicksTicks between frames. 1 for smooth trails, higher for cheaper effects. Values below 1 become 1.
defaultViewDistanceHow close, in blocks, a player must be to see an effect, unless the effect sets its own.

Renderable

Implement one per kind of effect:

public final class FlameTrail implements Renderable {

private final UUID owner;

public FlameTrail(UUID owner) {
this.owner = owner;
}

@Override
public UUID owner() {
return owner;
}

@Override
public String key() {
return "trail";
}

@Override
public void render(RenderContext context) {
Location at = context.anchor().add(0, 0.1, 0);
for (Player viewer : context.viewers()) {
viewer.spawnParticle(Particle.FLAME, at, 2, 0.1, 0.0, 0.1, 0.0);
}
}
}
MethodDefaultMeaning
owner()requiredThe player the effect belongs to. Its position is the anchor.
key()"default"Distinguishes an owner's effects. One per owner per key.
render(RenderContext)requiredDraws one frame. Runs off the main thread.
viewDistance()-1A value above 0 overrides the loop's default view distance for this effect.
active()trueWhether to draw this frame. Checked first, so it is the cheap place for a per-world toggle or a hidden setting.
cleanUp()does nothingCalled once when the effect is replaced or removed. Runs on the main thread, so it may despawn entities.

:::danger render runs off the main thread Inside render, only send things to the viewers you were given, such as particles, sounds, and entity metadata packets, and read the RenderContext. Never spawn entities, change blocks, or call any Bukkit method that changes state. Schedule that onto the main thread. :::

RenderContext

public record RenderContext(long tick, Player owner, Location anchor, List<Player> viewers) {
public double seconds(long periodTicks) { ... }
}
PartMeaning
tickA frame counter that increases every frame. Use it for animation phase.
ownerThe owning player, or null if they left since the last snapshot.
anchorA copy of the owner's position from the last snapshot. Safe to modify.
viewersPlayers in the same world within view distance. Never empty: a frame nobody can see is skipped. The owner is included when in range.
seconds(periodTicks)Elapsed seconds derived from tick. Pass the loop's period.
double angle = context.seconds(1L) * Math.PI; // half a turn per second
Location halo = context.anchor().add(Math.cos(angle) * 0.4, 2.2, Math.sin(angle) * 0.4);

RenderLoop API

MethodMeaning
start()Starts the snapshot and render tasks. Calling it again while running does nothing.
stop()Stops drawing, and removes and cleans up every effect.
add(Renderable)Registers an effect. If the owner already has one with the same key, that one is replaced and cleaned up.
remove(UUID owner)Removes and cleans up everything the owner has.
remove(UUID owner, String key)Removes and cleans up one effect.
get(UUID owner, String key)The registered effect, or null.
size()Total registered effects. For a diagnostics command.

add, remove, get, and size are safe from any thread.

Keys

An owner can wear several effects at once, such as a trail, a hat, and wings, as long as each has a different key. Two effects with the same key replace each other, which is what makes swapping one slot straightforward:

renderLoop.add(new FlameTrail(uuid)); // key "trail"
renderLoop.add(new CrownHat(uuid)); // key "hat"
renderLoop.add(new HeartTrail(uuid)); // key "trail": replaces FlameTrail, which is cleaned up

The default key means an implementation that forgets to set one replaces rather than silently duplicates.

Wiring it into a plugin

@EventHandler
public void onJoin(PlayerJoinEvent event) {
UUID uuid = event.getPlayer().getUniqueId();
selections.forPlayer(uuid).forEach(renderLoop::add);
}

@EventHandler
public void onQuit(PlayerQuitEvent event) {
renderLoop.remove(event.getPlayer().getUniqueId());
}

@Override
public void onDisable() {
renderLoop.stop();
scheduler.cancelAll();
}

Behaviour to know about

  • You must remove effects on quit. The loop skips an owner who is offline, but does not unregister their effects. Without remove in a quit listener, effects for players who left build up for the life of the server.
  • stop does not cancel the loop's tasks. It stops drawing and clears effects, but the tasks stay scheduled until scheduler.cancelAll(). Always pair stop with cancelAll on disable.
  • Do not restart a stopped loop. start after stop schedules a second pair of tasks alongside the first, doubling the frame rate. Create a new RenderLoop instead, after cancelling the scheduler's tasks.
  • An effect that throws stays registered. The error is logged as Renderable <key> for <owner> threw; it stays registered, and it is tried again next frame. Guard against a render that fails every frame, or its log will be very noisy.
  • Culling is by world and straight-line distance from the owner, compared as squared distances so a player in another world costs almost nothing.