Make the map explain what it shows

Highlight a biome or region (legend click, right-click, or H under the
cursor) to fade everything else and outline it; Esc clears it. A live
legend beside the map lists the biomes or regions in view with their
share of the screen.

The hover card now answers at once from the drawn tiles and then adds
height, sea level, land or sea, slope, cave biome and the Minecraft
biome; Shift adds keys and files.

After a refresh or hotload, C compares with the map from before: a
draggable before/now divider, or a view that fades out everything that
stayed the same and says how much changed.

Shading (S) adds hillshade relief to every view and Borders (B) outlines
the regions. Tiles now remember what each pixel shows, so all of this
restyles without rendering again.

Model used: Claude Opus 5.5
This commit is contained in:
piexel committed 2026-10-19 12:46:35 +02:00
1 parent cebd582018
commit e58d065ca4
12 files changed
+1823 -517

No files matched your search

@@ -0,0 +1,279 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.volmit.iris.core.gui;
import com.volmit.iris.core.gui.components.MapStyle;
import com.volmit.iris.core.gui.components.RenderType;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
import java.util.Locale;
/**
* Keeps the picture of the map from just before a hotload, and shows it next to the new one: either split by a
* draggable divider, or with everything that stayed the same faded out.
*/
final class HotloadComparison {
private static final double DEFAULT_SWIPE_FRACTION = 0.5D;
private static final int DIVIDER_GRAB_DISTANCE = 8;
private static final int DIVIDER_HANDLE_RADIUS = 9;
private static final int UNCHANGED_FADE_TARGET = 0x1E1F22;
private static final double UNCHANGED_FADE = 0.78D;
private static final Color DIVIDER = new Color(255, 255, 255, 220);
private BufferedImage before;
private Scene beforeScene;
private Mode mode = Mode.OFF;
private double swipeFraction = DEFAULT_SWIPE_FRACTION;
private BufferedImage changes;
private ChangesKey changesKey;
private double changedShare;
enum Mode {
OFF,
SWIPE,
CHANGES;
Mode next() {
Mode[] modes = values();
return modes[(ordinal() + 1) % modes.length];
}
}
/**
* What a map picture shows, so the before picture is only laid over a now picture of the same kind and zoom.
*/
record Scene(RenderType type, MapStyle style, double blocksPerPixel, double centerX, double centerZ) {
boolean looksLike(Scene other) {
return type == other.type && style.equals(other.style);
}
}
boolean hasBefore() {
return before != null;
}
Mode mode() {
return mode;
}
void setMode(Mode nextMode) {
mode = nextMode;
}
void capture(BufferedImage layer, Scene scene) {
BufferedImage copy = new BufferedImage(layer.getWidth(), layer.getHeight(), BufferedImage.TYPE_INT_RGB);
copy.setAccelerationPriority(0F);
Graphics2D graphics = copy.createGraphics();
try {
graphics.drawImage(layer, 0, 0, null);
} finally {
graphics.dispose();
}
before = copy;
beforeScene = scene;
changes = null;
changesKey = null;
}
void forget() {
before = null;
beforeScene = null;
changes = null;
changesKey = null;
mode = Mode.OFF;
}
boolean isShowing() {
return mode != Mode.OFF && before != null;
}
boolean isNearDivider(int screenX, int width) {
return isShowing() && mode == Mode.SWIPE
&& Math.abs(screenX - dividerX(width)) <= StudioTheme.scale(DIVIDER_GRAB_DISTANCE);
}
void moveDivider(int screenX, int width) {
swipeFraction = Math.max(0D, Math.min(1D, screenX / (double) Math.max(1, width)));
}
/**
* Draws the comparison in place of the plain map picture.
*
* @param nowVersion changes whenever the now picture is redrawn, so the change mask is only rebuilt when needed
* @param nowComplete whether every tile of the now picture has been drawn; changes are only counted then
*/
void paint(Graphics2D canvas, BufferedImage now, long nowVersion, Scene nowScene, boolean nowComplete) {
canvas.drawImage(now, 0, 0, null);
if (!beforeScene.looksLike(nowScene)) {
drawCaption(canvas, now.getWidth(), "Before and after needs the view and layers the hotload had");
return;
}
if (mode == Mode.SWIPE) {
paintSwipe(canvas, now.getWidth(), now.getHeight(), nowScene);
return;
}
if (beforeScene.blocksPerPixel() != nowScene.blocksPerPixel()) {
drawCaption(canvas, now.getWidth(), "Changes needs the zoom the hotload had");
return;
}
if (!nowComplete) {
drawCaption(canvas, now.getWidth(), "Comparing once the map has finished drawing");
return;
}
int offsetX = (int) Math.round(beforeScreenX(now.getWidth(), nowScene));
int offsetZ = (int) Math.round(beforeScreenZ(now.getHeight(), nowScene));
canvas.drawImage(changesImage(now, nowVersion, offsetX, offsetZ), 0, 0, null);
drawCaption(canvas, now.getWidth(), changesCaption());
}
private void paintSwipe(Graphics2D canvas, int width, int height, Scene now) {
int dividerX = dividerX(width);
double scale = beforeScene.blocksPerPixel() / now.blocksPerPixel();
Graphics2D beforeSide = (Graphics2D) canvas.create();
try {
beforeSide.clipRect(0, 0, dividerX, height);
beforeSide.drawImage(before,
(int) Math.round(beforeScreenX(width, now)),
(int) Math.round(beforeScreenZ(height, now)),
(int) Math.round(before.getWidth() * scale),
(int) Math.round(before.getHeight() * scale),
null);
} finally {
beforeSide.dispose();
}
canvas.setColor(DIVIDER);
canvas.setStroke(new BasicStroke(StudioTheme.scale(2)));
canvas.drawLine(dividerX, 0, dividerX, height);
int radius = StudioTheme.scale(DIVIDER_HANDLE_RADIUS);
canvas.fillOval(dividerX - radius, height / 2 - radius, radius * 2, radius * 2);
canvas.setColor(StudioTheme.EDITOR_BACKGROUND);
int gripHalfHeight = radius / 2;
int gripGap = Math.max(1, radius / 4);
canvas.drawLine(dividerX - gripGap, height / 2 - gripHalfHeight, dividerX - gripGap, height / 2 + gripHalfHeight);
canvas.drawLine(dividerX + gripGap, height / 2 - gripHalfHeight, dividerX + gripGap, height / 2 + gripHalfHeight);
drawSideLabel(canvas, "Before", dividerX - StudioTheme.scale(12), 1D);
drawSideLabel(canvas, "Now", dividerX + StudioTheme.scale(12), 0D);
}
private int dividerX(int width) {
return (int) Math.round(width * swipeFraction);
}
// Where the before picture's top left corner lands on screen now, after any panning, zooming or resizing since.
private double beforeScreenX(int width, Scene now) {
double beforeLeftWorld = beforeScene.centerX() - before.getWidth() * 0.5D * beforeScene.blocksPerPixel();
return width * 0.5D + (beforeLeftWorld - now.centerX()) / now.blocksPerPixel();
}
private double beforeScreenZ(int height, Scene now) {
double beforeTopWorld = beforeScene.centerZ() - before.getHeight() * 0.5D * beforeScene.blocksPerPixel();
return height * 0.5D + (beforeTopWorld - now.centerZ()) / now.blocksPerPixel();
}
private BufferedImage changesImage(BufferedImage now, long nowVersion, int offsetX, int offsetZ) {
ChangesKey key = new ChangesKey(nowVersion, offsetX, offsetZ, now.getWidth(), now.getHeight());
if (changes != null && key.equals(changesKey)) {
return changes;
}
int width = now.getWidth();
int height = now.getHeight();
int[] nowPixels = pixels(now);
int[] beforePixels = pixels(before);
BufferedImage mask = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
mask.setAccelerationPriority(0F);
int[] maskPixels = pixels(mask);
long compared = 0L;
long changed = 0L;
for (int y = 0; y < height; y++) {
int beforeY = y - offsetZ;
boolean rowInBefore = beforeY >= 0 && beforeY < before.getHeight();
for (int x = 0; x < width; x++) {
int index = y * width + x;
int nowColor = nowPixels[index] & 0xFFFFFF;
int beforeX = x - offsetX;
boolean inBefore = rowInBefore && beforeX >= 0 && beforeX < before.getWidth();
boolean differs = inBefore && (beforePixels[beforeY * before.getWidth() + beforeX] & 0xFFFFFF) != nowColor;
if (inBefore) {
compared++;
}
if (differs) {
changed++;
maskPixels[index] = nowColor;
} else {
maskPixels[index] = fade(nowColor);
}
}
}
changedShare = compared == 0L ? 0D : changed / (double) compared;
changes = mask;
changesKey = key;
return mask;
}
private String changesCaption() {
if (changedShare == 0D) {
return "Nothing on screen changed in the hotload";
}
double percent = changedShare * 100D;
String amount = percent < 0.1D ? "<0.1" : String.format(Locale.ROOT, percent < 10D ? "%.1f" : "%.0f", percent);
return amount + "% of the map on screen changed in the hotload";
}
private static int[] pixels(BufferedImage image) {
return ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
}
private static int fade(int color) {
int red = (int) (((color >> 16) & 0xFF) * (1D - UNCHANGED_FADE) + ((UNCHANGED_FADE_TARGET >> 16) & 0xFF) * UNCHANGED_FADE);
int green = (int) (((color >> 8) & 0xFF) * (1D - UNCHANGED_FADE) + ((UNCHANGED_FADE_TARGET >> 8) & 0xFF) * UNCHANGED_FADE);
int blue = (int) ((color & 0xFF) * (1D - UNCHANGED_FADE) + (UNCHANGED_FADE_TARGET & 0xFF) * UNCHANGED_FADE);
return (red << 16) | (green << 8) | blue;
}
private static void drawSideLabel(Graphics2D canvas, String text, int anchorX, double pushX) {
canvas.setFont(StudioTheme.boldFont());
int padding = StudioTheme.scale(8);
int width = canvas.getFontMetrics().stringWidth(text) + padding * 2;
int height = canvas.getFontMetrics().getHeight() + padding;
int x = (int) Math.round(anchorX - width * pushX);
int y = StudioTheme.scale(12);
StudioOverlay.drawCard(canvas, x, y, width, height);
canvas.setColor(StudioTheme.TEXT);
canvas.drawString(text, x + padding, y + padding / 2 + canvas.getFontMetrics().getAscent());
}
private static void drawCaption(Graphics2D canvas, int width, String text) {
canvas.setFont(StudioTheme.font());
int padding = StudioTheme.scale(10);
int captionWidth = canvas.getFontMetrics().stringWidth(text) + padding * 2;
int height = canvas.getFontMetrics().getHeight() + padding;
int x = (width - captionWidth) / 2;
int y = StudioTheme.scale(12);
StudioOverlay.drawCard(canvas, x, y, captionWidth, height);
canvas.setColor(StudioTheme.TEXT);
canvas.drawString(text, x + padding, y + padding / 2 + canvas.getFontMetrics().getAscent());
}
private record ChangesKey(long nowVersion, int offsetX, int offsetZ, int width, int height) {
}
}
@@ -20,11 +20,12 @@ package com.volmit.iris.core.gui;
import com.volmit.iris.Iris; import com.volmit.iris.Iris;
import com.volmit.iris.core.gui.components.IrisRenderer; import com.volmit.iris.core.gui.components.IrisRenderer;
import com.volmit.iris.core.gui.components.MapLabels;
import com.volmit.iris.core.gui.components.MapStyle;
import com.volmit.iris.core.gui.components.RenderType; import com.volmit.iris.core.gui.components.RenderType;
import com.volmit.iris.engine.framework.Engine; import com.volmit.iris.engine.framework.Engine;
import com.volmit.iris.engine.object.IrisBiome; import com.volmit.iris.engine.object.IrisBiome;
import com.volmit.iris.engine.object.IrisDimension; import com.volmit.iris.engine.object.IrisDimension;
import com.volmit.iris.engine.object.IrisRegion;
import com.volmit.iris.util.format.Form; import com.volmit.iris.util.format.Form;
import javax.swing.JMenuItem; import javax.swing.JMenuItem;
@@ -33,6 +34,7 @@ import javax.swing.JPopupMenu;
import javax.swing.SwingUtilities; import javax.swing.SwingUtilities;
import javax.swing.Timer; import javax.swing.Timer;
import java.awt.Color; import java.awt.Color;
import java.awt.Cursor;
import java.awt.EventQueue; import java.awt.EventQueue;
import java.awt.Graphics; import java.awt.Graphics;
import java.awt.Graphics2D; import java.awt.Graphics2D;
@@ -73,6 +75,7 @@ final class MapCanvas extends JPanel {
private static final double DEFAULT_BLOCKS_PER_PIXEL = 4D; private static final double DEFAULT_BLOCKS_PER_PIXEL = 4D;
private static final double WHEEL_ZOOM_EXPONENT = 0.08D; private static final double WHEEL_ZOOM_EXPONENT = 0.08D;
private static final double KEYBOARD_ZOOM_FACTOR = 1.189207115002721D; private static final double KEYBOARD_ZOOM_FACTOR = 1.189207115002721D;
private static final int LEGEND_REFRESH_MILLIS = 200;
private final UUID openerId; private final UUID openerId;
private final Listener listener; private final Listener listener;
@@ -82,7 +85,9 @@ final class MapCanvas extends JPanel {
private final Timer hoverTimer; private final Timer hoverTimer;
private final Timer markerTimer; private final Timer markerTimer;
private final Timer notificationTimer; private final Timer notificationTimer;
private final Timer legendTimer;
private final Map<String, Long> notifications = new LinkedHashMap<>(); private final Map<String, Long> notifications = new LinkedHashMap<>();
private final HotloadComparison comparison = new HotloadComparison();
private Engine engine; private Engine engine;
private IrisRenderer renderer; private IrisRenderer renderer;
@@ -90,13 +95,17 @@ final class MapCanvas extends JPanel {
private VisionRenderController.Frame renderFrame; private VisionRenderController.Frame renderFrame;
private BufferedImage tileLayer; private BufferedImage tileLayer;
private boolean tileLayerStale = true; private boolean tileLayerStale = true;
private long tileLayerVersion;
private MapStyle style = MapStyle.PLAIN;
private RenderType currentType = RenderType.BIOME; private RenderType currentType = RenderType.BIOME;
private String viewLabel = MapView.BIOMES.label(); private String viewLabel = MapView.BIOMES.label();
private List<GuiMarker> players = List.of(); private List<GuiMarker> players = List.of();
private List<GuiMarker> entities = List.of(); private List<GuiMarker> entities = List.of();
private HoverInfo hoverInfo; private QuickHover quickHover;
private MapHoverProbe.Details hoverDetails;
private Point cursor; private Point cursor;
private Point dragOrigin; private Point dragOrigin;
private boolean draggingDivider;
private double centerX; private double centerX;
private double centerZ; private double centerZ;
private double heightMaximum; private double heightMaximum;
@@ -135,6 +144,7 @@ final class MapCanvas extends JPanel {
}); });
this.hoverTimer = singleShotTimer(90, this::requestHover); this.hoverTimer = singleShotTimer(90, this::requestHover);
this.legendTimer = singleShotTimer(LEGEND_REFRESH_MILLIS, this::publishLegend);
this.markerTimer = new Timer(650, event -> refreshMarkers()); this.markerTimer = new Timer(650, event -> refreshMarkers());
this.markerTimer.setCoalesce(true); this.markerTimer.setCoalesce(true);
this.notificationTimer = new Timer(200, event -> expireNotifications()); this.notificationTimer = new Timer(200, event -> expireNotifications());
@@ -172,6 +182,18 @@ final class MapCanvas extends JPanel {
return follow; return follow;
} }
MapStyle style() {
return style;
}
HotloadComparison.Mode comparisonMode() {
return comparison.hasBefore() ? comparison.mode() : HotloadComparison.Mode.OFF;
}
boolean hasBeforePicture() {
return comparison.hasBefore();
}
void start() { void start() {
refreshMarkers(); refreshMarkers();
requestRender(); requestRender();
@@ -195,6 +217,10 @@ final class MapCanvas extends JPanel {
requestRender(); requestRender();
} }
String legendTitle() {
return currentType == RenderType.REGION ? "Regions in view" : "Biomes in view";
}
// Tiles keep loading in the background only for the view on screen; a hidden canvas stops asking for new ones. // Tiles keep loading in the background only for the view on screen; a hidden canvas stops asking for new ones.
void setActive(boolean nextActive) { void setActive(boolean nextActive) {
active = nextActive; active = nextActive;
@@ -211,12 +237,117 @@ final class MapCanvas extends JPanel {
notifyUser("No active Iris engine"); notifyUser("No active Iris engine");
return; return;
} }
boolean captured = captureBeforePicture();
renderer = new IrisRenderer(engine); renderer = new IrisRenderer(engine);
overlay = new VisionOverlay(engine, openerId); overlay = new VisionOverlay(engine, openerId);
contentRevision++; contentRevision++;
captureHeightRange(); captureHeightRange();
requestRender(); requestRender();
notifyUser("Refreshing"); notifyUser(captured && comparison.mode() == HotloadComparison.Mode.OFF
? "Refreshing · press C to compare with before"
: "Refreshing");
listener.onTogglesChanged();
}
// Only a finished picture is worth comparing against; a half drawn one would show its gaps as changes.
private boolean captureBeforePicture() {
if (tileLayer == null || controller.progress(renderFrame).completion() < 1D) {
comparison.forget();
return false;
}
comparison.capture(currentTileLayer(), currentScene());
return true;
}
void toggleShading() {
applyStyle(style.withShading(!style.shading()));
notifyUser(style.shading() ? "Terrain shading on" : "Terrain shading off");
listener.onTogglesChanged();
}
void toggleRegionBorders() {
applyStyle(style.withRegionBorders(!style.regionBorders()));
notifyUser(style.regionBorders() ? "Region borders on" : "Region borders off");
listener.onTogglesChanged();
}
/**
* Highlights the biome or region, or clears the highlight when it is the one already highlighted.
*/
void toggleHighlight(MapLabels.Label label) {
if (label == null) {
return;
}
MapStyle.Highlight current = style.highlight();
if (current != null && current.kind() == label.kind() && current.key().equals(label.key())) {
clearHighlight();
return;
}
applyStyle(style.withHighlight(new MapStyle.Highlight(label.kind(), label.key(), label.name())));
notifyUser("Highlighting " + label.name() + " · Esc clears");
}
void highlightUnderCursor() {
VisionRenderController.TilePixel pixel = cursor == null ? null : tilePixelAt(cursor);
if (pixel == null) {
notifyUser("Point at the map to highlight what is there");
return;
}
toggleHighlight(pixel.subject());
}
/**
* Esc steps back one layer of emphasis: the highlight first, then the comparison, then the help.
*/
void dismissEmphasis() {
if (style.highlight() != null) {
clearHighlight();
} else if (comparisonMode() != HotloadComparison.Mode.OFF) {
showComparison(HotloadComparison.Mode.OFF);
} else if (help) {
toggleHelp();
}
}
private void clearHighlight() {
applyStyle(style.withHighlight(null));
notifyUser("Highlight cleared");
}
void cycleComparison() {
showComparison(comparisonMode().next());
}
void showComparison(HotloadComparison.Mode mode) {
if (!comparison.hasBefore()) {
notifyUser("Refresh or hotload the pack first, then compare with before");
listener.onTogglesChanged();
return;
}
comparison.setMode(mode);
notifyUser(switch (mode) {
case OFF -> "Comparison off";
case SWIPE -> "Drag the divider to compare before and now";
case CHANGES -> "Showing what changed in the hotload";
});
repaint();
listener.onTogglesChanged();
}
private void applyStyle(MapStyle next) {
boolean heightsWereNeeded = needsHeights();
style = next;
if (needsHeights() != heightsWereNeeded) {
requestRender();
} else {
tileLayerStale = true;
repaint();
}
scheduleLegendUpdate();
}
private boolean needsHeights() {
return style.shading() || currentType == RenderType.HEIGHT;
} }
void toggleGrid() { void toggleGrid() {
@@ -289,6 +420,7 @@ final class MapCanvas extends JPanel {
} }
closed = true; closed = true;
hoverTimer.stop(); hoverTimer.stop();
legendTimer.stop();
markerTimer.stop(); markerTimer.stop();
notificationTimer.stop(); notificationTimer.stop();
GuiHost.unregisterHotloadHook(hotloadHook); GuiHost.unregisterHotloadHook(hotloadHook);
@@ -308,6 +440,10 @@ final class MapCanvas extends JPanel {
public void mouseMoved(MouseEvent event) { public void mouseMoved(MouseEvent event) {
cursor = event.getPoint(); cursor = event.getPoint();
setDetailedHover(event.isShiftDown()); setDetailedHover(event.isShiftDown());
setCursor(comparison.isNearDivider(event.getX(), getWidth())
? Cursor.getPredefinedCursor(Cursor.E_RESIZE_CURSOR)
: Cursor.getDefaultCursor());
showQuickHover();
hoverTimer.restart(); hoverTimer.restart();
refreshStatus(); refreshStatus();
} }
@@ -315,6 +451,11 @@ final class MapCanvas extends JPanel {
@Override @Override
public void mouseDragged(MouseEvent event) { public void mouseDragged(MouseEvent event) {
cursor = event.getPoint(); cursor = event.getPoint();
if (draggingDivider) {
comparison.moveDivider(event.getX(), getWidth());
repaint();
return;
}
if (dragOrigin == null) { if (dragOrigin == null) {
return; return;
} }
@@ -322,7 +463,7 @@ final class MapCanvas extends JPanel {
centerZ += (dragOrigin.y - event.getY()) * blocksPerPixel; centerZ += (dragOrigin.y - event.getY()) * blocksPerPixel;
dragOrigin = event.getPoint(); dragOrigin = event.getPoint();
stopFollowing(); stopFollowing();
hoverInfo = null; clearHover();
requestRender(); requestRender();
} }
@@ -339,6 +480,8 @@ final class MapCanvas extends JPanel {
cursor = event.getPoint(); cursor = event.getPoint();
if (event.isPopupTrigger()) { if (event.isPopupTrigger()) {
showContextMenu(event); showContextMenu(event);
} else if (SwingUtilities.isLeftMouseButton(event) && comparison.isNearDivider(event.getX(), getWidth())) {
draggingDivider = true;
} else if (SwingUtilities.isLeftMouseButton(event)) { } else if (SwingUtilities.isLeftMouseButton(event)) {
dragOrigin = event.getPoint(); dragOrigin = event.getPoint();
} }
@@ -347,6 +490,7 @@ final class MapCanvas extends JPanel {
@Override @Override
public void mouseReleased(MouseEvent event) { public void mouseReleased(MouseEvent event) {
dragOrigin = null; dragOrigin = null;
draggingDivider = false;
if (event.isPopupTrigger()) { if (event.isPopupTrigger()) {
showContextMenu(event); showContextMenu(event);
} }
@@ -375,7 +519,7 @@ final class MapCanvas extends JPanel {
@Override @Override
public void mouseExited(MouseEvent event) { public void mouseExited(MouseEvent event) {
cursor = null; cursor = null;
hoverInfo = null; clearHover();
hoverTimer.stop(); hoverTimer.stop();
refreshStatus(); refreshStatus();
repaint(); repaint();
@@ -391,6 +535,7 @@ final class MapCanvas extends JPanel {
int blockX = floorWorldCoordinate(screenToWorldX(point.x)); int blockX = floorWorldCoordinate(screenToWorldX(point.x));
int blockZ = floorWorldCoordinate(screenToWorldZ(point.y)); int blockZ = floorWorldCoordinate(screenToWorldZ(point.y));
JPopupMenu menu = new JPopupMenu(); JPopupMenu menu = new JPopupMenu();
addHighlightItems(menu, tilePixelAt(point));
menu.add(menuItem("Show palettes here", () -> showPalettesAt(point))); menu.add(menuItem("Show palettes here", () -> showPalettesAt(point)));
menu.add(menuItem("Teleport here", () -> teleportTo(point))); menu.add(menuItem("Teleport here", () -> teleportTo(point)));
menu.add(menuItem(currentType == RenderType.REGION ? "Open region file" : "Open biome file", () -> openInEditor(point))); menu.add(menuItem(currentType == RenderType.REGION ? "Open region file" : "Open biome file", () -> openInEditor(point)));
@@ -399,6 +544,30 @@ final class MapCanvas extends JPanel {
menu.show(this, point.x, point.y); menu.show(this, point.x, point.y);
} }
private void addHighlightItems(JPopupMenu menu, VisionRenderController.TilePixel pixel) {
MapLabels.Label subject = pixel == null ? null : pixel.subject();
MapLabels.Label region = pixel == null ? null : pixel.region();
if (subject != null) {
menu.add(menuItem(highlightText(subject), () -> toggleHighlight(subject)));
}
if (region != null && subject != null && subject.kind() != MapLabels.Kind.REGION) {
menu.add(menuItem(highlightText(region), () -> toggleHighlight(region)));
}
if (style.highlight() != null) {
menu.add(menuItem("Clear highlight", this::clearHighlight));
}
if (menu.getComponentCount() > 0) {
menu.addSeparator();
}
}
private String highlightText(MapLabels.Label label) {
MapStyle.Highlight current = style.highlight();
boolean highlighted = current != null && current.kind() == label.kind() && current.key().equals(label.key());
String kind = label.kind() == MapLabels.Kind.REGION ? "region " : "";
return (highlighted ? "Stop highlighting " : "Highlight ") + kind + label.name();
}
private static JMenuItem menuItem(String text, Runnable action) { private static JMenuItem menuItem(String text, Runnable action) {
JMenuItem item = new JMenuItem(text); JMenuItem item = new JMenuItem(text);
item.addActionListener(event -> action.run()); item.addActionListener(event -> action.run());
@@ -459,7 +628,17 @@ final class MapCanvas extends JPanel {
// The display server gets one finished picture per paint: handing it every tile separately made the map lag. // The display server gets one finished picture per paint: handing it every tile separately made the map lag.
private void renderTiles(Graphics2D canvas) { private void renderTiles(Graphics2D canvas) {
canvas.drawImage(currentTileLayer(), 0, 0, null); BufferedImage layer = currentTileLayer();
if (!comparison.isShowing()) {
canvas.drawImage(layer, 0, 0, null);
return;
}
boolean complete = controller.progress(renderFrame).completion() >= 1D;
comparison.paint(canvas, layer, tileLayerVersion, currentScene(), complete);
}
private HotloadComparison.Scene currentScene() {
return new HotloadComparison.Scene(currentType, style, blocksPerPixel, centerX, centerZ);
} }
private BufferedImage currentTileLayer() { private BufferedImage currentTileLayer() {
@@ -478,6 +657,7 @@ final class MapCanvas extends JPanel {
layer.dispose(); layer.dispose();
} }
tileLayerStale = false; tileLayerStale = false;
tileLayerVersion++;
} }
return tileLayer; return tileLayer;
} }
@@ -498,9 +678,8 @@ final class MapCanvas extends JPanel {
int screenY = (int) Math.round(worldToScreenZ(worldZ)); int screenY = (int) Math.round(worldToScreenZ(worldZ));
int tileWidth = Math.max(1, (int) Math.round(worldToScreenX(worldX + frameTileSpan)) - screenX); int tileWidth = Math.max(1, (int) Math.round(worldToScreenX(worldX + frameTileSpan)) - screenX);
int tileHeight = Math.max(1, (int) Math.round(worldToScreenZ(worldZ + frameTileSpan)) - screenY); int tileHeight = Math.max(1, (int) Math.round(worldToScreenZ(worldZ + frameTileSpan)) - screenY);
BufferedImage image = tile.image(); if (tile.tile() != null) {
if (image != null) { canvas.drawImage(tile.tile().image(style), screenX, screenY, tileWidth, tileHeight, null);
canvas.drawImage(image, screenX, screenY, tileWidth, tileHeight, null);
continue; continue;
} }
canvas.setColor(((tile.tileX() ^ tile.tileZ()) & 1L) == 0L ? TILE_EMPTY_A : TILE_EMPTY_B); canvas.setColor(((tile.tileX() ^ tile.tileZ()) & 1L) == 0L ? TILE_EMPTY_A : TILE_EMPTY_B);
@@ -509,7 +688,7 @@ final class MapCanvas extends JPanel {
} }
} }
private static void drawFallback(Graphics2D canvas, VisionRenderController.Fallback fallback, int screenX, int screenY, private void drawFallback(Graphics2D canvas, VisionRenderController.Fallback fallback, int screenX, int screenY,
int tileWidth, int tileHeight) { int tileWidth, int tileHeight) {
if (fallback == null) { if (fallback == null) {
return; return;
@@ -519,7 +698,7 @@ final class MapCanvas extends JPanel {
int top = screenY + (int) Math.round(piece.offsetZ() * tileHeight); int top = screenY + (int) Math.round(piece.offsetZ() * tileHeight);
int right = screenX + (int) Math.round((piece.offsetX() + piece.size()) * tileWidth); int right = screenX + (int) Math.round((piece.offsetX() + piece.size()) * tileWidth);
int bottom = screenY + (int) Math.round((piece.offsetZ() + piece.size()) * tileHeight); int bottom = screenY + (int) Math.round((piece.offsetZ() + piece.size()) * tileHeight);
canvas.drawImage(piece.image(), left, top, right, bottom, canvas.drawImage(piece.tile().image(style), left, top, right, bottom,
piece.sourceX(), piece.sourceZ(), piece.sourceX() + piece.sourceSize(), piece.sourceZ() + piece.sourceSize(), null); piece.sourceX(), piece.sourceZ(), piece.sourceX() + piece.sourceSize(), piece.sourceZ() + piece.sourceSize(), null);
} }
} }
@@ -651,30 +830,21 @@ final class MapCanvas extends JPanel {
} }
private void renderHover(Graphics2D canvas) { private void renderHover(Graphics2D canvas) {
HoverInfo info = hoverInfo; QuickHover quick = quickHover;
if (info == null) { if (quick == null || draggingDivider) {
return; return;
} }
ArrayList<String> lines = new ArrayList<>(); MapHoverProbe.Details details = hoverDetails;
lines.add(info.biomeName()); List<String> lines = MapHoverCard.lines(quick.subject(), quick.region(), details, currentType, detailedHover,
lines.add(info.regionName()); quick.worldX(), quick.worldZ());
lines.add("Block " + ((int) info.worldX()) + ", " + ((int) info.worldZ())); int screenX = (int) Math.round(worldToScreenX(quick.worldX())) + StudioTheme.scale(16);
if (detailedHover) { int screenY = (int) Math.round(worldToScreenZ(quick.worldZ()));
lines.add("Chunk " + ((int) info.worldX() >> 4) + ", " + ((int) info.worldZ() >> 4));
lines.add("Region file " + ((int) info.worldX() >> 9) + ", " + ((int) info.worldZ() >> 9));
lines.add("Key " + info.biomeKey());
lines.add("File " + info.biomeFile());
} else {
lines.add("Hold Shift for more, double-click for palettes");
}
int screenX = (int) Math.round(worldToScreenX(info.worldX())) + StudioTheme.scale(16);
int screenY = (int) Math.round(worldToScreenZ(info.worldZ()));
drawCard(canvas, screenX, screenY, 0D, 0D, lines); drawCard(canvas, screenX, screenY, 0D, 0D, lines);
} }
private void renderHelp(Graphics2D canvas) { private void renderHelp(Graphics2D canvas) {
List<String> keys = List.of("Drag", "Wheel / + -", "\\", "Double-click", "Right-click", "Ctrl+Click", "Alt+Click", List<String> keys = List.of("Drag", "Wheel / + -", "\\", "Double-click", "Right-click", "Ctrl+Click", "Alt+Click",
"Shift", "R", "F", "G", "E", "M", "1 to 0, P", "/"); "Shift", "H", "Esc", "S", "B", "C", "R", "F", "G", "E", "M", "1 to 0, P", "/");
List<String> descriptions = List.of( List<String> descriptions = List.of(
"Move the map", "Move the map",
"Zoom in and out", "Zoom in and out",
@@ -684,6 +854,11 @@ final class MapCanvas extends JPanel {
"Teleport there", "Teleport there",
"Open the biome file", "Open the biome file",
"More details while hovering", "More details while hovering",
"Highlight what is under the cursor",
"Clear the highlight",
"Terrain shading",
"Region borders",
"Compare with before the last hotload",
"Redraw the map", "Redraw the map",
"Follow your player", "Follow your player",
"Chunk and region grid", "Chunk and region grid",
@@ -776,6 +951,28 @@ final class MapCanvas extends JPanel {
tileLayerStale = true; tileLayerStale = true;
repaint(); repaint();
refreshStatus(); refreshStatus();
scheduleLegendUpdate();
if (quickHover != null && quickHover.subject() == null) {
showQuickHover();
}
}
// At most one count per interval however fast tiles arrive, so the legend stays live without costing frames.
private void scheduleLegendUpdate() {
if (!legendTimer.isRunning()) {
legendTimer.start();
}
}
private void publishLegend() {
if (closed || !active) {
return;
}
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));
listener.onLegendChanged(legendTitle(), shares, style.highlight());
} }
private void requestRender() { private void requestRender() {
@@ -784,7 +981,7 @@ final class MapCanvas extends JPanel {
repaint(); repaint();
return; return;
} }
hoverInfo = null; clearHover();
VisionRenderController.RenderSpec spec = new VisionRenderController.RenderSpec( VisionRenderController.RenderSpec spec = new VisionRenderController.RenderSpec(
renderer, renderer,
currentType, currentType,
@@ -793,11 +990,13 @@ final class MapCanvas extends JPanel {
centerZ, centerZ,
blocksPerPixel, blocksPerPixel,
getWidth(), getWidth(),
getHeight() getHeight(),
needsHeights()
); );
renderFrame = controller.request(spec); renderFrame = controller.request(spec);
repaint(); repaint();
refreshStatus(); refreshStatus();
scheduleLegendUpdate();
} }
private void refreshStatus() { private void refreshStatus() {
@@ -894,25 +1093,38 @@ final class MapCanvas extends JPanel {
double worldZ = screenToWorldZ(point.y); double worldZ = screenToWorldZ(point.y);
controller.submitProbe( controller.submitProbe(
frame, frame,
() -> sampleHover(probeEngine, worldX, worldZ), () -> MapHoverProbe.sample(probeEngine, worldX, worldZ),
info -> { details -> {
hoverInfo = info; QuickHover quick = quickHover;
repaint(); if (quick != null && quick.worldX() == details.worldX() && quick.worldZ() == details.worldZ()) {
hoverDetails = details;
repaint();
}
} }
); );
} }
private static HoverInfo sampleHover(Engine engine, double worldX, double worldZ) { // The drawn tiles already know the biome and region under the cursor, so the card follows the mouse without waiting.
IrisBiome biome = engine.getComplex().getBaseBiomeStream().get(worldX, worldZ); private void showQuickHover() {
IrisRegion region = engine.getComplex().getRegionStream().get(worldX, worldZ); Point point = cursor;
return new HoverInfo( if (point == null || dragOrigin != null) {
worldX, return;
worldZ, }
biome.getName(), double worldX = screenToWorldX(point.x);
region.getName(), double worldZ = screenToWorldZ(point.y);
biome.getLoadKey(), VisionRenderController.TilePixel pixel = tilePixelAt(point);
String.valueOf(biome.getLoadFile()) quickHover = new QuickHover(worldX, worldZ, pixel == null ? null : pixel.subject(), pixel == null ? null : pixel.region());
); repaint();
}
private void clearHover() {
quickHover = null;
hoverDetails = null;
}
private VisionRenderController.TilePixel tilePixelAt(Point point) {
VisionRenderController.Frame frame = renderFrame;
return frame == null ? null : frame.pixelAt(screenToWorldX(point.x), screenToWorldZ(point.y));
} }
private void stopFollowing() { private void stopFollowing() {
@@ -963,7 +1175,7 @@ final class MapCanvas extends JPanel {
centerZ = next.centerZ(); centerZ = next.centerZ();
blocksPerPixel = next.blocksPerPixel(); blocksPerPixel = next.blocksPerPixel();
stopFollowing(); stopFollowing();
hoverInfo = null; clearHover();
requestRender(); requestRender();
} }
@@ -1017,15 +1229,10 @@ final class MapCanvas extends JPanel {
void onTogglesChanged(); void onTogglesChanged();
void onShowPalettesAt(double worldX, double worldZ); void onShowPalettesAt(double worldX, double worldZ);
void onLegendChanged(String title, List<MapCensus.Share> shares, MapStyle.Highlight highlight);
} }
private record HoverInfo( private record QuickHover(double worldX, double worldZ, MapLabels.Label subject, MapLabels.Label region) {
double worldX,
double worldZ,
String biomeName,
String regionName,
String biomeKey,
String biomeFile
) {
} }
} }
@@ -0,0 +1,109 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.volmit.iris.core.gui;
import com.volmit.iris.core.gui.components.MapLabels;
import com.volmit.iris.core.gui.components.MapTile;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
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.
*/
final class MapCensus {
private static final long MAXIMUM_COUNTED_PIXELS = 250_000L;
private MapCensus() {
}
static List<Share> count(VisionRenderController.Frame frame, double minimumX, double minimumZ, double maximumX,
double maximumZ, ToIntFunction<MapLabels.Label> colorOf) {
if (frame == null) {
return List.of();
}
double blocksPerPixel = frame.renderBlocksPerPixel();
int stride = countingStride(maximumX - minimumX, maximumZ - minimumZ, blocksPerPixel);
double tileSpan = VisionRenderController.TILE_PIXELS * blocksPerPixel;
Map<Short, long[]> counts = new HashMap<>();
MapLabels labels = null;
long counted = 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());
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++;
}
}
}
return shares(counts, labels, counted, colorOf);
}
private static List<Share> shares(Map<Short, long[]> counts, MapLabels labels, long counted,
ToIntFunction<MapLabels.Label> colorOf) {
if (labels == null || counted == 0L) {
return List.of();
}
List<Share> shares = new ArrayList<>(counts.size());
for (Map.Entry<Short, long[]> entry : counts.entrySet()) {
MapLabels.Label label = labels.label(entry.getKey());
if (label != null) {
shares.add(new Share(label, colorOf.applyAsInt(label), entry.getValue()[0] / (double) counted));
}
}
shares.sort(Comparator.comparingDouble(Share::fraction).reversed().thenComparing(share -> share.label().name()));
return List.copyOf(shares);
}
// Counting every pixel of a big screen is wasted work; an evenly spread sample gives the same percentages.
private static int countingStride(double widthBlocks, double heightBlocks, double blocksPerPixel) {
double pixels = (widthBlocks / blocksPerPixel) * (heightBlocks / blocksPerPixel);
return Math.max(1, (int) Math.ceil(Math.sqrt(pixels / MAXIMUM_COUNTED_PIXELS)));
}
// Starting on the global stride grid keeps neighbouring tiles from sampling the same columns twice as densely.
private static int firstPixel(double offsetBlocks, double blocksPerPixel, int stride, long tileIndex) {
int first = Math.max(0, (int) Math.ceil(offsetBlocks / blocksPerPixel));
long globalPixel = tileIndex * VisionRenderController.TILE_PIXELS + first;
int misalignment = (int) Math.floorMod(globalPixel, (long) stride);
return misalignment == 0 ? first : first + stride - misalignment;
}
private static int endPixel(double offsetBlocks, double blocksPerPixel, int size) {
return Math.min(size, (int) Math.ceil(offsetBlocks / blocksPerPixel));
}
record Share(MapLabels.Label label, int color, double fraction) {
}
}
@@ -0,0 +1,112 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.volmit.iris.core.gui;
import com.volmit.iris.core.gui.components.MapLabels;
import com.volmit.iris.core.gui.components.RenderType;
import com.volmit.iris.core.loader.IrisRegistrant;
import com.volmit.iris.engine.object.IrisBiome;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
/**
* The text of the card that follows the cursor: what the drawn map shows there at once, and the rest once probed.
*/
final class MapHoverCard {
private MapHoverCard() {
}
/**
* @param subject the biome, or region in the region view, the drawn tile shows; null while that tile is drawing
* @param details the probed facts about the spot, or null until they arrive
* @param detailed whether Shift is held, which adds keys and files
*/
static List<String> lines(MapLabels.Label subject, MapLabels.Label region, MapHoverProbe.Details details,
RenderType type, boolean detailed, double worldX, double worldZ) {
List<String> lines = new ArrayList<>();
lines.add(title(subject, details));
if (type != RenderType.REGION) {
String regionName = region != null ? region.name() : details != null ? details.region().getName() : null;
if (regionName != null) {
lines.add("Region " + regionName);
}
}
if (details != null) {
addTerrain(lines, details, type);
}
int blockX = MapCanvas.floorWorldCoordinate(worldX);
int blockZ = MapCanvas.floorWorldCoordinate(worldZ);
lines.add("Block " + blockX + ", " + blockZ + " · chunk " + (blockX >> 4) + ", " + (blockZ >> 4));
if (!detailed) {
lines.add("Shift for keys and files · H highlights");
return lines;
}
addSource(lines, subject);
if (subject != null && subject.kind() != MapLabels.Kind.REGION) {
addSource(lines, region);
}
lines.add("Region file r." + (blockX >> 9) + "." + (blockZ >> 9) + ".mca");
return lines;
}
private static String title(MapLabels.Label subject, MapHoverProbe.Details details) {
if (subject != null) {
return subject.name();
}
if (details != null && details.surfaceBiome() != null) {
return details.surfaceBiome().getName();
}
return "Drawing…";
}
private static void addTerrain(List<String> lines, MapHoverProbe.Details details, RenderType type) {
lines.add("Height " + Math.round(details.height()) + " · sea level " + Math.round(details.fluidHeight()));
String slope = "slope " + Math.round(details.slopeDegrees()) + "°";
lines.add(details.terrainKind() == null ? capitalize(slope) : details.terrainKind() + " · " + slope);
IrisBiome surface = details.surfaceBiome();
IrisBiome cave = details.caveBiome();
if (type == RenderType.CAVE_LAND && surface != null) {
lines.add("Surface biome " + surface.getName());
} else if (type != RenderType.CAVE_LAND && cave != null && cave != surface) {
lines.add("Cave biome " + cave.getName());
}
if (details.vanillaBiome() != null) {
lines.add("Minecraft biome " + details.vanillaBiome().replace('_', ' '));
}
}
private static void addSource(List<String> lines, MapLabels.Label label) {
if (label == null || !(label.subject() instanceof IrisRegistrant registrant)) {
return;
}
String kind = label.kind() == MapLabels.Kind.REGION ? "Region" : "Biome";
lines.add(kind + " key " + label.key());
File file = registrant.getLoadFile();
if (file != null) {
File folder = file.getParentFile();
lines.add(kind + " file " + (folder == null ? "" : folder.getName() + "/") + file.getName());
}
}
private static String capitalize(String text) {
return Character.toUpperCase(text.charAt(0)) + text.substring(1);
}
}
@@ -0,0 +1,90 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.volmit.iris.core.gui;
import com.volmit.iris.engine.IrisComplex;
import com.volmit.iris.engine.framework.Engine;
import com.volmit.iris.engine.object.InferredType;
import com.volmit.iris.engine.object.IrisBiome;
import com.volmit.iris.engine.object.IrisDimension;
import com.volmit.iris.engine.object.IrisRegion;
import org.bukkit.block.Biome;
import java.util.Locale;
/**
* Looks up everything the hover card can tell about one spot of the world; runs off the Swing thread.
*/
final class MapHoverProbe {
// IrisComplex measures slope as the height change over this many blocks.
private static final double SLOPE_STREAM_RANGE_BLOCKS = 3D;
private MapHoverProbe() {
}
static Details sample(Engine engine, double worldX, double worldZ) {
IrisComplex complex = engine.getComplex();
IrisBiome surfaceBiome = complex.getBaseBiomeStream().get(worldX, worldZ);
IrisBiome caveBiome = complex.getCaveBiomeStream().get(worldX, worldZ);
IrisRegion region = complex.getRegionStream().get(worldX, worldZ);
InferredType terrainKind = complex.getBridgeStream().get(worldX, worldZ);
Biome vanillaBiome = complex.getTrueBiomeDerivativeStream().get(worldX, worldZ);
double height = complex.getHeightStream().getDouble(worldX, worldZ);
double slope = complex.getSlopeStream().getDouble(worldX, worldZ);
IrisDimension dimension = engine.getDimension();
return new Details(
worldX,
worldZ,
surfaceBiome,
caveBiome,
region,
terrainKindName(surfaceBiome, terrainKind),
vanillaBiome == null ? null : vanillaBiome.getKey().getKey(),
height,
dimension == null ? 0D : dimension.getFluidHeight(),
Math.toDegrees(Math.atan(slope / SLOPE_STREAM_RANGE_BLOCKS))
);
}
// A shore biome sits on land the continent stream calls land or sea, so the biome's own type says it best.
private static String terrainKindName(IrisBiome surfaceBiome, InferredType continentKind) {
InferredType kind = surfaceBiome != null && surfaceBiome.getInferredType() != null
? surfaceBiome.getInferredType()
: continentKind;
if (kind == null) {
return null;
}
String name = kind.name().toLowerCase(Locale.ROOT);
return Character.toUpperCase(name.charAt(0)) + name.substring(1);
}
record Details(
double worldX,
double worldZ,
IrisBiome surfaceBiome,
IrisBiome caveBiome,
IrisRegion region,
String terrainKind,
String vanillaBiome,
double height,
double fluidHeight,
double slopeDegrees
) {
}
}
@@ -0,0 +1,204 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.volmit.iris.core.gui;
import com.volmit.iris.core.gui.components.MapStyle;
import javax.swing.BorderFactory;
import javax.swing.DefaultListCellRenderer;
import javax.swing.DefaultListModel;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JPanel;
import javax.swing.ListSelectionModel;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.List;
import java.util.Locale;
import java.util.function.Consumer;
/**
* The biomes or regions on screen with how much of it each covers; clicking one highlights it on the map.
*/
final class MapLegendPanel extends JPanel {
private static final long serialVersionUID = 4471297416386034128L;
private static final int PANEL_WIDTH = 250;
private static final int ROW_HEIGHT = 26;
private static final int SWATCH_SIZE = 12;
private static final int SWATCH_GAP = 8;
private static final int ROW_PADDING = 10;
private static final int PERCENT_WIDTH = 42;
private static final String DRAWING_MESSAGE = "Drawing the map…";
private final JLabel title = new JLabel();
private final DefaultListModel<MapCensus.Share> rows = new DefaultListModel<>();
private final JList<MapCensus.Share> list = new JList<>(rows);
private MapStyle.Highlight highlight;
MapLegendPanel(Consumer<MapCensus.Share> onRowClicked) {
super(new BorderLayout());
setPreferredSize(StudioTheme.scaledSize(PANEL_WIDTH, 0));
setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, StudioTheme.SEPARATOR));
setBackground(StudioTheme.PANEL_BACKGROUND);
title.setFont(StudioTheme.sectionFont());
title.setForeground(StudioTheme.MUTED_TEXT);
title.setBorder(StudioTheme.emptyBorder(10, 16, 6, 16));
add(title, BorderLayout.NORTH);
list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
list.setFocusable(false);
list.setFixedCellHeight(StudioTheme.scale(ROW_HEIGHT));
list.setCellRenderer(new ShareRenderer());
list.setBackground(StudioTheme.PANEL_BACKGROUND);
list.setBorder(StudioTheme.emptyBorder(0, 6, 4, 6));
list.addMouseListener(new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent event) {
MapCensus.Share share = shareAt(event.getPoint());
if (share != null) {
onRowClicked.accept(share);
}
}
});
add(StudioToolbar.scroll(list), BorderLayout.CENTER);
JLabel hint = new JLabel("Click to highlight · Esc clears");
hint.setFont(StudioTheme.smallFont());
hint.setForeground(StudioTheme.MUTED_TEXT);
hint.setBorder(StudioTheme.emptyBorder(6, 16, 8, 16));
add(hint, BorderLayout.SOUTH);
}
void show(String subjectTitle, List<MapCensus.Share> shares, MapStyle.Highlight nextHighlight) {
title.setText(subjectTitle.toUpperCase(Locale.ROOT));
highlight = nextHighlight;
if (!sameRows(shares)) {
rows.clear();
rows.addAll(shares);
}
list.repaint();
}
private boolean sameRows(List<MapCensus.Share> shares) {
if (rows.size() != shares.size()) {
return false;
}
for (int index = 0; index < shares.size(); index++) {
if (!rows.get(index).equals(shares.get(index))) {
return false;
}
}
return true;
}
private MapCensus.Share shareAt(Point point) {
int index = list.locationToIndex(point);
if (index < 0) {
return null;
}
Rectangle bounds = list.getCellBounds(index, index);
return bounds != null && bounds.contains(point) ? rows.get(index) : null;
}
private boolean isHighlighted(MapCensus.Share share) {
return highlight != null
&& highlight.kind() == share.label().kind()
&& highlight.key().equals(share.label().key());
}
@Override
protected void paintChildren(Graphics graphics) {
super.paintChildren(graphics);
if (!rows.isEmpty()) {
return;
}
Graphics2D canvas = (Graphics2D) graphics.create();
try {
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
canvas.setFont(StudioTheme.font());
canvas.setColor(StudioTheme.MUTED_TEXT);
canvas.drawString(DRAWING_MESSAGE, StudioTheme.scale(16),
title.getHeight() + StudioTheme.scale(4) + canvas.getFontMetrics().getAscent());
} finally {
canvas.dispose();
}
}
private final class ShareRenderer extends DefaultListCellRenderer {
private static final long serialVersionUID = -2350212934617052618L;
private Color swatch = Color.BLACK;
private String percent = "";
@Override
public Component getListCellRendererComponent(JList<?> owner, Object value, int index, boolean selected, boolean focused) {
MapCensus.Share share = (MapCensus.Share) value;
super.getListCellRendererComponent(owner, share.label().name(), index, isHighlighted(share), false);
setFont(StudioTheme.font());
setBorder(StudioTheme.emptyBorder(0, ROW_PADDING + SWATCH_SIZE + SWATCH_GAP, 0, ROW_PADDING + PERCENT_WIDTH));
setToolTipText(share.label().key());
swatch = new Color(share.color());
percent = formatPercent(share.fraction());
return this;
}
@Override
protected void paintComponent(Graphics graphics) {
super.paintComponent(graphics);
Graphics2D canvas = (Graphics2D) graphics.create();
try {
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
int size = StudioTheme.scale(SWATCH_SIZE);
int swatchX = StudioTheme.scale(ROW_PADDING);
int swatchY = (getHeight() - size) / 2;
int arc = StudioTheme.scale(4);
canvas.setColor(swatch);
canvas.fillRoundRect(swatchX, swatchY, size, size, arc, arc);
canvas.setColor(StudioTheme.CONTROL_BORDER);
canvas.drawRoundRect(swatchX, swatchY, size - 1, size - 1, arc, arc);
canvas.setFont(StudioTheme.smallFont());
canvas.setColor(StudioTheme.SECONDARY_TEXT);
int textWidth = canvas.getFontMetrics().stringWidth(percent);
int baseline = (getHeight() + canvas.getFontMetrics().getAscent() - canvas.getFontMetrics().getDescent()) / 2;
canvas.drawString(percent, getWidth() - StudioTheme.scale(ROW_PADDING) - textWidth, baseline);
} finally {
canvas.dispose();
}
}
}
private static String formatPercent(double fraction) {
double percent = fraction * 100D;
if (percent < 0.1D) {
return "<0.1%";
}
return percent < 10D
? String.format(Locale.ROOT, "%.1f%%", percent)
: String.format(Locale.ROOT, "%.0f%%", percent);
}
}
@@ -18,6 +18,8 @@
package com.volmit.iris.core.gui; package com.volmit.iris.core.gui;
import com.volmit.iris.core.gui.components.MapStyle;
import javax.swing.AbstractAction; import javax.swing.AbstractAction;
import javax.swing.BorderFactory; import javax.swing.BorderFactory;
import javax.swing.Box; import javax.swing.Box;
@@ -30,6 +32,7 @@ import javax.swing.JSplitPane;
import javax.swing.JToggleButton; import javax.swing.JToggleButton;
import javax.swing.JToolBar; import javax.swing.JToolBar;
import javax.swing.KeyStroke; import javax.swing.KeyStroke;
import javax.swing.SwingConstants;
import javax.swing.text.JTextComponent; import javax.swing.text.JTextComponent;
import java.awt.BorderLayout; import java.awt.BorderLayout;
import java.awt.CardLayout; import java.awt.CardLayout;
@@ -68,6 +71,13 @@ public final class MapWindow implements MapCanvas.Listener {
private final JToggleButton gridToggle = StudioToolbar.toggle("Grid", "Show chunk and region borders (G)"); private final JToggleButton gridToggle = StudioToolbar.toggle("Grid", "Show chunk and region borders (G)");
private final JToggleButton entitiesToggle = StudioToolbar.toggle("Entities", "Show mobs and other entities (E)"); private final JToggleButton entitiesToggle = StudioToolbar.toggle("Entities", "Show mobs and other entities (E)");
private final JToggleButton followToggle = StudioToolbar.toggle("Follow", "Keep your player in the middle (F)"); private final JToggleButton followToggle = StudioToolbar.toggle("Follow", "Keep your player in the middle (F)");
private final JToggleButton shadingToggle = StudioToolbar.toggle("Shading", "Shade hills and valleys on every view (S)");
private final JToggleButton bordersToggle = StudioToolbar.toggle("Borders", "Outline the regions on every view (B)");
private final JToggleButton swipeToggle = StudioToolbar.toggle("Before / after",
"Split the map between before and after the last hotload or refresh (C)");
private final JToggleButton changesToggle = StudioToolbar.toggle("Changes",
"Fade out everything the last hotload or refresh left the same (C twice)");
private final MapLegendPanel legendPanel;
private NoiseExplorerGUI paletteExplorer; private NoiseExplorerGUI paletteExplorer;
private MapView currentView; private MapView currentView;
@@ -80,6 +90,7 @@ public final class MapWindow implements MapCanvas.Listener {
this.canvas = new MapCanvas(start.engine(), start.playerId(), this); this.canvas = new MapCanvas(start.engine(), start.playerId(), this);
this.viewList = new MapViewList(this::showView); this.viewList = new MapViewList(this::showView);
this.viewListScroll = StudioToolbar.scroll(viewList); this.viewListScroll = StudioToolbar.scroll(viewList);
this.legendPanel = new MapLegendPanel(share -> canvas.toggleHighlight(share.label()));
canvas.centerOn(start.centerX(), start.centerZ()); canvas.centerOn(start.centerX(), start.centerZ());
buildFrame(); buildFrame();
} }
@@ -136,9 +147,21 @@ public final class MapWindow implements MapCanvas.Listener {
mapControls.add(gridToggle); mapControls.add(gridToggle);
mapControls.add(entitiesToggle); mapControls.add(entitiesToggle);
mapControls.add(followToggle); mapControls.add(followToggle);
mapControls.add(toolbarSeparator());
mapControls.add(shadingToggle);
mapControls.add(bordersToggle);
mapControls.add(toolbarSeparator());
mapControls.add(swipeToggle);
mapControls.add(changesToggle);
gridToggle.addActionListener(event -> canvas.toggleGrid()); gridToggle.addActionListener(event -> canvas.toggleGrid());
entitiesToggle.addActionListener(event -> canvas.toggleEntities()); entitiesToggle.addActionListener(event -> canvas.toggleEntities());
followToggle.addActionListener(event -> canvas.toggleFollow()); followToggle.addActionListener(event -> canvas.toggleFollow());
shadingToggle.addActionListener(event -> canvas.toggleShading());
bordersToggle.addActionListener(event -> canvas.toggleRegionBorders());
swipeToggle.addActionListener(event -> canvas.showComparison(
swipeToggle.isSelected() ? HotloadComparison.Mode.SWIPE : HotloadComparison.Mode.OFF));
changesToggle.addActionListener(event -> canvas.showComparison(
changesToggle.isSelected() ? HotloadComparison.Mode.CHANGES : HotloadComparison.Mode.OFF));
mapControls.forEach(toolbar::add); mapControls.forEach(toolbar::add);
paletteControls.add(StudioToolbar.button("Reset view", "Go back to the starting position and zoom (R)", this::resetPaletteView)); paletteControls.add(StudioToolbar.button("Reset view", "Go back to the starting position and zoom (R)", this::resetPaletteView));
@@ -151,6 +174,12 @@ public final class MapWindow implements MapCanvas.Listener {
return toolbar; return toolbar;
} }
private static JComponent toolbarSeparator() {
JToolBar.Separator separator = new JToolBar.Separator();
separator.setOrientation(SwingConstants.VERTICAL);
return separator;
}
private void showView(MapView view) { private void showView(MapView view) {
if (view == null || view == currentView) { if (view == null || view == currentView) {
return; return;
@@ -179,6 +208,7 @@ public final class MapWindow implements MapCanvas.Listener {
setControlsVisible(paletteControls, false); setControlsVisible(paletteControls, false);
useSidebar(viewListScroll); useSidebar(viewListScroll);
inspectorSlot.removeAll(); inspectorSlot.removeAll();
inspectorSlot.add(legendPanel, BorderLayout.CENTER);
showCard(MAP_CARD); showCard(MAP_CARD);
canvas.requestFocusInWindow(); canvas.requestFocusInWindow();
} }
@@ -265,6 +295,15 @@ public final class MapWindow implements MapCanvas.Listener {
gridToggle.setSelected(canvas.isGridVisible()); gridToggle.setSelected(canvas.isGridVisible());
entitiesToggle.setSelected(canvas.areEntitiesVisible()); entitiesToggle.setSelected(canvas.areEntitiesVisible());
followToggle.setSelected(canvas.isFollowing()); followToggle.setSelected(canvas.isFollowing());
shadingToggle.setSelected(canvas.style().shading());
bordersToggle.setSelected(canvas.style().regionBorders());
swipeToggle.setSelected(canvas.comparisonMode() == HotloadComparison.Mode.SWIPE);
changesToggle.setSelected(canvas.comparisonMode() == HotloadComparison.Mode.CHANGES);
}
@Override
public void onLegendChanged(String title, List<MapCensus.Share> shares, MapStyle.Highlight highlight) {
legendPanel.show(title, shares, highlight);
} }
private void resetPaletteView() { private void resetPaletteView() {
@@ -297,6 +336,11 @@ public final class MapWindow implements MapCanvas.Listener {
bindMap(rootPane, KeyEvent.VK_G, 0, "grid", canvas::toggleGrid); bindMap(rootPane, KeyEvent.VK_G, 0, "grid", canvas::toggleGrid);
bindMap(rootPane, KeyEvent.VK_E, 0, "entities", canvas::toggleEntities); bindMap(rootPane, KeyEvent.VK_E, 0, "entities", canvas::toggleEntities);
bindMap(rootPane, KeyEvent.VK_F3, 0, "debug", canvas::toggleDebug); bindMap(rootPane, KeyEvent.VK_F3, 0, "debug", canvas::toggleDebug);
bindMap(rootPane, KeyEvent.VK_H, 0, "highlight", canvas::highlightUnderCursor);
bindMap(rootPane, KeyEvent.VK_ESCAPE, 0, "dismiss", canvas::dismissEmphasis);
bindMap(rootPane, KeyEvent.VK_S, 0, "shading", canvas::toggleShading);
bindMap(rootPane, KeyEvent.VK_B, 0, "borders", canvas::toggleRegionBorders);
bindMap(rootPane, KeyEvent.VK_C, 0, "compare", canvas::cycleComparison);
bindMap(rootPane, KeyEvent.VK_EQUALS, 0, "zoom-in", canvas::zoomIn); bindMap(rootPane, KeyEvent.VK_EQUALS, 0, "zoom-in", canvas::zoomIn);
bindMap(rootPane, KeyEvent.VK_EQUALS, KeyEvent.SHIFT_DOWN_MASK, "zoom-in-plus", canvas::zoomIn); bindMap(rootPane, KeyEvent.VK_EQUALS, KeyEvent.SHIFT_DOWN_MASK, "zoom-in-plus", canvas::zoomIn);
bindMap(rootPane, KeyEvent.VK_ADD, 0, "zoom-in-keypad", canvas::zoomIn); bindMap(rootPane, KeyEvent.VK_ADD, 0, "zoom-in-keypad", canvas::zoomIn);
@@ -20,10 +20,11 @@ package com.volmit.iris.core.gui;
import com.volmit.iris.Iris; import com.volmit.iris.Iris;
import com.volmit.iris.core.gui.components.IrisRenderer; import com.volmit.iris.core.gui.components.IrisRenderer;
import com.volmit.iris.core.gui.components.MapLabels;
import com.volmit.iris.core.gui.components.MapTile;
import com.volmit.iris.core.gui.components.RenderType; import com.volmit.iris.core.gui.components.RenderType;
import java.awt.EventQueue; import java.awt.EventQueue;
import java.awt.image.BufferedImage;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
@@ -60,7 +61,7 @@ final class VisionRenderController implements AutoCloseable {
private static final int MAXIMUM_VISIBLE_TILES = 32_768; private static final int MAXIMUM_VISIBLE_TILES = 32_768;
private static final int PROBE_QUEUE_CAPACITY = 1; private static final int PROBE_QUEUE_CAPACITY = 1;
private static final int COARSER_FALLBACK_LEVELS = 6; private static final int COARSER_FALLBACK_LEVELS = 6;
private static final long CACHE_BYTES = 128L * 1024L * 1024L; private static final long CACHE_BYTES = 256L * 1024L * 1024L;
private final Runnable listener; private final Runnable listener;
private final int workerCount; private final int workerCount;
@@ -119,7 +120,7 @@ final class VisionRenderController implements AutoCloseable {
List<VisibleTile> tiles = visibleTiles(spec, renderScale); List<VisibleTile> tiles = visibleTiles(spec, renderScale);
Frame frame = new Frame(viewSequence.incrementAndGet(), spec, renderScale, tiles); Frame frame = new Frame(viewSequence.incrementAndGet(), spec, renderScale, tiles);
for (VisibleTile tile : tiles) { for (VisibleTile tile : tiles) {
tile.setImage(cache.get(tile.key())); tile.setTile(cachedTileServing(tile.key()));
} }
currentFrame = frame; currentFrame = frame;
probeSequence.incrementAndGet(); probeSequence.incrementAndGet();
@@ -133,17 +134,30 @@ final class VisionRenderController implements AutoCloseable {
* A tile from another zoom level covering the given one, to draw while the real tile renders. * A tile from another zoom level covering the given one, to draw while the real tile renders.
*/ */
Fallback fallback(Frame frame, VisibleTile tile) { Fallback fallback(Frame frame, VisibleTile tile) {
MapTile withoutHeights = cache.get(tile.key().withHeights(false));
if (withoutHeights != null) {
return new Fallback(List.of(new FallbackPiece(withoutHeights, 0, 0, TILE_PIXELS, 0D, 0D, 1D)));
}
Fallback coarser = coarserFallback(frame, tile); Fallback coarser = coarserFallback(frame, tile);
return coarser != null ? coarser : finerFallback(frame, tile); return coarser != null ? coarser : finerFallback(frame, tile);
} }
// 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);
if (exact != null || key.heights()) {
return exact;
}
return cache.get(key.withHeights(true));
}
Progress progress(Frame frame) { Progress progress(Frame frame) {
if (frame == null) { if (frame == null) {
return new Progress(0, 0, 0); return new Progress(0, 0, 0);
} }
int ready = 0; int ready = 0;
for (VisibleTile tile : frame.tiles()) { for (VisibleTile tile : frame.tiles()) {
if (tile.image() != null) { if (tile.tile() != null) {
ready++; ready++;
} }
} }
@@ -198,7 +212,7 @@ final class VisionRenderController implements AutoCloseable {
for (long tileX = minimumX; tileX <= maximumX; tileX++) { for (long tileX = minimumX; tileX <= maximumX; tileX++) {
double deltaX = tileX + 0.5D - centerTileX; double deltaX = tileX + 0.5D - centerTileX;
double deltaZ = tileZ + 0.5D - centerTileZ; double deltaZ = tileZ + 0.5D - centerTileZ;
TileKey key = new TileKey(spec.contentRevision(), spec.type(), renderScale, tileX, tileZ); TileKey key = new TileKey(spec.contentRevision(), spec.type(), spec.heights(), renderScale, tileX, tileZ);
tiles.add(new VisibleTile(key, deltaX * deltaX + deltaZ * deltaZ)); tiles.add(new VisibleTile(key, deltaX * deltaX + deltaZ * deltaZ));
} }
} }
@@ -283,19 +297,20 @@ final class VisionRenderController implements AutoCloseable {
TileKey key = job.key(); TileKey key = job.key();
try { try {
double tileSpan = TILE_PIXELS * key.blocksPerPixel(); double tileSpan = TILE_PIXELS * key.blocksPerPixel();
BufferedImage image = job.renderer().renderStudio( MapTile tile = job.renderer().renderTile(
key.tileX() * tileSpan, key.tileX() * tileSpan,
key.tileZ() * tileSpan, key.tileZ() * tileSpan,
tileSpan, key.blocksPerPixel(),
TILE_PIXELS, TILE_PIXELS,
key.type(), key.type(),
key.heights(),
() -> !isWanted(key) () -> !isWanted(key)
); );
cache.put(key, image); cache.put(key, tile);
Frame frame = currentFrame; Frame frame = currentFrame;
VisibleTile visible = frame == null ? null : frame.tile(key); VisibleTile visible = frame == null ? null : frame.tile(key);
if (visible != null) { if (visible != null) {
visible.setImage(image); visible.setTile(tile);
publish(); publish();
} }
} catch (CancellationException ignored) { } catch (CancellationException ignored) {
@@ -322,7 +337,8 @@ final class VisionRenderController implements AutoCloseable {
} }
long parentX = Math.floorDiv(key.tileX(), scale); long parentX = Math.floorDiv(key.tileX(), scale);
long parentZ = Math.floorDiv(key.tileZ(), scale); long parentZ = Math.floorDiv(key.tileZ(), scale);
BufferedImage parent = cache.get(new TileKey(key.contentRevision(), key.type(), parentScale, parentX, parentZ)); MapTile parent = cache.get(new TileKey(key.contentRevision(), key.type(), key.heights(), parentScale, parentX,
parentZ));
if (parent != null) { if (parent != null) {
int sourceX = (int) (key.tileX() - parentX * scale) * sourceSize; int sourceX = (int) (key.tileX() - parentX * scale) * sourceSize;
int sourceZ = (int) (key.tileZ() - parentZ * scale) * sourceSize; int sourceZ = (int) (key.tileZ() - parentZ * scale) * sourceSize;
@@ -341,7 +357,7 @@ final class VisionRenderController implements AutoCloseable {
List<FallbackPiece> pieces = new ArrayList<>(4); List<FallbackPiece> pieces = new ArrayList<>(4);
for (int childZ = 0; childZ < 2; childZ++) { for (int childZ = 0; childZ < 2; childZ++) {
for (int childX = 0; childX < 2; childX++) { for (int childX = 0; childX < 2; childX++) {
BufferedImage child = cache.get(new TileKey(key.contentRevision(), key.type(), childScale, MapTile child = cache.get(new TileKey(key.contentRevision(), key.type(), key.heights(), childScale,
key.tileX() * 2L + childX, key.tileZ() * 2L + childZ)); key.tileX() * 2L + childX, key.tileZ() * 2L + childZ));
if (child != null) { if (child != null) {
pieces.add(new FallbackPiece(child, 0, 0, TILE_PIXELS, childX * 0.5D, childZ * 0.5D, 0.5D)); pieces.add(new FallbackPiece(child, 0, 0, TILE_PIXELS, childX * 0.5D, childZ * 0.5D, 0.5D));
@@ -410,7 +426,8 @@ final class VisionRenderController implements AutoCloseable {
double centerZ, double centerZ,
double blocksPerPixel, double blocksPerPixel,
int width, int width,
int height int height,
boolean heights
) { ) {
RenderSpec { RenderSpec {
Objects.requireNonNull(renderer, "renderer"); Objects.requireNonNull(renderer, "renderer");
@@ -468,11 +485,33 @@ final class VisionRenderController implements AutoCloseable {
return tilesByKey.get(key); return tilesByKey.get(key);
} }
/**
* The tile covering a world position together with the pixel of it there, or null when that tile is not drawn yet.
*/
TilePixel pixelAt(double worldX, double worldZ) {
double tileSpan = TILE_PIXELS * renderBlocksPerPixel;
long tileX = (long) Math.floor(worldX / tileSpan);
long tileZ = (long) Math.floor(worldZ / tileSpan);
VisibleTile visible = tilesByKey.get(new TileKey(spec.contentRevision(), spec.type(), spec.heights(),
renderBlocksPerPixel, tileX, tileZ));
MapTile tile = visible == null ? null : visible.tile();
if (tile == null) {
return null;
}
int pixelX = pixelWithin(worldX - tileX * tileSpan);
int pixelZ = pixelWithin(worldZ - tileZ * tileSpan);
return new TilePixel(tile, pixelX, pixelZ);
}
private int pixelWithin(double offsetBlocks) {
return Math.max(0, Math.min(TILE_PIXELS - 1, (int) Math.floor(offsetBlocks / renderBlocksPerPixel)));
}
// Tiles are sorted centre first, and everything before the cursor is drawn or claimed, so each search resumes there. // 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) {
for (int index = nextCandidate; index < tiles.size(); index++) { for (int index = nextCandidate; index < tiles.size(); index++) {
VisibleTile tile = tiles.get(index); VisibleTile tile = tiles.get(index);
if (tile.image() == null && !inFlight.contains(tile.key())) { if (tile.tile() == null && !inFlight.contains(tile.key())) {
if (advance) { if (advance) {
nextCandidate = index + 1; nextCandidate = index + 1;
} }
@@ -489,7 +528,7 @@ final class VisionRenderController implements AutoCloseable {
static final class VisibleTile { static final class VisibleTile {
private final TileKey key; private final TileKey key;
private final double distanceSquared; private final double distanceSquared;
private volatile BufferedImage image; private volatile MapTile tile;
private VisibleTile(TileKey key, double distanceSquared) { private VisibleTile(TileKey key, double distanceSquared) {
this.key = key; this.key = key;
@@ -512,22 +551,39 @@ final class VisionRenderController implements AutoCloseable {
return distanceSquared; return distanceSquared;
} }
BufferedImage image() { MapTile tile() {
return image; return tile;
} }
private void setImage(BufferedImage image) { private void setTile(MapTile tile) {
this.image = image; this.tile = tile;
} }
} }
record TileKey(long contentRevision, RenderType type, double blocksPerPixel, long tileX, long tileZ) { /**
* @param heights whether the tile also carries terrain heights, for shading
*/
record TileKey(long contentRevision, RenderType type, boolean heights, double blocksPerPixel, long tileX, long tileZ) {
TileKey { TileKey {
Objects.requireNonNull(type, "type"); Objects.requireNonNull(type, "type");
if (!Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) { if (!Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) {
throw new IllegalArgumentException("Vision cache scale must be finite and positive"); throw new IllegalArgumentException("Vision cache scale must be finite and positive");
} }
} }
TileKey withHeights(boolean nextHeights) {
return new TileKey(contentRevision, type, nextHeights, blocksPerPixel, tileX, tileZ);
}
}
record TilePixel(MapTile tile, int pixelX, int pixelZ) {
MapLabels.Label subject() {
return tile.labels().label(tile.subjectAt(pixelX, pixelZ));
}
MapLabels.Label region() {
return tile.labels().label(tile.regionAt(pixelX, pixelZ));
}
} }
/** /**
@@ -536,7 +592,7 @@ final class VisionRenderController implements AutoCloseable {
record Fallback(List<FallbackPiece> pieces) { record Fallback(List<FallbackPiece> pieces) {
} }
record FallbackPiece(BufferedImage image, int sourceX, int sourceZ, int sourceSize, double offsetX, double offsetZ, record FallbackPiece(MapTile tile, int sourceX, int sourceZ, int sourceSize, double offsetX, double offsetZ,
double size) { double size) {
} }
@@ -562,18 +618,18 @@ final class VisionRenderController implements AutoCloseable {
this.entries = new LinkedHashMap<>(128, 0.75F, true); this.entries = new LinkedHashMap<>(128, 0.75F, true);
} }
synchronized BufferedImage get(TileKey key) { synchronized MapTile get(TileKey key) {
CacheEntry entry = entries.get(key); CacheEntry entry = entries.get(key);
return entry == null ? null : entry.image(); return entry == null ? null : entry.tile();
} }
synchronized void put(TileKey key, BufferedImage image) { synchronized void put(TileKey key, MapTile tile) {
long imageBytes = (long) image.getWidth() * image.getHeight() * Integer.BYTES; long tileBytes = tile.bytes();
CacheEntry previous = entries.put(key, new CacheEntry(image, imageBytes)); CacheEntry previous = entries.put(key, new CacheEntry(tile, tileBytes));
if (previous != null) { if (previous != null) {
bytes -= previous.bytes(); bytes -= previous.bytes();
} }
bytes += imageBytes; bytes += tileBytes;
while (bytes > maximumBytes && !entries.isEmpty()) { while (bytes > maximumBytes && !entries.isEmpty()) {
Iterator<Map.Entry<TileKey, CacheEntry>> iterator = entries.entrySet().iterator(); Iterator<Map.Entry<TileKey, CacheEntry>> iterator = entries.entrySet().iterator();
Map.Entry<TileKey, CacheEntry> eldest = iterator.next(); Map.Entry<TileKey, CacheEntry> eldest = iterator.next();
@@ -588,6 +644,6 @@ final class VisionRenderController implements AutoCloseable {
} }
} }
private record CacheEntry(BufferedImage image, long bytes) { private record CacheEntry(MapTile tile, long bytes) {
} }
} }
@@ -18,7 +18,6 @@
package com.volmit.iris.core.gui.components; package com.volmit.iris.core.gui.components;
import com.volmit.iris.core.gui.GuiHost;
import com.volmit.iris.engine.IrisComplex; import com.volmit.iris.engine.IrisComplex;
import com.volmit.iris.engine.framework.Engine; import com.volmit.iris.engine.framework.Engine;
import com.volmit.iris.engine.object.IrisBiome; import com.volmit.iris.engine.object.IrisBiome;
@@ -28,121 +27,87 @@ import com.volmit.iris.engine.object.IrisRegion;
import com.volmit.iris.util.stream.ProceduralStream; import com.volmit.iris.util.stream.ProceduralStream;
import java.awt.Color; import java.awt.Color;
import java.awt.image.BufferedImage;
import java.util.Arrays; import java.util.Arrays;
import java.util.IdentityHashMap; import java.util.IdentityHashMap;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.CancellationException; import java.util.concurrent.CancellationException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BooleanSupplier; import java.util.function.BooleanSupplier;
/**
* Draws map tiles for one generation of the pack: a hotload needs a new renderer, which also starts new labels.
*/
public final class IrisRenderer { public final class IrisRenderer {
private static final int BLUE = new Color(45, 91, 156).getRGB(); private static final int BLUE = rgb(45, 91, 156);
private static final int YELLOW = new Color(211, 164, 67).getRGB(); private static final int YELLOW = rgb(211, 164, 67);
private static final int GREEN = new Color(78, 137, 83).getRGB(); private static final int GREEN = rgb(78, 137, 83);
private static final int DEEP_WATER = new Color(20, 48, 92).getRGB(); private static final int DEEP_WATER = rgb(20, 48, 92);
private static final int SHALLOW_WATER = new Color(50, 112, 154).getRGB(); private static final int SHALLOW_WATER = rgb(50, 112, 154);
private static final int LOWLAND = new Color(78, 128, 76).getRGB(); private static final int LOWLAND = rgb(78, 128, 76);
private static final int HIGHLAND = new Color(151, 139, 92).getRGB(); private static final int HIGHLAND = rgb(151, 139, 92);
private static final int ROCK = new Color(126, 119, 112).getRGB(); private static final int ROCK = rgb(126, 119, 112);
private static final int SNOW = new Color(226, 230, 232).getRGB(); private static final int SNOW = rgb(226, 230, 232);
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 final Engine renderer; private final Engine engine;
private final MapLabels labels = new MapLabels();
private final ConcurrentHashMap<LabelColorKey, Integer> labelColors = new ConcurrentHashMap<>();
public IrisRenderer(Engine renderer) { public IrisRenderer(Engine engine) {
this.renderer = Objects.requireNonNull(renderer, "renderer"); this.engine = Objects.requireNonNull(engine, "engine");
} }
public BufferedImage render(double sx, double sz, double size, int resolution, RenderType currentType) { public MapLabels labels() {
return render(sx, sz, size, resolution, currentType, () -> false); return labels;
} }
public BufferedImage render( /**
double sx, * Renders the square of {@code size} pixels whose top left pixel samples the given world position.
double sz, *
double size, * @param withHeights also sample terrain heights for shading; the height view always has them
int resolution, */
RenderType currentType, public MapTile renderTile(
double startX,
double startZ,
double blocksPerPixel,
int size,
RenderType type,
boolean withHeights,
BooleanSupplier cancelled BooleanSupplier cancelled
) { ) {
return render(sx, sz, size, resolution, currentType, cancelled, false); if (!Double.isFinite(startX) || !Double.isFinite(startZ) || !Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) {
} throw new IllegalArgumentException("Map tile coordinates and scale must be finite");
public BufferedImage renderStudio(
double sx,
double sz,
double size,
int resolution,
RenderType currentType,
BooleanSupplier cancelled
) {
return render(sx, sz, size, resolution, currentType, cancelled, true);
}
private BufferedImage render(
double sx,
double sz,
double size,
int resolution,
RenderType currentType,
BooleanSupplier cancelled,
boolean studio
) {
if (!Double.isFinite(sx) || !Double.isFinite(sz) || !Double.isFinite(size) || size <= 0D) {
throw new IllegalArgumentException("Vision render coordinates and size must be finite");
} }
if (resolution < 1) { if (size < 1) {
throw new IllegalArgumentException("Vision render resolution must be positive"); throw new IllegalArgumentException("Map tile size must be positive");
} }
Objects.requireNonNull(currentType, "currentType"); Objects.requireNonNull(type, "type");
Objects.requireNonNull(cancelled, "cancelled"); Objects.requireNonNull(cancelled, "cancelled");
checkCancelled(cancelled); checkCancelled(cancelled);
int[] pixels = renderPixels(sx, sz, size, resolution, currentType, cancelled, studio); TileGrid grid = new TileGrid(size + 2, startX - blocksPerPixel, startZ - blocksPerPixel, blocksPerPixel, cancelled);
return GuiHost.uncachedImage(pixels, resolution, resolution); int[] identities = sampleIdentities(grid, type);
double[] heights = withHeights || type == RenderType.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 = tileColors(size, type, subjects, heights);
MapLabels.Kind subjectKind = type == RenderType.REGION ? MapLabels.Kind.REGION : MapLabels.Kind.BIOME;
return new MapTile(size, blocksPerPixel, labels, subjectKind, colors, subjects, regions, heights);
} }
private int[] renderPixels( /**
double sx, * The colour a biome or region has on the map in the given view, for legends.
double sz, */
double size, public int legendColor(MapLabels.Label label, RenderType type) {
int resolution, RenderType colorType = type == RenderType.HEIGHT ? RenderType.BIOME : type;
RenderType currentType, return labelColor(label.id(), colorType);
BooleanSupplier cancelled,
boolean studio
) {
int[] pixels = new int[resolution * resolution];
double step = size / resolution;
if (studio && currentType == RenderType.HEIGHT) {
renderHeightAtlas(pixels, resolution, sx, sz, step, renderer, cancelled);
return pixels;
}
PixelShader shader = shader(currentType, studio);
if (studio && adaptiveStudioType(currentType)) {
renderAdaptiveAtlas(pixels, resolution, sx, sz, step, shader, cancelled);
return pixels;
}
int groupSize = sampleGroup(step, resolution);
for (int groupZ = 0; groupZ < resolution; groupZ += groupSize) {
checkCancelled(cancelled);
int maximumZ = Math.min(resolution, groupZ + groupSize);
for (int groupX = 0; groupX < resolution; groupX += groupSize) {
checkCancelled(cancelled);
int maximumX = Math.min(resolution, groupX + groupSize);
for (int pixelZ = groupZ; pixelZ < maximumZ; pixelZ++) {
double z = sz + step * pixelZ;
int row = pixelZ * resolution;
for (int pixelX = groupX; pixelX < maximumX; pixelX++) {
checkCancelled(cancelled);
double x = sx + step * pixelX;
pixels[row + pixelX] = shader.color(x, z);
}
}
}
}
return pixels;
} }
public static int heightColor(double height, double maximumHeight, double fluidHeight) { public static int heightColor(double height, double maximumHeight, double fluidHeight) {
@@ -164,151 +129,96 @@ public final class IrisRenderer {
return blend(ROCK, SNOW, (land - 0.78D) / 0.22D); return blend(ROCK, SNOW, (land - 0.78D) / 0.22D);
} }
static int sampleGroup(double step, int resolution) { private int[] sampleIdentities(TileGrid grid, RenderType type) {
double absoluteStep = Math.abs(step); IdentitySampler sampler = new IdentitySampler(grid, identityShader(type));
if (!Double.isFinite(absoluteStep) || absoluteStep <= 0D) { sampler.renderAll(sampleBlockPixels(grid.step()));
return 1; return sampler.identities;
}
return Math.max(1, Math.min(resolution, (int) Math.floor(16D / absoluteStep)));
} }
private PixelShader shader(RenderType currentType, boolean studio) { private double[] sampleHeights(TileGrid grid) {
IrisComplex complex = renderer.getComplex(); return new HeightLattice(grid, engine.getComplex().getHeightStream()).interpolatedHeights();
return switch (currentType) {
case BIOME, DECORATOR_LOAD, OBJECT_LOAD, LAYER_LOAD -> biomeShader(
studio ? complex.getBaseBiomeStream() : complex.getTrueBiomeStream(), currentType);
case BIOME_LAND -> biomeShader(complex.getLandBiomeStream(), currentType);
case BIOME_SEA -> biomeShader(complex.getSeaBiomeStream(), currentType);
case REGION -> regionShader(complex, currentType);
case CAVE_LAND -> biomeShader(complex.getCaveBiomeStream(), currentType);
case HEIGHT -> heightShader(complex.getHeightStream());
case CONTINENT -> studio
? continentShader(complex.getBaseBiomeStream())
: this::continentColor;
};
} }
private static boolean adaptiveStudioType(RenderType type) { // Large uniform squares are filled after a few samples; squares stay small enough to not skip tiny biomes.
return switch (type) { private static int sampleBlockPixels(double blocksPerPixel) {
case BIOME, DECORATOR_LOAD, OBJECT_LOAD, LAYER_LOAD, BIOME_LAND, BIOME_SEA, REGION, CAVE_LAND, int largestPixels = (int) Math.floor(LARGEST_SAMPLE_BLOCK_BLOCKS / blocksPerPixel);
CONTINENT -> true; return Integer.highestOneBit(Math.max(1, Math.min(LARGEST_SAMPLE_BLOCK_PIXELS, largestPixels)));
case HEIGHT -> false;
};
} }
private static void renderAdaptiveAtlas( private IdentityShader identityShader(RenderType type) {
int[] pixels,
int resolution,
double startX,
double startZ,
double step,
PixelShader shader,
BooleanSupplier cancelled
) {
int maximumPixels = Math.max(1, Math.min(16, (int) Math.floor(64D / step)));
int blockPixels = Integer.highestOneBit(maximumPixels);
AdaptiveSampler sampler = new AdaptiveSampler(pixels, resolution, startX, startZ, step, shader, cancelled);
for (int pixelZ = 0; pixelZ < resolution; pixelZ += blockPixels) {
int height = Math.min(blockPixels, resolution - pixelZ);
for (int pixelX = 0; pixelX < resolution; pixelX += blockPixels) {
sampler.render(pixelX, pixelZ, Math.min(blockPixels, resolution - pixelX), height);
}
}
}
private static void renderHeightAtlas(
int[] pixels,
int resolution,
double startX,
double startZ,
double step,
Engine engine,
BooleanSupplier cancelled
) {
IrisComplex complex = engine.getComplex(); IrisComplex complex = engine.getComplex();
ProceduralStream<IrisRegion> regionStream = complex.getRegionStream();
LabelLookup lookup = new LabelLookup(labels);
if (type == RenderType.REGION) {
return (double x, double z) -> {
short region = lookup.idOf(regionStream.get(x, z));
return identity(region, region);
};
}
ProceduralStream<IrisBiome> biomeStream = subjectBiomeStream(complex, type);
return (double x, double z) -> identity(lookup.idOf(biomeStream.get(x, z)), lookup.idOf(regionStream.get(x, z)));
}
private static ProceduralStream<IrisBiome> subjectBiomeStream(IrisComplex complex, RenderType type) {
return switch (type) {
case BIOME_LAND -> complex.getLandBiomeStream();
case BIOME_SEA -> complex.getSeaBiomeStream();
case CAVE_LAND -> complex.getCaveBiomeStream();
case BIOME, DECORATOR_LOAD, OBJECT_LOAD, LAYER_LOAD, HEIGHT, CONTINENT, REGION -> complex.getBaseBiomeStream();
};
}
private int[] tileColors(int size, RenderType type, short[] subjects, double[] heights) {
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;
previousColor = labelColor(subject, type);
}
colors[pixelZ * size + pixelX] = previousColor;
}
}
return colors;
}
private double fluidHeight() {
IrisDimension dimension = engine.getDimension(); IrisDimension dimension = engine.getDimension();
double fluidHeight = dimension == null ? 0D : dimension.getFluidHeight(); return dimension == null ? 0D : dimension.getFluidHeight();
int maximumPixels = Math.max(1, Math.min(16, (int) Math.floor(64D / step))); }
int blockPixels = Integer.highestOneBit(maximumPixels);
HeightSampler sampler = new HeightSampler( private int labelColor(short id, RenderType type) {
pixels, MapLabels.Label label = labels.label(id);
resolution, if (label == null) {
startX, return UNKNOWN_SUBJECT;
startZ,
step,
complex.getHeightStream(),
engine.getHeight(),
fluidHeight,
cancelled
);
for (int pixelZ = 0; pixelZ < resolution; pixelZ += blockPixels) {
int height = Math.min(blockPixels, resolution - pixelZ);
for (int pixelX = 0; pixelX < resolution; pixelX += blockPixels) {
sampler.render(pixelX, pixelZ, Math.min(blockPixels, resolution - pixelX), height);
}
} }
return labelColors.computeIfAbsent(new LabelColorKey(type, id), key -> subjectColor(label.subject(), type));
} }
private PixelShader biomeShader(ProceduralStream<IrisBiome> stream, RenderType currentType) { private int subjectColor(Object subject, RenderType type) {
IdentityHashMap<IrisBiome, Integer> colors = new IdentityHashMap<>(); if (type == RenderType.CONTINENT && subject instanceof IrisBiome biome) {
return (double x, double z) -> { return continentColor(biome);
IrisBiome biome = stream.get(x, z);
Integer color = colors.get(biome);
if (color == null) {
color = biome.getColor(renderer, currentType).getRGB();
colors.put(biome, color);
}
return color;
};
}
private PixelShader regionShader(IrisComplex complex, RenderType currentType) {
ProceduralStream<IrisRegion> stream = complex.getRegionStream();
IdentityHashMap<IrisRegion, Integer> colors = new IdentityHashMap<>();
return (double x, double z) -> {
IrisRegion region = stream.get(x, z);
Integer color = colors.get(region);
if (color == null) {
color = region.getColor(complex, currentType).getRGB();
colors.put(region, color);
}
return color;
};
}
private PixelShader heightShader(ProceduralStream<Double> stream) {
double maximumHeight = renderer.getHeight();
IrisDimension dimension = renderer.getDimension();
double fluidHeight = dimension == null ? 0D : dimension.getFluidHeight();
return (double x, double z) -> heightColor(stream.getDouble(x, z), maximumHeight, fluidHeight);
}
private int continentColor(double x, double z) {
IrisBiome biome = renderer.getBiome(
(int) Math.round(x),
renderer.getMaxHeight() - 1,
(int) Math.round(z)
);
return continentColor(biome);
}
private PixelShader continentShader(ProceduralStream<IrisBiome> stream) {
IdentityHashMap<IrisBiome, Integer> colors = new IdentityHashMap<>();
return (double x, double z) -> {
IrisBiome biome = stream.get(x, z);
Integer color = colors.get(biome);
if (color == null) {
color = continentColor(biome);
colors.put(biome, color);
}
return color;
};
}
private int continentColor(IrisBiome biome) {
if (biome == null) {
return GREEN;
} }
if (subject instanceof IrisRegion region) {
return region.getColor(engine.getComplex(), type).getRGB() & 0xFFFFFF;
}
if (subject instanceof IrisBiome biome) {
return biome.getColor(engine, type).getRGB() & 0xFFFFFF;
}
return UNKNOWN_SUBJECT;
}
private static int continentColor(IrisBiome biome) {
List<IrisBiomeGeneratorLink> generators = biome.getGenerators(); List<IrisBiomeGeneratorLink> generators = biome.getGenerators();
if (generators.isEmpty()) { if (generators.isEmpty()) {
return GREEN; return GREEN;
@@ -323,6 +233,22 @@ public final class IrisRenderer {
return GREEN; return GREEN;
} }
private static int identity(short subject, short region) {
return (subject << 16) | (region & 0xFFFF);
}
private static short subjectOf(int identity) {
return (short) (identity >> 16);
}
private static short regionOf(int identity) {
return (short) identity;
}
private static int rgb(int red, int green, int blue) {
return new Color(red, green, blue).getRGB() & 0xFFFFFF;
}
private static int blend(int first, int second, double progress) { private static int blend(int first, int second, double progress) {
double bounded = clamp(progress, 0D, 1D); double bounded = clamp(progress, 0D, 1D);
int red = (int) Math.round(((first >> 16) & 0xFF) * (1D - bounded) + ((second >> 16) & 0xFF) * bounded); int red = (int) Math.round(((first >> 16) & 0xFF) * (1D - bounded) + ((second >> 16) & 0xFF) * bounded);
@@ -337,262 +263,208 @@ public final class IrisRenderer {
private static void checkCancelled(BooleanSupplier cancelled) { private static void checkCancelled(BooleanSupplier cancelled) {
if (cancelled.getAsBoolean() || Thread.currentThread().isInterrupted()) { if (cancelled.getAsBoolean() || Thread.currentThread().isInterrupted()) {
throw new CancellationException("Vision render cancelled"); throw new CancellationException("Map tile render cancelled");
} }
} }
@FunctionalInterface @FunctionalInterface
private interface PixelShader { private interface IdentityShader {
int color(double x, double z); int identity(double x, double z);
} }
private static final class AdaptiveSampler { private record LabelColorKey(RenderType type, short id) {
private final int[] pixels; }
private final boolean[] sampled;
private final int resolution;
private final double startX;
private final double startZ;
private final double step;
private final PixelShader shader;
private final BooleanSupplier cancelled;
private AdaptiveSampler( /**
int[] pixels, * The square of world positions one tile samples, including the one pixel ring borrowed from its neighbours.
int resolution, */
double startX, private record TileGrid(int size, double startX, double startZ, double step, BooleanSupplier cancelled) {
double startZ, double worldX(int gridX) {
double step, return startX + gridX * step;
PixelShader shader,
BooleanSupplier cancelled
) {
this.pixels = pixels;
this.sampled = new boolean[pixels.length];
this.resolution = resolution;
this.startX = startX;
this.startZ = startZ;
this.step = step;
this.shader = shader;
this.cancelled = cancelled;
} }
private void render(int pixelX, int pixelZ, int width, int height) { double worldZ(int gridZ) {
checkCancelled(cancelled); return startZ + gridZ * step;
}
}
/**
* Labels the biomes and regions one tile meets, asking the shared labels only for ones this tile has not seen yet.
*/
private static final class LabelLookup {
private final MapLabels labels;
private final IdentityHashMap<Object, Short> seen = new IdentityHashMap<>();
private LabelLookup(MapLabels labels) {
this.labels = labels;
}
short idOf(IrisBiome biome) {
if (biome == null) {
return MapLabels.NONE;
}
return seen.computeIfAbsent(biome, subject -> labels.idOf(biome));
}
short idOf(IrisRegion region) {
if (region == null) {
return MapLabels.NONE;
}
return seen.computeIfAbsent(region, subject -> labels.idOf(region));
}
}
/**
* Samples the biome and region of every grid cell in squares, filling a square without sampling its inside when
* its corners and centre agree, and splitting it into quarters otherwise.
*/
private static final class IdentitySampler {
private final TileGrid grid;
private final IdentityShader shader;
private final int[] identities;
private final boolean[] sampled;
private IdentitySampler(TileGrid grid, IdentityShader shader) {
this.grid = grid;
this.shader = shader;
this.identities = new int[grid.size() * grid.size()];
this.sampled = new boolean[identities.length];
}
void renderAll(int blockPixels) {
int size = grid.size();
for (int gridZ = 0; gridZ < size; gridZ += blockPixels) {
int height = Math.min(blockPixels, size - gridZ);
for (int gridX = 0; gridX < size; gridX += blockPixels) {
render(gridX, gridZ, Math.min(blockPixels, size - gridX), height);
}
}
}
private void render(int gridX, int gridZ, int width, int height) {
checkCancelled(grid.cancelled());
if (width == 1 && height == 1) { if (width == 1 && height == 1) {
sample(pixelX, pixelZ); sample(gridX, gridZ);
return; return;
} }
int maximumX = pixelX + width - 1; if (fillIfUniform(gridX, gridZ, width, height)) {
int maximumZ = pixelZ + height - 1;
int centerX = pixelX + width / 2;
int centerZ = pixelZ + height / 2;
int color = sample(pixelX, pixelZ);
if (sample(maximumX, pixelZ) == color
&& sample(pixelX, maximumZ) == color
&& sample(maximumX, maximumZ) == color
&& sample(centerX, centerZ) == color) {
fill(pixelX, pixelZ, width, height, color);
return; return;
} }
int leftWidth = Math.max(1, width / 2); int leftWidth = Math.max(1, width / 2);
int rightWidth = width - leftWidth; int rightWidth = width - leftWidth;
int topHeight = Math.max(1, height / 2); int topHeight = Math.max(1, height / 2);
int bottomHeight = height - topHeight; int bottomHeight = height - topHeight;
render(pixelX, pixelZ, leftWidth, topHeight); render(gridX, gridZ, leftWidth, topHeight);
if (rightWidth > 0) { if (rightWidth > 0) {
render(pixelX + leftWidth, pixelZ, rightWidth, topHeight); render(gridX + leftWidth, gridZ, rightWidth, topHeight);
} }
if (bottomHeight > 0) { if (bottomHeight > 0) {
render(pixelX, pixelZ + topHeight, leftWidth, bottomHeight); render(gridX, gridZ + topHeight, leftWidth, bottomHeight);
if (rightWidth > 0) { if (rightWidth > 0) {
render(pixelX + leftWidth, pixelZ + topHeight, rightWidth, bottomHeight); render(gridX + leftWidth, gridZ + topHeight, rightWidth, bottomHeight);
} }
} }
} }
private int sample(int pixelX, int pixelZ) { private boolean fillIfUniform(int gridX, int gridZ, int width, int height) {
int index = pixelZ * resolution + pixelX; int maximumX = gridX + width - 1;
if (!sampled[index]) { int maximumZ = gridZ + height - 1;
pixels[index] = shader.color(startX + pixelX * step, startZ + pixelZ * step); int identity = sample(gridX, gridZ);
sampled[index] = true; boolean uniform = sample(maximumX, gridZ) == identity
&& sample(gridX, maximumZ) == identity
&& sample(maximumX, maximumZ) == identity
&& sample(gridX + width / 2, gridZ + height / 2) == identity;
if (!uniform) {
return false;
} }
return pixels[index]; for (int row = gridZ; row <= maximumZ; row++) {
} int start = row * grid.size() + gridX;
Arrays.fill(identities, start, start + width, identity);
private void fill(int pixelX, int pixelZ, int width, int height, int color) {
for (int row = pixelZ; row < pixelZ + height; row++) {
int start = row * resolution + pixelX;
Arrays.fill(pixels, start, start + width, color);
Arrays.fill(sampled, start, start + width, true); Arrays.fill(sampled, start, start + width, true);
} }
return true;
}
private int sample(int gridX, int gridZ) {
int index = gridZ * grid.size() + gridX;
if (!sampled[index]) {
identities[index] = shader.identity(grid.worldX(gridX), grid.worldZ(gridZ));
sampled[index] = true;
}
return identities[index];
} }
} }
private static final class HeightSampler { /**
private static final double MAXIMUM_INTERPOLATION_ERROR = 1.5D; * Terrain heights from samples on a coarse lattice, smoothly interpolated in between. Iris heights are costly to
* sample, and shading turns the steps of cheaper shortcuts into visible creases, so a smooth curve through fewer
* exact samples looks better and draws an order of magnitude faster than sampling every pixel. The lattice lines
* up across tiles, so slopes continue seamlessly from one tile into the next.
*/
private static final class HeightLattice {
private static final int LATTICE_PIXELS = 4;
private static final int LATTICE_MARGIN = 2;
private final int[] pixels; private final TileGrid grid;
private final double[] heights;
private final boolean[] sampled;
private final int resolution;
private final double startX;
private final double startZ;
private final double step;
private final ProceduralStream<Double> stream; private final ProceduralStream<Double> stream;
private final double maximumHeight; private final int pointsPerSide;
private final double fluidHeight; private final double[] points;
private final BooleanSupplier cancelled;
private HeightSampler( private HeightLattice(TileGrid grid, ProceduralStream<Double> stream) {
int[] pixels, this.grid = grid;
int resolution,
double startX,
double startZ,
double step,
ProceduralStream<Double> stream,
double maximumHeight,
double fluidHeight,
BooleanSupplier cancelled
) {
this.pixels = pixels;
this.heights = new double[pixels.length];
this.sampled = new boolean[pixels.length];
this.resolution = resolution;
this.startX = startX;
this.startZ = startZ;
this.step = step;
this.stream = stream; this.stream = stream;
this.maximumHeight = maximumHeight; this.pointsPerSide = (grid.size() - 2) / LATTICE_PIXELS + LATTICE_MARGIN * 2 + 1;
this.fluidHeight = fluidHeight; this.points = new double[pointsPerSide * pointsPerSide];
this.cancelled = cancelled;
} }
private void render(int pixelX, int pixelZ, int width, int height) { double[] interpolatedHeights() {
checkCancelled(cancelled); samplePoints();
if (width == 1 && height == 1) { int size = grid.size();
pixels[pixelZ * resolution + pixelX] = heightColor( double[] heights = new double[size * size];
sample(pixelX, pixelZ), maximumHeight, fluidHeight); for (int gridZ = 0; gridZ < size; gridZ++) {
return; for (int gridX = 0; gridX < size; gridX++) {
heights[gridZ * size + gridX] = heightAt(gridX - 1, gridZ - 1);
}
} }
int maximumX = pixelX + width - 1; return heights;
int maximumZ = pixelZ + height - 1; }
int centerX = pixelX + width / 2;
int centerZ = pixelZ + height / 2; // Lattice point 0 sits on the tile's first pixel; the margin covers the border ring and the curve's reach.
double topLeft = sample(pixelX, pixelZ); private void samplePoints() {
double topRight = sample(maximumX, pixelZ); double latticeStep = grid.step() * LATTICE_PIXELS;
double bottomLeft = sample(pixelX, maximumZ); double firstX = grid.worldX(1) - LATTICE_MARGIN * latticeStep;
double bottomRight = sample(maximumX, maximumZ); double firstZ = grid.worldZ(1) - LATTICE_MARGIN * latticeStep;
if (matchesPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight, for (int pointZ = 0; pointZ < pointsPerSide; pointZ++) {
centerX, pixelZ) checkCancelled(grid.cancelled());
&& matchesPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight, for (int pointX = 0; pointX < pointsPerSide; pointX++) {
pixelX, centerZ) points[pointZ * pointsPerSide + pointX] = stream.getDouble(firstX + pointX * latticeStep,
&& matchesPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight, firstZ + pointZ * latticeStep);
maximumX, centerZ)
&& matchesPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight,
centerX, maximumZ)
&& matchesPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight,
centerX, centerZ)) {
fillPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight);
return;
}
int leftWidth = Math.max(1, width / 2);
int rightWidth = width - leftWidth;
int topHeight = Math.max(1, height / 2);
int bottomHeight = height - topHeight;
render(pixelX, pixelZ, leftWidth, topHeight);
if (rightWidth > 0) {
render(pixelX + leftWidth, pixelZ, rightWidth, topHeight);
}
if (bottomHeight > 0) {
render(pixelX, pixelZ + topHeight, leftWidth, bottomHeight);
if (rightWidth > 0) {
render(pixelX + leftWidth, pixelZ + topHeight, rightWidth, bottomHeight);
} }
} }
} }
private boolean matchesPlane( private double heightAt(int pixelX, int pixelZ) {
int pixelX, int cellX = Math.floorDiv(pixelX, LATTICE_PIXELS);
int pixelZ, int cellZ = Math.floorDiv(pixelZ, LATTICE_PIXELS);
int width, double fractionX = (pixelX - cellX * LATTICE_PIXELS) / (double) LATTICE_PIXELS;
int height, double fractionZ = (pixelZ - cellZ * LATTICE_PIXELS) / (double) LATTICE_PIXELS;
double topLeft, int baseX = cellX + LATTICE_MARGIN;
double topRight, int baseZ = cellZ + LATTICE_MARGIN;
double bottomLeft, double[] rows = new double[4];
double bottomRight, for (int row = 0; row < 4; row++) {
int sampleX, int offset = (baseZ - 1 + row) * pointsPerSide + baseX - 1;
int sampleZ rows[row] = catmullRom(points[offset], points[offset + 1], points[offset + 2], points[offset + 3], fractionX);
) {
double predicted = interpolate(
pixelX,
pixelZ,
width,
height,
topLeft,
topRight,
bottomLeft,
bottomRight,
sampleX,
sampleZ
);
return Math.abs(sample(sampleX, sampleZ) - predicted) <= MAXIMUM_INTERPOLATION_ERROR;
}
private void fillPlane(
int pixelX,
int pixelZ,
int width,
int height,
double topLeft,
double topRight,
double bottomLeft,
double bottomRight
) {
for (int row = pixelZ; row < pixelZ + height; row++) {
int offset = row * resolution;
for (int column = pixelX; column < pixelX + width; column++) {
double value = interpolate(
pixelX,
pixelZ,
width,
height,
topLeft,
topRight,
bottomLeft,
bottomRight,
column,
row
);
pixels[offset + column] = heightColor(value, maximumHeight, fluidHeight);
}
} }
return catmullRom(rows[0], rows[1], rows[2], rows[3], fractionZ);
} }
private double sample(int pixelX, int pixelZ) { private static double catmullRom(double before, double start, double end, double after, double fraction) {
int index = pixelZ * resolution + pixelX; double squared = fraction * fraction;
if (!sampled[index]) { double cubed = squared * fraction;
heights[index] = stream.getDouble(startX + pixelX * step, startZ + pixelZ * step); return 0.5D * (2D * start
sampled[index] = true; + (end - before) * fraction
} + (2D * before - 5D * start + 4D * end - after) * squared
return heights[index]; + (3D * start - before - 3D * end + after) * cubed);
}
private static double interpolate(
int pixelX,
int pixelZ,
int width,
int height,
double topLeft,
double topRight,
double bottomLeft,
double bottomRight,
int sampleX,
int sampleZ
) {
double x = width <= 1 ? 0D : (sampleX - pixelX) / (double) (width - 1);
double z = height <= 1 ? 0D : (sampleZ - pixelZ) / (double) (height - 1);
double top = topLeft + (topRight - topLeft) * x;
double bottom = bottomLeft + (bottomRight - bottomLeft) * x;
return top + (bottom - top) * z;
} }
} }
} }
@@ -0,0 +1,80 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.volmit.iris.core.gui.components;
import com.volmit.iris.engine.object.IrisBiome;
import com.volmit.iris.engine.object.IrisRegion;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
/**
* Numbers every biome and region the map has drawn, so a tile can remember what each of its pixels shows in two bytes.
* One set of labels belongs to one generation of the pack: a hotload starts a new one.
*/
public final class MapLabels {
public static final short NONE = -1;
private final IdentityHashMap<Object, Short> idsBySubject = new IdentityHashMap<>();
private final Map<String, Short> idsByKey = new HashMap<>();
private final List<Label> labels = new ArrayList<>();
synchronized short idOf(IrisBiome biome) {
return idOf(biome, Kind.BIOME, biome.getLoadKey(), biome.getName());
}
synchronized short idOf(IrisRegion region) {
return idOf(region, Kind.REGION, region.getLoadKey(), region.getName());
}
public synchronized Label label(short id) {
return id < 0 || id >= labels.size() ? null : labels.get(id);
}
public synchronized short idOf(Kind kind, String key) {
Short id = idsByKey.get(kind.name() + ':' + key);
return id == null ? NONE : id;
}
private short idOf(Object subject, Kind kind, String key, String name) {
Short existing = idsBySubject.get(subject);
if (existing != null) {
return existing;
}
if (labels.size() >= Short.MAX_VALUE) {
return NONE;
}
short id = (short) labels.size();
labels.add(new Label(id, kind, key, name, subject));
idsBySubject.put(subject, id);
idsByKey.putIfAbsent(kind.name() + ':' + key, id);
return id;
}
public enum Kind {
BIOME,
REGION
}
public record Label(short id, Kind kind, String key, String name, Object subject) {
}
}
@@ -0,0 +1,43 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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
*/
public record MapStyle(boolean shading, boolean regionBorders, Highlight highlight) {
public static final MapStyle PLAIN = new MapStyle(false, false, null);
public MapStyle withShading(boolean nextShading) {
return new MapStyle(nextShading, regionBorders, highlight);
}
public MapStyle withRegionBorders(boolean nextRegionBorders) {
return new MapStyle(shading, nextRegionBorders, highlight);
}
public MapStyle withHighlight(Highlight nextHighlight) {
return new MapStyle(shading, regionBorders, nextHighlight);
}
public record Highlight(MapLabels.Kind kind, String key, String name) {
}
}
@@ -0,0 +1,210 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.volmit.iris.core.gui.components;
import com.volmit.iris.core.gui.GuiHost;
import java.awt.image.BufferedImage;
/**
* One rendered square of the map. Besides its colours it remembers which biome or region and which region every pixel
* shows, and optionally the terrain height, so highlighting, borders, shading and the legend never need a new render.
* The label and height grids have a one pixel border taken from the neighbouring tiles, so edges and slopes line up
* across tile boundaries.
*/
public final class MapTile {
private static final double SHADING_EXAGGERATION = 1.2D;
private static final double LIGHT_X = -1D;
private static final double LIGHT_Y = 1.5D;
private static final double LIGHT_Z = -1D;
private static final double LIGHT_LENGTH = Math.sqrt(LIGHT_X * LIGHT_X + LIGHT_Y * LIGHT_Y + LIGHT_Z * LIGHT_Z);
private static final double FLAT_LIGHT = LIGHT_Y / LIGHT_LENGTH;
private static final double DARKEST_SHADE = 0.55D;
private static final double BRIGHTEST_SHADE = 1.25D;
private static final int DIM_TARGET = 0x1E1F22;
private static final double DIM_AMOUNT = 0.72D;
private static final double DIM_DESATURATION = 0.6D;
private static final int HIGHLIGHT_EDGE = 0xFFFFFF;
private static final int REGION_BORDER = 0x101114;
private static final double REGION_BORDER_STRENGTH = 0.6D;
private final int size;
private final double blocksPerPixel;
private final MapLabels labels;
private final MapLabels.Kind subjectKind;
private final int[] colors;
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) {
this.size = size;
this.blocksPerPixel = blocksPerPixel;
this.labels = labels;
this.subjectKind = subjectKind;
this.colors = colors;
this.subjects = subjects;
this.regions = regions;
this.heights = heights;
}
public int size() {
return size;
}
public MapLabels labels() {
return labels;
}
/**
* Whether the pixels name biomes or, in the region view, regions.
*/
public MapLabels.Kind subjectKind() {
return subjectKind;
}
public boolean hasHeights() {
return heights != null;
}
public short subjectAt(int pixelX, int pixelZ) {
return subjects[gridIndex(pixelX, pixelZ)];
}
public short regionAt(int pixelX, int pixelZ) {
return regions[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;
}
/**
* The tile as it looks in the given style; the last style's picture is kept, so redrawing an unchanged map is free.
*/
public synchronized BufferedImage image(MapStyle style) {
if (styledImage == null || !style.equals(styledFor)) {
styledImage = GuiHost.uncachedImage(styledPixels(style), size, size);
styledFor = style;
}
return styledImage;
}
private int[] styledPixels(MapStyle style) {
int[] pixels = new int[size * size];
boolean shading = style.shading() && heights != null;
short[] highlightGrid = highlightGrid(style.highlight());
short highlighted = highlightGrid == null
? MapLabels.NONE
: labels.idOf(style.highlight().kind(), style.highlight().key());
for (int pixelZ = 0; pixelZ < size; pixelZ++) {
for (int pixelX = 0; pixelX < size; pixelX++) {
int color = colors[pixelZ * size + pixelX];
if (shading) {
color = shade(color, pixelX, pixelZ);
}
if (highlightGrid != null) {
color = highlight(color, highlightGrid, pixelX, pixelZ, highlighted);
}
if (style.regionBorders() && isRegionEdge(pixelX, pixelZ)) {
color = blend(color, REGION_BORDER, REGION_BORDER_STRENGTH);
}
pixels[pixelZ * size + pixelX] = color;
}
}
return pixels;
}
// Lit from the north west like a relief map, so slopes read as hills and valleys at a glance.
private int shade(int color, int pixelX, int pixelZ) {
double slopeX = (height(pixelX + 1, pixelZ) - height(pixelX - 1, pixelZ)) / (2D * blocksPerPixel);
double slopeZ = (height(pixelX, pixelZ + 1) - height(pixelX, pixelZ - 1)) / (2D * blocksPerPixel);
double normalX = -slopeX * SHADING_EXAGGERATION;
double normalZ = -slopeZ * SHADING_EXAGGERATION;
double normalLength = Math.sqrt(normalX * normalX + 1D + normalZ * normalZ);
double light = (normalX * LIGHT_X + LIGHT_Y + normalZ * LIGHT_Z) / (normalLength * LIGHT_LENGTH);
double factor = Math.max(DARKEST_SHADE, Math.min(BRIGHTEST_SHADE, light / FLAT_LIGHT));
return scale(color, factor);
}
// Every view knows its regions, so a region can be highlighted in a biome view too.
private short[] highlightGrid(MapStyle.Highlight highlight) {
if (highlight == null) {
return null;
}
if (highlight.kind() == subjectKind) {
return subjects;
}
return highlight.kind() == MapLabels.Kind.REGION ? regions : null;
}
private int highlight(int color, short[] grid, int pixelX, int pixelZ, short highlighted) {
if (grid[gridIndex(pixelX, pixelZ)] != highlighted) {
return dim(color);
}
boolean edge = grid[gridIndex(pixelX + 1, pixelZ)] != highlighted
|| grid[gridIndex(pixelX - 1, pixelZ)] != highlighted
|| grid[gridIndex(pixelX, pixelZ + 1)] != highlighted
|| grid[gridIndex(pixelX, pixelZ - 1)] != highlighted;
return edge ? HIGHLIGHT_EDGE : color;
}
private boolean isRegionEdge(int pixelX, int pixelZ) {
short region = regionAt(pixelX, pixelZ);
return regions[gridIndex(pixelX + 1, pixelZ)] != region || regions[gridIndex(pixelX, pixelZ + 1)] != region;
}
private double height(int pixelX, int pixelZ) {
return heights[gridIndex(pixelX, pixelZ)];
}
// Pixel coordinates run from -1 to size, the outer ring being the neighbouring tiles' edge pixels.
private int gridIndex(int pixelX, int pixelZ) {
return (pixelZ + 1) * (size + 2) + pixelX + 1;
}
private static int dim(int color) {
int red = (color >> 16) & 0xFF;
int green = (color >> 8) & 0xFF;
int blue = color & 0xFF;
int gray = (red + green + blue) / 3;
int desaturated = blend(color, (gray << 16) | (gray << 8) | gray, DIM_DESATURATION);
return blend(desaturated, DIM_TARGET, DIM_AMOUNT);
}
private static int scale(int color, double factor) {
int red = Math.min(255, (int) (((color >> 16) & 0xFF) * factor));
int green = Math.min(255, (int) (((color >> 8) & 0xFF) * factor));
int blue = Math.min(255, (int) ((color & 0xFF) * factor));
return (red << 16) | (green << 8) | blue;
}
static int blend(int first, int second, double amount) {
int red = (int) Math.round(((first >> 16) & 0xFF) * (1D - amount) + ((second >> 16) & 0xFF) * amount);
int green = (int) Math.round(((first >> 8) & 0xFF) * (1D - amount) + ((second >> 8) & 0xFF) * amount);
int blue = (int) Math.round((first & 0xFF) * (1D - amount) + (second & 0xFF) * amount);
return (red << 16) | (green << 8) | blue;
}
}