diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java b/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java
index 9505610..ccbbbde 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java
@@ -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;
}
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/RenderManager.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/RenderManager.java
new file mode 100644
index 0000000..90ba7af
--- /dev/null
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/RenderManager.java
@@ -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:
+ *
+ * - Per player: at most one photo in the system at a time (running or queued).
+ * - Globally: at most {@code maxConcurrent} photos render at once, with up to
+ * {@code queueSize} more waiting; further requests are rejected.
+ * - CPU: 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.
+ *
+ *
+ * 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 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 void dispatch(UUID user, Function work, Consumer 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;
+ };
+ }
+}
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java
index 66788a9..5e5d8d5 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java
@@ -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 buildRayMap(Location eyeLocation, int width, int height) {
Vector lineDirection = eyeLocation.getDirection();
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/EntitySnapshotBuilder.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/EntitySnapshotBuilder.java
index 04f54d7..7a1ebd6 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/EntitySnapshotBuilder.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/EntitySnapshotBuilder.java
@@ -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}. */
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraListener.java b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraListener.java
index aa28316..bfba90d 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraListener.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraListener.java
@@ -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));
+ }
}
}
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PhotoService.java b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PhotoService.java
index 59e9742..00a9938 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PhotoService.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PhotoService.java
@@ -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;
}
diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml
new file mode 100644
index 0000000..d2677da
--- /dev/null
+++ b/src/main/resources/config.yml
@@ -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