add RenderManager: improve CPU control with global render queue and parallel trace pooling

This commit is contained in:
2026-06-21 17:49:57 +02:00
parent 220cda1deb
commit f83ccdc7ff
7 changed files with 312 additions and 62 deletions
@@ -7,6 +7,7 @@ import eu.mhsl.minecraft.pixelpics.assets.ResourcePackLoader;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import eu.mhsl.minecraft.pixelpics.commands.PixelPicsCommand;
import eu.mhsl.minecraft.pixelpics.listeners.OnMapInitialize;
import eu.mhsl.minecraft.pixelpics.render.RenderManager;
import eu.mhsl.minecraft.pixelpics.render.render.DefaultScreenRenderer;
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
import org.bukkit.Bukkit;
@@ -23,6 +24,7 @@ public final class Main extends JavaPlugin {
private ResourcePack resourcePack;
private DefaultScreenRenderer screenRenderer;
private RenderManager renderManager;
public final NamespacedKey pictureIdFlag = new NamespacedKey(this, "imageid");
@@ -37,6 +39,9 @@ public final class Main extends JavaPlugin {
public void onEnable() {
instance = this;
saveDefaultConfig();
initRenderManager();
Bukkit.getPluginManager().registerEvents(new OnMapInitialize(), this);
Objects.requireNonNull(Bukkit.getPluginCommand("pixelPic")).setExecutor(new PixelPicsCommand());
@@ -48,6 +53,20 @@ public final class Main extends JavaPlugin {
initRenderer();
}
private void initRenderManager() {
int cores = Runtime.getRuntime().availableProcessors();
int threads = getConfig().getInt("render.threads", 0);
if (threads <= 0) threads = Math.max(1, cores - 2);
threads = Math.min(threads, cores);
int maxConcurrent = Math.max(1, getConfig().getInt("render.max-concurrent", 2));
int queueSize = Math.max(0, getConfig().getInt("render.queue-size", 8));
int timeoutSeconds = Math.max(1, getConfig().getInt("render.timeout-seconds", 30));
this.renderManager = new RenderManager(this, threads, maxConcurrent, queueSize, timeoutSeconds);
getLogger().info("Render pool: " + threads + " core(s), max " + maxConcurrent
+ " concurrent, queue " + queueSize + ", timeout " + timeoutSeconds + "s.");
}
private void initRenderer() {
File resourcePackDir = new File(getDataFolder(), "resourcepack");
if (!resourcePackDir.exists() && !resourcePackDir.mkdirs()) {
@@ -85,7 +104,8 @@ public final class Main extends JavaPlugin {
eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker blockEntityBaker =
new eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker(cemLoader, textures, skinCache, font);
this.screenRenderer = new DefaultScreenRenderer(registry, tintProvider, textures, entityBaker, blockEntityBaker, getLogger());
this.screenRenderer = new DefaultScreenRenderer(registry, tintProvider, textures, entityBaker,
blockEntityBaker, getLogger(), renderManager.tracePool());
// Warm the map palette on the main thread so off-thread dithering never triggers its first init.
eu.mhsl.minecraft.pixelpics.utils.MapColorPalette.size();
getLogger().info("PixelPics renderer initialized with resource pack assets.");
@@ -94,6 +114,10 @@ public final class Main extends JavaPlugin {
@Override
public void onDisable() {
eu.mhsl.minecraft.pixelpics.survival.SurvivalRecipes.unregister();
if (renderManager != null) {
renderManager.shutdown();
renderManager = null;
}
if (resourcePack != null) {
resourcePack.close();
resourcePack = null;
@@ -105,6 +129,11 @@ public final class Main extends JavaPlugin {
return this.screenRenderer;
}
/** The render queue/limiter; always available after {@code onEnable}. */
public RenderManager getRenderManager() {
return this.renderManager;
}
public static Main getInstance() {
return instance;
}
@@ -0,0 +1,151 @@
package eu.mhsl.minecraft.pixelpics.render;
import org.bukkit.Bukkit;
import org.bukkit.plugin.Plugin;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinWorkerThread;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* Server-friendly scheduling for photo renders. Bounds CPU use and protects the server tick by:
* <ul>
* <li><b>Per player:</b> at most one photo in the system at a time (running or queued).</li>
* <li><b>Globally:</b> at most {@code maxConcurrent} photos render at once, with up to
* {@code queueSize} more waiting; further requests are rejected.</li>
* <li><b>CPU:</b> the ray tracing runs on a dedicated {@link ForkJoinPool} of {@code threads}
* low-priority worker threads ({@link Thread#MIN_PRIORITY}), so it tends to use spare CPU and
* never fans out across every core like the default common pool would.</li>
* </ul>
*
* <p>Reserve a slot with {@link #tryReserve(UUID)}; if accepted, run the heavy work via
* {@link #dispatch} (which releases the slot on completion) — or call {@link #release(UUID)} if you
* bail out before dispatching.
*/
public final class RenderManager {
public enum Outcome { ACCEPTED, USER_BUSY, QUEUE_FULL }
private final Plugin plugin;
private final ForkJoinPool tracePool;
private final ThreadPoolExecutor dispatcher;
private final ScheduledExecutorService watchdog;
private final Set<UUID> activeUsers = ConcurrentHashMap.newKeySet();
private final AtomicInteger inFlight = new AtomicInteger();
private final int capacity;
private final long timeoutMillis;
public RenderManager(Plugin plugin, int threads, int maxConcurrent, int queueSize, int timeoutSeconds) {
this.plugin = plugin;
this.capacity = maxConcurrent + queueSize;
this.timeoutMillis = Math.max(1L, timeoutSeconds) * 1000L;
this.tracePool = new ForkJoinPool(threads, lowPriorityForkJoinFactory(), null, false);
this.dispatcher = new ThreadPoolExecutor(
maxConcurrent, maxConcurrent, 30, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(), // capacity is enforced by inFlight, not this queue
lowPriorityThreadFactory());
this.dispatcher.allowCoreThreadTimeOut(true);
this.watchdog = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "PixelPics-render-watchdog");
thread.setDaemon(true);
return thread;
});
}
/** The bounded, low-priority pool the ray tracer must run its parallel work on. */
public ForkJoinPool tracePool() {
return tracePool;
}
/** Atomically reserves a slot for {@code user}. On {@code ACCEPTED}, you must later release it. */
public Outcome tryReserve(UUID user) {
if (!activeUsers.add(user)) return Outcome.USER_BUSY;
if (inFlight.incrementAndGet() > capacity) {
inFlight.decrementAndGet();
activeUsers.remove(user);
return Outcome.QUEUE_FULL;
}
return Outcome.ACCEPTED;
}
/** Releases a reservation made by {@link #tryReserve}. Safe to call once per accepted reserve. */
public void release(UUID user) {
if (activeUsers.remove(user)) {
inFlight.decrementAndGet();
}
}
/**
* Runs {@code work} off the main thread (honoring the global concurrency limit), then delivers the
* result back on the main thread via {@code onSuccess}, or {@code onFailure} if it fails, returns
* null, or exceeds the configured timeout. The {@link AtomicBoolean} handed to {@code work} is set
* once the deadline passes — {@code work} should poll it and bail out cooperatively. Releases the
* caller's reservation when done. Requires a prior {@link #tryReserve} success.
*/
public <T> void dispatch(UUID user, Function<AtomicBoolean, T> work, Consumer<T> onSuccess, Runnable onFailure) {
dispatcher.execute(() -> {
AtomicBoolean cancelled = new AtomicBoolean(false);
ScheduledFuture<?> deadline = watchdog.schedule(() -> cancelled.set(true), timeoutMillis, TimeUnit.MILLISECONDS);
T result = null;
boolean ok = false;
try {
result = work.apply(cancelled);
ok = result != null && !cancelled.get();
} catch (Throwable t) {
plugin.getLogger().warning("Render job failed: " + t);
} finally {
deadline.cancel(false);
if (cancelled.get()) {
plugin.getLogger().warning("Render for " + user + " aborted after "
+ (timeoutMillis / 1000) + "s (timeout).");
}
release(user);
}
T finalResult = result;
boolean finalOk = ok;
Bukkit.getScheduler().runTask(plugin, () -> {
if (finalOk) onSuccess.accept(finalResult);
else onFailure.run();
});
});
}
public void shutdown() {
watchdog.shutdownNow();
dispatcher.shutdownNow();
tracePool.shutdownNow();
}
private static ForkJoinPool.ForkJoinWorkerThreadFactory lowPriorityForkJoinFactory() {
return pool -> {
ForkJoinWorkerThread thread = ForkJoinPool.defaultForkJoinWorkerThreadFactory.newThread(pool);
thread.setName("PixelPics-trace-" + thread.getPoolIndex());
thread.setPriority(Thread.MIN_PRIORITY);
thread.setDaemon(true);
return thread;
};
}
private static ThreadFactory lowPriorityThreadFactory() {
AtomicInteger counter = new AtomicInteger();
return runnable -> {
Thread thread = new Thread(runnable, "PixelPics-render-" + counter.incrementAndGet());
thread.setDaemon(true);
thread.setPriority(Thread.MIN_PRIORITY);
return thread;
};
}
}
@@ -30,6 +30,8 @@ import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Logger;
import java.util.stream.IntStream;
@@ -54,16 +56,25 @@ public class DefaultScreenRenderer implements Renderer {
private final BlockEntityBaker blockEntityBaker;
private final DecorationBaker decorationBaker;
private final Logger logger;
/** Bounds parallel ray tracing to a fixed, low-priority pool; {@code null} = use the common pool. */
private final ForkJoinPool tracePool;
public DefaultScreenRenderer(BlockModelRegistry registry, BiomeTintProvider tintProvider,
TextureCache textures, CemBaker entityBaker,
BlockEntityBaker blockEntityBaker, Logger logger) {
this(registry, tintProvider, textures, entityBaker, blockEntityBaker, logger, null);
}
public DefaultScreenRenderer(BlockModelRegistry registry, BiomeTintProvider tintProvider,
TextureCache textures, CemBaker entityBaker,
BlockEntityBaker blockEntityBaker, Logger logger, ForkJoinPool tracePool) {
SkyRenderer skyRenderer = new SkyRenderer(textures);
this.raytracer = new SnapshotRaytracer(registry, tintProvider, skyRenderer, MAX_DISTANCE, REFLECTION_DEPTH);
this.entityBaker = entityBaker;
this.blockEntityBaker = blockEntityBaker;
this.decorationBaker = new DecorationBaker(textures);
this.logger = logger;
this.tracePool = tracePool;
}
/** Convenience: prepare and execute in one call (must run on the main thread). */
@@ -94,6 +105,15 @@ public class DefaultScreenRenderer implements Renderer {
/** Traces every (super)ray in parallel, then downsamples gamma-correctly. Safe off the main thread. */
public BufferedImage execute(RenderJob job) {
return execute(job, new AtomicBoolean(false));
}
/**
* As {@link #execute(RenderJob)}, but cooperatively abortable: once {@code cancelled} is set, the
* parallel loops short-circuit (remaining work becomes a no-op) so a stuck/overlong render drains
* out quickly. The returned image is then partial and should be discarded by the caller.
*/
public BufferedImage execute(RenderJob job, AtomicBoolean cancelled) {
int finalW = job.width();
int finalH = job.height();
int superW = finalW * SSAA;
@@ -105,12 +125,14 @@ public class DefaultScreenRenderer implements Renderer {
job.decorations(), decorationBaker);
int[] superBuf = new int[rayMap.size()];
IntStream.range(0, rayMap.size()).parallel().forEach(i ->
superBuf[i] = raytracer.trace(snapshot, origin, rayMap.get(i), sky, scene));
runParallel(() -> IntStream.range(0, rayMap.size()).parallel().forEach(i -> {
if (!cancelled.get()) superBuf[i] = raytracer.trace(snapshot, origin, rayMap.get(i), sky, scene);
}));
BufferedImage image = new BufferedImage(finalW, finalH, BufferedImage.TYPE_INT_RGB);
int[] imageData = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
IntStream.range(0, finalH).parallel().forEach(fy -> {
runParallel(() -> IntStream.range(0, finalH).parallel().forEach(fy -> {
if (cancelled.get()) return;
int[] block = new int[SSAA * SSAA];
for (int fx = 0; fx < finalW; fx++) {
int n = 0;
@@ -122,10 +144,23 @@ public class DefaultScreenRenderer implements Renderer {
}
imageData[fy * finalW + fx] = ColorUtil.averageLinear(block, 0, n);
}
});
}));
return image;
}
/**
* Runs a parallel-stream task. With a {@link #tracePool} the work is confined to that bounded,
* low-priority pool (parallel streams adopt the pool of the running fork-join worker); without one
* it runs inline on the common pool (used by the offline render tools).
*/
private void runParallel(Runnable task) {
if (tracePool == null) {
task.run();
} else {
tracePool.submit(task).join();
}
}
private List<Vector> buildRayMap(Location eyeLocation, int width, int height) {
Vector lineDirection = eyeLocation.getDirection();
@@ -129,14 +129,12 @@ public final class EntitySnapshotBuilder {
} else if (e instanceof org.bukkit.entity.AbstractHorse ah) {
// Skeleton/zombie horse: only saddle (no colour/markings/armor variants).
saddle = isSaddled(ah);
} else if (e instanceof org.bukkit.entity.AbstractNautilus && e instanceof LivingEntity nl) {
} else if (e instanceof org.bukkit.entity.AbstractNautilus nl) {
// Nautilus body armor + saddle are same-UV overlays (like horse armor).
org.bukkit.inventory.EntityEquipment eq = nl.getEquipment();
if (eq != null) {
bodyEquip = equipAsset(eq.getItem(org.bukkit.inventory.EquipmentSlot.BODY));
org.bukkit.inventory.ItemStack sd = eq.getItem(org.bukkit.inventory.EquipmentSlot.SADDLE);
saddle = sd != null && !sd.getType().isAir();
}
bodyEquip = equipAsset(eq.getItem(org.bukkit.inventory.EquipmentSlot.BODY));
org.bukkit.inventory.ItemStack sd = eq.getItem(org.bukkit.inventory.EquipmentSlot.SADDLE);
saddle = !sd.getType().isAir();
} else if (e instanceof org.bukkit.entity.Fox f) {
variant = keyOf(f.getFoxType());
} else if (e instanceof org.bukkit.entity.MushroomCow mc) {
@@ -149,7 +147,6 @@ public final class EntitySnapshotBuilder {
variant = s.getColor() == null ? null : keyOf(s.getColor());
} else if (e instanceof org.bukkit.entity.ZombieVillager zv) {
variant = keyOf(zv.getVillagerType());
profession = keyOf(zv.getVillagerProfession());
// ZombieVillager exposes no level via Bukkit -> no profession-level badge (matches vanilla).
} else if (e instanceof org.bukkit.entity.Villager vi) {
variant = keyOf(vi.getVillagerType());
@@ -265,10 +262,12 @@ public final class EntitySnapshotBuilder {
/** Registry/Keyed values yield their key path; plain enums yield their lower-case name. */
private static String keyOf(Object o) {
if (o == null) return null;
if (o instanceof org.bukkit.Keyed k) return k.getKey().getKey();
if (o instanceof Enum<?> en) return en.name().toLowerCase(java.util.Locale.ROOT);
return o.toString().toLowerCase(java.util.Locale.ROOT);
return switch(o) {
case null -> null;
case org.bukkit.Keyed k -> k.getKey().getKey();
case Enum<?> en -> en.name().toLowerCase(java.util.Locale.ROOT);
default -> o.toString().toLowerCase(java.util.Locale.ROOT);
};
}
/** Returns {skinUrl, model} from the player's profile texture property, or {null, null}. */
@@ -52,8 +52,9 @@ public class CameraListener implements Listener {
return;
}
// Consume one film and update the in-hand camera (count + lore) before the shot.
player.getInventory().setItemInMainHand(CameraItems.withFilmCount(inHand, film - 1));
PhotoService.takePhoto(player);
// Only charge film if the shot was actually accepted (renderer ready, not queue-rejected).
if (PhotoService.takePhoto(player)) {
player.getInventory().setItemInMainHand(CameraItems.withFilmCount(inHand, film - 1));
}
}
}
@@ -1,6 +1,7 @@
package eu.mhsl.minecraft.pixelpics.survival;
import eu.mhsl.minecraft.pixelpics.Main;
import eu.mhsl.minecraft.pixelpics.render.RenderManager;
import eu.mhsl.minecraft.pixelpics.render.render.DefaultScreenRenderer;
import eu.mhsl.minecraft.pixelpics.render.render.RenderJob;
import eu.mhsl.minecraft.pixelpics.render.render.Resolution;
@@ -26,15 +27,22 @@ import java.util.UUID;
/**
* Renders a photo of a player's current view and delivers it as a developing {@code FILLED_MAP},
* with action-bar feedback, a shutter sound + flash particles on capture, and a chime when the
* picture finishes developing. Shared by the camera item (survival) and the {@code /pixelPic}
* debug command. Film accounting is the caller's responsibility — this method never touches film.
* with a shutter sound + flash particles on capture and a chime when the picture finishes developing.
* Heavy work is funneled through {@link RenderManager} (per-player single slot, global concurrency
* limit + queue, bounded low-priority CPU). Shared by the camera item (survival) and the
* {@code /pixelPic} debug command. Film accounting is the caller's responsibility.
*/
public final class PhotoService {
/** Render result carried back to the main thread: PNG source + dithered map indices. */
private record RenderOutput(BufferedImage image, byte[] indices) {}
private PhotoService() {}
/** Captures and delivers a photo. Returns {@code false} if the renderer is unavailable. */
/**
* Captures and delivers a photo. Returns {@code false} (with action-bar feedback) if the renderer
* is unavailable or the request is rejected by the queue — callers should not charge film then.
*/
public static boolean takePhoto(Player player) {
Main plugin = Main.getInstance();
DefaultScreenRenderer renderer = plugin.getScreenRenderer();
@@ -44,50 +52,60 @@ public final class PhotoService {
return false;
}
Resolution resolution = new Resolution(Resolution.Pixels._128P, Resolution.AspectRatio._1_1);
RenderManager manager = plugin.getRenderManager();
UUID user = player.getUniqueId();
RenderManager.Outcome outcome = manager.tryReserve(user);
if (outcome != RenderManager.Outcome.ACCEPTED) {
player.sendActionBar(Component.text(outcome == RenderManager.Outcome.USER_BUSY
? "Deine letzte Aufnahme wird noch entwickelt …"
: "Zu viele Aufnahmen gerade — versuch es gleich erneut.", NamedTextColor.RED));
return false;
}
// Capture the world snapshot on the main thread.
RenderJob job = renderer.prepare(player.getEyeLocation(), resolution, player.getUniqueId());
boolean dispatched = false;
try {
Resolution resolution = new Resolution(Resolution.Pixels._128P, Resolution.AspectRatio._1_1);
// Hand the map over immediately, showing blank "film"; it develops once the render is ready.
ImageMapRenderer mapRenderer = new ImageMapRenderer();
MapView mapView = Bukkit.createMap(player.getWorld());
int id = mapView.getId();
MapManager.attachView(mapView, mapRenderer);
// Capture the world snapshot on the main thread (the moment of the shot).
RenderJob job = renderer.prepare(player.getEyeLocation(), resolution, user);
ItemStack map = new ItemStack(Material.FILLED_MAP, 1);
MapMeta meta = (MapMeta) map.getItemMeta();
meta.getPersistentDataContainer().set(plugin.pictureIdFlag,
PersistentDataType.STRING, UUID.randomUUID().toString());
meta.setMapView(mapView);
map.setItemMeta(meta);
player.getInventory().addItem(map);
// Hand the map over immediately, showing blank "film"; it develops once the render is ready.
ImageMapRenderer mapRenderer = new ImageMapRenderer();
MapView mapView = Bukkit.createMap(player.getWorld());
int id = mapView.getId();
MapManager.attachView(mapView, mapRenderer);
// Feedback is sound + particles only; no action-bar text on a normal shot.
playShutter(player);
ItemStack map = new ItemStack(Material.FILLED_MAP, 1);
MapMeta meta = (MapMeta) map.getItemMeta();
meta.getPersistentDataContainer().set(plugin.pictureIdFlag,
PersistentDataType.STRING, UUID.randomUUID().toString());
meta.setMapView(mapView);
map.setItemMeta(meta);
player.getInventory().addItem(map);
// Trace + dither off-thread, then start the developing animation on the main thread.
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
BufferedImage image;
byte[] indices;
try {
image = renderer.execute(job);
indices = MapImageDither.dither(image);
} catch (Exception e) {
plugin.getLogger().warning("Render failed: " + e.getMessage());
Bukkit.getScheduler().runTask(plugin, () ->
player.sendActionBar(Component.text("Rendern fehlgeschlagen.", NamedTextColor.RED)));
return;
}
BufferedImage finalImage = image;
byte[] finalIndices = indices;
Bukkit.getScheduler().runTask(plugin, () -> {
MapManager.saveImage(finalImage, id);
MapManager.saveIndices(finalIndices, id);
mapRenderer.develop(finalIndices);
player.playSound(player.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 0.8f, 1.2f);
});
});
// Feedback is sound + particles only; no action-bar text on a normal shot.
playShutter(player);
// Trace + dither off-thread (queued), then develop on the main thread. The cancel token is
// tripped by the watchdog on timeout; we bail out before the (now pointless) dithering.
manager.dispatch(user,
cancelled -> {
BufferedImage image = renderer.execute(job, cancelled);
if (cancelled.get()) return null;
return new RenderOutput(image, MapImageDither.dither(image));
},
out -> {
MapManager.saveImage(out.image(), id);
MapManager.saveIndices(out.indices(), id);
mapRenderer.develop(out.indices());
player.playSound(player.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 0.8f, 1.2f);
},
() -> player.sendActionBar(Component.text("Rendern fehlgeschlagen.", NamedTextColor.RED)));
dispatched = true;
} finally {
// prepare()/map setup threw before handing off — free the reserved slot.
if (!dispatched) manager.release(user);
}
return true;
}
+17
View File
@@ -0,0 +1,17 @@
# PixelPics rendering performance settings.
render:
# CPU cores the ray tracer may use. 0 = auto (available processors minus 2, at least 1).
# Lower this to leave more CPU headroom for the server tick.
threads: 0
# Maximum number of photos rendered at the same time, server-wide.
# These jobs share the 'threads' cores above.
max-concurrent: 2
# Maximum number of photos that may wait in the queue once max-concurrent is reached.
# Further requests are rejected with a hint. Each player may only have one photo in the system.
queue-size: 8
# Safety net: a render exceeding this many seconds is aborted (cooperatively) so a buggy/overlong
# job never hangs forever, freeing its slot and telling the player it failed.
timeout-seconds: 30