diff --git a/core/src/main/java/art/arcane/iris/studio/map/TerrainCover.java b/core/src/main/java/art/arcane/iris/studio/map/TerrainCover.java index 33622dbaf..5e00b73ae 100644 --- a/core/src/main/java/art/arcane/iris/studio/map/TerrainCover.java +++ b/core/src/main/java/art/arcane/iris/studio/map/TerrainCover.java @@ -21,7 +21,11 @@ package art.arcane.iris.studio.map; import java.util.ArrayList; import java.util.Comparator; import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.Set; /** @@ -30,7 +34,7 @@ import java.util.Set; * whole view and more right away. Preview tiles one level coarser than detail come between them: a quarter of the * columns, so the whole view looks nearly finished long before the detail is in. Until it is, coarser tiles stand in * for it, with holes where finer tiles are ready; detail left over from before zooming out stands in too, as does - * ground from before the noise changed. + * ground from before the noise changed, and backdrop left over from before zooming in. */ final class TerrainCover { private static final double MOST_COLUMN_PIXELS = 1D; @@ -96,9 +100,57 @@ final class TerrainCover { } private void coverSight() { + Map> coveredByBackdrop = new LinkedHashMap<>(); for (TerrainTile backdrop : sight.tilesInSight(backdropLevel, BACKDROP_MARGIN_SHARE)) { - cover(backdrop, true); + coveredByBackdrop.put(backdrop, cover(backdrop, true)); } + coverMissingBackdrop(coveredByBackdrop); + } + + /** + * Backdrop that isn't sampled yet, as right after zooming in, borrows the nearest coarser tile that is, with holes + * where the backdrop and finer ground around it are ready. Only as far up as holes can still be cut. + */ + private void coverMissingBackdrop(Map> coveredByBackdrop) { + Set standIns = new LinkedHashSet<>(); + coveredByBackdrop.forEach((backdrop, covered) -> { + if (!covered.equals(List.of(backdrop))) { + nearestSampledAncestor(backdrop).ifPresent(standIns::add); + } + }); + for (TerrainTile standIn : standIns) { + TerrainLayer layer = tiles.layerOf(standIn); + if (layer != null) { + draw(standIn, layer, fillersOf(standIn, coveredByBackdrop, standIns)); + } + } + } + + private Optional nearestSampledAncestor(TerrainTile backdrop) { + for (int levelsUp = 1; levelsUp <= TerrainHole.MOST_LEVELS_FINER; levelsUp++) { + TerrainTile ancestor = backdrop.ancestorAt(backdrop.level() + levelsUp); + if (tiles.isSampled(ancestor)) { + return Optional.of(ancestor); + } + } + return Optional.empty(); + } + + // Everything drawn inside the stand-in: the backdrop's ready ground, and finer stand-ins. + private static List fillersOf(TerrainTile standIn, Map> coveredByBackdrop, + Set standIns) { + List fillers = new ArrayList<>(); + coveredByBackdrop.forEach((backdrop, covered) -> { + if (backdrop.ancestorAt(standIn.level()).equals(standIn)) { + fillers.addAll(covered); + } + }); + for (TerrainTile finer : standIns) { + if (finer.level() < standIn.level() && finer.ancestorAt(standIn.level()).equals(standIn)) { + fillers.add(finer); + } + } + return fillers; } /** diff --git a/core/src/main/java/art/arcane/iris/studio/map/TerrainTile.java b/core/src/main/java/art/arcane/iris/studio/map/TerrainTile.java index ff0b7154d..31043e4a7 100644 --- a/core/src/main/java/art/arcane/iris/studio/map/TerrainTile.java +++ b/core/src/main/java/art/arcane/iris/studio/map/TerrainTile.java @@ -66,6 +66,14 @@ record TerrainTile(int level, int tileX, int tileZ) { new TerrainTile(finer, childX, childZ + 1), new TerrainTile(finer, childX + 1, childZ + 1)); } + /** + * The tile of a coarser level that this one lies inside. + */ + TerrainTile ancestorAt(int coarserLevel) { + int levelsUp = coarserLevel - level; + return new TerrainTile(coarserLevel, tileX >> levelsUp, tileZ >> levelsUp); + } + /** * This tile's columns that a finer tile inside it covers. */ diff --git a/core/src/test/java/art/arcane/iris/studio/map/TerrainCoverTest.java b/core/src/test/java/art/arcane/iris/studio/map/TerrainCoverTest.java new file mode 100644 index 000000000..cf7c50b37 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/studio/map/TerrainCoverTest.java @@ -0,0 +1,90 @@ +package art.arcane.iris.studio.map; + +import org.junit.Test; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.AbstractExecutorService; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class TerrainCoverTest { + private static final int WIDTH = 1200; + private static final int HEIGHT = 800; + private static final TerrainSampling FLAT = new TerrainSampling((grid, row, heights, colors) -> { + }, 0, 64, Double.NaN); + private static final OrbitProjection PROJECTION = NoiseTerrainScene.projectionFrom(Vec3.ZERO, NoiseTerrainScene.START, + WIDTH, HEIGHT, 0); + + @Test + public void backdropNotSampledYetBorrowsTheNearestSampledCoarserTile() { + TerrainTile coarser = TerrainTile.containing(0D, 0D, backdropLevel() + 1); + + List drawn = drawnTiles(Map.of(coarser, flatLayer(coarser))); + + assertTrue(drawn.contains(coarser)); + } + + @Test + public void tilesTooCoarseToCutHolesInDoNotStandIn() { + TerrainTile tooCoarse = TerrainTile.containing(0D, 0D, backdropLevel() + TerrainHole.MOST_LEVELS_FINER + 1); + + List drawn = drawnTiles(Map.of(tooCoarse, flatLayer(tooCoarse))); + + assertFalse(drawn.contains(tooCoarse)); + } + + private static int backdropLevel() { + TerrainCover.Result nothingSampled = TerrainCover.of(tilesWith(Map.of()), PROJECTION, WIDTH, HEIGHT, 0); + int detailLevel = Integer.numberOfTrailingZeros(nothingSampled.detailStep()); + return detailLevel + 3; + } + + private static List drawnTiles(Map sampled) { + return TerrainCover.of(tilesWith(sampled), PROJECTION, WIDTH, HEIGHT, 0).layers().stream() + .map(HoledLayer::tile) + .toList(); + } + + private static TerrainTiles tilesWith(Map sampled) { + return new TerrainTiles(FLAT, new NeverRuns(), 1, () -> { + }, sampled); + } + + private static TerrainLayer flatLayer(TerrainTile tile) { + return TerrainLayer.of(NoiseTerrain.empty(tile.grid()), FLAT.floorY()); + } + + // Keeps the wanted tiles unsampled, so each test decides exactly what is ready. + private static final class NeverRuns extends AbstractExecutorService { + @Override + public void execute(Runnable command) { + } + + @Override + public void shutdown() { + } + + @Override + public List shutdownNow() { + return List.of(); + } + + @Override + public boolean isShutdown() { + return false; + } + + @Override + public boolean isTerminated() { + return false; + } + + @Override + public boolean awaitTermination(long timeout, TimeUnit unit) { + return true; + } + } +}