diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/RenderManager.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/RenderManager.java
index cb8dedd..09e94e2 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/RenderManager.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/RenderManager.java
@@ -5,6 +5,7 @@ import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.ForkJoinPool;
@@ -24,6 +25,7 @@ import java.util.function.Supplier;
import org.bukkit.Bukkit;
import org.bukkit.plugin.IllegalPluginAccessException;
import org.bukkit.plugin.Plugin;
+import org.bukkit.scheduler.BukkitTask;
/**
* Server-friendly scheduling for photo renders. Bounds CPU use and protects the server tick by:
@@ -33,6 +35,8 @@ import org.bukkit.plugin.Plugin;
* {@code queueSize} more waiting; further requests are rejected.
*
CPU: the ray tracing runs on a dedicated {@link ForkJoinPool} of {@code threads}
* {@link Thread#MIN_PRIORITY} workers instead of fanning out over the common pool.
+ * Main thread: at most one world snapshot ({@link #onMainThread}) per tick, also when several
+ * jobs start at once.
*
*
* Reserve a slot with {@link #tryReserve(UUID)}; if accepted, run the heavy work via
@@ -55,6 +59,10 @@ public final class RenderManager {
private final ScheduledExecutorService watchdog;
private final Set activeUsers = ConcurrentHashMap.newKeySet();
private final Set> jobs = ConcurrentHashMap.newKeySet();
+ /** Main-thread tasks of the jobs ({@link #onMainThread}), run one per tick by {@link #runNextMainTask}. */
+ private final ConcurrentLinkedQueue mainTasks = new ConcurrentLinkedQueue<>();
+
+ private final BukkitTask mainTaskPump;
private volatile boolean shuttingDown = false;
private final AtomicInteger inFlight = new AtomicInteger();
private final int capacity;
@@ -78,6 +86,7 @@ public final class RenderManager {
thread.setDaemon(true);
return thread;
});
+ this.mainTaskPump = Bukkit.getScheduler().runTaskTimer(plugin, this::runNextMainTask, 1, 1);
}
/** The bounded, low-priority pool the ray tracer must run its parallel work on. */
@@ -148,28 +157,24 @@ public final class RenderManager {
}
/**
- * From a dispatched job's {@code work}: runs {@code task} on the main thread (next tick) and waits for its result.
- * Lets a job capture its world snapshot only once it is its turn, so queued photos hold no snapshot. Returns null
- * without running {@code task} once {@code cancelled} is set (timeout, shutdown), the plugin is disabled or the
- * worker is interrupted.
+ * From a dispatched job's {@code work}: runs {@code task} on the main thread and waits for its result. Lets a job
+ * capture its world snapshot only once it is its turn, so queued photos hold no snapshot. The tasks of all jobs run
+ * one per tick in request order, so jobs starting together (a burst, or {@code max-concurrent} > 1) never stack
+ * their snapshots into one long tick. Returns null without running {@code task} once {@code cancelled} is set
+ * (timeout, shutdown), the plugin is disabled or the worker is interrupted.
*/
public T onMainThread(Supplier task, AtomicBoolean cancelled) {
+ if (shuttingDown) return null;
CompletableFuture result = new CompletableFuture<>();
- try {
- Bukkit.getScheduler().runTask(plugin, () -> {
- if (cancelled.get()) {
- result.complete(null);
- return;
- }
- try {
- result.complete(task.get());
- } catch (Throwable t) {
- result.completeExceptionally(t);
- }
- });
- } catch (IllegalPluginAccessException e) {
- return null; // disabled meanwhile
- }
+ mainTasks.add(new MainTask(
+ () -> {
+ try {
+ result.complete(task.get());
+ } catch (Throwable t) {
+ result.completeExceptionally(t);
+ }
+ },
+ cancelled));
while (!cancelled.get()) {
try {
return result.get(50, TimeUnit.MILLISECONDS);
@@ -187,6 +192,18 @@ public final class RenderManager {
return null;
}
+ /** Every tick: runs the oldest main-thread task whose job is still alive (cancelled ones are dropped). */
+ private void runNextMainTask() {
+ MainTask next;
+ while ((next = mainTasks.poll()) != null) {
+ if (next.cancelled.get()) continue; // its worker returns null on its own
+ next.run.run();
+ return;
+ }
+ }
+
+ private record MainTask(Runnable run, AtomicBoolean cancelled) {}
+
/**
* Stops all rendering without leaving a job undelivered: running and queued jobs are cancelled, the workers get
* a few seconds to wind down, then every job's callback runs here (on the main thread) — {@code onSuccess} for
@@ -194,7 +211,9 @@ public final class RenderManager {
*/
public void shutdown() {
shuttingDown = true;
+ mainTaskPump.cancel();
for (Job> job : jobs) job.cancelled.set(true);
+ mainTasks.clear();
watchdog.shutdownNow();
dispatcher.shutdownNow();
try {
@@ -237,9 +256,11 @@ public final class RenderManager {
}
private static ForkJoinPool.ForkJoinWorkerThreadFactory lowPriorityForkJoinFactory() {
+ AtomicInteger counter = new AtomicInteger();
return pool -> {
ForkJoinWorkerThread thread = ForkJoinPool.defaultForkJoinWorkerThreadFactory.newThread(pool);
- thread.setName("PixelPics-trace-" + thread.getPoolIndex());
+ // not getPoolIndex(): it is only assigned once the worker registers, here every name would end in -0
+ thread.setName("PixelPics-trace-" + counter.incrementAndGet());
thread.setPriority(Thread.MIN_PRIORITY);
thread.setDaemon(true);
return thread;