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: + * + * + *

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