add RenderManager: improve CPU control with global render queue and parallel trace pooling
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
+10
-11
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user