Fit the height colours to the terrain on screen

The height view spread its colours over the whole world height, so a
mountain range and the plains next to it got nearly the same colour.
The legend now measures the visible heights (1st to 99th percentile,
rounded to 8 blocks) and fits the gradient to that range, with sea level
kept as the water/land split.

The map also re-finds its own world after a reload instead of whichever
Iris world is active, now that the active world prefers the open studio.

Model used: Claude Opus 5.5
This commit is contained in:
piexel
2026-10-19 13:31:45 +02:00
parent ec0d2d816a
commit 19bbca0e4b
6 changed files with 182 additions and 70 deletions
@@ -25,7 +25,6 @@ import com.volmit.iris.core.gui.components.MapStyle;
import com.volmit.iris.core.gui.components.RenderType;
import com.volmit.iris.engine.framework.Engine;
import com.volmit.iris.engine.object.IrisBiome;
import com.volmit.iris.engine.object.IrisDimension;
import com.volmit.iris.util.format.Form;
import javax.swing.JMenuItem;
@@ -77,6 +76,7 @@ final class MapCanvas extends JPanel {
private static final double KEYBOARD_ZOOM_FACTOR = 1.189207115002721D;
private static final int LEGEND_REFRESH_MILLIS = 200;
private final UUID worldId;
private final UUID openerId;
private final Listener listener;
private final VisionRenderController controller;
@@ -108,8 +108,6 @@ final class MapCanvas extends JPanel {
private boolean draggingDivider;
private double centerX;
private double centerZ;
private double heightMaximum;
private double fluidHeight;
private long contentRevision = 1L;
private long previousPaintStartedNanos;
private double blocksPerPixel = DEFAULT_BLOCKS_PER_PIXEL;
@@ -123,15 +121,15 @@ final class MapCanvas extends JPanel {
private boolean active = true;
private boolean closed;
MapCanvas(Engine engine, UUID openerId, Listener listener) {
MapCanvas(Engine engine, UUID worldId, UUID openerId, Listener listener) {
this.engine = Objects.requireNonNull(engine, "engine");
this.worldId = Objects.requireNonNull(worldId, "worldId");
this.openerId = openerId;
this.listener = Objects.requireNonNull(listener, "listener");
this.renderer = new IrisRenderer(engine);
this.overlay = new VisionOverlay(engine, openerId);
this.controller = new VisionRenderController(this::onTilesChanged);
this.hotloadHook = () -> EventQueue.invokeLater(this::refreshContent);
captureHeightRange();
setBackground(StudioTheme.EDITOR_BACKGROUND);
setFocusable(true);
@@ -214,6 +212,9 @@ final class MapCanvas extends JPanel {
return;
}
currentType = view.renderType();
if (currentType != RenderType.HEIGHT) {
style = style.withHeightRange(null);
}
requestRender();
}
@@ -241,7 +242,6 @@ final class MapCanvas extends JPanel {
renderer = new IrisRenderer(engine);
overlay = new VisionOverlay(engine, openerId);
contentRevision++;
captureHeightRange();
requestRender();
notifyUser(captured && comparison.mode() == HotloadComparison.Mode.OFF
? "Refreshing · press C to compare with before"
@@ -796,6 +796,7 @@ final class MapCanvas extends JPanel {
if (currentType != RenderType.HEIGHT) {
return;
}
MapStyle.HeightRange range = currentHeightRange();
canvas.setFont(StudioTheme.smallFont());
int padding = StudioTheme.scale(CARD_PADDING);
int gradientHeight = StudioTheme.scale(14);
@@ -808,23 +809,24 @@ final class MapCanvas extends JPanel {
int gradientX = x + padding;
int gradientY = y + padding;
int gradientWidth = width - padding * 2;
double span = Math.max(1D, range.highest() - range.lowest());
for (int pixel = 0; pixel < gradientWidth; pixel++) {
double sampleHeight = heightMaximum * pixel / Math.max(1D, gradientWidth - 1D);
canvas.setColor(new Color(IrisRenderer.heightColor(sampleHeight, heightMaximum, fluidHeight)));
double sampleHeight = range.lowest() + span * pixel / Math.max(1D, gradientWidth - 1D);
canvas.setColor(new Color(IrisRenderer.heightColor(sampleHeight, range)));
canvas.drawLine(gradientX + pixel, gradientY, gradientX + pixel, gradientY + gradientHeight);
}
canvas.setColor(StudioTheme.SECONDARY_TEXT);
int labelBaseline = gradientY + gradientHeight + labelGap + canvas.getFontMetrics().getAscent();
String minimum = "0";
String fluid = Integer.toString((int) Math.round(fluidHeight));
String maximum = Integer.toString((int) Math.round(heightMaximum));
String minimum = Integer.toString((int) Math.round(range.lowest()));
String sea = "sea " + Math.round(range.seaLevel());
String maximum = Integer.toString((int) Math.round(range.highest()));
int minimumRight = gradientX + canvas.getFontMetrics().stringWidth(minimum);
int maximumX = gradientX + gradientWidth - canvas.getFontMetrics().stringWidth(maximum);
int fluidWidth = canvas.getFontMetrics().stringWidth(fluid);
int fluidX = gradientX + (int) Math.round(gradientWidth * fluidHeight / Math.max(1D, heightMaximum)) - fluidWidth / 2;
int seaWidth = canvas.getFontMetrics().stringWidth(sea);
int seaX = gradientX + (int) Math.round(gradientWidth * (range.seaLevel() - range.lowest()) / span) - seaWidth / 2;
canvas.drawString(minimum, gradientX, labelBaseline);
if (fluidX > minimumRight + labelGap && fluidX + fluidWidth < maximumX - labelGap) {
canvas.drawString(fluid, fluidX, labelBaseline);
if (seaX > minimumRight + labelGap && seaX + seaWidth < maximumX - labelGap) {
canvas.drawString(sea, seaX, labelBaseline);
}
canvas.drawString(maximum, maximumX, labelBaseline);
}
@@ -970,9 +972,25 @@ final class MapCanvas extends JPanel {
}
IrisRenderer legendRenderer = renderer;
RenderType type = currentType;
List<MapCensus.Share> shares = MapCensus.count(renderFrame, screenToWorldX(0D), screenToWorldZ(0D),
screenToWorldX(getWidth()), screenToWorldZ(getHeight()), label -> legendRenderer.legendColor(label, type));
MapCensus.ViewArea area = new MapCensus.ViewArea(screenToWorldX(0D), screenToWorldZ(0D),
screenToWorldX(getWidth()), screenToWorldZ(getHeight()));
List<MapCensus.Share> shares = MapCensus.count(renderFrame, area, label -> legendRenderer.legendColor(label, type));
listener.onLegendChanged(legendTitle(), shares, style.highlight());
if (type == RenderType.HEIGHT) {
fitHeightColorsToView(area);
}
}
// The height colours stretch over the heights on screen, so plains and mountains don't share one shade of green.
private void fitHeightColorsToView(MapCensus.ViewArea area) {
MapStyle.HeightRange measured = MapCensus.heightRange(renderFrame, area, renderer.defaultHeightRange().seaLevel());
if (measured != null && !measured.equals(style.heightRange())) {
applyStyle(style.withHeightRange(measured));
}
}
private MapStyle.HeightRange currentHeightRange() {
return style.heightRange() == null ? renderer.defaultHeightRange() : style.heightRange();
}
private void requestRender() {
@@ -1027,7 +1045,7 @@ final class MapCanvas extends JPanel {
return true;
}
try {
Engine reacquired = GuiHost.findActiveEngine();
Engine reacquired = GuiHost.findEngine(worldId);
if (reacquired == null || reacquired.isClosed() || reacquired.getComplex() == null) {
return false;
}
@@ -1035,7 +1053,6 @@ final class MapCanvas extends JPanel {
renderer = new IrisRenderer(reacquired);
overlay = new VisionOverlay(reacquired, openerId);
contentRevision++;
captureHeightRange();
return true;
} catch (Throwable error) {
Iris.debug("Vision engine reacquisition failed: " + error.getClass().getSimpleName() + ": " + error.getMessage());
@@ -1043,14 +1060,6 @@ final class MapCanvas extends JPanel {
}
}
private void captureHeightRange() {
heightMaximum = Math.max(1D, engine.getHeight());
IrisDimension dimension = engine.getDimension();
fluidHeight = dimension == null
? 0D
: Math.max(0D, Math.min(heightMaximum, dimension.getFluidHeight()));
}
private void refreshMarkers() {
if (closed || overlay == null) {
return;
@@ -19,9 +19,11 @@
package com.volmit.iris.core.gui;
import com.volmit.iris.core.gui.components.MapLabels;
import com.volmit.iris.core.gui.components.MapStyle;
import com.volmit.iris.core.gui.components.MapTile;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
@@ -29,45 +31,84 @@ import java.util.Map;
import java.util.function.ToIntFunction;
/**
* Counts how much of the visible map each biome or region covers, from the tiles already drawn.
* Measures the visible part of the map from the tiles already drawn: how much each biome or region covers, and which
* heights it spans.
*/
final class MapCensus {
private static final long MAXIMUM_COUNTED_PIXELS = 250_000L;
private static final int MINIMUM_HEIGHT_SAMPLES = 64;
private static final double LOW_HEIGHT_PERCENTILE = 0.01D;
private static final double HIGH_HEIGHT_PERCENTILE = 0.99D;
private static final double HEIGHT_RANGE_STEP_BLOCKS = 8D;
private static final double MINIMUM_WATER_DEPTH_BLOCKS = 8D;
private static final double MINIMUM_LAND_HEIGHT_BLOCKS = 16D;
private MapCensus() {
}
static List<Share> count(VisionRenderController.Frame frame, double minimumX, double minimumZ, double maximumX,
double maximumZ, ToIntFunction<MapLabels.Label> colorOf) {
static List<Share> count(VisionRenderController.Frame frame, ViewArea area, ToIntFunction<MapLabels.Label> colorOf) {
Map<Short, long[]> counts = new HashMap<>();
MapLabels[] labels = new MapLabels[1];
long counted = visitVisiblePixels(frame, area, (tile, pixelX, pixelZ) -> {
labels[0] = tile.labels();
counts.computeIfAbsent(tile.subjectAt(pixelX, pixelZ), id -> new long[1])[0]++;
});
return shares(counts, labels[0], counted, colorOf);
}
/**
* The heights on screen, ignoring the highest and lowest few percent so one spike doesn't wash out every colour.
* Rounded to whole steps, so panning around doesn't recolour the map for every small change.
*
* @return the range, or null until enough of the map is drawn to tell
*/
static MapStyle.HeightRange heightRange(VisionRenderController.Frame frame, ViewArea area, double seaLevel) {
DoubleList heights = new DoubleList();
visitVisiblePixels(frame, area, (tile, pixelX, pixelZ) -> {
if (tile.hasHeights()) {
heights.add(tile.heightAt(pixelX, pixelZ));
}
});
if (heights.size() < MINIMUM_HEIGHT_SAMPLES) {
return null;
}
double[] sorted = heights.sorted();
double low = sorted[(int) ((sorted.length - 1) * LOW_HEIGHT_PERCENTILE)];
double high = sorted[(int) ((sorted.length - 1) * HIGH_HEIGHT_PERCENTILE)];
double lowest = Math.floor(Math.min(low, seaLevel - MINIMUM_WATER_DEPTH_BLOCKS) / HEIGHT_RANGE_STEP_BLOCKS)
* HEIGHT_RANGE_STEP_BLOCKS;
double highest = Math.ceil(Math.max(high, seaLevel + MINIMUM_LAND_HEIGHT_BLOCKS) / HEIGHT_RANGE_STEP_BLOCKS)
* HEIGHT_RANGE_STEP_BLOCKS;
return new MapStyle.HeightRange(lowest, seaLevel, highest);
}
private static long visitVisiblePixels(VisionRenderController.Frame frame, ViewArea area, PixelVisitor visitor) {
if (frame == null) {
return List.of();
return 0L;
}
double blocksPerPixel = frame.renderBlocksPerPixel();
int stride = countingStride(maximumX - minimumX, maximumZ - minimumZ, blocksPerPixel);
int stride = countingStride(area.maximumX() - area.minimumX(), area.maximumZ() - area.minimumZ(), blocksPerPixel);
double tileSpan = VisionRenderController.TILE_PIXELS * blocksPerPixel;
Map<Short, long[]> counts = new HashMap<>();
MapLabels labels = null;
long counted = 0L;
long visited = 0L;
for (VisionRenderController.VisibleTile visible : frame.tiles()) {
MapTile tile = visible.tile();
if (tile == null) {
continue;
}
labels = tile.labels();
double originX = visible.tileX() * tileSpan;
double originZ = visible.tileZ() * tileSpan;
int firstX = firstPixel(minimumX - originX, blocksPerPixel, stride, visible.tileX());
int firstZ = firstPixel(minimumZ - originZ, blocksPerPixel, stride, visible.tileZ());
int endX = endPixel(maximumX - originX, blocksPerPixel, tile.size());
int endZ = endPixel(maximumZ - originZ, blocksPerPixel, tile.size());
int firstX = firstPixel(area.minimumX() - originX, blocksPerPixel, stride, visible.tileX());
int firstZ = firstPixel(area.minimumZ() - originZ, blocksPerPixel, stride, visible.tileZ());
int endX = endPixel(area.maximumX() - originX, blocksPerPixel, tile.size());
int endZ = endPixel(area.maximumZ() - originZ, blocksPerPixel, tile.size());
for (int pixelZ = firstZ; pixelZ < endZ; pixelZ += stride) {
for (int pixelX = firstX; pixelX < endX; pixelX += stride) {
counts.computeIfAbsent(tile.subjectAt(pixelX, pixelZ), id -> new long[1])[0]++;
counted++;
visitor.visit(tile, pixelX, pixelZ);
visited++;
}
}
}
return shares(counts, labels, counted, colorOf);
return visited;
}
private static List<Share> shares(Map<Short, long[]> counts, MapLabels labels, long counted,
@@ -106,4 +147,37 @@ final class MapCensus {
record Share(MapLabels.Label label, int color, double fraction) {
}
/**
* The world area the screen shows, in blocks.
*/
record ViewArea(double minimumX, double minimumZ, double maximumX, double maximumZ) {
}
@FunctionalInterface
private interface PixelVisitor {
void visit(MapTile tile, int pixelX, int pixelZ);
}
private static final class DoubleList {
private double[] values = new double[1024];
private int size;
void add(double value) {
if (size == values.length) {
values = Arrays.copyOf(values, size * 2);
}
values[size++] = value;
}
int size() {
return size;
}
double[] sorted() {
double[] sorted = Arrays.copyOf(values, size);
Arrays.sort(sorted);
return sorted;
}
}
}
@@ -87,7 +87,7 @@ public final class MapWindow implements MapCanvas.Listener {
private MapWindow(PaletteExplorerStart start) {
this.start = start;
this.frame = GuiHost.createFrame("Iris Map");
this.canvas = new MapCanvas(start.engine(), start.playerId(), this);
this.canvas = new MapCanvas(start.engine(), start.worldId(), start.playerId(), this);
this.viewList = new MapViewList(this::showView);
this.viewListScroll = StudioToolbar.scroll(viewList);
this.legendPanel = new MapLegendPanel(share -> canvas.toggleHighlight(share.label()));
@@ -51,6 +51,8 @@ public final class IrisRenderer {
private static final int UNKNOWN_SUBJECT = rgb(90, 92, 98);
private static final int LARGEST_SAMPLE_BLOCK_PIXELS = 16;
private static final double LARGEST_SAMPLE_BLOCK_BLOCKS = 64D;
private static final double DEFAULT_DEPTH_BELOW_SEA_BLOCKS = 48D;
private static final double DEFAULT_HEIGHT_ABOVE_SEA_BLOCKS = 160D;
private final Engine engine;
private final MapLabels labels = new MapLabels();
@@ -97,9 +99,9 @@ public final class IrisRenderer {
subjects[index] = subjectOf(identities[index]);
regions[index] = regionOf(identities[index]);
}
int[] colors = tileColors(size, type, subjects, heights);
int[] colors = type == RenderType.HEIGHT ? null : tileColors(size, type, subjects);
MapLabels.Kind subjectKind = type == RenderType.REGION ? MapLabels.Kind.REGION : MapLabels.Kind.BIOME;
return new MapTile(size, blocksPerPixel, labels, subjectKind, colors, subjects, regions, heights);
return new MapTile(size, blocksPerPixel, labels, subjectKind, colors, defaultHeightRange(), subjects, regions, heights);
}
/**
@@ -110,16 +112,24 @@ public final class IrisRenderer {
return labelColor(label.id(), colorType);
}
public static int heightColor(double height, double maximumHeight, double fluidHeight) {
double boundedMaximum = Math.max(1D, maximumHeight);
double boundedHeight = clamp(height, 0D, boundedMaximum);
double boundedFluid = clamp(fluidHeight, 0D, boundedMaximum);
if (boundedHeight <= boundedFluid && boundedFluid > 0D) {
return blend(DEEP_WATER, SHALLOW_WATER, boundedHeight / boundedFluid);
/**
* Until the map has measured the heights on screen, land colours cover the band most terrain sits in.
*/
public MapStyle.HeightRange defaultHeightRange() {
double seaLevel = fluidHeight();
return new MapStyle.HeightRange(Math.max(0D, seaLevel - DEFAULT_DEPTH_BELOW_SEA_BLOCKS), seaLevel,
Math.min(engine.getHeight(), seaLevel + DEFAULT_HEIGHT_ABOVE_SEA_BLOCKS));
}
public static int heightColor(double height, MapStyle.HeightRange range) {
double seaLevel = range.seaLevel();
if (height <= seaLevel) {
double waterDepthRange = Math.max(1D, seaLevel - range.lowest());
return blend(DEEP_WATER, SHALLOW_WATER, (height - range.lowest()) / waterDepthRange);
}
double landRange = Math.max(1D, boundedMaximum - boundedFluid);
double land = clamp((boundedHeight - boundedFluid) / landRange, 0D, 1D);
double landRange = Math.max(1D, range.highest() - seaLevel);
double land = clamp((height - seaLevel) / landRange, 0D, 1D);
if (land < 0.45D) {
return blend(LOWLAND, HIGHLAND, land / 0.45D);
}
@@ -168,19 +178,13 @@ public final class IrisRenderer {
};
}
private int[] tileColors(int size, RenderType type, short[] subjects, double[] heights) {
private int[] tileColors(int size, RenderType type, short[] subjects) {
int[] colors = new int[size * size];
double maximumHeight = engine.getHeight();
double fluidHeight = fluidHeight();
short previousSubject = MapLabels.NONE;
int previousColor = UNKNOWN_SUBJECT;
for (int pixelZ = 0; pixelZ < size; pixelZ++) {
for (int pixelX = 0; pixelX < size; pixelX++) {
int gridIndex = (pixelZ + 1) * (size + 2) + pixelX + 1;
if (type == RenderType.HEIGHT) {
colors[pixelZ * size + pixelX] = heightColor(heights[gridIndex], maximumHeight, fluidHeight);
continue;
}
short subject = subjects[gridIndex];
if (subject != previousSubject) {
previousSubject = subject;
@@ -22,22 +22,33 @@ package com.volmit.iris.core.gui.components;
* How map tiles are drawn on top of their plain colours: terrain shading, region borders and one highlighted biome or region.
*
* @param highlight the biome or region to keep bright while everything else dims, or null to show all
* @param heightRange the heights the height view spreads its colours over, or null outside the height view
*/
public record MapStyle(boolean shading, boolean regionBorders, Highlight highlight) {
public static final MapStyle PLAIN = new MapStyle(false, false, null);
public record MapStyle(boolean shading, boolean regionBorders, Highlight highlight, HeightRange heightRange) {
public static final MapStyle PLAIN = new MapStyle(false, false, null, null);
public MapStyle withShading(boolean nextShading) {
return new MapStyle(nextShading, regionBorders, highlight);
return new MapStyle(nextShading, regionBorders, highlight, heightRange);
}
public MapStyle withRegionBorders(boolean nextRegionBorders) {
return new MapStyle(shading, nextRegionBorders, highlight);
return new MapStyle(shading, nextRegionBorders, highlight, heightRange);
}
public MapStyle withHighlight(Highlight nextHighlight) {
return new MapStyle(shading, regionBorders, nextHighlight);
return new MapStyle(shading, regionBorders, nextHighlight, heightRange);
}
public MapStyle withHeightRange(HeightRange nextHeightRange) {
return new MapStyle(shading, regionBorders, highlight, nextHeightRange);
}
public record Highlight(MapLabels.Kind kind, String key, String name) {
}
/**
* Water colours run from the lowest height up to sea level, land colours from sea level up to the highest.
*/
public record HeightRange(double lowest, double seaLevel, double highest) {
}
}
@@ -51,19 +51,25 @@ public final class MapTile {
private final MapLabels labels;
private final MapLabels.Kind subjectKind;
private final int[] colors;
private final MapStyle.HeightRange defaultHeightRange;
private final short[] subjects;
private final short[] regions;
private final double[] heights;
private MapStyle styledFor;
private BufferedImage styledImage;
MapTile(int size, double blocksPerPixel, MapLabels labels, MapLabels.Kind subjectKind, int[] colors, short[] subjects,
short[] regions, double[] heights) {
/**
* @param colors the colour of every pixel, or null to colour the pixels by their height when they are drawn
* @param defaultHeightRange the height colours to use until the map has measured the heights on screen
*/
MapTile(int size, double blocksPerPixel, MapLabels labels, MapLabels.Kind subjectKind, int[] colors,
MapStyle.HeightRange defaultHeightRange, short[] subjects, short[] regions, double[] heights) {
this.size = size;
this.blocksPerPixel = blocksPerPixel;
this.labels = labels;
this.subjectKind = subjectKind;
this.colors = colors;
this.defaultHeightRange = defaultHeightRange;
this.subjects = subjects;
this.regions = regions;
this.heights = heights;
@@ -96,11 +102,16 @@ public final class MapTile {
return regions[gridIndex(pixelX, pixelZ)];
}
public double heightAt(int pixelX, int pixelZ) {
return heights[gridIndex(pixelX, pixelZ)];
}
public long bytes() {
long styledBytes = (long) size * size * Integer.BYTES;
long gridCells = (long) (size + 2) * (size + 2);
long heightBytes = heights == null ? 0L : gridCells * Double.BYTES;
return (long) colors.length * Integer.BYTES + gridCells * Short.BYTES * 2L + heightBytes + styledBytes;
long colorBytes = colors == null ? 0L : (long) colors.length * Integer.BYTES;
return colorBytes + gridCells * Short.BYTES * 2L + heightBytes + styledBytes;
}
/**
@@ -121,9 +132,12 @@ public final class MapTile {
short highlighted = highlightGrid == null
? MapLabels.NONE
: labels.idOf(style.highlight().kind(), style.highlight().key());
MapStyle.HeightRange heightRange = style.heightRange() == null ? defaultHeightRange : style.heightRange();
for (int pixelZ = 0; pixelZ < size; pixelZ++) {
for (int pixelX = 0; pixelX < size; pixelX++) {
int color = colors[pixelZ * size + pixelX];
int color = colors == null
? IrisRenderer.heightColor(heightAt(pixelX, pixelZ), heightRange)
: colors[pixelZ * size + pixelX];
if (shading) {
color = shade(color, pixelX, pixelZ);
}