mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-28 23:10:32 +00:00
Stabilized shutdown and Studio
- Shutdown protection initializes before enable and drains inline when retention fails - Cleanup preserves class loader retention and closes native providers in order - Error reports retain context and handle empty stack traces - Studio maps retain partial coverage and suspend hidden views - Hydrology previews refresh when plans and rasters complete - Terrain scratch hashing spreads lattice samples across cache slots - Plugin and probe artifacts include LWJGL libraries and native resources - Regression tests cover shutdown, error reporting, map progress, and terrain scratch
This commit is contained in:
1 parent
f64a7c6753
commit
3dc958edcf
31 files changed
+1973
-217
No files matched your search
+9
-1
@@ -47,6 +47,7 @@ import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.IntBinaryOperator;
|
||||
@@ -79,6 +80,7 @@ public final class HydrologyRuntime {
|
||||
private final HydrologyBinding binding;
|
||||
private final Function<PreparedHydrologyCacheIdentity, HydrologyPlanStore> stores;
|
||||
private final boolean caveFluidPolicies;
|
||||
private final AtomicLong rasterRevision = new AtomicLong();
|
||||
private volatile PreparedHydrologyCacheIdentity storeIdentity;
|
||||
private boolean storeOpen;
|
||||
private volatile boolean closed;
|
||||
@@ -210,6 +212,10 @@ public final class HydrologyRuntime {
|
||||
return settings;
|
||||
}
|
||||
|
||||
public long previewRevision() {
|
||||
return cache == null ? 0L : cache.readyRevision() + rasterRevision.get();
|
||||
}
|
||||
|
||||
public PreviewScope preview() {
|
||||
return new PreviewScope(cache == null ? null : last.get());
|
||||
}
|
||||
@@ -696,7 +702,9 @@ public final class HydrologyRuntime {
|
||||
|
||||
private ChunkWaterRaster remember(long key, ChunkWaterRaster raster) {
|
||||
synchronized (rasters) {
|
||||
rasters.put(key, raster);
|
||||
if (rasters.put(key, raster) == null) {
|
||||
rasterRevision.incrementAndGet();
|
||||
}
|
||||
}
|
||||
last.get().remember(key, raster);
|
||||
return raster;
|
||||
|
||||
+8
-1
@@ -57,6 +57,7 @@ public final class HydrologyPlanCache {
|
||||
private final AtomicLong demands = new AtomicLong();
|
||||
private final AtomicLong speculations = new AtomicLong();
|
||||
private final AtomicLong previewStamps = new AtomicLong(Long.MIN_VALUE);
|
||||
private final AtomicLong readyRevision = new AtomicLong();
|
||||
private final Shelf<RegionKey, RegionPlan> regions = new Shelf<>(REGION_CAPACITY, this::startRegion);
|
||||
private final Shelf<WindowKey, IndexedWindow> windows = new Shelf<>(WINDOW_CAPACITY, this::startWindow);
|
||||
private final Map<Object, Set<Slot<?, ?>>> epochSlots = new HashMap<>();
|
||||
@@ -112,6 +113,10 @@ public final class HydrologyPlanCache {
|
||||
return windows.evictions.get();
|
||||
}
|
||||
|
||||
public long readyRevision() {
|
||||
return readyRevision.get();
|
||||
}
|
||||
|
||||
public RegionPlan region(RegionKey key) {
|
||||
Objects.requireNonNull(key, "key");
|
||||
requireOffMainThread("region " + key.rx() + "," + key.rz());
|
||||
@@ -759,7 +764,9 @@ public final class HydrologyPlanCache {
|
||||
|
||||
private void succeed(V value) {
|
||||
release();
|
||||
future.complete(value);
|
||||
if (future.complete(value)) {
|
||||
readyRevision.incrementAndGet();
|
||||
}
|
||||
shelf.evict();
|
||||
}
|
||||
|
||||
|
||||
@@ -78,8 +78,10 @@ public final class TerrainBatchScratch {
|
||||
int lattice(double x, double z) {
|
||||
long xBits = Double.doubleToRawLongBits(x);
|
||||
long zBits = Double.doubleToRawLongBits(z);
|
||||
long mixed = xBits * 0x9E3779B97F4A7C15L ^ Long.rotateLeft(zBits * 0xC2B2AE3D27D4EB4FL, 31);
|
||||
mixed ^= mixed >>> 29;
|
||||
long mixed = xBits ^ Long.rotateLeft(zBits, 32);
|
||||
mixed = (mixed ^ (mixed >>> 33)) * 0xFF51AFD7ED558CCDL;
|
||||
mixed = (mixed ^ (mixed >>> 33)) * 0xC4CEB9FE1A85EC53L;
|
||||
mixed ^= mixed >>> 33;
|
||||
int mask = latticeCapacity - 1;
|
||||
int slot = (int) mixed & mask;
|
||||
while (true) {
|
||||
|
||||
@@ -1668,7 +1668,6 @@ public class IrisEngine implements Engine {
|
||||
} catch (GenerationSessionException e) {
|
||||
throw e;
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
fail("Failed to generate " + x + ", " + z, e);
|
||||
if (e instanceof WrongEngineBroException wrongEngine) {
|
||||
throw wrongEngine;
|
||||
|
||||
@@ -365,4 +365,10 @@ public final class BukkitPlatform implements IrisPlatform {
|
||||
public void reportError(Throwable error) {
|
||||
bridge().errorSink().accept(error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reportError(String context, Throwable error) {
|
||||
log(LogLevel.ERROR, context == null || context.isBlank() ? "Unhandled Iris failure." : context);
|
||||
reportError(error);
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.platform.bukkit.nms;
|
||||
|
||||
import art.arcane.iris.platform.agent.Agent;
|
||||
import art.arcane.iris.platform.bootstrap.SlimJar;
|
||||
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
|
||||
import art.arcane.iris.generation.runtime.EngineTarget;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
@@ -8,6 +9,7 @@ import art.arcane.iris.world.lifecycle.WorldLifecycleStaging;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorldLifecyclePolicy;
|
||||
import org.bukkit.generator.ChunkGenerator;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.instrument.Instrumentation;
|
||||
import java.util.Objects;
|
||||
|
||||
@@ -41,7 +43,23 @@ public enum BukkitWorldLifecyclePolicy implements NativeWorldLifecyclePolicy {
|
||||
|
||||
@Override
|
||||
public void releaseClassLoader(ClassLoader loader) {
|
||||
Agent.releaseClassLoader(loader);
|
||||
Throwable providerFailure = null;
|
||||
try {
|
||||
SlimJar.closeNativeProviders();
|
||||
} catch (IOException | RuntimeException | Error failure) {
|
||||
providerFailure = failure;
|
||||
}
|
||||
try {
|
||||
Agent.releaseClassLoader(loader);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
if (providerFailure != null) {
|
||||
failure.addSuppressed(providerFailure);
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
if (providerFailure != null) {
|
||||
throw new IllegalStateException("Failed to close Iris native providers", providerFailure);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -290,6 +290,10 @@ public interface INMSBinding extends NativeTerrainAccess {
|
||||
|
||||
boolean missingDimensionTypes(String... keys);
|
||||
|
||||
default void initializeLifecycle(ClassLoader loader) {
|
||||
throw new IllegalStateException("Plugin class loader shutdown protection is unavailable");
|
||||
}
|
||||
|
||||
default boolean injectBukkit() {
|
||||
return true;
|
||||
}
|
||||
@@ -301,8 +305,8 @@ public interface INMSBinding extends NativeTerrainAccess {
|
||||
default void uninjectBukkit() {
|
||||
}
|
||||
|
||||
default boolean deferPluginClassLoaderClose() {
|
||||
return false;
|
||||
default void deferPluginClassLoaderClose() {
|
||||
throw new IllegalStateException("Plugin class loader shutdown protection is unavailable");
|
||||
}
|
||||
|
||||
default void releasePluginClassLoaderClose() {
|
||||
|
||||
@@ -276,9 +276,21 @@ final class MapCanvas extends JPanel {
|
||||
|
||||
// Tiles keep loading in the background only for the view on screen; a hidden canvas stops asking for new ones.
|
||||
void setActive(boolean nextActive) {
|
||||
if (closed || active == nextActive) {
|
||||
return;
|
||||
}
|
||||
active = nextActive;
|
||||
if (active) {
|
||||
markerTimer.start();
|
||||
provisionalTimer.start();
|
||||
requestRender();
|
||||
} else {
|
||||
controller.suspend();
|
||||
hoverTimer.stop();
|
||||
legendTimer.stop();
|
||||
markerTimer.stop();
|
||||
provisionalTimer.stop();
|
||||
clearHover();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1021,6 +1033,9 @@ final class MapCanvas extends JPanel {
|
||||
}
|
||||
|
||||
private void onTilesChanged() {
|
||||
if (closed || !active) {
|
||||
return;
|
||||
}
|
||||
tileLayerStale = true;
|
||||
repaint();
|
||||
refreshStatus();
|
||||
|
||||
@@ -112,6 +112,7 @@ public final class MapWindow implements MapCanvas.Listener {
|
||||
private double worldCenterX;
|
||||
private double worldCenterZ;
|
||||
private MapView currentView;
|
||||
private boolean windowVisible = true;
|
||||
private double paletteCenterX;
|
||||
private double paletteCenterZ;
|
||||
|
||||
@@ -190,11 +191,55 @@ public final class MapWindow implements MapCanvas.Listener {
|
||||
frame.setMinimumSize(StudioTheme.scaledSize(820, 520));
|
||||
frame.setLocationRelativeTo(null);
|
||||
frame.addWindowListener(new WindowAdapter() {
|
||||
@Override
|
||||
public void windowIconified(WindowEvent event) {
|
||||
updateWindowVisibility();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void windowDeiconified(WindowEvent event) {
|
||||
updateWindowVisibility();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void windowClosed(WindowEvent event) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
frame.addComponentListener(new ComponentAdapter() {
|
||||
@Override
|
||||
public void componentShown(ComponentEvent event) {
|
||||
updateWindowVisibility();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void componentHidden(ComponentEvent event) {
|
||||
updateWindowVisibility();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void updateWindowVisibility() {
|
||||
boolean visible = isWindowVisible();
|
||||
boolean wasVisible = windowVisible;
|
||||
windowVisible = visible;
|
||||
canvas.setActive(visible && currentView != null && currentView.isTileMap());
|
||||
if (currentView == MapView.WORLD_3D && worldExplorer != null) {
|
||||
if (visible && !wasVisible) {
|
||||
worldExplorer.show(worldCenterX, worldCenterZ, false);
|
||||
} else if (!visible) {
|
||||
worldExplorer.hide();
|
||||
}
|
||||
}
|
||||
if (visible) {
|
||||
resourceUsage.start();
|
||||
} else {
|
||||
resourceUsage.stop();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isWindowVisible() {
|
||||
return frame.isShowing() && (frame.getExtendedState() & JFrame.ICONIFIED) == 0;
|
||||
}
|
||||
|
||||
private JToolBar buildToolbar() {
|
||||
@@ -265,6 +310,7 @@ public final class MapWindow implements MapCanvas.Listener {
|
||||
}
|
||||
frame.getRootPane().revalidate();
|
||||
frame.getRootPane().repaint();
|
||||
updateWindowVisibility();
|
||||
}
|
||||
|
||||
private void showMap(MapView previousView, MapView view) {
|
||||
@@ -272,7 +318,7 @@ public final class MapWindow implements MapCanvas.Listener {
|
||||
canvas.centerOn(paletteExplorer.centerX(), paletteExplorer.centerZ());
|
||||
}
|
||||
canvas.showView(view);
|
||||
canvas.setActive(true);
|
||||
canvas.setActive(isWindowVisible());
|
||||
showControls(mapControls);
|
||||
waterToggle.setVisible(view == MapView.BIOMES);
|
||||
if (view == MapView.REGIONS) {
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.generation.stream.ProvisionalSampling;
|
||||
import art.arcane.iris.studio.map.components.MapTileRenderer;
|
||||
import art.arcane.iris.studio.map.components.MapLabels;
|
||||
import art.arcane.iris.studio.map.components.MapLayer;
|
||||
@@ -39,6 +38,7 @@ import java.util.Set;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
@@ -48,6 +48,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/**
|
||||
* Renders the map as tiles on fixed zoom levels, so zooming within a level and panning reuse tiles that are already drawn.
|
||||
@@ -67,6 +68,7 @@ final class VisionRenderController implements AutoCloseable {
|
||||
private static final long CACHE_BYTES = 256L * 1024L * 1024L;
|
||||
|
||||
private final Runnable listener;
|
||||
private final LongSupplier nanoTime;
|
||||
private final int workerCount;
|
||||
private final ThreadPoolExecutor renderExecutor;
|
||||
private final ThreadPoolExecutor probeExecutor;
|
||||
@@ -79,9 +81,15 @@ final class VisionRenderController implements AutoCloseable {
|
||||
private final AtomicBoolean publicationQueued = new AtomicBoolean();
|
||||
private final AtomicBoolean publicationDirty = new AtomicBoolean();
|
||||
private volatile Frame currentFrame;
|
||||
private Future<?> activeProbe;
|
||||
|
||||
VisionRenderController(Runnable listener) {
|
||||
this(listener, System::nanoTime);
|
||||
}
|
||||
|
||||
VisionRenderController(Runnable listener, LongSupplier nanoTime) {
|
||||
this.listener = Objects.requireNonNull(listener, "listener");
|
||||
this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime");
|
||||
int processors = Math.max(1, Runtime.getRuntime().availableProcessors());
|
||||
this.workerCount = Math.max(1, Math.min(6, processors / 2));
|
||||
AtomicInteger threadSequence = new AtomicInteger();
|
||||
@@ -123,11 +131,10 @@ final class VisionRenderController implements AutoCloseable {
|
||||
List<VisibleTile> tiles = visibleTiles(spec, renderScale);
|
||||
Frame frame = new Frame(viewSequence.incrementAndGet(), spec, renderScale, tiles);
|
||||
for (VisibleTile tile : tiles) {
|
||||
tile.setTile(cachedTileServing(tile.key()));
|
||||
tile.state = cachedTileServing(tile.key());
|
||||
}
|
||||
currentFrame = frame;
|
||||
probeSequence.incrementAndGet();
|
||||
probeExecutor.getQueue().clear();
|
||||
cancelProbe();
|
||||
startWorkers();
|
||||
publish();
|
||||
return frame;
|
||||
@@ -141,25 +148,41 @@ final class VisionRenderController implements AutoCloseable {
|
||||
startWorkers();
|
||||
}
|
||||
|
||||
synchronized void suspend() {
|
||||
currentFrame = null;
|
||||
cancelProbe();
|
||||
}
|
||||
|
||||
/**
|
||||
* A tile from another zoom level covering the given one, to draw while the real tile renders.
|
||||
*/
|
||||
Fallback fallback(Frame frame, VisibleTile tile) {
|
||||
MapTile withoutHeights = cache.get(tile.key().withHeights(false));
|
||||
Fallback complete = fallback(tile.key(), false);
|
||||
return complete != null || tile.tile() != null ? complete : fallback(tile.key(), true);
|
||||
}
|
||||
|
||||
private Fallback fallback(TileKey key, boolean provisional) {
|
||||
MapTile withoutHeights = fallbackTile(key.withHeights(false), provisional);
|
||||
if (withoutHeights != null) {
|
||||
return new Fallback(List.of(new FallbackPiece(withoutHeights, 0, 0, TILE_PIXELS, 0D, 0D, 1D)));
|
||||
}
|
||||
Fallback coarser = coarserFallback(frame, tile);
|
||||
return coarser != null ? coarser : finerFallback(frame, tile);
|
||||
Fallback coarser = coarserFallback(key, provisional);
|
||||
return coarser != null ? coarser : finerFallback(key, provisional);
|
||||
}
|
||||
|
||||
// A tile drawn with heights serves just as well where no heights are needed, so turning shading off redraws nothing.
|
||||
private MapTile cachedTileServing(TileKey key) {
|
||||
MapTile exact = cache.get(key);
|
||||
private TileState cachedTileServing(TileKey key) {
|
||||
CacheEntry exact = cache.get(key);
|
||||
if (exact != null || key.heights()) {
|
||||
return exact;
|
||||
return exact == null ? null : exact.state();
|
||||
}
|
||||
return cache.get(key.withHeights(true));
|
||||
CacheEntry shaded = cache.get(key.withHeights(true));
|
||||
return shaded != null && !shaded.state().provisional() ? shaded.state() : null;
|
||||
}
|
||||
|
||||
private MapTile fallbackTile(TileKey key, boolean provisional) {
|
||||
CacheEntry entry = cache.get(key);
|
||||
return entry != null && entry.state().provisional() == provisional ? entry.state().tile() : null;
|
||||
}
|
||||
|
||||
Progress progress(Frame frame) {
|
||||
@@ -168,34 +191,44 @@ final class VisionRenderController implements AutoCloseable {
|
||||
}
|
||||
int ready = 0;
|
||||
for (VisibleTile tile : frame.tiles()) {
|
||||
if (tile.tile() != null && !tile.provisional) {
|
||||
if (tile.tile() != null && !tile.provisional()) {
|
||||
ready++;
|
||||
}
|
||||
}
|
||||
return new Progress(frame.tiles().size(), ready, activeWorkers.get());
|
||||
}
|
||||
|
||||
<T> void submitProbe(Frame frame, Callable<T> probe, Consumer<T> consumer) {
|
||||
synchronized <T> void submitProbe(Frame frame, Callable<T> probe, Consumer<T> consumer) {
|
||||
Objects.requireNonNull(probe, "probe");
|
||||
Objects.requireNonNull(consumer, "consumer");
|
||||
if (frame == null || closed.get() || currentFrame != frame) {
|
||||
return;
|
||||
}
|
||||
long probeRevision = probeSequence.incrementAndGet();
|
||||
probeExecutor.getQueue().clear();
|
||||
cancelProbe();
|
||||
long probeRevision = probeSequence.get();
|
||||
try {
|
||||
probeExecutor.execute(() -> runProbe(probeRevision, probe, consumer));
|
||||
activeProbe = probeExecutor.submit(() -> runProbe(probeRevision, probe, consumer));
|
||||
} catch (RejectedExecutionException ignored) {
|
||||
probeExecutor.getQueue().clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void cancelProbe() {
|
||||
probeSequence.incrementAndGet();
|
||||
if (activeProbe != null) {
|
||||
activeProbe.cancel(true);
|
||||
activeProbe = null;
|
||||
}
|
||||
probeExecutor.getQueue().clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void close() {
|
||||
if (!closed.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
currentFrame = null;
|
||||
cancelProbe();
|
||||
renderExecutor.shutdownNow();
|
||||
probeExecutor.shutdownNow();
|
||||
cache.clear();
|
||||
@@ -285,7 +318,7 @@ final class VisionRenderController implements AutoCloseable {
|
||||
|
||||
private synchronized boolean hasWork() {
|
||||
Frame frame = currentFrame;
|
||||
return frame != null && frame.nextMissing(tilesInFlight, false) != null;
|
||||
return frame != null && frame.nextMissing(tilesInFlight, false, nanoTime.getAsLong()) != null;
|
||||
}
|
||||
|
||||
private synchronized TileJob claimNextTile() {
|
||||
@@ -293,51 +326,63 @@ final class VisionRenderController implements AutoCloseable {
|
||||
if (frame == null || closed.get()) {
|
||||
return null;
|
||||
}
|
||||
VisibleTile tile = frame.nextMissing(tilesInFlight, true);
|
||||
VisibleTile tile = frame.nextMissing(tilesInFlight, true, nanoTime.getAsLong());
|
||||
if (tile == null) {
|
||||
return null;
|
||||
}
|
||||
tilesInFlight.add(tile.key());
|
||||
return new TileJob(frame.spec().renderer(), tile.key());
|
||||
CacheEntry cached = cache.get(tile.key());
|
||||
return new TileJob(frame.spec().renderer(), tile.key(), cached == null ? null : cached.work(), tile.state);
|
||||
}
|
||||
|
||||
private synchronized void release(TileKey key) {
|
||||
private synchronized void release(TileKey key, boolean cancelled) {
|
||||
tilesInFlight.remove(key);
|
||||
Frame frame = currentFrame;
|
||||
VisibleTile visible = frame == null ? null : frame.tile(key);
|
||||
if (cancelled && visible != null && visible.needsRender(nanoTime.getAsLong(), frame.spec().renderer().previewRevision())) {
|
||||
frame.nextCandidate = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void render(TileJob job) {
|
||||
TileKey key = job.key();
|
||||
boolean cancelled = false;
|
||||
try {
|
||||
long previewRevision = job.renderer().previewRevision();
|
||||
double tileSpan = TILE_PIXELS * key.blocksPerPixel();
|
||||
long revision = ProvisionalSampling.revision();
|
||||
MapTile tile = job.renderer().renderTile(
|
||||
key.tileX() * tileSpan,
|
||||
key.tileZ() * tileSpan,
|
||||
key.blocksPerPixel(),
|
||||
TILE_PIXELS,
|
||||
key.layer(),
|
||||
key.heights(),
|
||||
() -> !isWanted(key)
|
||||
);
|
||||
boolean provisional = revision != ProvisionalSampling.revision();
|
||||
MapTileRenderer.TileRender work = job.work();
|
||||
if (work == null) {
|
||||
work = job.renderer().prepareTile(key.tileX() * tileSpan, key.tileZ() * tileSpan,
|
||||
key.blocksPerPixel(), TILE_PIXELS, key.layer(), key.heights());
|
||||
}
|
||||
MapTile tile = work.render(() -> !isWanted(key));
|
||||
boolean provisional = !work.complete();
|
||||
TileState previous = job.previous();
|
||||
long delay = 0L;
|
||||
if (provisional) {
|
||||
delay = previous == null || previous.retryDelay() == 0L
|
||||
? FIRST_RETRY_NANOS : Math.min(LAST_RETRY_NANOS, previous.retryDelay() * 2L);
|
||||
}
|
||||
long completedAt = nanoTime.getAsLong();
|
||||
TileState state = new TileState(tile, provisional, delay, completedAt + delay,
|
||||
completedAt + FIRST_RETRY_NANOS, previewRevision);
|
||||
synchronized (this) {
|
||||
if (!isWanted(key)) {
|
||||
if (closed.get()) {
|
||||
return;
|
||||
}
|
||||
VisibleTile visible = currentFrame.tile(key);
|
||||
if (provisional) {
|
||||
visible.markProvisional(tile, System.nanoTime());
|
||||
} else {
|
||||
cache.put(key, tile);
|
||||
visible.setTile(tile);
|
||||
cache.put(key, state, provisional ? work : null);
|
||||
if (isWanted(key)) {
|
||||
currentFrame.tile(key).state = state;
|
||||
}
|
||||
}
|
||||
publish();
|
||||
} catch (CancellationException ignored) {
|
||||
cancelled = true;
|
||||
cache.discardWork(key);
|
||||
} catch (Throwable error) {
|
||||
IrisLogging.debug("Vision tile render failed: " + error.getClass().getSimpleName() + ": " + error.getMessage());
|
||||
} finally {
|
||||
release(key);
|
||||
release(key, cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,8 +391,7 @@ final class VisionRenderController implements AutoCloseable {
|
||||
return !closed.get() && frame != null && frame.tile(key) != null;
|
||||
}
|
||||
|
||||
private Fallback coarserFallback(Frame frame, VisibleTile tile) {
|
||||
TileKey key = tile.key();
|
||||
private Fallback coarserFallback(TileKey key, boolean provisional) {
|
||||
for (int level = 1; level <= COARSER_FALLBACK_LEVELS; level++) {
|
||||
long scale = 1L << level;
|
||||
int sourceSize = TILE_PIXELS >> level;
|
||||
@@ -357,8 +401,8 @@ final class VisionRenderController implements AutoCloseable {
|
||||
}
|
||||
long parentX = Math.floorDiv(key.tileX(), scale);
|
||||
long parentZ = Math.floorDiv(key.tileZ(), scale);
|
||||
MapTile parent = cache.get(new TileKey(key.contentRevision(), key.layer(), key.heights(), parentScale, parentX,
|
||||
parentZ));
|
||||
MapTile parent = fallbackTile(new TileKey(key.contentRevision(), key.layer(), key.heights(), parentScale,
|
||||
parentX, parentZ), provisional);
|
||||
if (parent != null) {
|
||||
int sourceX = (int) (key.tileX() - parentX * scale) * sourceSize;
|
||||
int sourceZ = (int) (key.tileZ() - parentZ * scale) * sourceSize;
|
||||
@@ -368,8 +412,7 @@ final class VisionRenderController implements AutoCloseable {
|
||||
return null;
|
||||
}
|
||||
|
||||
private Fallback finerFallback(Frame frame, VisibleTile tile) {
|
||||
TileKey key = tile.key();
|
||||
private Fallback finerFallback(TileKey key, boolean provisional) {
|
||||
double childScale = key.blocksPerPixel() / 2D;
|
||||
if (childScale < MINIMUM_RENDER_BLOCKS_PER_PIXEL) {
|
||||
return null;
|
||||
@@ -377,8 +420,8 @@ final class VisionRenderController implements AutoCloseable {
|
||||
List<FallbackPiece> pieces = new ArrayList<>(4);
|
||||
for (int childZ = 0; childZ < 2; childZ++) {
|
||||
for (int childX = 0; childX < 2; childX++) {
|
||||
MapTile child = cache.get(new TileKey(key.contentRevision(), key.layer(), key.heights(), childScale,
|
||||
key.tileX() * 2L + childX, key.tileZ() * 2L + childZ));
|
||||
MapTile child = fallbackTile(new TileKey(key.contentRevision(), key.layer(), key.heights(), childScale,
|
||||
key.tileX() * 2L + childX, key.tileZ() * 2L + childZ), provisional);
|
||||
if (child != null) {
|
||||
pieces.add(new FallbackPiece(child, 0, 0, TILE_PIXELS, childX * 0.5D, childZ * 0.5D, 0.5D));
|
||||
}
|
||||
@@ -528,10 +571,11 @@ final class VisionRenderController implements AutoCloseable {
|
||||
}
|
||||
|
||||
// Tiles are sorted centre first, and everything before the cursor is drawn or claimed, so each search resumes there.
|
||||
private VisibleTile nextMissing(Set<TileKey> inFlight, boolean advance) {
|
||||
private VisibleTile nextMissing(Set<TileKey> inFlight, boolean advance, long now) {
|
||||
long previewRevision = spec.renderer().previewRevision();
|
||||
for (int index = nextCandidate; index < tiles.size(); index++) {
|
||||
VisibleTile tile = tiles.get(index);
|
||||
if (tile.needsRender(System.nanoTime()) && !inFlight.contains(tile.key())) {
|
||||
if (tile.needsRender(now, previewRevision) && !inFlight.contains(tile.key())) {
|
||||
if (advance) {
|
||||
nextCandidate = index + 1;
|
||||
}
|
||||
@@ -548,10 +592,7 @@ final class VisionRenderController implements AutoCloseable {
|
||||
static final class VisibleTile {
|
||||
private final TileKey key;
|
||||
private final double distanceSquared;
|
||||
private volatile MapTile tile;
|
||||
private volatile boolean provisional;
|
||||
private long retryDelay;
|
||||
private long retryAt;
|
||||
private volatile TileState state;
|
||||
|
||||
private VisibleTile(TileKey key, double distanceSquared) {
|
||||
this.key = key;
|
||||
@@ -575,28 +616,19 @@ final class VisionRenderController implements AutoCloseable {
|
||||
}
|
||||
|
||||
MapTile tile() {
|
||||
return tile;
|
||||
TileState current = state;
|
||||
return current == null ? null : current.tile();
|
||||
}
|
||||
|
||||
boolean provisional() {
|
||||
return provisional;
|
||||
TileState current = state;
|
||||
return current != null && current.provisional();
|
||||
}
|
||||
|
||||
private void setTile(MapTile tile) {
|
||||
this.tile = tile;
|
||||
provisional = false;
|
||||
retryDelay = 0L;
|
||||
}
|
||||
|
||||
private void markProvisional(MapTile tile, long now) {
|
||||
this.tile = tile;
|
||||
retryDelay = retryDelay == 0L ? FIRST_RETRY_NANOS : Math.min(LAST_RETRY_NANOS, retryDelay * 2L);
|
||||
retryAt = now + retryDelay;
|
||||
provisional = true;
|
||||
}
|
||||
|
||||
private boolean needsRender(long now) {
|
||||
return tile == null || provisional && now - retryAt >= 0L;
|
||||
private boolean needsRender(long now, long previewRevision) {
|
||||
TileState current = state;
|
||||
return current == null || current.provisional() && (now - current.retryAt() >= 0L
|
||||
|| previewRevision != current.previewRevision() && now - current.earliestRetryAt() >= 0L);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -645,7 +677,11 @@ final class VisionRenderController implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
private record TileJob(MapTileRenderer renderer, TileKey key) {
|
||||
private record TileJob(MapTileRenderer renderer, TileKey key, MapTileRenderer.TileRender work, TileState previous) {
|
||||
}
|
||||
|
||||
private record TileState(MapTile tile, boolean provisional, long retryDelay, long retryAt, long earliestRetryAt,
|
||||
long previewRevision) {
|
||||
}
|
||||
|
||||
private static final class WeightedTileCache {
|
||||
@@ -658,14 +694,13 @@ final class VisionRenderController implements AutoCloseable {
|
||||
this.entries = new LinkedHashMap<>(128, 0.75F, true);
|
||||
}
|
||||
|
||||
synchronized MapTile get(TileKey key) {
|
||||
CacheEntry entry = entries.get(key);
|
||||
return entry == null ? null : entry.tile();
|
||||
synchronized CacheEntry get(TileKey key) {
|
||||
return entries.get(key);
|
||||
}
|
||||
|
||||
synchronized void put(TileKey key, MapTile tile) {
|
||||
long tileBytes = tile.bytes();
|
||||
CacheEntry previous = entries.put(key, new CacheEntry(tile, tileBytes));
|
||||
synchronized void put(TileKey key, TileState state, MapTileRenderer.TileRender work) {
|
||||
long tileBytes = state.tile().bytes() + (work == null ? 0L : work.bytes());
|
||||
CacheEntry previous = entries.put(key, new CacheEntry(state, work, tileBytes));
|
||||
if (previous != null) {
|
||||
bytes -= previous.bytes();
|
||||
}
|
||||
@@ -682,8 +717,18 @@ final class VisionRenderController implements AutoCloseable {
|
||||
entries.clear();
|
||||
bytes = 0L;
|
||||
}
|
||||
|
||||
synchronized void discardWork(TileKey key) {
|
||||
CacheEntry entry = entries.get(key);
|
||||
if (entry == null || entry.work() == null) {
|
||||
return;
|
||||
}
|
||||
long retainedBytes = entry.state().tile().bytes();
|
||||
entries.put(key, new CacheEntry(entry.state(), null, retainedBytes));
|
||||
bytes -= entry.bytes() - retainedBytes;
|
||||
}
|
||||
}
|
||||
|
||||
private record CacheEntry(MapTile tile, long bytes) {
|
||||
private record CacheEntry(TileState state, MapTileRenderer.TileRender work, long bytes) {
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ package art.arcane.iris.studio.map.components;
|
||||
import art.arcane.iris.generation.runtime.IrisComplex;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime;
|
||||
import art.arcane.iris.generation.stream.ProvisionalSampling;
|
||||
import art.arcane.iris.generation.terrain.InferredType;
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
@@ -30,7 +31,6 @@ import art.arcane.iris.studio.render.RenderType;
|
||||
import art.arcane.volmlib.util.stream.ProceduralStream;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.Arrays;
|
||||
import java.util.EnumMap;
|
||||
import java.util.IdentityHashMap;
|
||||
@@ -73,6 +73,11 @@ public final class MapTileRenderer {
|
||||
return labels;
|
||||
}
|
||||
|
||||
public long previewRevision() {
|
||||
HydrologyRuntime runtime = engine.getComplex().getHydrologyRuntime();
|
||||
return runtime == null ? 0L : runtime.previewRevision();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the square of {@code size} pixels whose top left pixel samples the given world position.
|
||||
*
|
||||
@@ -87,6 +92,13 @@ public final class MapTileRenderer {
|
||||
boolean withHeights,
|
||||
BooleanSupplier cancelled
|
||||
) {
|
||||
Objects.requireNonNull(cancelled, "cancelled");
|
||||
checkCancelled(cancelled);
|
||||
return prepareTile(startX, startZ, blocksPerPixel, size, layer, withHeights).render(cancelled);
|
||||
}
|
||||
|
||||
public TileRender prepareTile(double startX, double startZ, double blocksPerPixel, int size, MapLayer layer,
|
||||
boolean withHeights) {
|
||||
if (!Double.isFinite(startX) || !Double.isFinite(startZ) || !Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) {
|
||||
throw new IllegalArgumentException("Map tile coordinates and scale must be finite");
|
||||
}
|
||||
@@ -94,35 +106,7 @@ public final class MapTileRenderer {
|
||||
throw new IllegalArgumentException("Map tile size must be positive");
|
||||
}
|
||||
Objects.requireNonNull(layer, "layer");
|
||||
Objects.requireNonNull(cancelled, "cancelled");
|
||||
TileType type = layer.type();
|
||||
checkCancelled(cancelled);
|
||||
|
||||
TileGrid grid = new TileGrid(size + 2, startX - blocksPerPixel, startZ - blocksPerPixel, blocksPerPixel, cancelled);
|
||||
int[] identities = sampleIdentities(grid, layer);
|
||||
double[] heights = withHeights || type == TileType.HEIGHT ? sampleHeights(grid) : null;
|
||||
short[] subjects = new short[identities.length];
|
||||
short[] regions = new short[identities.length];
|
||||
for (int index = 0; index < identities.length; index++) {
|
||||
subjects[index] = subjectOf(identities[index]);
|
||||
regions[index] = regionOf(identities[index]);
|
||||
}
|
||||
int[] colors = switch (type) {
|
||||
case HEIGHT -> null;
|
||||
case RIVER -> riverColors(startX, startZ, blocksPerPixel, size, cancelled);
|
||||
default -> tileColors(size, type, subjects);
|
||||
};
|
||||
if (layer.display() != PickDisplay.PICKS) {
|
||||
drawPickNoise(colors, grid, size, layer);
|
||||
}
|
||||
if (layer.waterOverlay()) {
|
||||
BufferedImage base = new BufferedImage(size, size, BufferedImage.TYPE_INT_RGB);
|
||||
base.setRGB(0, 0, size, size, colors, 0, size);
|
||||
BufferedImage water = hydrologyRenderer.refineStudioBiome(startX, startZ, blocksPerPixel * size, base, cancelled);
|
||||
colors = water.getRGB(0, 0, size, size, null, 0, size);
|
||||
}
|
||||
MapLabels.Kind subjectKind = type == TileType.REGION ? MapLabels.Kind.REGION : MapLabels.Kind.BIOME;
|
||||
return new MapTile(size, blocksPerPixel, labels, subjectKind, colors, defaultHeightRange(), subjects, regions, heights);
|
||||
return new TileRender(new TileRequest(startX, startZ, blocksPerPixel, size, layer, withHeights));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,15 +150,15 @@ public final class MapTileRenderer {
|
||||
return sampler.identities;
|
||||
}
|
||||
|
||||
private double[] sampleHeights(TileGrid grid) {
|
||||
private double[] sampleHeights(HeightLattice lattice, BooleanSupplier cancelled) {
|
||||
IrisComplex complex = engine.getComplex();
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
HydrologyRuntime water = complex.getHydrologyRuntime();
|
||||
if (water == null || !water.enabled() || dimension != null && dimension.getTerrainTransform() != null) {
|
||||
return new HeightLattice(grid, complex.getHeightStream()).interpolatedHeights();
|
||||
return lattice.interpolatedHeights(cancelled);
|
||||
}
|
||||
try (HydrologyRuntime.PreviewScope ignored = water.preview()) {
|
||||
return new HeightLattice(grid, complex.getHeightStream()).interpolatedHeights();
|
||||
return lattice.interpolatedHeights(cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,17 +261,6 @@ public final class MapTileRenderer {
|
||||
};
|
||||
}
|
||||
|
||||
// Rivers are drawn by the hydrology renderer the engine ships; the biomes under them stay the tile's subjects.
|
||||
private int[] riverColors(double startX, double startZ, double blocksPerPixel, int size, BooleanSupplier cancelled) {
|
||||
BufferedImage rivers = hydrologyRenderer.renderStudio(startX, startZ, blocksPerPixel * size, size, RenderType.RIVER,
|
||||
cancelled);
|
||||
int[] colors = rivers.getRGB(0, 0, size, size, null, 0, size);
|
||||
for (int index = 0; index < colors.length; index++) {
|
||||
colors[index] &= 0xFFFFFF;
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
private int[] tileColors(int size, TileType type, short[] subjects) {
|
||||
int[] colors = new int[size * size];
|
||||
short previousSubject = MapLabels.NONE;
|
||||
@@ -376,6 +349,124 @@ public final class MapTileRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
public final class TileRender {
|
||||
private final TileRequest request;
|
||||
private short[] subjects;
|
||||
private short[] regions;
|
||||
private int[] baseColors;
|
||||
private int[] finalColors;
|
||||
private double[] heights;
|
||||
private HeightLattice heightLattice;
|
||||
private IrisRenderer.WaterTile water;
|
||||
private boolean identitiesComplete;
|
||||
private boolean baseComplete;
|
||||
private boolean heightsComplete;
|
||||
private boolean waterComplete;
|
||||
private volatile boolean complete;
|
||||
private volatile long bytes;
|
||||
|
||||
private TileRender(TileRequest request) {
|
||||
this.request = request;
|
||||
heightsComplete = !request.withHeights() && request.layer().type() != TileType.HEIGHT;
|
||||
waterComplete = request.layer().type() != TileType.RIVER && !request.layer().waterOverlay();
|
||||
}
|
||||
|
||||
public MapTile render(BooleanSupplier cancelled) {
|
||||
Objects.requireNonNull(cancelled, "cancelled");
|
||||
checkCancelled(cancelled);
|
||||
TileGrid grid = new TileGrid(request.size() + 2, request.startX() - request.blocksPerPixel(),
|
||||
request.startZ() - request.blocksPerPixel(), request.blocksPerPixel(), cancelled);
|
||||
sampleSubjects(grid);
|
||||
sampleBaseColors(grid);
|
||||
sampleTerrain(grid, cancelled);
|
||||
int[] colors = sampleWater(cancelled);
|
||||
checkCancelled(cancelled);
|
||||
complete = baseComplete && heightsComplete && waterComplete;
|
||||
bytes = (heightLattice == null ? 0L : heightLattice.bytes()) + (water == null ? 0L : water.bytes())
|
||||
+ (baseColors == null || colors == baseColors ? 0L : (long) baseColors.length * Integer.BYTES);
|
||||
if (!complete) {
|
||||
ProvisionalSampling.mark();
|
||||
}
|
||||
MapLabels.Kind kind = request.layer().type() == TileType.REGION ? MapLabels.Kind.REGION : MapLabels.Kind.BIOME;
|
||||
return new MapTile(request.size(), request.blocksPerPixel(), labels, kind, colors, defaultHeightRange(), subjects,
|
||||
regions, heights);
|
||||
}
|
||||
|
||||
public boolean complete() {
|
||||
return complete;
|
||||
}
|
||||
|
||||
public long bytes() {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private void sampleSubjects(TileGrid grid) {
|
||||
if (identitiesComplete) {
|
||||
return;
|
||||
}
|
||||
long revision = ProvisionalSampling.revision();
|
||||
int[] identities = sampleIdentities(grid, request.layer());
|
||||
subjects = new short[identities.length];
|
||||
regions = new short[identities.length];
|
||||
for (int index = 0; index < identities.length; index++) {
|
||||
subjects[index] = subjectOf(identities[index]);
|
||||
regions[index] = regionOf(identities[index]);
|
||||
}
|
||||
identitiesComplete = revision == ProvisionalSampling.revision();
|
||||
}
|
||||
|
||||
private void sampleBaseColors(TileGrid grid) {
|
||||
if (baseComplete) {
|
||||
return;
|
||||
}
|
||||
long revision = ProvisionalSampling.revision();
|
||||
baseColors = switch (request.layer().type()) {
|
||||
case HEIGHT, RIVER -> null;
|
||||
default -> tileColors(request.size(), request.layer().type(), subjects);
|
||||
};
|
||||
if (request.layer().display() != PickDisplay.PICKS) {
|
||||
drawPickNoise(baseColors, grid, request.size(), request.layer());
|
||||
}
|
||||
baseComplete = identitiesComplete && revision == ProvisionalSampling.revision();
|
||||
}
|
||||
|
||||
private void sampleTerrain(TileGrid grid, BooleanSupplier cancelled) {
|
||||
if (heightsComplete) {
|
||||
return;
|
||||
}
|
||||
if (heightLattice == null) {
|
||||
heightLattice = new HeightLattice(grid, engine.getComplex().getHeightStream());
|
||||
}
|
||||
heights = sampleHeights(heightLattice, cancelled);
|
||||
heightsComplete = heightLattice.complete();
|
||||
if (heightsComplete) {
|
||||
heightLattice = null;
|
||||
}
|
||||
}
|
||||
|
||||
private int[] sampleWater(BooleanSupplier cancelled) {
|
||||
if (waterComplete) {
|
||||
return finalColors == null ? baseColors : finalColors;
|
||||
}
|
||||
if (water == null) {
|
||||
water = hydrologyRenderer.prepareStudioWater(request.startX(), request.startZ(), request.blocksPerPixel(),
|
||||
request.size());
|
||||
}
|
||||
int[] colors = water.render(baseColors, request.layer().waterOverlay(), cancelled);
|
||||
if (baseComplete && water.complete()) {
|
||||
finalColors = colors;
|
||||
baseColors = null;
|
||||
waterComplete = true;
|
||||
water = null;
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
}
|
||||
|
||||
private record TileRequest(double startX, double startZ, double blocksPerPixel, int size, MapLayer layer,
|
||||
boolean withHeights) {
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface IdentityShader {
|
||||
int identity(double x, double z);
|
||||
@@ -554,16 +645,20 @@ public final class MapTileRenderer {
|
||||
private final ProceduralStream<Double> stream;
|
||||
private final int pointsPerSide;
|
||||
private final double[] points;
|
||||
private final boolean[] resolved;
|
||||
private int pending;
|
||||
|
||||
private HeightLattice(TileGrid grid, ProceduralStream<Double> stream) {
|
||||
this.grid = grid;
|
||||
this.stream = stream;
|
||||
this.pointsPerSide = (grid.size() - 2) / LATTICE_PIXELS + LATTICE_MARGIN * 2 + 1;
|
||||
this.points = new double[pointsPerSide * pointsPerSide];
|
||||
this.resolved = new boolean[points.length];
|
||||
this.pending = points.length;
|
||||
}
|
||||
|
||||
double[] interpolatedHeights() {
|
||||
samplePoints();
|
||||
double[] interpolatedHeights(BooleanSupplier cancelled) {
|
||||
samplePoints(cancelled);
|
||||
int size = grid.size();
|
||||
double[] heights = new double[size * size];
|
||||
for (int gridZ = 0; gridZ < size; gridZ++) {
|
||||
@@ -574,16 +669,33 @@ public final class MapTileRenderer {
|
||||
return heights;
|
||||
}
|
||||
|
||||
boolean complete() {
|
||||
return pending == 0;
|
||||
}
|
||||
|
||||
long bytes() {
|
||||
return (long) points.length * (Double.BYTES + 1L);
|
||||
}
|
||||
|
||||
// Lattice point 0 sits on the tile's first pixel; the margin covers the border ring and the curve's reach.
|
||||
private void samplePoints() {
|
||||
private void samplePoints(BooleanSupplier cancelled) {
|
||||
double latticeStep = grid.step() * LATTICE_PIXELS;
|
||||
double firstX = grid.worldX(1) - LATTICE_MARGIN * latticeStep;
|
||||
double firstZ = grid.worldZ(1) - LATTICE_MARGIN * latticeStep;
|
||||
for (int pointZ = 0; pointZ < pointsPerSide; pointZ++) {
|
||||
checkCancelled(grid.cancelled());
|
||||
checkCancelled(cancelled);
|
||||
for (int pointX = 0; pointX < pointsPerSide; pointX++) {
|
||||
points[pointZ * pointsPerSide + pointX] = stream.getDouble(firstX + pointX * latticeStep,
|
||||
int index = pointZ * pointsPerSide + pointX;
|
||||
if (resolved[index]) {
|
||||
continue;
|
||||
}
|
||||
long revision = ProvisionalSampling.revision();
|
||||
points[index] = stream.getDouble(firstX + pointX * latticeStep,
|
||||
firstZ + pointZ * latticeStep);
|
||||
if (revision == ProvisionalSampling.revision()) {
|
||||
resolved[index] = true;
|
||||
pending--;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,13 @@ public final class IrisRenderer {
|
||||
this.renderer = Objects.requireNonNull(renderer, "renderer");
|
||||
}
|
||||
|
||||
public WaterTile prepareStudioWater(double startX, double startZ, double step, int resolution) {
|
||||
if (!Double.isFinite(startX) || !Double.isFinite(startZ) || !Double.isFinite(step) || step <= 0D || resolution < 1) {
|
||||
throw new IllegalArgumentException("Water tile coordinates, scale and resolution must be valid");
|
||||
}
|
||||
return new WaterTile(startX, startZ, step, resolution);
|
||||
}
|
||||
|
||||
public BufferedImage render(double sx, double sz, double size, int resolution, RenderType currentType) {
|
||||
return render(sx, sz, size, resolution, currentType, () -> false);
|
||||
}
|
||||
@@ -445,6 +452,71 @@ public final class IrisRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
public final class WaterTile {
|
||||
private final double startX;
|
||||
private final double startZ;
|
||||
private final double step;
|
||||
private final int resolution;
|
||||
private final WaterMap map;
|
||||
private boolean complete;
|
||||
|
||||
private WaterTile(double startX, double startZ, double step, int resolution) {
|
||||
this.startX = startX;
|
||||
this.startZ = startZ;
|
||||
this.step = step;
|
||||
this.resolution = resolution;
|
||||
HydrologyRuntime runtime = renderer.getComplex().getHydrologyRuntime();
|
||||
this.map = runtime == null || !runtime.enabled() || step > WaterMap.MAX_STEP
|
||||
? null : new WaterMap(runtime, resolution, startX, startZ, step);
|
||||
complete = map == null;
|
||||
}
|
||||
|
||||
public int[] render(int[] base, boolean composite, BooleanSupplier cancelled) {
|
||||
Objects.requireNonNull(cancelled, "cancelled");
|
||||
checkCancelled(cancelled);
|
||||
if (composite && (base == null || base.length != resolution * resolution)) {
|
||||
throw new IllegalArgumentException("Water overlay requires matching base pixels");
|
||||
}
|
||||
if (!complete) {
|
||||
map.advance(cancelled);
|
||||
map.drawCavePools(GenerationSemanticQueries.recordedCavePools(renderer,
|
||||
Math.floorDiv((int) Math.floor(startX), 16), Math.floorDiv((int) Math.floor(startZ), 16),
|
||||
Math.floorDiv((int) Math.ceil(startX + resolution * step) - 1, 16),
|
||||
Math.floorDiv((int) Math.ceil(startZ + resolution * step) - 1, 16)));
|
||||
complete = !map.provisional();
|
||||
}
|
||||
int[] colors = composite ? base.clone() : new int[resolution * resolution];
|
||||
if (!composite) {
|
||||
Arrays.fill(colors, NO_RIVER & 0xFFFFFF);
|
||||
}
|
||||
if (map != null) {
|
||||
for (int z = 0; z < resolution; z++) {
|
||||
checkCancelled(cancelled);
|
||||
for (int x = 0; x < resolution; x++) {
|
||||
HydrologyFeatureType type = map.at(x, z);
|
||||
if (type != null) {
|
||||
int index = z * resolution + x;
|
||||
int color = hydrologyFeatureColor(type);
|
||||
colors[index] = (composite ? blend(colors[index], color, RIVER_OVERLAY) : color) & 0xFFFFFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!complete) {
|
||||
ProvisionalSampling.mark();
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
public boolean complete() {
|
||||
return complete;
|
||||
}
|
||||
|
||||
public long bytes() {
|
||||
return map == null ? 0L : map.bytes();
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface PixelShader {
|
||||
int color(double x, double z);
|
||||
|
||||
@@ -36,35 +36,85 @@ final class WaterMap {
|
||||
private final double startX;
|
||||
private final double startZ;
|
||||
private final double step;
|
||||
private final BooleanSupplier cancelled;
|
||||
private final int[] ranks;
|
||||
private final boolean[] resolved;
|
||||
private final IdentityHashMap<HydrologyTier, HydrologyFeatureType> channels = new IdentityHashMap<>();
|
||||
private final Set<Long> lakes = new HashSet<>();
|
||||
private BooleanSupplier cancelled;
|
||||
private int[] plannedRanks;
|
||||
private boolean plansComplete;
|
||||
private boolean provisional;
|
||||
|
||||
private WaterMap(HydrologyRuntime runtime, int resolution, double startX, double startZ, double step, BooleanSupplier cancelled) {
|
||||
WaterMap(HydrologyRuntime runtime, int resolution, double startX, double startZ, double step) {
|
||||
this.runtime = runtime;
|
||||
this.resolution = resolution;
|
||||
this.startX = startX;
|
||||
this.startZ = startZ;
|
||||
this.step = step;
|
||||
this.cancelled = cancelled;
|
||||
this.ranks = new int[resolution * resolution];
|
||||
this.resolved = step <= RASTER_STEP ? new boolean[ranks.length] : null;
|
||||
Arrays.fill(ranks, DRY);
|
||||
provisional = true;
|
||||
}
|
||||
|
||||
static WaterMap draw(HydrologyRuntime runtime, int resolution, double startX, double startZ, double step, BooleanSupplier cancelled) {
|
||||
WaterMap map = new WaterMap(runtime, resolution, startX, startZ, step, cancelled);
|
||||
HydrologyPlanPreview plans = runtime.planPreview((int) Math.floor(startX), (int) Math.floor(startZ),
|
||||
(int) Math.ceil(startX + resolution * step) - 1, (int) Math.ceil(startZ + resolution * step) - 1);
|
||||
map.provisional = !plans.complete();
|
||||
if (step <= RASTER_STEP) {
|
||||
map.drawRasters(plans);
|
||||
} else {
|
||||
map.drawPlans(plans);
|
||||
}
|
||||
WaterMap map = new WaterMap(runtime, resolution, startX, startZ, step);
|
||||
map.advance(cancelled);
|
||||
return map;
|
||||
}
|
||||
|
||||
void advance(BooleanSupplier cancelled) {
|
||||
if (!provisional) {
|
||||
return;
|
||||
}
|
||||
this.cancelled = cancelled;
|
||||
HydrologyPlanPreview plans = null;
|
||||
if (!plansComplete) {
|
||||
plans = preview();
|
||||
plannedRanks = null;
|
||||
lakes.clear();
|
||||
for (Pond lake : plans.lakes()) {
|
||||
lakes.add(lake.id());
|
||||
}
|
||||
if (step > RASTER_STEP) {
|
||||
prepareFallback(plans);
|
||||
}
|
||||
plansComplete = plans.complete();
|
||||
}
|
||||
if (step <= RASTER_STEP) {
|
||||
provisional = drawRasters(plans) || !plansComplete;
|
||||
} else {
|
||||
prepareFallback(plans);
|
||||
System.arraycopy(plannedRanks, 0, ranks, 0, ranks.length);
|
||||
provisional = !plansComplete;
|
||||
}
|
||||
if (!provisional) {
|
||||
plannedRanks = null;
|
||||
lakes.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private HydrologyPlanPreview preview() {
|
||||
return runtime.planPreview((int) Math.floor(startX), (int) Math.floor(startZ),
|
||||
(int) Math.ceil(startX + resolution * step) - 1, (int) Math.ceil(startZ + resolution * step) - 1);
|
||||
}
|
||||
|
||||
private void prepareFallback(HydrologyPlanPreview plans) {
|
||||
if (plannedRanks != null) {
|
||||
return;
|
||||
}
|
||||
WaterMap planned = new WaterMap(runtime, resolution, startX, startZ, step);
|
||||
planned.cancelled = cancelled;
|
||||
planned.drawPlans(plans == null ? preview() : plans);
|
||||
plannedRanks = planned.ranks;
|
||||
}
|
||||
|
||||
long bytes() {
|
||||
return (long) ranks.length * Integer.BYTES + (resolved == null ? 0L : resolved.length)
|
||||
+ (plannedRanks == null ? 0L : (long) plannedRanks.length * Integer.BYTES)
|
||||
+ (long) lakes.size() * 64L;
|
||||
}
|
||||
|
||||
HydrologyFeatureType at(int pixelX, int pixelZ) {
|
||||
int rank = ranks[pixelZ * resolution + pixelX];
|
||||
return rank == DRY ? null : DRAWN.get(rank);
|
||||
@@ -84,37 +134,39 @@ final class WaterMap {
|
||||
}
|
||||
}
|
||||
|
||||
private void drawRasters(HydrologyPlanPreview plans) {
|
||||
Set<Long> lakes = new HashSet<>();
|
||||
for (Pond lake : plans.lakes()) {
|
||||
lakes.add(lake.id());
|
||||
}
|
||||
private boolean drawRasters(HydrologyPlanPreview plans) {
|
||||
Map<Long, ChunkWaterRaster> rasters = new HashMap<>();
|
||||
WaterMap planned = null;
|
||||
boolean pending = false;
|
||||
for (int pixelZ = 0; pixelZ < resolution; pixelZ++) {
|
||||
checkCancelled();
|
||||
int minimumZ = (int) Math.floor(startZ + pixelZ * step);
|
||||
int maximumZ = Math.max(minimumZ, (int) Math.ceil(startZ + (pixelZ + 1) * step) - 1);
|
||||
for (int pixelX = 0; pixelX < resolution; pixelX++) {
|
||||
int index = pixelZ * resolution + pixelX;
|
||||
if (resolved[index]) {
|
||||
continue;
|
||||
}
|
||||
ranks[index] = DRY;
|
||||
boolean ready = plansComplete;
|
||||
int minimumX = (int) Math.floor(startX + pixelX * step);
|
||||
int maximumX = Math.max(minimumX, (int) Math.ceil(startX + (pixelX + 1) * step) - 1);
|
||||
for (int z = minimumZ; z <= maximumZ; z++) {
|
||||
for (int x = minimumX; x <= maximumX; x++) {
|
||||
ChunkWaterRaster raster = raster(rasters, x >> 4, z >> 4);
|
||||
if (raster.provisional()) {
|
||||
provisional = true;
|
||||
if (planned == null) {
|
||||
planned = new WaterMap(runtime, resolution, startX, startZ, step, cancelled);
|
||||
planned.drawPlans(plans);
|
||||
}
|
||||
stamp(pixelX, pixelZ, planned.at(pixelX, pixelZ));
|
||||
pending = true;
|
||||
ready = false;
|
||||
prepareFallback(plans);
|
||||
ranks[index] = Math.min(ranks[index], plannedRanks[index]);
|
||||
} else if (!raster.empty()) {
|
||||
stamp(pixelX, pixelZ, columnType(raster, x & 15, z & 15, lakes));
|
||||
}
|
||||
}
|
||||
}
|
||||
resolved[index] = ready;
|
||||
}
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
private ChunkWaterRaster raster(Map<Long, ChunkWaterRaster> rasters, int chunkX, int chunkZ) {
|
||||
@@ -251,7 +303,7 @@ final class WaterMap {
|
||||
}
|
||||
|
||||
private void checkCancelled() {
|
||||
if (cancelled.getAsBoolean()) {
|
||||
if (cancelled.getAsBoolean() || Thread.currentThread().isInterrupted()) {
|
||||
throw new CancellationException("Vision render was cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
+92
@@ -70,6 +70,7 @@ import java.util.stream.Stream;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
@@ -143,6 +144,95 @@ public class HydrologyRuntimeTest {
|
||||
assertTrue(planners.plannerThreads().stream().noneMatch(name -> name.equals(Thread.currentThread().getName())));
|
||||
}
|
||||
|
||||
@Test(timeout = 180_000L)
|
||||
public void previewRevisionAdvancesOnlyWhenPlansOrRastersBecomeAvailable() throws Exception {
|
||||
FakePlanners planners = new FakePlanners(settings(0, false));
|
||||
ExecutorService pool = pool(planners, 1);
|
||||
HydrologyRuntime runtime = runtime(planners, pool, () -> false);
|
||||
CountDownLatch blocker = block(pool);
|
||||
CompletableFuture<Void> plans = runtime.prepareWindowsAround(0, 0, 0, 0, 1);
|
||||
|
||||
assertEquals(0L, runtime.previewRevision());
|
||||
assertFalse(plans.isDone());
|
||||
blocker.countDown();
|
||||
plans.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
long planned = runtime.previewRevision();
|
||||
assertTrue(planned > 0L);
|
||||
assertTrue(runtime.planPreview(0, 0, 15, 15).complete());
|
||||
assertEquals(planned, runtime.previewRevision());
|
||||
|
||||
CountDownLatch rasterBlocker = block(pool);
|
||||
for (int repeat = 0; repeat < 100; repeat++) {
|
||||
assertTrue(runtime.rasterPreview(0, 0).provisional());
|
||||
assertEquals(planned, runtime.previewRevision());
|
||||
}
|
||||
rasterBlocker.countDown();
|
||||
await(() -> runtime.previewRevision() == planned + 1L, "raster publication advances preview readiness");
|
||||
ChunkWaterRaster ready = runtime.rasterPreview(0, 0);
|
||||
assertFalse(ready.provisional());
|
||||
for (int repeat = 0; repeat < 100; repeat++) {
|
||||
assertSame(ready, runtime.rasterPreview(0, 0));
|
||||
assertSame(ready, runtime.raster(0, 0));
|
||||
assertEquals(planned + 1L, runtime.previewRevision());
|
||||
}
|
||||
assertFalse(runtime.raster(1, 0).provisional());
|
||||
assertEquals(planned + 2L, runtime.previewRevision());
|
||||
}
|
||||
|
||||
@Test(timeout = 180_000L)
|
||||
public void concurrentRasterPublicationAdvancesPreviewRevisionOnce() throws Exception {
|
||||
FakePlanners planners = new FakePlanners(settings(0, false));
|
||||
ExecutorService pool = pool(planners, 1);
|
||||
HydrologyRuntime runtime = runtime(planners, pool, () -> false);
|
||||
runtime.prepareWindowsAround(0, 0, 0, 0, 1).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
long planned = runtime.previewRevision();
|
||||
CountDownLatch gate = new CountDownLatch(1);
|
||||
planners.naturalGate = gate;
|
||||
AtomicReference<Throwable> failure = new AtomicReference<>();
|
||||
Thread demand = new Thread(() -> {
|
||||
try {
|
||||
runtime.raster(0, 0);
|
||||
} catch (Throwable error) {
|
||||
failure.set(error);
|
||||
}
|
||||
});
|
||||
demand.start();
|
||||
try {
|
||||
await(() -> planners.naturalSamples.get() > 0, "demanded raster starts");
|
||||
assertTrue(runtime.rasterPreview(0, 0).provisional());
|
||||
await(() -> planners.naturalSamples.get() >= 2, "preview raster starts beside demand");
|
||||
assertEquals(planned, runtime.previewRevision());
|
||||
} finally {
|
||||
gate.countDown();
|
||||
demand.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
|
||||
}
|
||||
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
assertFalse(demand.isAlive());
|
||||
assertNull(failure.get());
|
||||
assertFalse(runtime.rasterPreview(0, 0).provisional());
|
||||
assertEquals(planned + 1L, runtime.previewRevision());
|
||||
}
|
||||
|
||||
@Test(timeout = 180_000L)
|
||||
public void failedRasterDoesNotAdvancePreviewRevision() throws Exception {
|
||||
FakePlanners planners = new FakePlanners(settings(0, false));
|
||||
ExecutorService pool = pool(planners, 1);
|
||||
HydrologyPlanCache cache = new HydrologyPlanCache(planners.settings, planners::region, planners::window, pool, () -> false);
|
||||
HydrologyRuntime runtime = new HydrologyRuntime(planners.settings, cache, (x, z) -> {
|
||||
throw new IllegalStateException("terrain sampling failed");
|
||||
}, () -> false, planners.ticker::get);
|
||||
runtimes.add(runtime);
|
||||
runtime.prepareWindowsAround(0, 0, 0, 0, 1).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
long planned = runtime.previewRevision();
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> runtime.raster(0, 0));
|
||||
|
||||
assertEquals(planned, runtime.previewRevision());
|
||||
}
|
||||
|
||||
@Test(timeout = 180_000L)
|
||||
public void rasterCachedPerChunk() throws Exception {
|
||||
FakePlanners planners = new FakePlanners(settings());
|
||||
@@ -868,6 +958,7 @@ public class HydrologyRuntimeTest {
|
||||
|
||||
private static void assertDisabled(HydrologyRuntime runtime) {
|
||||
assertFalse(runtime.enabled());
|
||||
assertEquals(0L, runtime.previewRevision());
|
||||
assertSame(ChunkWaterRaster.EMPTY, runtime.raster(0, 0));
|
||||
assertSame(ChunkWaterRaster.EMPTY, runtime.rasterSnapshot(0, 0));
|
||||
assertSame(ChunkWaterRaster.EMPTY, runtime.rasterAt(0, 0));
|
||||
@@ -879,6 +970,7 @@ public class HydrologyRuntimeTest {
|
||||
assertSame(ChunkWaterRaster.EMPTY, runtime.rasterPreview(0, 0));
|
||||
assertEquals(HydrologyPlanPreview.EMPTY, runtime.planPreview(0, 0, 511, 511));
|
||||
runtime.close();
|
||||
assertEquals(0L, runtime.previewRevision());
|
||||
}
|
||||
|
||||
private static void settle(FakePlanners planners, ExecutorService pool, int windows) throws Exception {
|
||||
|
||||
+67
@@ -139,6 +139,73 @@ public class HydrologyPlanCacheTest {
|
||||
assertEquals(KEY, results[0].plan().key());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void readyRevisionCountsSuccessfulPlanPublicationsWithoutCountingReads() throws Exception {
|
||||
FakePlanners planners = new FakePlanners(unpersisted());
|
||||
ExecutorService pool = pool(1);
|
||||
HydrologyPlanCache cache = cache(planners, pool, () -> false);
|
||||
RegionKey region = WindowPlanner.lakeRegions(KEY).getFirst();
|
||||
Gate gate = new Gate();
|
||||
planners.hook = key -> gate.pass();
|
||||
CompletableFuture<RegionPlan> pending = cache.previewRegion(region);
|
||||
await(() -> gate.arrivals() == 1, "region planning starts before publication");
|
||||
assertEquals(0L, cache.readyRevision());
|
||||
assertFalse(pending.isDone());
|
||||
gate.release();
|
||||
RegionPlan ready = pending.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
assertEquals(1L, cache.readyRevision());
|
||||
assertSame(ready, cache.regionIfReady(region).orElseThrow());
|
||||
assertSame(ready, cache.previewRegion(region).get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||
assertSame(ready, cache.region(region));
|
||||
assertEquals(1L, cache.readyRevision());
|
||||
|
||||
cache.previewWindow(KEY).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
long published = WindowPlanner.lakeRegions(KEY).size() + 1L;
|
||||
assertEquals(published, cache.readyRevision());
|
||||
assertTrue(cache.windowIfReady(KEY).isPresent());
|
||||
cache.window(KEY);
|
||||
assertEquals(published, cache.readyRevision());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedPlansDoNotAdvanceReadyRevision() throws Exception {
|
||||
FakePlanners planners = new FakePlanners(unpersisted());
|
||||
ExecutorService pool = pool(1);
|
||||
HydrologyPlanCache cache = cache(planners, pool, () -> false);
|
||||
RegionKey region = new RegionKey(0, 0);
|
||||
planners.hook = key -> {
|
||||
throw new IllegalStateException("region planning failed");
|
||||
};
|
||||
|
||||
CompletableFuture<RegionPlan> failed = cache.previewRegion(region);
|
||||
assertThrows(ExecutionException.class, () -> failed.get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
|
||||
assertEquals(0L, cache.readyRevision());
|
||||
planners.hook = key -> { };
|
||||
cache.previewRegion(region).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
assertEquals(1L, cache.readyRevision());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cancelledPlanCompletionDoesNotAdvanceReadyRevision() throws Exception {
|
||||
FakePlanners planners = new FakePlanners(unpersisted());
|
||||
ExecutorService pool = pool(1);
|
||||
HydrologyPlanCache cache = cache(planners, pool, () -> false);
|
||||
Gate gate = new Gate();
|
||||
planners.hook = key -> gate.pass();
|
||||
CompletableFuture<RegionPlan> pending = cache.previewRegion(new RegionKey(0, 0));
|
||||
await(() -> gate.arrivals() == 1, "region planning starts before cancellation");
|
||||
|
||||
cache.close();
|
||||
gate.release();
|
||||
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
|
||||
assertTrue(pending.isCompletedExceptionally());
|
||||
assertEquals(0L, cache.readyRevision());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void interruptedPlanIsNotCached() throws Exception {
|
||||
FakePlanners planners = new FakePlanners(unpersisted());
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package art.arcane.iris.generation.noise;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class TerrainBatchScratchTest {
|
||||
@Test
|
||||
public void integerLatticesDistributeWithoutLongLinearProbeClusters() throws Exception {
|
||||
TerrainBatchScratch scratch = new TerrainBatchScratch();
|
||||
for (int origin : new int[]{0, -1536, 1536, 16_777_212}) {
|
||||
scratch.clearLattice(4096);
|
||||
for (int index = 0; index < 1024; index++) {
|
||||
double x = origin + (index & 31) * 6D;
|
||||
double z = origin + (index >> 5) * 6D;
|
||||
assertEquals(index, scratch.lattice(x, z));
|
||||
}
|
||||
assertEquals(1024, scratch.latticeCount());
|
||||
for (int index = 1023; index >= 0; index--) {
|
||||
double x = origin + (index & 31) * 6D;
|
||||
double z = origin + (index >> 5) * 6D;
|
||||
assertEquals(index, scratch.lattice(x, z));
|
||||
assertEquals(x, scratch.latticeX(index), 0D);
|
||||
assertEquals(z, scratch.latticeZ(index), 0D);
|
||||
}
|
||||
assertTrue("clustered lattice at " + origin, longestCluster(scratch) < 32);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rawCoordinateIdentityAndInsertionOrderSurviveRehashingAndReset() {
|
||||
TerrainBatchScratch scratch = new TerrainBatchScratch();
|
||||
long[] bits = {0L, Long.MIN_VALUE, Double.doubleToRawLongBits(1D), Double.doubleToRawLongBits(-1D),
|
||||
Double.doubleToRawLongBits(0.125D), Double.doubleToRawLongBits(-0.125D),
|
||||
Double.doubleToRawLongBits(Double.POSITIVE_INFINITY), Double.doubleToRawLongBits(Double.NEGATIVE_INFINITY),
|
||||
0x7FF8000000000001L, 0x7FF8000000000002L};
|
||||
for (int pass = 0; pass < 2; pass++) {
|
||||
scratch.clearLattice(bits.length * bits.length);
|
||||
for (int x = 0; x < bits.length; x++) {
|
||||
for (int z = 0; z < bits.length; z++) {
|
||||
int expected = x * bits.length + z;
|
||||
double worldX = Double.longBitsToDouble(bits[x]);
|
||||
double worldZ = Double.longBitsToDouble(bits[z]);
|
||||
assertEquals(expected, scratch.lattice(worldX, worldZ));
|
||||
assertEquals(expected, scratch.lattice(worldX, worldZ));
|
||||
assertEquals(bits[x], Double.doubleToRawLongBits(scratch.latticeX(expected)));
|
||||
assertEquals(bits[z], Double.doubleToRawLongBits(scratch.latticeZ(expected)));
|
||||
}
|
||||
}
|
||||
assertEquals(bits.length * bits.length, scratch.latticeCount());
|
||||
}
|
||||
}
|
||||
|
||||
private static int longestCluster(TerrainBatchScratch scratch) throws Exception {
|
||||
Field field = TerrainBatchScratch.class.getDeclaredField("latticeSlots");
|
||||
field.setAccessible(true);
|
||||
int[] slots = (int[]) field.get(scratch);
|
||||
int longest = 0;
|
||||
int length = 0;
|
||||
for (int index = 0; index < slots.length * 2; index++) {
|
||||
length = slots[index % slots.length] == 0 ? 0 : length + 1;
|
||||
longest = Math.max(longest, length);
|
||||
}
|
||||
return longest;
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package art.arcane.iris.generation.runtime;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.doCallRealMethod;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
public class IrisEngineFailureReportingTest {
|
||||
@Test
|
||||
public void failedGenerationReportsOnceAndRetainsItsCause() throws Exception {
|
||||
IrisEngine engine = mock(IrisEngine.class);
|
||||
IllegalStateException failure = new IllegalStateException("Hydrology raster failed");
|
||||
Field platformHooks = IrisEngine.class.getDeclaredField("platformHooks");
|
||||
platformHooks.setAccessible(true);
|
||||
platformHooks.set(engine, mock(EnginePlatformHooks.class));
|
||||
doThrow(failure).when(engine).openGenerationHistoryCoordinateScope(-352, -144);
|
||||
doCallRealMethod().when(engine).generate(-352, -144, null, null, false);
|
||||
doCallRealMethod().when(engine).fail("Failed to generate -352, -144", failure);
|
||||
doCallRealMethod().when(engine).hasFailed();
|
||||
|
||||
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
|
||||
WrongEngineBroException reported = assertThrows(WrongEngineBroException.class,
|
||||
() -> engine.generate(-352, -144, null, null, false));
|
||||
|
||||
assertSame(failure, reported.getCause());
|
||||
assertTrue(engine.hasFailed());
|
||||
logging.verify(() -> IrisLogging.error("Failed to generate -352, -144"));
|
||||
logging.verify(() -> IrisLogging.reportError(failure));
|
||||
logging.verifyNoMoreInteractions();
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package art.arcane.iris.platform.bukkit;
|
||||
|
||||
import art.arcane.iris.spi.LogLevel;
|
||||
import org.junit.After;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.PrintStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class BukkitPlatformErrorReportingTest {
|
||||
@After
|
||||
public void releaseHost() {
|
||||
BukkitPlatform.releaseHost();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void contextualReportEmitsOneCompleteTraceThroughTheHost() {
|
||||
ArrayList<LogEntry> messages = new ArrayList<>();
|
||||
ArrayList<Throwable> reported = new ArrayList<>();
|
||||
BukkitPlatform.hostBridge(new BukkitPlatform.HostBridge(
|
||||
(level, message) -> messages.add(new LogEntry(level, message)), null,
|
||||
failure -> {
|
||||
reported.add(failure);
|
||||
failure.printStackTrace(System.err);
|
||||
}, null, null, null, null, null, null));
|
||||
IllegalStateException failure = new IllegalStateException("raster failure",
|
||||
new ArrayIndexOutOfBoundsException("invalid sample"));
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
PrintStream previousError = System.err;
|
||||
try (PrintStream errors = new PrintStream(output, true, StandardCharsets.UTF_8)) {
|
||||
System.setErr(errors);
|
||||
|
||||
new BukkitPlatform().reportError("Generation failed", failure);
|
||||
} finally {
|
||||
System.setErr(previousError);
|
||||
}
|
||||
|
||||
assertEquals(List.of(new LogEntry(LogLevel.ERROR, "Generation failed")), messages);
|
||||
assertEquals(List.of(failure), reported);
|
||||
String trace = output.toString(StandardCharsets.UTF_8);
|
||||
assertEquals(1L, trace.lines().filter(line -> line.contains("raster failure")).count());
|
||||
assertEquals(1L, trace.lines().filter(line -> line.contains("Caused by:")).count());
|
||||
assertTrue(trace.contains("BukkitPlatformErrorReportingTest.contextualReportEmitsOneCompleteTraceThroughTheHost"));
|
||||
}
|
||||
|
||||
private record LogEntry(LogLevel level, String message) {
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,10 @@ import org.junit.Test;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -38,13 +41,23 @@ public class BukkitBindingTest {
|
||||
Fixture fixture = new Fixture();
|
||||
when(fixture.lifecycle.isServerStopping()).thenReturn(true);
|
||||
assertTrue(fixture.binding.isServerStopping());
|
||||
when(fixture.lifecycle.deferPluginClassLoaderClose()).thenReturn(true);
|
||||
assertTrue(fixture.binding.deferPluginClassLoaderClose());
|
||||
fixture.binding.initializeLifecycle(getClass().getClassLoader());
|
||||
verify(fixture.lifecycle).initializeLifecycle(getClass().getClassLoader());
|
||||
fixture.binding.deferPluginClassLoaderClose();
|
||||
fixture.binding.releasePluginClassLoaderClose();
|
||||
verify(fixture.lifecycle).deferPluginClassLoaderClose();
|
||||
verify(fixture.lifecycle).releasePluginClassLoaderClose();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void limitedBindingRejectsClassLoaderProtectionAndUsesSynchronousShutdown() {
|
||||
INMSBinding binding = mock(INMSBinding.class, CALLS_REAL_METHODS);
|
||||
assertFalse(binding.isServerStopping());
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> binding.initializeLifecycle(getClass().getClassLoader()));
|
||||
assertThrows(IllegalStateException.class, binding::deferPluginClassLoaderClose);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void injectAttachesVanillaSpawnersOnlyToOverworldEnvironments() throws NoSuchFieldException, IllegalAccessException {
|
||||
Fixture fixture = new Fixture();
|
||||
@@ -71,8 +84,8 @@ public class BukkitBindingTest {
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
private final NativeTerrainAccess terrain = mock(NativeTerrainAccess.class);
|
||||
private final NativeWorldGeneration generation = mock(NativeWorldGeneration.class);
|
||||
private final NativeTerrainAccess terrain = mock(NativeTerrainAccess.class);
|
||||
private final NativeWorldLifecycleFactory.Controller lifecycle = mock(NativeWorldLifecycleFactory.Controller.class);
|
||||
private final BukkitBinding binding;
|
||||
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package art.arcane.iris.platform.bukkit.nms;
|
||||
|
||||
import art.arcane.iris.platform.agent.Agent;
|
||||
import art.arcane.iris.platform.bootstrap.SlimJar;
|
||||
import art.arcane.iris.testsupport.PlatformBinding;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
public class BukkitWorldLifecyclePolicyTest {
|
||||
@ClassRule
|
||||
public static final PlatformBinding PLATFORM = PlatformBinding.mockPlatform();
|
||||
|
||||
@Test
|
||||
public void nativeProvidersCloseBeforeThePluginClassLoaderIsReleased() throws Exception {
|
||||
ClassLoader loader = mock(ClassLoader.class);
|
||||
try (MockedStatic<SlimJar> providers = mockStatic(SlimJar.class);
|
||||
MockedStatic<Agent> agent = mockStatic(Agent.class)) {
|
||||
agent.when(() -> Agent.releaseClassLoader(loader)).thenAnswer(invocation -> {
|
||||
providers.verify(SlimJar::closeNativeProviders);
|
||||
return null;
|
||||
});
|
||||
BukkitWorldLifecyclePolicy.INSTANCE.releaseClassLoader(loader);
|
||||
agent.verify(() -> Agent.releaseClassLoader(loader));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nativeProviderCloseFailureStillReleasesThePluginClassLoader() throws Exception {
|
||||
ClassLoader loader = mock(ClassLoader.class);
|
||||
IOException failure = new IOException("provider close failed");
|
||||
try (MockedStatic<SlimJar> providers = mockStatic(SlimJar.class);
|
||||
MockedStatic<Agent> agent = mockStatic(Agent.class)) {
|
||||
providers.when(SlimJar::closeNativeProviders).thenThrow(failure);
|
||||
IllegalStateException reported = assertThrows(IllegalStateException.class,
|
||||
() -> BukkitWorldLifecyclePolicy.INSTANCE.releaseClassLoader(loader));
|
||||
assertSame(failure, reported.getCause());
|
||||
agent.verify(() -> Agent.releaseClassLoader(loader));
|
||||
}
|
||||
}
|
||||
@Test
|
||||
public void providerAndLoaderCloseFailuresAreBothPreserved() throws Exception {
|
||||
ClassLoader loader = mock(ClassLoader.class);
|
||||
IOException providerFailure = new IOException("provider close failed");
|
||||
IllegalStateException loaderFailure = new IllegalStateException("loader close failed");
|
||||
try (MockedStatic<SlimJar> providers = mockStatic(SlimJar.class);
|
||||
MockedStatic<Agent> agent = mockStatic(Agent.class)) {
|
||||
providers.when(SlimJar::closeNativeProviders).thenThrow(providerFailure);
|
||||
agent.when(() -> Agent.releaseClassLoader(loader)).thenThrow(loaderFailure);
|
||||
IllegalStateException reported = assertThrows(IllegalStateException.class,
|
||||
() -> BukkitWorldLifecyclePolicy.INSTANCE.releaseClassLoader(loader));
|
||||
assertSame(loaderFailure, reported);
|
||||
assertEquals(1, reported.getSuppressed().length);
|
||||
assertSame(providerFailure, reported.getSuppressed()[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.generation.hydrology.engine.HydrologyPlanPreview;
|
||||
import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime;
|
||||
import art.arcane.iris.generation.hydrology.raster.ChunkWaterRaster;
|
||||
import art.arcane.iris.generation.hydrology.raster.WaterRole;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.runtime.IrisComplex;
|
||||
import art.arcane.iris.generation.stream.ProvisionalSampling;
|
||||
import art.arcane.iris.generation.terrain.IrisRegion;
|
||||
import art.arcane.iris.studio.map.components.MapLayer;
|
||||
import art.arcane.iris.studio.map.components.MapStyle;
|
||||
import art.arcane.iris.studio.map.components.MapTile;
|
||||
import art.arcane.iris.studio.map.components.MapTileRenderer;
|
||||
import art.arcane.iris.studio.map.components.PickDisplay;
|
||||
import art.arcane.iris.studio.map.components.TileType;
|
||||
import art.arcane.volmlib.util.stream.ProceduralStream;
|
||||
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class MapTileProgressTest {
|
||||
@Test
|
||||
public void retriesOnlySampleUnresolvedHeightPointsAndReuseBiomeData() {
|
||||
AtomicBoolean pending = new AtomicBoolean(true);
|
||||
AtomicInteger heightSamples = new AtomicInteger();
|
||||
AtomicInteger biomeSamples = new AtomicInteger();
|
||||
Engine engine = engine(biomeSamples);
|
||||
when(engine.getComplex().getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> {
|
||||
heightSamples.incrementAndGet();
|
||||
if (x == 0D && z == 0D && pending.get()) {
|
||||
ProvisionalSampling.mark();
|
||||
return 60D;
|
||||
}
|
||||
return 80D + x * 0.02D + z * 0.01D;
|
||||
}));
|
||||
MapTileRenderer renderer = new MapTileRenderer(engine);
|
||||
MapLayer layer = MapLayer.biomes(PickDisplay.PICKS, false);
|
||||
MapTileRenderer.TileRender progress = renderer.prepareTile(-8D, -8D, 0.5D, 16, layer, true);
|
||||
|
||||
progress.render(() -> false);
|
||||
int initialHeights = heightSamples.get();
|
||||
int initialBiomes = biomeSamples.get();
|
||||
assertFalse(progress.complete());
|
||||
assertTrue(progress.bytes() > 0L);
|
||||
|
||||
progress.render(() -> false);
|
||||
assertEquals(initialHeights + 1, heightSamples.get());
|
||||
assertEquals(initialBiomes, biomeSamples.get());
|
||||
pending.set(false);
|
||||
MapTile settled = progress.render(() -> false);
|
||||
assertTrue(progress.complete());
|
||||
assertEquals(initialHeights + 2, heightSamples.get());
|
||||
assertEquals(initialBiomes, biomeSamples.get());
|
||||
assertEquals(0L, progress.bytes());
|
||||
progress.render(() -> false);
|
||||
assertEquals(initialHeights + 2, heightSamples.get());
|
||||
assertEquals(initialBiomes, biomeSamples.get());
|
||||
|
||||
MapTile expected = renderer.renderTile(-8D, -8D, 0.5D, 16, layer, true, () -> false);
|
||||
for (int z = 0; z < 16; z++) {
|
||||
for (int x = 0; x < 16; x++) {
|
||||
assertEquals(expected.heightAt(x, z), settled.heightAt(x, z), 0D);
|
||||
}
|
||||
}
|
||||
assertArrayEquals(pixels(expected.image(MapStyle.PLAIN.withShading(true))),
|
||||
pixels(settled.image(MapStyle.PLAIN.withShading(true))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void completedWaterIsNotBlendedAgainWhileHeightsWait() {
|
||||
AtomicBoolean pending = new AtomicBoolean(true);
|
||||
Engine engine = engine(new AtomicInteger());
|
||||
HydrologyRuntime runtime = mock(HydrologyRuntime.class);
|
||||
ChunkWaterRaster raster = mock(ChunkWaterRaster.class);
|
||||
when(runtime.enabled()).thenReturn(true);
|
||||
when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(HydrologyPlanPreview.EMPTY);
|
||||
when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(raster);
|
||||
when(raster.role(anyInt(), anyInt())).thenReturn(WaterRole.POND);
|
||||
when(engine.getComplex().getHydrologyRuntime()).thenReturn(runtime);
|
||||
when(engine.getComplex().getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> {
|
||||
if (pending.get()) {
|
||||
ProvisionalSampling.mark();
|
||||
}
|
||||
return 60D;
|
||||
}));
|
||||
MapTileRenderer renderer = new MapTileRenderer(engine);
|
||||
MapTileRenderer.TileRender progress = renderer.prepareTile(0D, 0D, 1D, 16, MapLayer.of(TileType.BIOME), true);
|
||||
|
||||
MapTile first = progress.render(() -> false);
|
||||
assertFalse(progress.complete());
|
||||
assertNotEquals(0xFF738249, first.image(MapStyle.PLAIN).getRGB(0, 0));
|
||||
MapTile second = progress.render(() -> false);
|
||||
assertArrayEquals(pixels(first.image(MapStyle.PLAIN)), pixels(second.image(MapStyle.PLAIN)));
|
||||
|
||||
pending.set(false);
|
||||
MapTile complete = progress.render(() -> false);
|
||||
assertTrue(progress.complete());
|
||||
assertEquals(0L, progress.bytes());
|
||||
assertArrayEquals(pixels(first.image(MapStyle.PLAIN)), pixels(complete.image(MapStyle.PLAIN)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void twoBlockPixelsRefineMixedWaterAndHeightsToTheSameFinalTile() {
|
||||
AtomicBoolean pending = new AtomicBoolean(true);
|
||||
Engine engine = engine(new AtomicInteger());
|
||||
HydrologyRuntime runtime = mock(HydrologyRuntime.class);
|
||||
ChunkWaterRaster raster = mock(ChunkWaterRaster.class);
|
||||
when(runtime.enabled()).thenReturn(true);
|
||||
when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(HydrologyPlanPreview.EMPTY);
|
||||
when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(raster);
|
||||
when(runtime.rasterPreview(0, 0)).thenAnswer(invocation -> pending.get() ? ChunkWaterRaster.PROVISIONAL : raster);
|
||||
when(raster.role(anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
int x = invocation.getArgument(0);
|
||||
int z = invocation.getArgument(1);
|
||||
if (x == 1 && z < 4) {
|
||||
return WaterRole.POND;
|
||||
}
|
||||
if (x == 7 && z < 8) {
|
||||
return WaterRole.WATERFALL;
|
||||
}
|
||||
return x < 4 && z == 9 ? WaterRole.INLET : WaterRole.NONE;
|
||||
});
|
||||
when(engine.getComplex().getHydrologyRuntime()).thenReturn(runtime);
|
||||
when(engine.getComplex().getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> {
|
||||
if (x == 0D && z == 0D && pending.get()) {
|
||||
ProvisionalSampling.mark();
|
||||
return 60D;
|
||||
}
|
||||
return 80D + x * 0.02D + z * 0.01D;
|
||||
}));
|
||||
MapTileRenderer renderer = new MapTileRenderer(engine);
|
||||
MapLayer layer = MapLayer.of(TileType.BIOME);
|
||||
MapTileRenderer.TileRender progress = renderer.prepareTile(-16D, -16D, 2D, 16, layer, true);
|
||||
|
||||
MapTile first = progress.render(() -> false);
|
||||
int[] firstPixels = pixels(first.image(MapStyle.PLAIN.withShading(true)));
|
||||
assertFalse(progress.complete());
|
||||
assertEquals(0xFF738249, first.image(MapStyle.PLAIN).getRGB(8, 8));
|
||||
MapTile waiting = progress.render(() -> false);
|
||||
assertFalse(progress.complete());
|
||||
assertArrayEquals(firstPixels, pixels(waiting.image(MapStyle.PLAIN.withShading(true))));
|
||||
|
||||
pending.set(false);
|
||||
MapTile actual = progress.render(() -> false);
|
||||
assertTrue(progress.complete());
|
||||
assertEquals(0L, progress.bytes());
|
||||
BufferedImage colors = actual.image(MapStyle.PLAIN);
|
||||
assertEquals(0xFF738249, colors.getRGB(14, 14));
|
||||
assertNotEquals(0xFF738249, colors.getRGB(8, 8));
|
||||
assertNotEquals(colors.getRGB(8, 8), colors.getRGB(11, 8));
|
||||
assertNotEquals(colors.getRGB(8, 8), colors.getRGB(8, 12));
|
||||
assertNotEquals(colors.getRGB(11, 8), colors.getRGB(8, 12));
|
||||
assertArrayEquals(firstPixels, pixels(first.image(MapStyle.PLAIN.withShading(true))));
|
||||
|
||||
MapTile expected = renderer.renderTile(-16D, -16D, 2D, 16, layer, true, () -> false);
|
||||
assertArrayEquals(pixels(expected.image(MapStyle.PLAIN.withShading(true))),
|
||||
pixels(actual.image(MapStyle.PLAIN.withShading(true))));
|
||||
for (int z = 0; z < 16; z++) {
|
||||
for (int x = 0; x < 16; x++) {
|
||||
assertEquals(expected.heightAt(x, z), actual.heightAt(x, z), 0D);
|
||||
assertEquals(expected.subjectAt(x, z), actual.subjectAt(x, z));
|
||||
assertEquals(expected.regionAt(x, z), actual.regionAt(x, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cancelledPreparationCanResumeWithoutReusingIncompleteHeightSamples() {
|
||||
Engine engine = engine(new AtomicInteger());
|
||||
AtomicInteger samples = new AtomicInteger();
|
||||
when(engine.getComplex().getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> {
|
||||
samples.incrementAndGet();
|
||||
return x + z + 80D;
|
||||
}));
|
||||
MapTileRenderer renderer = new MapTileRenderer(engine);
|
||||
MapLayer layer = MapLayer.biomes(PickDisplay.PICKS, false);
|
||||
MapTileRenderer.TileRender progress = renderer.prepareTile(-16D, -16D, 1D, 16, layer, true);
|
||||
assertThrows(CancellationException.class, () -> progress.render(() -> samples.get() >= 9));
|
||||
assertFalse(progress.complete());
|
||||
MapTile actual = progress.render(() -> false);
|
||||
assertTrue(progress.complete());
|
||||
assertEquals(81, samples.get());
|
||||
MapTile expected = renderer.renderTile(-16D, -16D, 1D, 16, layer, true, () -> false);
|
||||
assertArrayEquals(pixels(expected.image(MapStyle.PLAIN.withShading(true))),
|
||||
pixels(actual.image(MapStyle.PLAIN.withShading(true))));
|
||||
}
|
||||
|
||||
private static Engine engine(AtomicInteger biomeSamples) {
|
||||
Engine engine = mock(Engine.class);
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
IrisBiome biome = new IrisBiome();
|
||||
biome.setColor("#738249");
|
||||
IrisRegion region = new IrisRegion();
|
||||
when(engine.getComplex()).thenReturn(complex);
|
||||
when(engine.getHeight()).thenReturn(256);
|
||||
when(complex.getBaseBiomeStream()).thenReturn(ProceduralStream.of((x, z) -> {
|
||||
biomeSamples.incrementAndGet();
|
||||
return biome;
|
||||
}, Interpolated.of(value -> 0D, value -> biome)));
|
||||
when(complex.getRegionStream()).thenReturn(ProceduralStream.of((x, z) -> region,
|
||||
Interpolated.of(value -> 0D, value -> region)));
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static int[] pixels(BufferedImage image) {
|
||||
return image.getRGB(0, 0, image.getWidth(), image.getHeight(), null, 0, image.getWidth());
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,23 @@
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import art.arcane.iris.generation.stream.ProvisionalSampling;
|
||||
import art.arcane.iris.studio.map.components.MapLayer;
|
||||
import art.arcane.iris.studio.map.components.MapTile;
|
||||
import art.arcane.iris.studio.map.components.MapTileRenderer;
|
||||
import art.arcane.iris.studio.map.components.TileType;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.awt.EventQueue;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@@ -22,21 +25,23 @@ import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyDouble;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class VisionRenderControllerTest {
|
||||
@Test
|
||||
public void provisionalTilesRetryWithBackoffAndConvergeWithoutMoving() throws Exception {
|
||||
MapTileRenderer renderer = mock(MapTileRenderer.class);
|
||||
MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class);
|
||||
MapTileRenderer renderer = renderer(work);
|
||||
MapTile tile = mock(MapTile.class);
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any()))
|
||||
when(work.render(any()))
|
||||
.thenAnswer(invocation -> {
|
||||
if (attempts.incrementAndGet() < 3) {
|
||||
ProvisionalSampling.mark();
|
||||
}
|
||||
attempts.incrementAndGet();
|
||||
return tile;
|
||||
});
|
||||
when(work.complete()).thenAnswer(invocation -> attempts.get() >= 3);
|
||||
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
|
||||
VisionRenderController.Frame frame = controller.request(spec(renderer, 1L));
|
||||
await(() -> frame.tiles().getFirst().tile() != null);
|
||||
@@ -55,30 +60,47 @@ public class VisionRenderControllerTest {
|
||||
});
|
||||
assertEquals(3, attempts.get());
|
||||
assertSame(tile, controller.request(spec(renderer, 1L)).tiles().getFirst().tile());
|
||||
verify(renderer).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void provisionalTilesAreShownButNeverReusedAsFinal() throws Exception {
|
||||
MapTileRenderer renderer = mock(MapTileRenderer.class);
|
||||
public void resizeZoomAndSuspendRetainProvisionalImageWorkAndBackoff() throws Exception {
|
||||
MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class);
|
||||
MapTileRenderer renderer = renderer(work);
|
||||
MapTile incomplete = mock(MapTile.class);
|
||||
MapTile complete = mock(MapTile.class);
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any()))
|
||||
when(work.render(any()))
|
||||
.thenAnswer(invocation -> {
|
||||
if (attempts.incrementAndGet() == 1) {
|
||||
ProvisionalSampling.mark();
|
||||
return incomplete;
|
||||
}
|
||||
return complete;
|
||||
});
|
||||
when(work.complete()).thenAnswer(invocation -> attempts.get() >= 2);
|
||||
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
|
||||
VisionRenderController.Frame first = controller.request(spec(renderer, 1L));
|
||||
await(() -> first.tiles().getFirst().tile() != null);
|
||||
await(() -> first.tiles().getFirst().tile() != null && controller.progress(first).activeWorkers() == 0);
|
||||
assertSame(incomplete, first.tiles().getFirst().tile());
|
||||
VisionRenderController.Frame resized = controller.request(new VisionRenderController.RenderSpec(renderer,
|
||||
MapLayer.of(TileType.RIVER), 1L, 32D, 32D, 1D, 48, 48, false));
|
||||
assertSame(incomplete, resized.tiles().getFirst().tile());
|
||||
assertTrue(resized.tiles().getFirst().provisional());
|
||||
VisionRenderController.Frame zoomed = controller.request(new VisionRenderController.RenderSpec(renderer,
|
||||
MapLayer.of(TileType.RIVER), 1L, 32D, 32D, 1.15D, 48, 48, false));
|
||||
assertSame(incomplete, zoomed.tiles().getFirst().tile());
|
||||
controller.suspend();
|
||||
VisionRenderController.Frame second = controller.request(spec(renderer, 1L));
|
||||
await(() -> second.tiles().getFirst().tile() == complete);
|
||||
assertSame(incomplete, second.tiles().getFirst().tile());
|
||||
assertEquals(0, controller.progress(second).ready());
|
||||
assertEquals(1, attempts.get());
|
||||
await(() -> {
|
||||
controller.retryProvisional(second);
|
||||
return controller.progress(second).ready() == 1;
|
||||
});
|
||||
assertEquals(2, attempts.get());
|
||||
verify(renderer).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
|
||||
VisionRenderController.Frame third = controller.request(spec(renderer, 1L));
|
||||
assertSame(complete, third.tiles().getFirst().tile());
|
||||
assertEquals(2, attempts.get());
|
||||
@@ -87,10 +109,11 @@ public class VisionRenderControllerTest {
|
||||
|
||||
@Test
|
||||
public void contentRevisionInvalidatesFinalTiles() throws Exception {
|
||||
MapTileRenderer renderer = mock(MapTileRenderer.class);
|
||||
MapTile tile = mock(MapTile.class);
|
||||
MapTileRenderer.TileRender work = finished(tile);
|
||||
MapTileRenderer renderer = renderer(work);
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any()))
|
||||
when(work.render(any()))
|
||||
.thenAnswer(invocation -> {
|
||||
attempts.incrementAndGet();
|
||||
return tile;
|
||||
@@ -109,14 +132,16 @@ public class VisionRenderControllerTest {
|
||||
MapTileRenderer renderer = mock(MapTileRenderer.class);
|
||||
MapTile overview = mock(MapTile.class);
|
||||
MapTile incomplete = mock(MapTile.class);
|
||||
when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any()))
|
||||
MapTileRenderer.TileRender overviewWork = finished(overview);
|
||||
MapTileRenderer.TileRender detailWork = mock(MapTileRenderer.TileRender.class);
|
||||
when(detailWork.render(any())).thenReturn(incomplete);
|
||||
when(renderer.prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean()))
|
||||
.thenAnswer(invocation -> {
|
||||
double scale = invocation.getArgument(2);
|
||||
if (scale < 4D) {
|
||||
ProvisionalSampling.mark();
|
||||
return incomplete;
|
||||
return detailWork;
|
||||
}
|
||||
return overview;
|
||||
return overviewWork;
|
||||
});
|
||||
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
|
||||
VisionRenderController.Frame coarse = controller.request(new VisionRenderController.RenderSpec(renderer,
|
||||
@@ -142,10 +167,11 @@ public class VisionRenderControllerTest {
|
||||
|
||||
@Test
|
||||
public void closeRejectsLatePublication() throws Exception {
|
||||
MapTileRenderer renderer = mock(MapTileRenderer.class);
|
||||
MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class);
|
||||
MapTileRenderer renderer = renderer(work);
|
||||
CountDownLatch entered = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any()))
|
||||
when(work.render(any()))
|
||||
.thenAnswer(invocation -> {
|
||||
entered.countDown();
|
||||
while (release.getCount() != 0L) {
|
||||
@@ -167,7 +193,261 @@ public class VisionRenderControllerTest {
|
||||
controller.close();
|
||||
}
|
||||
await(() -> controller.progress(frame).activeWorkers() == 0);
|
||||
assertEquals(null, frame.tiles().getFirst().tile());
|
||||
assertNull(frame.tiles().getFirst().tile());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resizeAndSameLevelZoomReuseFinalTiles() throws Exception {
|
||||
MapTile tile = mock(MapTile.class);
|
||||
MapTileRenderer.TileRender work = finished(tile);
|
||||
MapTileRenderer renderer = renderer(work);
|
||||
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
|
||||
VisionRenderController.Frame first = controller.request(new VisionRenderController.RenderSpec(renderer,
|
||||
MapLayer.of(TileType.RIVER), 1L, 32D, 32D, 1D, 48, 48, false));
|
||||
await(() -> controller.progress(first).ready() == 1);
|
||||
VisionRenderController.Frame resized = controller.request(spec(renderer, 1L));
|
||||
assertSame(tile, resized.tiles().getFirst().tile());
|
||||
VisionRenderController.Frame zoomed = controller.request(new VisionRenderController.RenderSpec(renderer,
|
||||
MapLayer.of(TileType.RIVER), 1L, 32D, 32D, 0.9D, 64, 64, false));
|
||||
assertSame(tile, zoomed.tiles().getFirst().tile());
|
||||
assertEquals(1, controller.progress(zoomed).ready());
|
||||
verify(work).render(any());
|
||||
verify(renderer).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void contentRevisionInvalidatesProvisionalWork() throws Exception {
|
||||
MapTile tile = mock(MapTile.class);
|
||||
MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class);
|
||||
MapTileRenderer renderer = renderer(work);
|
||||
when(work.render(any())).thenReturn(tile);
|
||||
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
|
||||
VisionRenderController.Frame first = controller.request(spec(renderer, 1L));
|
||||
await(() -> first.tiles().getFirst().tile() != null);
|
||||
VisionRenderController.Frame second = controller.request(spec(renderer, 2L));
|
||||
await(() -> second.tiles().getFirst().tile() != null);
|
||||
verify(renderer, times(2)).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void missingDetailUsesProvisionalOverviewWithoutMarkingItFinal() throws Exception {
|
||||
MapTileRenderer renderer = mock(MapTileRenderer.class);
|
||||
MapTile overview = mock(MapTile.class);
|
||||
MapTileRenderer.TileRender overviewWork = mock(MapTileRenderer.TileRender.class);
|
||||
when(overviewWork.render(any())).thenReturn(overview);
|
||||
CountDownLatch entered = new CountDownLatch(1);
|
||||
MapTileRenderer.TileRender detailWork = cancellable(entered);
|
||||
when(renderer.prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean()))
|
||||
.thenAnswer(invocation -> (double) invocation.getArgument(2) < 4D ? detailWork : overviewWork);
|
||||
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
|
||||
VisionRenderController.Frame coarse = controller.request(new VisionRenderController.RenderSpec(renderer,
|
||||
MapLayer.of(TileType.RIVER), 1L, 128D, 128D, 4D, 64, 64, false));
|
||||
await(() -> coarse.tiles().getFirst().tile() != null);
|
||||
VisionRenderController.Frame fine = controller.request(spec(renderer, 1L));
|
||||
assertTrue(entered.await(5L, TimeUnit.SECONDS));
|
||||
assertNull(fine.tiles().getFirst().tile());
|
||||
VisionRenderController.Fallback fallback = controller.fallback(fine, fine.tiles().getFirst());
|
||||
assertNotNull(fallback);
|
||||
assertSame(overview, fallback.pieces().getFirst().tile());
|
||||
assertEquals(0, controller.progress(fine).ready());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void zoomingOutKeepsCompleteFinerTilesOverProvisionalExactTile() throws Exception {
|
||||
MapTileRenderer renderer = mock(MapTileRenderer.class);
|
||||
MapTile fineTile = mock(MapTile.class);
|
||||
MapTile coarseTile = mock(MapTile.class);
|
||||
MapTileRenderer.TileRender fineWork = finished(fineTile);
|
||||
MapTileRenderer.TileRender coarseWork = mock(MapTileRenderer.TileRender.class);
|
||||
when(coarseWork.render(any())).thenReturn(coarseTile);
|
||||
when(renderer.prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean()))
|
||||
.thenAnswer(invocation -> (double) invocation.getArgument(2) < 2D ? fineWork : coarseWork);
|
||||
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
|
||||
VisionRenderController.Frame fine = controller.request(spec(renderer, 1L));
|
||||
await(() -> controller.progress(fine).ready() == 1);
|
||||
VisionRenderController.Frame coarse = controller.request(new VisionRenderController.RenderSpec(renderer,
|
||||
MapLayer.of(TileType.RIVER), 1L, 64D, 64D, 2D, 64, 64, false));
|
||||
await(() -> coarse.tiles().getFirst().tile() != null);
|
||||
VisionRenderController.Fallback fallback = controller.fallback(coarse, coarse.tiles().getFirst());
|
||||
assertNotNull(fallback);
|
||||
assertSame(fineTile, fallback.pieces().getFirst().tile());
|
||||
assertEquals(0.5D, fallback.pieces().getFirst().size(), 0D);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void suspendedViewCancelsSamplingAndProbeButKeepsCompletedTiles() throws Exception {
|
||||
MapTile tile = mock(MapTile.class);
|
||||
MapTileRenderer renderer = mock(MapTileRenderer.class);
|
||||
CountDownLatch sampling = new CountDownLatch(1);
|
||||
MapTileRenderer.TileRender pending = cancellable(sampling);
|
||||
MapTileRenderer.TileRender complete = finished(tile);
|
||||
when(renderer.prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean()))
|
||||
.thenReturn(complete, pending);
|
||||
CountDownLatch probing = new CountDownLatch(1);
|
||||
CountDownLatch interrupted = new CountDownLatch(1);
|
||||
AtomicInteger published = new AtomicInteger();
|
||||
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
|
||||
VisionRenderController.Frame first = controller.request(spec(renderer, 1L));
|
||||
await(() -> controller.progress(first).ready() == 1);
|
||||
VisionRenderController.Frame next = controller.request(new VisionRenderController.RenderSpec(renderer,
|
||||
MapLayer.of(TileType.RIVER), 1L, 96D, 32D, 1D, 64, 64, false));
|
||||
assertTrue(sampling.await(5L, TimeUnit.SECONDS));
|
||||
controller.submitProbe(next, () -> {
|
||||
probing.countDown();
|
||||
try {
|
||||
new CountDownLatch(1).await();
|
||||
return "late";
|
||||
} catch (InterruptedException error) {
|
||||
interrupted.countDown();
|
||||
throw error;
|
||||
}
|
||||
}, value -> published.incrementAndGet());
|
||||
assertTrue(probing.await(5L, TimeUnit.SECONDS));
|
||||
controller.suspend();
|
||||
assertTrue(interrupted.await(5L, TimeUnit.SECONDS));
|
||||
await(() -> controller.progress(next).activeWorkers() == 0);
|
||||
EventQueue.invokeAndWait(() -> { });
|
||||
assertEquals(0, published.get());
|
||||
assertNull(next.tiles().getFirst().tile());
|
||||
VisionRenderController.Frame resumed = controller.request(spec(renderer, 1L));
|
||||
assertSame(tile, resumed.tiles().getFirst().tile());
|
||||
verify(renderer, times(2)).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retainedWorkCountsAgainstCacheBudgetAndIsReleasedWhenComplete() throws Exception {
|
||||
MapTile tile = mock(MapTile.class);
|
||||
when(tile.bytes()).thenReturn(1L);
|
||||
MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class);
|
||||
MapTileRenderer renderer = renderer(work);
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
when(work.render(any())).thenAnswer(invocation -> {
|
||||
attempts.incrementAndGet();
|
||||
return tile;
|
||||
});
|
||||
when(work.complete()).thenAnswer(invocation -> attempts.get() >= 2);
|
||||
when(work.bytes()).thenReturn(256L * 1024L * 1024L);
|
||||
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
|
||||
VisionRenderController.Frame first = controller.request(spec(renderer, 1L));
|
||||
await(() -> first.tiles().getFirst().tile() != null);
|
||||
VisionRenderController.Frame second = controller.request(spec(renderer, 1L));
|
||||
await(() -> controller.progress(second).ready() == 1);
|
||||
VisionRenderController.Frame third = controller.request(spec(renderer, 1L));
|
||||
assertSame(tile, third.tiles().getFirst().tile());
|
||||
verify(renderer, times(2)).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
|
||||
assertEquals(2, attempts.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cancelledRetryReleasesWorkAndKeepsPublishedImage() throws Exception {
|
||||
MapTile incomplete = mock(MapTile.class);
|
||||
MapTile complete = mock(MapTile.class);
|
||||
CountDownLatch entered = new CountDownLatch(1);
|
||||
MapTileRenderer.TileRender partial = mock(MapTileRenderer.TileRender.class);
|
||||
MapTileRenderer.TileRender replacement = finished(complete);
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
when(partial.render(any())).thenAnswer(invocation -> {
|
||||
if (attempts.incrementAndGet() == 1) {
|
||||
return incomplete;
|
||||
}
|
||||
BooleanSupplier cancelled = invocation.getArgument(0);
|
||||
entered.countDown();
|
||||
await(cancelled);
|
||||
throw new CancellationException();
|
||||
});
|
||||
MapTileRenderer renderer = mock(MapTileRenderer.class);
|
||||
when(renderer.prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean()))
|
||||
.thenReturn(partial, replacement);
|
||||
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
|
||||
VisionRenderController.Frame first = controller.request(spec(renderer, 1L));
|
||||
await(() -> first.tiles().getFirst().tile() != null);
|
||||
await(() -> {
|
||||
controller.retryProvisional(first);
|
||||
return entered.getCount() == 0L;
|
||||
});
|
||||
controller.suspend();
|
||||
await(() -> controller.progress(first).activeWorkers() == 0);
|
||||
assertSame(incomplete, first.tiles().getFirst().tile());
|
||||
VisionRenderController.Frame resumed = controller.request(spec(renderer, 1L));
|
||||
await(() -> controller.progress(resumed).ready() == 1);
|
||||
assertSame(complete, resumed.tiles().getFirst().tile());
|
||||
verify(renderer, times(2)).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void previewReadinessWakesBackoffAtBoundedCadenceIncludingCompletionDuringRender() throws Exception {
|
||||
MapTile tile = mock(MapTile.class);
|
||||
MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class);
|
||||
MapTileRenderer renderer = renderer(work);
|
||||
AtomicLong time = new AtomicLong();
|
||||
AtomicLong readiness = new AtomicLong();
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
when(renderer.previewRevision()).thenAnswer(invocation -> readiness.get());
|
||||
when(work.render(any())).thenAnswer(invocation -> {
|
||||
if (attempts.incrementAndGet() == 3) {
|
||||
readiness.incrementAndGet();
|
||||
}
|
||||
return tile;
|
||||
});
|
||||
when(work.complete()).thenAnswer(invocation -> attempts.get() >= 4);
|
||||
try (VisionRenderController controller = new VisionRenderController(() -> { }, time::get)) {
|
||||
VisionRenderController.Frame frame = controller.request(spec(renderer, 1L));
|
||||
await(() -> attempts.get() == 1 && controller.progress(frame).activeWorkers() == 0);
|
||||
time.set(VisionRenderController.FIRST_RETRY_NANOS);
|
||||
controller.retryProvisional(frame);
|
||||
await(() -> attempts.get() == 2 && controller.progress(frame).activeWorkers() == 0);
|
||||
time.addAndGet(VisionRenderController.FIRST_RETRY_NANOS);
|
||||
controller.retryProvisional(frame);
|
||||
assertEquals(2, attempts.get());
|
||||
readiness.incrementAndGet();
|
||||
controller.retryProvisional(frame);
|
||||
await(() -> attempts.get() == 3 && controller.progress(frame).activeWorkers() == 0);
|
||||
time.addAndGet(VisionRenderController.FIRST_RETRY_NANOS - 1L);
|
||||
controller.retryProvisional(frame);
|
||||
assertEquals(3, attempts.get());
|
||||
time.incrementAndGet();
|
||||
controller.retryProvisional(frame);
|
||||
await(() -> controller.progress(frame).ready() == 1);
|
||||
assertEquals(4, attempts.get());
|
||||
verify(renderer).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
|
||||
}
|
||||
}
|
||||
|
||||
private static MapTileRenderer.TileRender cancellable(CountDownLatch entered) {
|
||||
MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class);
|
||||
when(work.render(any())).thenAnswer(invocation -> {
|
||||
BooleanSupplier cancelled = invocation.getArgument(0);
|
||||
entered.countDown();
|
||||
while (!cancelled.getAsBoolean() && !Thread.currentThread().isInterrupted()) {
|
||||
try {
|
||||
Thread.sleep(1L);
|
||||
} catch (InterruptedException error) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
throw new CancellationException();
|
||||
});
|
||||
return work;
|
||||
}
|
||||
|
||||
private static MapTileRenderer renderer(MapTileRenderer.TileRender work) {
|
||||
MapTileRenderer renderer = mock(MapTileRenderer.class);
|
||||
when(renderer.prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean())).thenReturn(work);
|
||||
return renderer;
|
||||
}
|
||||
|
||||
private static MapTileRenderer.TileRender finished(MapTile tile) {
|
||||
MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class);
|
||||
when(work.render(any())).thenReturn(tile);
|
||||
when(work.complete()).thenReturn(true);
|
||||
return work;
|
||||
}
|
||||
|
||||
private static VisionRenderController.RenderSpec spec(MapTileRenderer renderer, long revision) {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package art.arcane.iris.studio.render;
|
||||
|
||||
import art.arcane.iris.generation.hydrology.engine.HydrologyPlanPreview;
|
||||
import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime;
|
||||
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
|
||||
import art.arcane.iris.generation.hydrology.raster.ChunkWaterRaster;
|
||||
import art.arcane.iris.generation.hydrology.raster.WaterRole;
|
||||
import art.arcane.iris.generation.hydrology.water.Reach;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class WaterMapProgressTest {
|
||||
@Test
|
||||
public void completeDetailedRastersDoNotDrawPlanFallbacks() {
|
||||
HydrologyRuntime runtime = mock(HydrologyRuntime.class);
|
||||
Reach reach = mock(Reach.class);
|
||||
when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt()))
|
||||
.thenReturn(new HydrologyPlanPreview(List.of(reach), List.of(), List.of(), List.of(), true));
|
||||
when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(ChunkWaterRaster.EMPTY);
|
||||
|
||||
WaterMap map = WaterMap.draw(runtime, 32, 0D, 0D, 1D, () -> false);
|
||||
|
||||
assertFalse(map.provisional());
|
||||
verifyNoInteractions(reach);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void completedPixelsSurviveRuntimeRasterEviction() {
|
||||
HydrologyRuntime runtime = mock(HydrologyRuntime.class);
|
||||
ChunkWaterRaster pond = pond();
|
||||
AtomicBoolean pending = new AtomicBoolean(true);
|
||||
when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(HydrologyPlanPreview.EMPTY);
|
||||
when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(ChunkWaterRaster.EMPTY);
|
||||
when(runtime.rasterPreview(0, 0)).thenReturn(pond, ChunkWaterRaster.PROVISIONAL);
|
||||
when(runtime.rasterPreview(1, 0)).thenAnswer(invocation -> pending.get() ? ChunkWaterRaster.PROVISIONAL : pond);
|
||||
WaterMap map = new WaterMap(runtime, 32, 0D, 0D, 1D);
|
||||
|
||||
map.advance(() -> false);
|
||||
assertTrue(map.provisional());
|
||||
assertEquals(HydrologyFeatureType.POND, map.at(1, 1));
|
||||
assertNull(map.at(17, 1));
|
||||
pending.set(false);
|
||||
map.advance(() -> false);
|
||||
|
||||
assertFalse(map.provisional());
|
||||
assertEquals(HydrologyFeatureType.POND, map.at(1, 1));
|
||||
assertEquals(HydrologyFeatureType.POND, map.at(17, 1));
|
||||
assertNull(map.at(1, 17));
|
||||
assertTrue(map.bytes() < 16_384L);
|
||||
verify(runtime, times(1)).rasterPreview(0, 0);
|
||||
verify(runtime, times(2)).rasterPreview(1, 0);
|
||||
verify(runtime, times(1)).planPreview(anyInt(), anyInt(), anyInt(), anyInt());
|
||||
|
||||
map.advance(() -> false);
|
||||
verify(runtime, times(2)).rasterPreview(1, 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cancelledWaterSamplingResumesToTheSameFinalPixels() {
|
||||
HydrologyRuntime runtime = mock(HydrologyRuntime.class);
|
||||
ChunkWaterRaster raster = pond();
|
||||
when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(HydrologyPlanPreview.EMPTY);
|
||||
when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(raster);
|
||||
AtomicInteger rows = new AtomicInteger();
|
||||
WaterMap map = new WaterMap(runtime, 32, -16.5D, -16.5D, 0.5D);
|
||||
|
||||
assertThrows(CancellationException.class, () -> map.advance(() -> rows.incrementAndGet() > 2));
|
||||
assertTrue(map.provisional());
|
||||
map.advance(() -> false);
|
||||
assertFalse(map.provisional());
|
||||
WaterMap expected = WaterMap.draw(runtime, 32, -16.5D, -16.5D, 0.5D, () -> false);
|
||||
for (int z = 0; z < 32; z++) {
|
||||
for (int x = 0; x < 32; x++) {
|
||||
assertEquals(expected.at(x, z), map.at(x, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ChunkWaterRaster pond() {
|
||||
ChunkWaterRaster raster = mock(ChunkWaterRaster.class);
|
||||
when(raster.role(anyInt(), anyInt())).thenReturn(WaterRole.POND);
|
||||
return raster;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user