Put the palettes inside one modern, faster map window

/iris std map now opens a single window with a grouped list of views
(world, biome layers, generation load and the new Palettes view), each
with a number or letter key. The palette explorer lives in that window
instead of its own: double-click or right-click a spot on the map to see
its palettes, and switching views keeps both looking at the same place.
/iris std pal still works and opens the map straight on its palettes.

The map draws much faster. Tiles are cached per power-of-two zoom level
so zooming reuses them, coarser and finer tiles already in the cache
stand in while new ones render, renders start the moment you drag or
zoom, and tiles that are still on screen keep rendering instead of being
thrown away. The noise explorer keeps one worker pool instead of making
threads for every frame and skips work for frames that were replaced.
Pointer coordinates now live in a status bar, so moving the mouse no
longer redraws the whole map.

Model used: Claude Opus 5.5
This commit is contained in:
piexel committed 2026-10-19 10:42:40 +02:00
1 parent 8e71c14f65
commit 80e7e37791
13 files changed
+2319 -2001

No files matched your search

@@ -21,9 +21,9 @@ package com.volmit.iris.core.commands;
import com.volmit.iris.Iris;
import com.volmit.iris.core.IrisSettings;
import com.volmit.iris.core.gui.GuiHost;
import com.volmit.iris.core.gui.MapWindow;
import com.volmit.iris.core.gui.NoiseExplorerGUI;
import com.volmit.iris.core.gui.PaletteExplorerStart;
import com.volmit.iris.core.gui.VisionGUI;
import com.volmit.iris.core.loader.IrisData;
import com.volmit.iris.core.project.IrisProject;
import com.volmit.iris.core.service.ConversionSVC;
@@ -263,8 +263,8 @@ public class CommandStudio implements DecreeExecutor {
}
Location location = player().getLocation();
IrisBiome surfaceBiome = engine.getSurfaceBiome(location.getBlockX(), location.getBlockZ());
sender().sendMessage(C.GREEN + "Opening Palette Explorer!");
NoiseExplorerGUI.launchPalettes(new PaletteExplorerStart(
sender().sendMessage(C.GREEN + "Opening the map on its palettes!");
MapWindow.openPalettes(new PaletteExplorerStart(
engine,
location.getWorld().getUID(),
player().getUniqueId(),
@@ -468,7 +468,15 @@ public class CommandStudio implements DecreeExecutor {
return;
}
VisionGUI.launch(IrisToolbelt.access(world).getEngine(), sender().isPlayer() ? player().getUniqueId() : null);
Location center = sender().isPlayer() && player().getWorld().equals(world) ? player().getLocation() : null;
MapWindow.openMap(new PaletteExplorerStart(
IrisToolbelt.access(world).getEngine(),
world.getUID(),
sender().isPlayer() ? player().getUniqueId() : null,
null,
center == null ? 0D : center.getX(),
center == null ? 0D : center.getZ()
));
sender().sendMessage(C.GREEN + "Opening map!");
}
@@ -1,70 +0,0 @@
/*
* 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 javax.swing.AbstractButton;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JToggleButton;
import static com.volmit.iris.core.gui.NoiseExplorerGUI.ACCENT;
import static com.volmit.iris.core.gui.NoiseExplorerGUI.BORDER;
import static com.volmit.iris.core.gui.NoiseExplorerGUI.FIELD_BACKGROUND;
import static com.volmit.iris.core.gui.NoiseExplorerGUI.ITEM_FONT;
import static com.volmit.iris.core.gui.NoiseExplorerGUI.PRIMARY_TEXT;
import static com.volmit.iris.core.gui.NoiseExplorerGUI.SECONDARY_TEXT;
final class ExplorerStyle {
private static final java.awt.Color SELECTED_BACKGROUND = ACCENT.darker().darker();
private ExplorerStyle() {
}
static JToggleButton toggleButton(String text) {
JToggleButton button = new JToggleButton(text);
applyFrame(button);
button.addItemListener(event -> paintToggleState(button));
paintToggleState(button);
return button;
}
static JButton button(String text) {
JButton button = new JButton(text);
applyFrame(button);
button.setBackground(FIELD_BACKGROUND);
button.setForeground(PRIMARY_TEXT);
return button;
}
private static void applyFrame(AbstractButton button) {
button.setFocusPainted(false);
button.setFont(ITEM_FONT);
button.setContentAreaFilled(false);
button.setOpaque(true);
button.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createLineBorder(BORDER),
BorderFactory.createEmptyBorder(5, 9, 5, 9)
));
}
private static void paintToggleState(JToggleButton button) {
button.setBackground(button.isSelected() ? SELECTED_BACKGROUND : FIELD_BACKGROUND);
button.setForeground(button.isSelected() ? PRIMARY_TEXT : SECONDARY_TEXT);
}
}
@@ -0,0 +1,981 @@
/*
* 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.Iris;
import com.volmit.iris.core.gui.components.IrisRenderer;
import com.volmit.iris.core.gui.components.RenderType;
import com.volmit.iris.engine.framework.Engine;
import com.volmit.iris.engine.object.IrisBiome;
import com.volmit.iris.engine.object.IrisDimension;
import com.volmit.iris.engine.object.IrisRegion;
import com.volmit.iris.util.format.Form;
import javax.swing.JMenuItem;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import java.awt.Color;
import java.awt.EventQueue;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.RenderingHints;
import java.awt.Toolkit;
import java.awt.datatransfer.StringSelection;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseWheelEvent;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
/**
* The tile map of the map window: draws the chosen view of the world and lets you drag, zoom and inspect it.
*/
final class MapCanvas extends JPanel {
private static final long serialVersionUID = 2094606939770332040L;
private static final Color PLAYER_COLOR = StudioTheme.SUCCESS;
private static final Color PLAYER_HALO = new Color(0x5F, 0xB8, 0x65, 48);
private static final Color MOB_COLOR = StudioTheme.ERROR;
private static final Color GRID_MINOR = new Color(255, 255, 255, 18);
private static final Color GRID_MAJOR = new Color(255, 255, 255, 38);
private static final Color TILE_EMPTY_A = new Color(0x232427);
private static final Color TILE_EMPTY_B = new Color(0x26272B);
private static final int CARD_PADDING = 12;
private static final int NOTIFICATION_MILLIS = 2_500;
private static final double DEFAULT_BLOCKS_PER_PIXEL = 4D;
private static final double WHEEL_ZOOM_EXPONENT = 0.08D;
private static final double KEYBOARD_ZOOM_FACTOR = 1.189207115002721D;
private final UUID openerId;
private final Listener listener;
private final VisionRenderController controller;
private final StudioStatusBar statusBar = new StudioStatusBar();
private final Runnable hotloadHook;
private final Timer hoverTimer;
private final Timer markerTimer;
private final Timer notificationTimer;
private final Map<String, Long> notifications = new LinkedHashMap<>();
private Engine engine;
private IrisRenderer renderer;
private VisionOverlay overlay;
private VisionRenderController.Frame renderFrame;
private RenderType currentType = RenderType.BIOME;
private String viewLabel = MapView.BIOMES.label();
private List<GuiMarker> players = List.of();
private List<GuiMarker> entities = List.of();
private HoverInfo hoverInfo;
private Point cursor;
private Point dragOrigin;
private double centerX;
private double centerZ;
private double heightMaximum;
private double fluidHeight;
private long contentRevision = 1L;
private long previousPaintStartedNanos;
private double blocksPerPixel = DEFAULT_BLOCKS_PER_PIXEL;
private int paintCadenceFps;
private boolean grid;
private boolean entitiesVisible;
private boolean follow;
private boolean help;
private boolean debug;
private boolean detailedHover;
private boolean active = true;
private boolean closed;
MapCanvas(Engine engine, UUID openerId, Listener listener) {
this.engine = Objects.requireNonNull(engine, "engine");
this.openerId = openerId;
this.listener = Objects.requireNonNull(listener, "listener");
this.renderer = new IrisRenderer(engine);
this.overlay = new VisionOverlay(engine, openerId);
this.controller = new VisionRenderController(this::onTilesChanged);
this.hotloadHook = () -> EventQueue.invokeLater(this::refreshContent);
captureHeightRange();
setBackground(StudioTheme.EDITOR_BACKGROUND);
setFocusable(true);
installMouseHandlers();
addComponentListener(new ComponentAdapter() {
@Override
public void componentResized(ComponentEvent event) {
requestRender();
}
});
this.hoverTimer = singleShotTimer(90, this::requestHover);
this.markerTimer = new Timer(650, event -> refreshMarkers());
this.markerTimer.setCoalesce(true);
this.notificationTimer = new Timer(200, event -> expireNotifications());
this.notificationTimer.setCoalesce(true);
GuiHost.registerHotloadHook(hotloadHook);
markerTimer.start();
notificationTimer.start();
}
StudioStatusBar statusBar() {
return statusBar;
}
Engine engine() {
return engine;
}
double centerX() {
return centerX;
}
double centerZ() {
return centerZ;
}
boolean isGridVisible() {
return grid;
}
boolean areEntitiesVisible() {
return entitiesVisible;
}
boolean isFollowing() {
return follow;
}
void start() {
refreshMarkers();
requestRender();
}
void centerOn(double worldX, double worldZ) {
centerX = worldX;
centerZ = worldZ;
requestRender();
}
void showView(MapView view) {
if (!view.isTileMap()) {
return;
}
viewLabel = view.label();
if (view.renderType() == currentType) {
return;
}
currentType = view.renderType();
requestRender();
}
// Tiles keep loading in the background only for the view on screen; a hidden canvas stops asking for new ones.
void setActive(boolean nextActive) {
active = nextActive;
if (active) {
requestRender();
}
}
void refreshContent() {
if (closed) {
return;
}
if (!ensureEngine()) {
notifyUser("No active Iris engine");
return;
}
renderer = new IrisRenderer(engine);
overlay = new VisionOverlay(engine, openerId);
contentRevision++;
captureHeightRange();
requestRender();
notifyUser("Refreshing");
}
void toggleGrid() {
grid = !grid;
repaint();
notifyUser(grid ? "Grid on" : "Grid off");
listener.onTogglesChanged();
}
void toggleEntities() {
entitiesVisible = !entitiesVisible;
if (entitiesVisible) {
refreshMarkers();
} else {
entities = List.of();
repaint();
}
notifyUser(entitiesVisible ? "Entities on" : "Entities off");
listener.onTogglesChanged();
}
void toggleFollow() {
follow = !follow;
if (follow && players.isEmpty()) {
follow = false;
notifyUser("No player in this world");
} else if (follow) {
GuiMarker player = players.get(0);
centerOn(player.worldX(), player.worldZ());
notifyUser("Following " + player.label());
} else {
notifyUser("Stopped following");
}
listener.onTogglesChanged();
}
void toggleHelp() {
help = !help;
repaint();
}
void toggleDebug() {
debug = !debug;
repaint();
}
void setDetailedHover(boolean detailed) {
if (detailedHover != detailed) {
detailedHover = detailed;
repaint();
}
}
void zoomIn() {
changeZoom(1D / KEYBOARD_ZOOM_FACTOR, cursor);
}
void zoomOut() {
changeZoom(KEYBOARD_ZOOM_FACTOR, cursor);
}
void resetZoom() {
changeZoom(DEFAULT_BLOCKS_PER_PIXEL / blocksPerPixel, null);
notifyUser("Zoom reset");
}
void close() {
if (closed) {
return;
}
closed = true;
hoverTimer.stop();
markerTimer.stop();
notificationTimer.stop();
GuiHost.unregisterHotloadHook(hotloadHook);
controller.close();
}
private static Timer singleShotTimer(int delay, Runnable task) {
Timer timer = new Timer(delay, event -> task.run());
timer.setRepeats(false);
timer.setCoalesce(true);
return timer;
}
private void installMouseHandlers() {
MouseAdapter handler = new MouseAdapter() {
@Override
public void mouseMoved(MouseEvent event) {
cursor = event.getPoint();
setDetailedHover(event.isShiftDown());
hoverTimer.restart();
refreshStatus();
}
@Override
public void mouseDragged(MouseEvent event) {
cursor = event.getPoint();
if (dragOrigin == null) {
return;
}
centerX += (dragOrigin.x - event.getX()) * blocksPerPixel;
centerZ += (dragOrigin.y - event.getY()) * blocksPerPixel;
dragOrigin = event.getPoint();
stopFollowing();
hoverInfo = null;
requestRender();
}
@Override
public void mouseWheelMoved(MouseWheelEvent event) {
cursor = event.getPoint();
changeZoom(Math.pow(2D, event.getPreciseWheelRotation() * WHEEL_ZOOM_EXPONENT), cursor);
event.consume();
}
@Override
public void mousePressed(MouseEvent event) {
requestFocusInWindow();
cursor = event.getPoint();
if (event.isPopupTrigger()) {
showContextMenu(event);
} else if (SwingUtilities.isLeftMouseButton(event)) {
dragOrigin = event.getPoint();
}
}
@Override
public void mouseReleased(MouseEvent event) {
dragOrigin = null;
if (event.isPopupTrigger()) {
showContextMenu(event);
}
}
@Override
public void mouseClicked(MouseEvent event) {
cursor = event.getPoint();
if (!SwingUtilities.isLeftMouseButton(event)) {
return;
}
if (event.isControlDown()) {
teleportTo(event.getPoint());
} else if (event.isAltDown()) {
openInEditor(event.getPoint());
} else if (event.getClickCount() == 2) {
showPalettesAt(event.getPoint());
}
}
@Override
public void mouseEntered(MouseEvent event) {
cursor = event.getPoint();
}
@Override
public void mouseExited(MouseEvent event) {
cursor = null;
hoverInfo = null;
hoverTimer.stop();
refreshStatus();
repaint();
}
};
addMouseListener(handler);
addMouseMotionListener(handler);
addMouseWheelListener(handler);
}
private void showContextMenu(MouseEvent event) {
Point point = event.getPoint();
int blockX = floorWorldCoordinate(screenToWorldX(point.x));
int blockZ = floorWorldCoordinate(screenToWorldZ(point.y));
JPopupMenu menu = new JPopupMenu();
menu.add(menuItem("Show palettes here", () -> showPalettesAt(point)));
menu.add(menuItem("Teleport here", () -> teleportTo(point)));
menu.add(menuItem(currentType == RenderType.REGION ? "Open region file" : "Open biome file", () -> openInEditor(point)));
menu.addSeparator();
menu.add(menuItem("Copy coordinates (" + blockX + ", " + blockZ + ")", () -> copyCoordinates(blockX, blockZ)));
menu.show(this, point.x, point.y);
}
private static JMenuItem menuItem(String text, Runnable action) {
JMenuItem item = new JMenuItem(text);
item.addActionListener(event -> action.run());
return item;
}
private void copyCoordinates(int blockX, int blockZ) {
Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(blockX + " " + blockZ), null);
notifyUser("Copied " + blockX + ", " + blockZ);
}
private void showPalettesAt(Point point) {
listener.onShowPalettesAt(screenToWorldX(point.x), screenToWorldZ(point.y));
}
void findSurfaceBiome(double worldX, double worldZ, Consumer<String> onFound) {
Engine probeEngine = engine;
controller.submitProbe(renderFrame, () -> {
IrisBiome biome = probeEngine.getSurfaceBiome(floorWorldCoordinate(worldX), floorWorldCoordinate(worldZ));
return biome == null ? null : biome.getLoadKey();
}, onFound);
}
@Override
protected void paintComponent(Graphics graphics) {
long started = System.nanoTime();
if (previousPaintStartedNanos > 0L) {
long interval = started - previousPaintStartedNanos;
paintCadenceFps = interval > 2_000_000_000L
? 0
: (int) Math.min(240L, Math.round(1_000_000_000D / Math.max(1L, interval)));
}
previousPaintStartedNanos = started;
super.paintComponent(graphics);
Graphics2D canvas = (Graphics2D) graphics.create();
try {
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
renderTiles(canvas);
if (grid) {
renderGrid(canvas);
}
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
renderMarkers(canvas);
renderLegend(canvas);
if (help) {
renderHelp(canvas);
} else if (debug) {
renderDebug(canvas);
}
renderHover(canvas);
renderNotifications(canvas);
} catch (Throwable error) {
Iris.debug("Vision paint failed: " + error.getClass().getSimpleName() + ": " + error.getMessage());
} finally {
canvas.dispose();
}
}
private void renderTiles(Graphics2D canvas) {
VisionRenderController.Frame frame = renderFrame;
canvas.setColor(StudioTheme.EDITOR_BACKGROUND);
canvas.fillRect(0, 0, getWidth(), getHeight());
if (frame == null) {
return;
}
canvas.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
double frameTileSpan = VisionRenderController.TILE_PIXELS * frame.renderBlocksPerPixel();
for (VisionRenderController.VisibleTile tile : frame.tiles()) {
double worldX = tile.tileX() * frameTileSpan;
double worldZ = tile.tileZ() * frameTileSpan;
int screenX = (int) Math.round(worldToScreenX(worldX));
int screenY = (int) Math.round(worldToScreenZ(worldZ));
int tileWidth = Math.max(1, (int) Math.round(worldToScreenX(worldX + frameTileSpan)) - screenX);
int tileHeight = Math.max(1, (int) Math.round(worldToScreenZ(worldZ + frameTileSpan)) - screenY);
BufferedImage image = tile.image();
if (image != null) {
canvas.drawImage(image, screenX, screenY, tileWidth, tileHeight, null);
continue;
}
canvas.setColor(((tile.tileX() ^ tile.tileZ()) & 1L) == 0L ? TILE_EMPTY_A : TILE_EMPTY_B);
canvas.fillRect(screenX, screenY, tileWidth, tileHeight);
drawFallback(canvas, controller.fallback(frame, tile), screenX, screenY, tileWidth, tileHeight);
}
}
private static void drawFallback(Graphics2D canvas, VisionRenderController.Fallback fallback, int screenX, int screenY,
int tileWidth, int tileHeight) {
if (fallback == null) {
return;
}
for (VisionRenderController.FallbackPiece piece : fallback.pieces()) {
int left = screenX + (int) Math.round(piece.offsetX() * tileWidth);
int top = screenY + (int) Math.round(piece.offsetZ() * tileHeight);
int right = screenX + (int) Math.round((piece.offsetX() + piece.size()) * tileWidth);
int bottom = screenY + (int) Math.round((piece.offsetZ() + piece.size()) * tileHeight);
canvas.drawImage(piece.image(), left, top, right, bottom,
piece.sourceX(), piece.sourceZ(), piece.sourceX() + piece.sourceSize(), piece.sourceZ() + piece.sourceSize(), null);
}
}
private void renderGrid(Graphics2D canvas) {
double chunkPixels = 16D / blocksPerPixel;
if (chunkPixels >= 8D) {
canvas.setColor(GRID_MINOR);
drawWorldGrid(canvas, 16D, chunkPixels);
}
canvas.setColor(GRID_MAJOR);
double regionPixels = 512D / blocksPerPixel;
if (regionPixels >= 8D) {
drawWorldGrid(canvas, 512D, regionPixels);
}
}
private void drawWorldGrid(Graphics2D canvas, double blockSpacing, double pixelSpacing) {
double firstX = Math.floor(screenToWorldX(0D) / blockSpacing) * blockSpacing;
double firstZ = Math.floor(screenToWorldZ(0D) / blockSpacing) * blockSpacing;
for (double screen = worldToScreenX(firstX); screen <= getWidth(); screen += pixelSpacing) {
int screenX = (int) Math.round(screen);
canvas.drawLine(screenX, 0, screenX, getHeight());
}
for (double screen = worldToScreenZ(firstZ); screen <= getHeight(); screen += pixelSpacing) {
int screenY = (int) Math.round(screen);
canvas.drawLine(0, screenY, getWidth(), screenY);
}
}
private void renderMarkers(Graphics2D canvas) {
if (entitiesVisible) {
canvas.setColor(MOB_COLOR);
for (GuiMarker marker : entities) {
int screenX = (int) Math.round(worldToScreenX(marker.worldX()));
int screenY = (int) Math.round(worldToScreenZ(marker.worldZ()));
canvas.fillOval(screenX - 3, screenY - 3, 6, 6);
}
}
canvas.setFont(StudioTheme.smallFont());
for (GuiMarker marker : players) {
int screenX = (int) Math.round(worldToScreenX(marker.worldX()));
int screenY = (int) Math.round(worldToScreenZ(marker.worldZ()));
canvas.setColor(PLAYER_HALO);
canvas.fillOval(screenX - 12, screenY - 12, 24, 24);
canvas.setColor(PLAYER_COLOR);
canvas.fillOval(screenX - 5, screenY - 5, 10, 10);
canvas.setColor(StudioTheme.TEXT);
int labelWidth = canvas.getFontMetrics().stringWidth(marker.label());
canvas.drawString(marker.label(), screenX - labelWidth / 2, screenY - 15);
}
if (entitiesVisible && detailedHover) {
renderNearestEntity(canvas);
}
}
private void renderNearestEntity(Graphics2D canvas) {
Point point = cursor;
if (point == null || entities.isEmpty()) {
return;
}
double worldX = screenToWorldX(point.x);
double worldZ = screenToWorldZ(point.y);
GuiMarker nearest = null;
double nearestDistance = Double.MAX_VALUE;
for (GuiMarker marker : entities) {
double deltaX = marker.worldX() - worldX;
double deltaZ = marker.worldZ() - worldZ;
double distance = deltaX * deltaX + deltaZ * deltaZ;
if (distance < nearestDistance) {
nearestDistance = distance;
nearest = marker;
}
}
if (nearest == null) {
return;
}
ArrayList<String> lines = new ArrayList<>();
lines.add(nearest.label());
lines.add("Position " + ((int) nearest.worldX()) + ", " + ((int) nearest.worldY()) + ", " + ((int) nearest.worldZ()));
if (nearest.maxHealth() > 0D) {
lines.add("Health " + Form.f(nearest.health(), 1) + " / " + Form.f(nearest.maxHealth(), 1));
}
drawCard(canvas, getWidth() - CARD_PADDING, CARD_PADDING, 1D, 0D, lines);
}
private void renderLegend(Graphics2D canvas) {
if (currentType != RenderType.HEIGHT) {
return;
}
int width = 244;
int height = 54;
int x = getWidth() - width - CARD_PADDING;
int y = getHeight() - height - CARD_PADDING;
StudioOverlay.drawCard(canvas, x, y, width, height);
int gradientX = x + CARD_PADDING;
int gradientY = y + CARD_PADDING;
int gradientWidth = width - CARD_PADDING * 2;
for (int pixel = 0; pixel < gradientWidth; pixel++) {
double sampleHeight = heightMaximum * pixel / Math.max(1D, gradientWidth - 1D);
canvas.setColor(new Color(IrisRenderer.heightColor(sampleHeight, heightMaximum, fluidHeight)));
canvas.drawLine(gradientX + pixel, gradientY, gradientX + pixel, gradientY + 14);
}
canvas.setFont(StudioTheme.smallFont());
canvas.setColor(StudioTheme.SECONDARY_TEXT);
canvas.drawString("0", gradientX, gradientY + 32);
String fluid = Integer.toString((int) Math.round(fluidHeight));
int fluidX = gradientX + (int) Math.round(gradientWidth * fluidHeight / Math.max(1D, heightMaximum));
canvas.drawString(fluid, Math.max(gradientX, fluidX - canvas.getFontMetrics().stringWidth(fluid) / 2), gradientY + 32);
String maximum = Integer.toString((int) Math.round(heightMaximum));
canvas.drawString(maximum, gradientX + gradientWidth - canvas.getFontMetrics().stringWidth(maximum), gradientY + 32);
}
private void renderHover(Graphics2D canvas) {
HoverInfo info = hoverInfo;
if (info == null) {
return;
}
ArrayList<String> lines = new ArrayList<>();
lines.add(info.biomeName());
lines.add(info.regionName());
lines.add("Block " + ((int) info.worldX()) + ", " + ((int) info.worldZ()));
if (detailedHover) {
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())) + 16;
int screenY = (int) Math.round(worldToScreenZ(info.worldZ()));
drawCard(canvas, screenX, screenY, 0D, 0D, lines);
}
private void renderHelp(Graphics2D canvas) {
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", "/");
List<String> descriptions = List.of(
"Move the map",
"Zoom in and out",
"Reset zoom",
"Show the palettes of that spot",
"More actions for that spot",
"Teleport there",
"Open the biome file",
"More details while hovering",
"Redraw the map",
"Follow your player",
"Chunk and region grid",
"Show entities",
"Next view",
"Pick a view",
"Hide this help"
);
canvas.setFont(StudioTheme.boldFont());
int keyWidth = 0;
for (String key : keys) {
keyWidth = Math.max(keyWidth, canvas.getFontMetrics().stringWidth(key));
}
canvas.setFont(StudioTheme.font());
int descriptionWidth = 0;
for (String description : descriptions) {
descriptionWidth = Math.max(descriptionWidth, canvas.getFontMetrics().stringWidth(description));
}
int lineHeight = 22;
int width = keyWidth + descriptionWidth + CARD_PADDING * 3 + 8;
int height = keys.size() * lineHeight + CARD_PADDING * 2;
StudioOverlay.drawCard(canvas, CARD_PADDING, CARD_PADDING, width, height);
for (int index = 0; index < keys.size(); index++) {
int y = CARD_PADDING * 2 + 10 + index * lineHeight;
canvas.setFont(StudioTheme.boldFont());
canvas.setColor(StudioTheme.LINK);
canvas.drawString(keys.get(index), CARD_PADDING * 2, y);
canvas.setFont(StudioTheme.font());
canvas.setColor(StudioTheme.SECONDARY_TEXT);
canvas.drawString(descriptions.get(index), CARD_PADDING * 2 + keyWidth + 18, y);
}
}
private void renderDebug(Graphics2D canvas) {
VisionRenderController.Progress progress = controller.progress(renderFrame);
VisionRenderController.Frame frame = renderFrame;
ArrayList<String> lines = new ArrayList<>();
lines.add("Tiles " + progress.ready() + " / " + progress.total() + " drawn");
lines.add("Render threads busy " + progress.activeWorkers());
lines.add("Tile scale " + (frame == null ? "-" : Form.f(frame.renderBlocksPerPixel(), 0)) + " blocks/px");
lines.add(paintCadenceFps + " paints per second");
drawCard(canvas, CARD_PADDING, getHeight() - CARD_PADDING, 0D, 1D, lines);
}
private void renderNotifications(Graphics2D canvas) {
if (notifications.isEmpty()) {
return;
}
String text = String.join(" · ", notifications.keySet());
canvas.setFont(StudioTheme.font());
int textWidth = canvas.getFontMetrics().stringWidth(text);
int width = textWidth + CARD_PADDING * 2;
int height = 34;
int x = (getWidth() - width) / 2;
int y = getHeight() - height - 16;
StudioOverlay.drawCard(canvas, x, y, width, height);
canvas.setColor(StudioTheme.TEXT);
canvas.drawString(text, x + CARD_PADDING, y + 22);
}
private void drawCard(Graphics2D canvas, int anchorX, int anchorY, double pushX, double pushY, List<String> lines) {
canvas.setFont(StudioTheme.font());
int lineHeight = canvas.getFontMetrics().getHeight();
int width = 0;
for (int index = 0; index < lines.size(); index++) {
canvas.setFont(index == 0 ? StudioTheme.boldFont() : StudioTheme.font());
width = Math.max(width, canvas.getFontMetrics().stringWidth(lines.get(index)));
}
width += CARD_PADDING * 2;
int height = lines.size() * lineHeight + CARD_PADDING * 2 - 2;
int x = (int) Math.round(anchorX - width * pushX);
int y = (int) Math.round(anchorY - height * pushY);
x = Math.max(CARD_PADDING, Math.min(getWidth() - width - CARD_PADDING, x));
y = Math.max(CARD_PADDING, Math.min(getHeight() - height - CARD_PADDING, y));
StudioOverlay.drawCard(canvas, x, y, width, height);
for (int index = 0; index < lines.size(); index++) {
boolean title = index == 0;
canvas.setFont(title ? StudioTheme.boldFont() : StudioTheme.font());
canvas.setColor(title ? StudioTheme.TEXT : StudioTheme.SECONDARY_TEXT);
canvas.drawString(lines.get(index), x + CARD_PADDING, y + CARD_PADDING + lineHeight - 3 + index * lineHeight);
}
}
private void onTilesChanged() {
repaint();
refreshStatus();
}
private void requestRender() {
if (closed || !active || getWidth() < 1 || getHeight() < 1 || !ensureEngine()) {
repaint();
return;
}
hoverInfo = null;
VisionRenderController.RenderSpec spec = new VisionRenderController.RenderSpec(
renderer,
currentType,
contentRevision,
centerX,
centerZ,
blocksPerPixel,
getWidth(),
getHeight()
);
renderFrame = controller.request(spec);
repaint();
refreshStatus();
}
private void refreshStatus() {
int viewWidthBlocks = (int) (blocksPerPixel * getWidth());
int viewHeightBlocks = (int) (blocksPerPixel * getHeight());
statusBar.showSummary(viewLabel + " " + formatScale(blocksPerPixel) + " blocks/px "
+ Form.f(viewWidthBlocks) + " × " + Form.f(viewHeightBlocks) + " blocks");
VisionRenderController.Progress progress = controller.progress(renderFrame);
boolean drawing = progress.completion() < 1D;
statusBar.showActivity(drawing ? "Drawing " + progress.ready() + " of " + progress.total() + " tiles" : "");
statusBar.showProgress(progress.completion());
Point point = cursor;
double worldX = point == null ? centerX : screenToWorldX(point.x);
double worldZ = point == null ? centerZ : screenToWorldZ(point.y);
statusBar.showPosition("X " + Form.f(floorWorldCoordinate(worldX)) + " Z " + Form.f(floorWorldCoordinate(worldZ)));
}
private static String formatScale(double blocksPerPixel) {
if (blocksPerPixel >= 1D) {
return Form.f(blocksPerPixel, 1);
}
return "1/" + String.format(Locale.ROOT, "%.0f", 1D / blocksPerPixel);
}
private boolean ensureEngine() {
Engine activeEngine = engine;
if (activeEngine != null && !activeEngine.isClosed() && activeEngine.getComplex() != null) {
return true;
}
try {
Engine reacquired = GuiHost.findActiveEngine();
if (reacquired == null || reacquired.isClosed() || reacquired.getComplex() == null) {
return false;
}
engine = reacquired;
renderer = new IrisRenderer(reacquired);
overlay = new VisionOverlay(reacquired, openerId);
contentRevision++;
captureHeightRange();
return true;
} catch (Throwable error) {
Iris.debug("Vision engine reacquisition failed: " + error.getClass().getSimpleName() + ": " + error.getMessage());
return false;
}
}
private void captureHeightRange() {
heightMaximum = Math.max(1D, engine.getHeight());
IrisDimension dimension = engine.getDimension();
fluidHeight = dimension == null
? 0D
: Math.max(0D, Math.min(heightMaximum, dimension.getFluidHeight()));
}
private void refreshMarkers() {
if (closed || overlay == null) {
return;
}
try {
List<GuiMarker> nextPlayers = overlay.lastKnownPlayers();
players = nextPlayers == null ? List.of() : List.copyOf(nextPlayers);
if (entitiesVisible) {
overlay.requestEntities(next -> EventQueue.invokeLater(() -> {
if (!closed && entitiesVisible) {
entities = next == null ? List.of() : List.copyOf(next);
repaint();
}
}));
} else if (!entities.isEmpty()) {
entities = List.of();
}
if (follow && !players.isEmpty()) {
GuiMarker player = players.get(0);
if (Math.abs(centerX - player.worldX()) > 0.5D || Math.abs(centerZ - player.worldZ()) > 0.5D) {
centerOn(player.worldX(), player.worldZ());
}
}
if (active) {
repaint();
}
} catch (Throwable error) {
Iris.debug("Vision marker refresh failed: " + error.getClass().getSimpleName() + ": " + error.getMessage());
}
}
private void requestHover() {
Point point = cursor;
VisionRenderController.Frame frame = renderFrame;
Engine probeEngine = engine;
if (closed || !active || point == null || frame == null || probeEngine == null || probeEngine.getComplex() == null) {
return;
}
double worldX = screenToWorldX(point.x);
double worldZ = screenToWorldZ(point.y);
controller.submitProbe(
frame,
() -> sampleHover(probeEngine, worldX, worldZ),
info -> {
hoverInfo = info;
repaint();
}
);
}
private static HoverInfo sampleHover(Engine engine, double worldX, double worldZ) {
IrisBiome biome = engine.getComplex().getBaseBiomeStream().get(worldX, worldZ);
IrisRegion region = engine.getComplex().getRegionStream().get(worldX, worldZ);
return new HoverInfo(
worldX,
worldZ,
biome.getName(),
region.getName(),
biome.getLoadKey(),
String.valueOf(biome.getLoadFile())
);
}
private void stopFollowing() {
if (follow) {
follow = false;
listener.onTogglesChanged();
}
}
private void notifyUser(String message) {
notifications.put(message, System.currentTimeMillis() + NOTIFICATION_MILLIS);
repaint();
}
private void expireNotifications() {
long now = System.currentTimeMillis();
boolean changed = false;
Iterator<Map.Entry<String, Long>> iterator = notifications.entrySet().iterator();
while (iterator.hasNext()) {
if (iterator.next().getValue() <= now) {
iterator.remove();
changed = true;
}
}
if (changed) {
repaint();
}
}
private void changeZoom(double factor, Point anchor) {
if (!Double.isFinite(factor) || factor <= 0D) {
return;
}
Point effectiveAnchor = anchor == null ? new Point(getWidth() / 2, getHeight() / 2) : anchor;
VisionViewport next = new VisionViewport(centerX, centerZ, blocksPerPixel).zoomAt(
effectiveAnchor.x,
effectiveAnchor.y,
getWidth(),
getHeight(),
factor,
VisionRenderController.MINIMUM_VIEW_BLOCKS_PER_PIXEL,
VisionRenderController.MAXIMUM_BLOCKS_PER_PIXEL
);
if (next.blocksPerPixel() == blocksPerPixel) {
return;
}
centerX = next.centerX();
centerZ = next.centerZ();
blocksPerPixel = next.blocksPerPixel();
stopFollowing();
hoverInfo = null;
requestRender();
}
private double screenToWorldX(double screenX) {
return centerX + (screenX - getWidth() * 0.5D) * blocksPerPixel;
}
private double screenToWorldZ(double screenY) {
return centerZ + (screenY - getHeight() * 0.5D) * blocksPerPixel;
}
private double worldToScreenX(double worldX) {
return getWidth() * 0.5D + (worldX - centerX) / blocksPerPixel;
}
private double worldToScreenZ(double worldZ) {
return getHeight() * 0.5D + (worldZ - centerZ) / blocksPerPixel;
}
private void openInEditor(Point point) {
VisionOverlay activeOverlay = overlay;
if (activeOverlay == null || point == null) {
return;
}
double worldX = screenToWorldX(point.x);
double worldZ = screenToWorldZ(point.y);
RenderType type = currentType;
controller.submitProbe(
renderFrame,
() -> activeOverlay.openInEditor(worldX, worldZ, type),
opened -> opened.ifPresent(fileName -> notifyUser("Opened " + fileName))
);
}
private void teleportTo(Point point) {
if (overlay == null || point == null) {
notifyUser("No player in this world");
return;
}
int worldX = floorWorldCoordinate(screenToWorldX(point.x));
int worldZ = floorWorldCoordinate(screenToWorldZ(point.y));
overlay.teleport(worldX, worldZ);
notifyUser("Teleporting to " + worldX + ", " + worldZ);
}
static int floorWorldCoordinate(double coordinate) {
return (int) StrictMath.floor(coordinate);
}
interface Listener {
void onTogglesChanged();
void onShowPalettesAt(double worldX, double worldZ);
}
private record HoverInfo(
double worldX,
double worldZ,
String biomeName,
String regionName,
String biomeKey,
String biomeFile
) {
}
}
@@ -0,0 +1,118 @@
/*
* 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.RenderType;
import java.awt.event.KeyEvent;
/**
* Everything the map window can show, in the order and groups of its view list.
*/
enum MapView {
BIOMES("Biomes", "The biome at each spot, in the biome's colour", Group.WORLD, RenderType.BIOME, KeyEvent.VK_1),
REGIONS("Regions", "The region each spot belongs to", Group.WORLD, RenderType.REGION, KeyEvent.VK_2),
HEIGHT("Height", "Terrain height, from deep water to snowy peaks", Group.WORLD, RenderType.HEIGHT, KeyEvent.VK_3),
CONTINENTS("Continents", "Land, coast or sea, judged by each biome's first height generator",
Group.WORLD, RenderType.CONTINENT, KeyEvent.VK_4),
LAND_BIOMES("Land biomes", "The land biome each spot would get, also where the sea is",
Group.BIOME_STREAMS, RenderType.BIOME_LAND, KeyEvent.VK_5),
SEA_BIOMES("Sea biomes", "The sea biome each spot would get, also where the land is",
Group.BIOME_STREAMS, RenderType.BIOME_SEA, KeyEvent.VK_6),
CAVE_BIOMES("Cave biomes", "The cave biome under each spot", Group.BIOME_STREAMS, RenderType.CAVE_LAND, KeyEvent.VK_7),
OBJECT_LOAD("Objects", "How densely each biome places objects, brighter is denser",
Group.LOAD, RenderType.OBJECT_LOAD, KeyEvent.VK_8),
DECORATOR_LOAD("Decorators", "How densely each biome places decorations, brighter is denser",
Group.LOAD, RenderType.DECORATOR_LOAD, KeyEvent.VK_9),
LAYER_LOAD("Layers", "How many block layers each biome stacks, brighter is more",
Group.LOAD, RenderType.LAYER_LOAD, KeyEvent.VK_0),
PALETTES("Palettes", "The blocks each biome's palette layers place, with their textures",
Group.BLOCKS, null, KeyEvent.VK_P);
private final String label;
private final String description;
private final Group group;
private final RenderType renderType;
private final int shortcutKey;
MapView(String label, String description, Group group, RenderType renderType, int shortcutKey) {
this.label = label;
this.description = description;
this.group = group;
this.renderType = renderType;
this.shortcutKey = shortcutKey;
}
String label() {
return label;
}
String description() {
return description;
}
Group group() {
return group;
}
/**
* The tile renderer's mode for this view, or null for the palette view, which draws itself.
*/
RenderType renderType() {
return renderType;
}
int shortcutKey() {
return shortcutKey;
}
String shortcutText() {
return KeyEvent.getKeyText(shortcutKey);
}
boolean isTileMap() {
return renderType != null;
}
static MapView forShortcut(int keyCode) {
for (MapView view : values()) {
if (view.shortcutKey == keyCode) {
return view;
}
}
return null;
}
enum Group {
WORLD("World"),
BIOME_STREAMS("Biome layers"),
LOAD("Generation load"),
BLOCKS("Blocks");
private final String label;
Group(String label) {
this.label = label;
}
String label() {
return label;
}
}
}
@@ -0,0 +1,167 @@
/*
* 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 javax.swing.BorderFactory;
import javax.swing.DefaultListCellRenderer;
import javax.swing.DefaultListModel;
import javax.swing.JList;
import javax.swing.ListSelectionModel;
import java.awt.Component;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.util.Locale;
import java.util.function.Consumer;
/**
* The map window's list of views, grouped under headers, each row showing the key that opens it.
*/
final class MapViewList extends JList<MapViewList.Row> {
private static final long serialVersionUID = -4130872409914437415L;
private static final int ROW_HEIGHT = 26;
private final DefaultListModel<Row> rows = new DefaultListModel<>();
private int lastViewRow;
MapViewList(Consumer<MapView> onViewChosen) {
fillRows();
setModel(rows);
setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
setFixedCellHeight(ROW_HEIGHT);
setCellRenderer(new RowRenderer());
setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6));
addListSelectionListener(event -> {
if (event.getValueIsAdjusting() || skipHeaderRow()) {
return;
}
Row selected = getSelectedValue();
if (selected == null) {
return;
}
lastViewRow = getSelectedIndex();
onViewChosen.accept(selected.view());
});
}
void select(MapView view) {
for (int row = 0; row < rows.size(); row++) {
if (rows.get(row).view() == view) {
lastViewRow = row;
setSelectedIndex(row);
ensureIndexIsVisible(row);
return;
}
}
}
private void fillRows() {
MapView.Group currentGroup = null;
for (MapView view : MapView.values()) {
if (view.group() != currentGroup) {
currentGroup = view.group();
rows.addElement(Row.header(currentGroup));
}
rows.addElement(Row.of(view));
}
}
// Headers can't be chosen: clicking one keeps the current view, and the arrow keys carry on past it.
private boolean skipHeaderRow() {
int row = getSelectedIndex();
if (row < 0 || !rows.get(row).isHeader()) {
return false;
}
int step = row >= lastViewRow ? 1 : -1;
int next = nextViewRow(row, step);
if (next < 0) {
next = nextViewRow(row, -step);
}
setSelectedIndex(next < 0 ? lastViewRow : next);
ensureIndexIsVisible(getSelectedIndex());
return true;
}
private int nextViewRow(int from, int step) {
for (int row = from + step; row >= 0 && row < rows.size(); row += step) {
if (!rows.get(row).isHeader()) {
return row;
}
}
return -1;
}
record Row(MapView.Group group, MapView view) {
static Row header(MapView.Group group) {
return new Row(group, null);
}
static Row of(MapView view) {
return new Row(view.group(), view);
}
boolean isHeader() {
return view == null;
}
}
private static final class RowRenderer extends DefaultListCellRenderer {
private static final long serialVersionUID = 6302156810211578474L;
private String shortcut;
@Override
public Component getListCellRendererComponent(JList<?> list, Object value, int index, boolean selected, boolean focused) {
Row row = (Row) value;
super.getListCellRendererComponent(list, row.isHeader() ? "" : row.view().label(), index, selected && !row.isHeader(), false);
if (row.isHeader()) {
setText(row.group().label().toUpperCase(Locale.ROOT));
setFont(StudioTheme.sectionFont());
setForeground(StudioTheme.MUTED_TEXT);
setBorder(BorderFactory.createEmptyBorder(index == 0 ? 0 : 6, 6, 0, 6));
setToolTipText(null);
shortcut = null;
return this;
}
setFont(StudioTheme.font());
setBorder(BorderFactory.createEmptyBorder(0, 10, 0, 10));
setToolTipText(row.view().description());
shortcut = row.view().shortcutText();
return this;
}
@Override
protected void paintComponent(Graphics graphics) {
super.paintComponent(graphics);
if (shortcut == null) {
return;
}
Graphics2D canvas = (Graphics2D) graphics.create();
try {
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
canvas.setFont(StudioTheme.smallFont());
canvas.setColor(StudioTheme.MUTED_TEXT);
int textWidth = canvas.getFontMetrics().stringWidth(shortcut);
int baseline = (getHeight() + canvas.getFontMetrics().getAscent() - canvas.getFontMetrics().getDescent()) / 2;
canvas.drawString(shortcut, getWidth() - getInsets().right - textWidth, baseline);
} finally {
canvas.dispose();
}
}
}
}
@@ -0,0 +1,352 @@
/*
* 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 javax.swing.AbstractAction;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.JComponent;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JRootPane;
import javax.swing.JSplitPane;
import javax.swing.JToggleButton;
import javax.swing.JToolBar;
import javax.swing.KeyStroke;
import javax.swing.text.JTextComponent;
import java.awt.BorderLayout;
import java.awt.CardLayout;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.KeyboardFocusManager;
import java.awt.event.ActionEvent;
import java.awt.event.KeyEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.util.ArrayList;
import java.util.List;
/**
* The studio map: one window showing the world as tiles in several views, with the palette explorer as its Palettes view.
*/
public final class MapWindow implements MapCanvas.Listener {
private static final String MAP_CARD = "map";
private static final String PALETTES_CARD = "palettes";
private static final int SIDEBAR_WIDTH = 270;
private static final double PALETTE_RECENTER_DISTANCE_BLOCKS = 1D;
private static final double VIEW_LIST_SHARE_WITH_PALETTES = 0.34D;
private final JFrame frame;
private final PaletteExplorerStart start;
private final MapCanvas canvas;
private final MapViewList viewList;
private final JLabel viewTitle = new JLabel();
private final JPanel content = new JPanel(new CardLayout());
private final JPanel statusBars = new JPanel(new CardLayout());
private final JPanel sidebar = new JPanel(new BorderLayout());
private final JPanel inspectorSlot = new JPanel(new BorderLayout());
private final JComponent viewListScroll;
private final List<JComponent> mapControls = new ArrayList<>();
private final List<JComponent> paletteControls = new ArrayList<>();
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 followToggle = StudioToolbar.toggle("Follow", "Keep your player in the middle (F)");
private NoiseExplorerGUI paletteExplorer;
private MapView currentView;
private double paletteCenterX;
private double paletteCenterZ;
private MapWindow(PaletteExplorerStart start) {
this.start = start;
this.frame = GuiHost.createFrame("Iris Map");
this.canvas = new MapCanvas(start.engine(), start.playerId(), this);
this.viewList = new MapViewList(this::showView);
this.viewListScroll = StudioToolbar.scroll(viewList);
canvas.centerOn(start.centerX(), start.centerZ());
buildFrame();
}
public static void openMap(PaletteExplorerStart start) {
EventQueue.invokeLater(() -> open(start, MapView.BIOMES));
}
public static void openPalettes(PaletteExplorerStart start) {
EventQueue.invokeLater(() -> open(start, MapView.PALETTES));
}
private static void open(PaletteExplorerStart start, MapView initialView) {
MapWindow window = new MapWindow(start);
window.frame.setVisible(true);
window.canvas.start();
window.showView(initialView);
}
private void buildFrame() {
content.add(canvas, MAP_CARD);
statusBars.add(canvas.statusBar(), MAP_CARD);
sidebar.setPreferredSize(new Dimension(SIDEBAR_WIDTH, 0));
sidebar.setBorder(BorderFactory.createMatteBorder(0, 0, 0, 1, StudioTheme.SEPARATOR));
sidebar.add(viewListScroll, BorderLayout.CENTER);
frame.setLayout(new BorderLayout());
frame.add(buildToolbar(), BorderLayout.NORTH);
frame.add(sidebar, BorderLayout.WEST);
frame.add(content, BorderLayout.CENTER);
frame.add(inspectorSlot, BorderLayout.EAST);
frame.add(statusBars, BorderLayout.SOUTH);
installShortcuts(frame.getRootPane());
frame.setSize(1440, 880);
frame.setMinimumSize(new Dimension(820, 520));
frame.setLocationRelativeTo(null);
frame.addWindowListener(new WindowAdapter() {
@Override
public void windowClosed(WindowEvent event) {
close();
}
});
}
private JToolBar buildToolbar() {
JToolBar toolbar = StudioToolbar.create();
viewTitle.setFont(StudioTheme.titleFont());
viewTitle.setBorder(BorderFactory.createEmptyBorder(0, 6, 0, 14));
toolbar.add(viewTitle);
mapControls.add(StudioToolbar.button("Refresh", "Redraw the map with the latest pack files (R)", canvas::refreshContent));
mapControls.add(StudioToolbar.button("Reset zoom", "Go back to the default zoom (\\)", canvas::resetZoom));
mapControls.add(gridToggle);
mapControls.add(entitiesToggle);
mapControls.add(followToggle);
gridToggle.addActionListener(event -> canvas.toggleGrid());
entitiesToggle.addActionListener(event -> canvas.toggleEntities());
followToggle.addActionListener(event -> canvas.toggleFollow());
mapControls.forEach(toolbar::add);
paletteControls.add(StudioToolbar.button("Reset view", "Go back to the starting position and zoom (R)", this::resetPaletteView));
paletteControls.forEach(toolbar::add);
toolbar.add(Box.createHorizontalGlue());
JComponent help = StudioToolbar.button("Shortcuts", "Show every mouse and key shortcut (/)", canvas::toggleHelp);
mapControls.add(help);
toolbar.add(help);
return toolbar;
}
private void showView(MapView view) {
if (view == null || view == currentView) {
return;
}
MapView previousView = currentView;
currentView = view;
viewList.select(view);
viewTitle.setText(view.label());
viewTitle.setToolTipText(view.description());
if (view.isTileMap()) {
showMap(previousView, view);
} else {
showPalettes(previousView);
}
frame.getRootPane().revalidate();
frame.getRootPane().repaint();
}
private void showMap(MapView previousView, MapView view) {
if (previousView == MapView.PALETTES && paletteExplorer != null) {
canvas.centerOn(paletteExplorer.centerX(), paletteExplorer.centerZ());
}
canvas.showView(view);
canvas.setActive(true);
setControlsVisible(mapControls, true);
setControlsVisible(paletteControls, false);
useSidebar(viewListScroll);
inspectorSlot.removeAll();
showCard(MAP_CARD);
canvas.requestFocusInWindow();
}
private void showPalettes(MapView previousView) {
boolean firstOpen = paletteExplorer == null;
if (firstOpen) {
createPaletteExplorer(previousView == null);
} else {
followMapIntoPalettes();
}
canvas.setActive(false);
setControlsVisible(mapControls, false);
setControlsVisible(paletteControls, true);
JSplitPane split = new JSplitPane(JSplitPane.VERTICAL_SPLIT, viewListScroll, paletteExplorer.paletteSidebar());
split.setBorder(BorderFactory.createEmptyBorder());
split.setResizeWeight(0D);
split.setDividerLocation(viewListHeightBesidePalettes());
useSidebar(split);
inspectorSlot.removeAll();
inspectorSlot.add(paletteExplorer.paletteInspector(), BorderLayout.CENTER);
showCard(PALETTES_CARD);
EventQueue.invokeLater(() -> viewList.select(currentView));
if (firstOpen) {
paletteExplorer.openViewer();
}
}
// The palettes need the room more than the view list, which scrolls once it is squeezed.
private int viewListHeightBesidePalettes() {
int fullHeight = viewList.getPreferredSize().height + 4;
if (sidebar.getHeight() < 1) {
return fullHeight;
}
return Math.min(fullHeight, (int) (sidebar.getHeight() * VIEW_LIST_SHARE_WITH_PALETTES));
}
private void createPaletteExplorer(boolean openedOnPalettes) {
PaletteExplorerStart explorerStart = openedOnPalettes
? start
: new PaletteExplorerStart(start.engine(), start.worldId(), start.playerId(), null, canvas.centerX(), canvas.centerZ());
paletteExplorer = NoiseExplorerGUI.createPaletteExplorer(explorerStart);
content.add(paletteExplorer, PALETTES_CARD);
statusBars.add(paletteExplorer.statusBar(), PALETTES_CARD);
rememberPaletteCenter(explorerStart.centerX(), explorerStart.centerZ());
if (!openedOnPalettes) {
showSurfaceBiomePalettes(explorerStart.centerX(), explorerStart.centerZ());
}
}
// Coming back from the map only moves the palettes when the map was moved, so a chosen layer survives a quick look.
private void followMapIntoPalettes() {
double movedX = canvas.centerX() - paletteCenterX;
double movedZ = canvas.centerZ() - paletteCenterZ;
if (Math.hypot(movedX, movedZ) < PALETTE_RECENTER_DISTANCE_BLOCKS) {
return;
}
paletteExplorer.centerOn(canvas.centerX(), canvas.centerZ());
rememberPaletteCenter(canvas.centerX(), canvas.centerZ());
showSurfaceBiomePalettes(canvas.centerX(), canvas.centerZ());
}
@Override
public void onShowPalettesAt(double worldX, double worldZ) {
canvas.centerOn(worldX, worldZ);
showView(MapView.PALETTES);
}
private void showSurfaceBiomePalettes(double worldX, double worldZ) {
canvas.findSurfaceBiome(worldX, worldZ, biomeKey -> {
if (paletteExplorer != null) {
paletteExplorer.showPalettesAt(worldX, worldZ, biomeKey);
}
});
}
private void rememberPaletteCenter(double worldX, double worldZ) {
paletteCenterX = worldX;
paletteCenterZ = worldZ;
}
@Override
public void onTogglesChanged() {
gridToggle.setSelected(canvas.isGridVisible());
entitiesToggle.setSelected(canvas.areEntitiesVisible());
followToggle.setSelected(canvas.isFollowing());
}
private void resetPaletteView() {
if (paletteExplorer != null) {
paletteExplorer.resetViewport();
}
}
private void useSidebar(JComponent component) {
sidebar.removeAll();
sidebar.add(component, BorderLayout.CENTER);
}
private void showCard(String card) {
((CardLayout) content.getLayout()).show(content, card);
((CardLayout) statusBars.getLayout()).show(statusBars, card);
}
private static void setControlsVisible(List<JComponent> controls, boolean visible) {
controls.forEach(control -> control.setVisible(visible));
}
private void installShortcuts(JRootPane rootPane) {
for (MapView view : MapView.values()) {
bind(rootPane, KeyStroke.getKeyStroke(view.shortcutKey(), 0), "view-" + view.name(), () -> showView(view));
}
bind(rootPane, KeyStroke.getKeyStroke(KeyEvent.VK_M, 0), "next-view", this::showNextView);
bind(rootPane, KeyStroke.getKeyStroke(KeyEvent.VK_R, 0), "refresh", this::refreshOrReset);
bindMap(rootPane, KeyEvent.VK_F, 0, "follow", canvas::toggleFollow);
bindMap(rootPane, KeyEvent.VK_G, 0, "grid", canvas::toggleGrid);
bindMap(rootPane, KeyEvent.VK_E, 0, "entities", canvas::toggleEntities);
bindMap(rootPane, KeyEvent.VK_F3, 0, "debug", canvas::toggleDebug);
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_ADD, 0, "zoom-in-keypad", canvas::zoomIn);
bindMap(rootPane, KeyEvent.VK_MINUS, 0, "zoom-out", canvas::zoomOut);
bindMap(rootPane, KeyEvent.VK_SUBTRACT, 0, "zoom-out-keypad", canvas::zoomOut);
bindMap(rootPane, KeyEvent.VK_BACK_SLASH, 0, "reset-zoom", canvas::resetZoom);
bindMap(rootPane, KeyEvent.VK_SLASH, 0, "help", canvas::toggleHelp);
bindMap(rootPane, KeyEvent.VK_SLASH, KeyEvent.SHIFT_DOWN_MASK, "help-question", canvas::toggleHelp);
bind(rootPane, KeyStroke.getKeyStroke(KeyEvent.VK_SHIFT, KeyEvent.SHIFT_DOWN_MASK, false), "details-on",
() -> canvas.setDetailedHover(true));
bind(rootPane, KeyStroke.getKeyStroke(KeyEvent.VK_SHIFT, 0, true), "details-off", () -> canvas.setDetailedHover(false));
}
private void bindMap(JRootPane rootPane, int keyCode, int modifiers, String name, Runnable action) {
bind(rootPane, KeyStroke.getKeyStroke(keyCode, modifiers), name, () -> {
if (currentView != null && currentView.isTileMap()) {
action.run();
}
});
}
// Keys typed into a search box belong to that box, not to the window's shortcuts.
private static void bind(JRootPane rootPane, KeyStroke keyStroke, String name, Runnable action) {
rootPane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(keyStroke, name);
rootPane.getActionMap().put(name, new AbstractAction() {
@Override
public void actionPerformed(ActionEvent event) {
if (!(KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner() instanceof JTextComponent)) {
action.run();
}
}
});
}
private void showNextView() {
MapView[] views = MapView.values();
showView(views[(currentView.ordinal() + 1) % views.length]);
}
private void refreshOrReset() {
if (currentView.isTileMap()) {
canvas.refreshContent();
} else {
resetPaletteView();
}
}
private void close() {
canvas.close();
if (paletteExplorer != null) {
paletteExplorer.close();
}
}
}
@@ -18,6 +18,7 @@
package com.volmit.iris.core.gui;
import com.formdev.flatlaf.FlatClientProperties;
import com.volmit.iris.Iris;
import com.volmit.iris.core.IrisSettings;
import com.volmit.iris.core.loader.IrisData;
@@ -31,16 +32,18 @@ import com.volmit.iris.util.noise.CNG;
import javax.swing.AbstractAction;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.DefaultListCellRenderer;
import javax.swing.DefaultListModel;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextField;
import javax.swing.JToolBar;
import javax.swing.KeyStroke;
import javax.swing.ListSelectionModel;
import javax.swing.SwingConstants;
@@ -53,15 +56,14 @@ import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.GridLayout;
import java.awt.Point;
import java.awt.RenderingHints;
import java.awt.event.ActionEvent;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.KeyEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseWheelEvent;
@@ -82,31 +84,16 @@ import java.util.function.LongFunction;
public final class NoiseExplorerGUI extends JPanel {
private static final long serialVersionUID = 2094606939770332040L;
private static final Color BACKGROUND = new Color(12, 15, 22);
static final Color SIDEBAR_BACKGROUND = new Color(17, 20, 29);
static final Color PANEL_BACKGROUND = new Color(23, 27, 38);
static final Color FIELD_BACKGROUND = new Color(29, 34, 47);
private static final Color SELECTED_BACKGROUND = new Color(42, 55, 78);
static final Color PRIMARY_TEXT = new Color(230, 234, 242);
static final Color SECONDARY_TEXT = new Color(151, 160, 178);
static final Color ACCENT = new Color(99, 161, 255);
static final Color BORDER = new Color(48, 55, 72);
static final Color ERROR = new Color(255, 116, 116);
private static final Color GRID = new Color(255, 255, 255, 15);
private static final Color GRID = new Color(255, 255, 255, 12);
private static final Color CROSSHAIR = new Color(255, 255, 255, 125);
private static final Color LEGEND_BACKGROUND = new Color(10, 12, 18, 215);
private static final Font MONOSPACED_FONT = new Font(Font.MONOSPACED, Font.PLAIN, 12);
static final Font HEADER_FONT = new Font(Font.SANS_SERIF, Font.BOLD, 11);
static final Font ITEM_FONT = new Font(Font.SANS_SERIF, Font.PLAIN, 12);
static final Font LEGEND_FONT = new Font(Font.SANS_SERIF, Font.PLAIN, 10);
private static final int SIDEBAR_WIDTH = 270;
private static final int STATUS_HEIGHT = 50;
private static final int LEGEND_WIDTH = 190;
private static final int LEGEND_HEIGHT = 40;
private static final int REFINEMENT_DELAY_MILLIS = 180;
private static final long DEFAULT_SEED = 12345L;
private static final double TEXTURE_PIXELS_MAX_BLOCKS_PER_PIXEL = 0.25D;
private static final double PALETTE_START_BLOCKS_PER_PIXEL = 1D / 16D;
private static final long COARSE_SAMPLE_BUDGET = 4_096L;
private static final long MAX_REFINED_SAMPLE_BUDGET = 48_000L;
private static final double REFINEMENT_TARGET_MILLIS = 250D;
private static final double PALETTE_REFINEMENT_TARGET_MILLIS = 2_000D;
private static final double INTERACTIVE_FRAME_TARGET_MILLIS = 33D;
@@ -151,11 +138,11 @@ public final class NoiseExplorerGUI extends JPanel {
private final DefaultListModel<ListItem> sourceModel = new DefaultListModel<>();
private final JList<ListItem> sourceList = new JList<>(sourceModel);
private final JTextField searchField = new JTextField();
private final JTextField seedField = new JTextField();
private final JTextField seedField = new JTextField(9);
private final JLabel selectedLabel = new JLabel("No source selected");
private final JLabel progressLabel = new JLabel("Opening noise explorer...");
private final JLabel catalogLabel = new JLabel("Pack catalog waiting");
private final JComboBox<NoisePalette> palettePicker = new JComboBox<>(NoisePalette.values());
private final StudioStatusBar statusBar = new StudioStatusBar();
private final PaletteInspectorPanel paletteInspector = new PaletteInspectorPanel(new PaletteInspectorListener());
private final PaletteNavigator paletteNavigator;
private PlayerLocationTracker locationTracker;
@@ -175,11 +162,13 @@ public final class NoiseExplorerGUI extends JPanel {
private Point dragPoint;
private long seed;
private long viewRevision;
private int lastSourceRow = -1;
private boolean catalogLoading;
private boolean sourceLoading;
private boolean rendering;
private boolean disposed;
private boolean awaitingInitialPackSource;
private String requestedPaletteBiome;
private String errorMessage;
private NoiseExplorerGUI(ExplorerMode mode, Engine engine, String customGeneratorKey, IrisGenerator fallbackGenerator,
@@ -188,6 +177,7 @@ public final class NoiseExplorerGUI extends JPanel {
this.paletteStart = paletteStart;
this.paletteNavigator = new PaletteNavigator(this::selectPaletteLayer);
this.awaitingInitialPackSource = mode == ExplorerMode.PALETTES;
this.requestedPaletteBiome = paletteStart == null ? null : paletteStart.biomeKey();
this.initialViewport = paletteStart == null
? new NoiseViewport(0D, 0D, 1D)
: new NoiseViewport(paletteStart.centerX(), paletteStart.centerZ(), PALETTE_START_BLOCKS_PER_PIXEL);
@@ -210,9 +200,12 @@ public final class NoiseExplorerGUI extends JPanel {
this.resizeTimer = new Timer(RESIZE_SETTLE_MILLIS, event -> requestPreview());
this.resizeTimer.setRepeats(false);
this.fixedSources = buildFixedSources();
setBackground(BACKGROUND);
setBackground(StudioTheme.EDITOR_BACKGROUND);
setFocusable(true);
installInputHandlers();
catalogLabel.setFont(StudioTheme.smallFont());
catalogLabel.setForeground(StudioTheme.SECONDARY_TEXT);
statusBar.showActivity(mode == ExplorerMode.PALETTES ? "Opening palettes..." : "Opening noise explorer...");
GuiHost.registerHotloadHook(hotloadHook);
}
@@ -220,14 +213,7 @@ public final class NoiseExplorerGUI extends JPanel {
Engine engine = GuiHost.findActiveEngine();
EventQueue.invokeLater(() -> {
NoiseExplorerGUI explorer = new NoiseExplorerGUI(ExplorerMode.NOISE, engine, null, null, null, DEFAULT_SEED, null);
buildFrame("Noise Explorer", explorer);
});
}
public static void launchPalettes(PaletteExplorerStart start) {
EventQueue.invokeLater(() -> {
NoiseExplorerGUI explorer = new NoiseExplorerGUI(ExplorerMode.PALETTES, start.engine(), null, null, null, DEFAULT_SEED, start);
buildFrame("Palette Explorer", explorer);
buildNoiseFrame("Noise Explorer", explorer);
});
}
@@ -244,18 +230,30 @@ public final class NoiseExplorerGUI extends JPanel {
initialSeed,
null
);
buildFrame("Noise Explorer: " + displayName, explorer);
buildNoiseFrame("Noise Explorer: " + displayName, explorer);
});
}
private static void buildFrame(String title, NoiseExplorerGUI explorer) {
// The palette explorer has no window of its own: the map window shows it as its Palettes view.
static NoiseExplorerGUI createPaletteExplorer(PaletteExplorerStart start) {
return new NoiseExplorerGUI(ExplorerMode.PALETTES, start.engine(), null, null, null, DEFAULT_SEED, start);
}
private static void buildNoiseFrame(String title, NoiseExplorerGUI explorer) {
JFrame frame = GuiHost.createFrame(title);
frame.getContentPane().setBackground(BACKGROUND);
frame.setLayout(new BorderLayout());
frame.add(explorer.buildSidebar(), BorderLayout.WEST);
frame.add(explorer.buildNoiseToolbar(), BorderLayout.NORTH);
frame.add(explorer.buildNoiseSidebar(), BorderLayout.WEST);
frame.add(explorer, BorderLayout.CENTER);
frame.add(explorer.paletteInspector, BorderLayout.EAST);
frame.setSize(1440, 820);
frame.add(explorer.statusBar, BorderLayout.SOUTH);
frame.getRootPane().getInputMap(WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke('R'), "reset-view");
frame.getRootPane().getActionMap().put("reset-view", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent event) {
explorer.resetViewport();
}
});
frame.setSize(1440, 860);
frame.setMinimumSize(new Dimension(760, 500));
frame.setLocationRelativeTo(null);
frame.addWindowListener(new WindowAdapter() {
@@ -294,28 +292,39 @@ public final class NoiseExplorerGUI extends JPanel {
return executor;
}
JPanel buildSidebar() {
private JToolBar buildNoiseToolbar() {
JToolBar toolbar = StudioToolbar.create();
selectedLabel.setFont(StudioTheme.boldFont());
selectedLabel.setBorder(BorderFactory.createEmptyBorder(0, 4, 0, 10));
toolbar.add(selectedLabel);
toolbar.addSeparator();
toolbar.add(StudioToolbar.label("Seed"));
seedField.setMaximumSize(seedField.getPreferredSize());
seedField.addActionListener(event -> applySeed());
toolbar.add(seedField);
toolbar.add(StudioToolbar.button("Apply", "Use this seed", this::applySeed));
toolbar.addSeparator();
toolbar.add(StudioToolbar.label("Colours"));
palettePicker.setSelectedItem(palette);
palettePicker.setMaximumSize(palettePicker.getPreferredSize());
palettePicker.addActionListener(event -> {
NoisePalette nextPalette = (NoisePalette) palettePicker.getSelectedItem();
if (nextPalette != null && nextPalette != palette) {
palette = nextPalette;
requestPreview();
}
});
toolbar.add(palettePicker);
toolbar.add(Box.createHorizontalGlue());
toolbar.add(StudioToolbar.button("Reset view", "Go back to the starting position and zoom (R)", this::resetViewport));
return toolbar;
}
private JPanel buildNoiseSidebar() {
JPanel sidebar = new JPanel(new BorderLayout());
sidebar.setPreferredSize(new Dimension(SIDEBAR_WIDTH, 0));
sidebar.setBackground(SIDEBAR_BACKGROUND);
sidebar.setBorder(BorderFactory.createMatteBorder(0, 0, 0, 1, BORDER));
JPanel heading = new JPanel(new BorderLayout());
heading.setBackground(SIDEBAR_BACKGROUND);
heading.setBorder(BorderFactory.createEmptyBorder(12, 12, 10, 12));
selectedLabel.setForeground(PRIMARY_TEXT);
selectedLabel.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 14));
progressLabel.setForeground(SECONDARY_TEXT);
progressLabel.setFont(ITEM_FONT);
heading.add(selectedLabel, BorderLayout.NORTH);
heading.add(progressLabel, BorderLayout.SOUTH);
configureTextField(searchField);
searchField.putClientProperty("JTextField.placeholderText", "Search...");
searchField.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createMatteBorder(1, 0, 1, 0, BORDER),
BorderFactory.createEmptyBorder(8, 12, 8, 12)
));
sidebar.setBorder(BorderFactory.createMatteBorder(0, 0, 0, 1, StudioTheme.SEPARATOR));
StudioToolbar.makeSearchField(searchField, "Search noise...");
searchField.getDocument().addDocumentListener(new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent event) {
@@ -332,125 +341,126 @@ public final class NoiseExplorerGUI extends JPanel {
populateSourceList();
}
});
// Down or Enter in the search box jumps to the results, so the list can be searched without the mouse.
searchField.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0), "focus-results");
searchField.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), "focus-results");
searchField.getActionMap().put("focus-results", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent event) {
focusFirstSource();
}
});
JPanel north = new JPanel(new BorderLayout());
north.setBackground(SIDEBAR_BACKGROUND);
north.add(heading, BorderLayout.NORTH);
if (mode == ExplorerMode.NOISE) {
north.add(searchField, BorderLayout.SOUTH);
}
sourceList.setBackground(SIDEBAR_BACKGROUND);
sourceList.setForeground(SECONDARY_TEXT);
sourceList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
sourceList.setCellRenderer(new SourceCellRenderer());
sourceList.addListSelectionListener(event -> {
if (event.getValueIsAdjusting()) {
return;
}
if (skipHeaderRow()) {
return;
}
ListItem item = sourceList.getSelectedValue();
lastSourceRow = sourceList.getSelectedIndex();
if (item != null && item.source() != null) {
selectSource(item.source());
}
});
JScrollPane scrollPane = new JScrollPane(sourceList);
scrollPane.setBorder(BorderFactory.createEmptyBorder());
scrollPane.getVerticalScrollBar().setUnitIncrement(18);
JPanel controls = buildControls();
sidebar.add(north, BorderLayout.NORTH);
sidebar.add(mode == ExplorerMode.PALETTES ? paletteNavigator : scrollPane, BorderLayout.CENTER);
sidebar.add(controls, BorderLayout.SOUTH);
sidebar.add(StudioToolbar.padded(searchField, 8, 8, 8, 8), BorderLayout.NORTH);
sidebar.add(StudioToolbar.scroll(sourceList), BorderLayout.CENTER);
sidebar.add(StudioToolbar.padded(catalogLabel, 6, 12, 8, 12), BorderLayout.SOUTH);
populateSourceList();
return sidebar;
}
private JPanel buildControls() {
JPanel controls = new JPanel(new GridLayout(0, 1, 0, 7));
controls.setBackground(PANEL_BACKGROUND);
controls.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createMatteBorder(1, 0, 0, 0, BORDER),
BorderFactory.createEmptyBorder(10, 12, 12, 12)
));
JComponent paletteSidebar() {
JPanel sidebar = new JPanel(new BorderLayout());
sidebar.add(paletteNavigator, BorderLayout.CENTER);
sidebar.add(StudioToolbar.padded(catalogLabel, 6, 12, 8, 12), BorderLayout.SOUTH);
return sidebar;
}
catalogLabel.setForeground(SECONDARY_TEXT);
catalogLabel.setFont(ITEM_FONT);
controls.add(catalogLabel);
PaletteInspectorPanel paletteInspector() {
return paletteInspector;
}
if (mode == ExplorerMode.NOISE) {
addSeedControls(controls);
addPaletteControls(controls);
StudioStatusBar statusBar() {
return statusBar;
}
double centerX() {
return viewport.centerX();
}
double centerZ() {
return viewport.centerZ();
}
void centerOn(double worldX, double worldZ) {
viewport = new NoiseViewport(worldX, worldZ, viewport.blocksPerPixel());
requestPreview();
}
// Jumps to a spot and opens the palette of the biome there, once the catalog has loaded if it hasn't yet.
void showPalettesAt(double worldX, double worldZ, String biomeKey) {
centerOn(worldX, worldZ);
if (biomeKey == null) {
return;
}
requestedPaletteBiome = biomeKey;
if (!packSources.isEmpty()) {
selectPaletteLayer(biomeKey, 0);
}
JButton resetView = createButton("Reset view");
resetView.addActionListener(event -> resetViewport());
controls.add(resetView);
return controls;
}
private void addPaletteControls(JPanel controls) {
JLabel paletteTitle = new JLabel("Palette");
paletteTitle.setForeground(SECONDARY_TEXT);
paletteTitle.setFont(HEADER_FONT);
controls.add(paletteTitle);
palettePicker.setSelectedItem(palette);
palettePicker.setBackground(FIELD_BACKGROUND);
palettePicker.setForeground(PRIMARY_TEXT);
palettePicker.addActionListener(event -> {
NoisePalette nextPalette = (NoisePalette) palettePicker.getSelectedItem();
if (nextPalette != null && nextPalette != palette) {
palette = nextPalette;
requestPreview();
// Header rows can't be chosen, so moving onto one with the arrow keys carries on to the next real source.
private boolean skipHeaderRow() {
int row = sourceList.getSelectedIndex();
if (row < 0 || sourceModel.get(row).source() != null) {
return false;
}
int step = row >= lastSourceRow ? 1 : -1;
int next = nextSourceRow(row, step);
if (next < 0) {
next = nextSourceRow(row, -step);
}
if (next < 0) {
sourceList.clearSelection();
} else {
sourceList.setSelectedIndex(next);
sourceList.ensureIndexIsVisible(next);
}
return true;
}
private int nextSourceRow(int from, int step) {
for (int row = from + step; row >= 0 && row < sourceModel.size(); row += step) {
if (sourceModel.get(row).source() != null) {
return row;
}
});
controls.add(palettePicker);
}
return -1;
}
private void addSeedControls(JPanel controls) {
JLabel seedTitle = new JLabel("Seed");
seedTitle.setForeground(SECONDARY_TEXT);
seedTitle.setFont(HEADER_FONT);
controls.add(seedTitle);
JPanel seedRow = new JPanel(new BorderLayout(7, 0));
seedRow.setOpaque(false);
configureTextField(seedField);
JButton applySeed = createButton("Apply");
applySeed.addActionListener(event -> applySeed());
seedField.addActionListener(event -> applySeed());
seedRow.add(seedField, BorderLayout.CENTER);
seedRow.add(applySeed, BorderLayout.EAST);
controls.add(seedRow);
}
private static JButton createButton(String text) {
JButton button = new JButton(text);
button.setBackground(FIELD_BACKGROUND);
button.setForeground(PRIMARY_TEXT);
button.setFocusPainted(false);
button.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createLineBorder(BORDER),
BorderFactory.createEmptyBorder(5, 9, 5, 9)
));
return button;
}
private static void configureTextField(JTextField field) {
field.setBackground(FIELD_BACKGROUND);
field.setForeground(PRIMARY_TEXT);
field.setCaretColor(PRIMARY_TEXT);
field.setFont(ITEM_FONT);
field.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createLineBorder(BORDER),
BorderFactory.createEmptyBorder(6, 8, 6, 8)
));
private void focusFirstSource() {
int row = sourceList.getSelectedIndex() >= 0 ? sourceList.getSelectedIndex() : nextSourceRow(-1, 1);
if (row < 0) {
return;
}
sourceList.setSelectedIndex(row);
sourceList.ensureIndexIsVisible(row);
sourceList.requestFocusInWindow();
}
void openViewer() {
loadCatalog();
if (mode == ExplorerMode.PALETTES) {
loadBlockTextures();
if (paletteStart != null) {
if (paletteStart != null && paletteStart.playerId() != null) {
locationTracker = PlayerLocationTracker.follow(paletteStart.playerId(), paletteStart.worldId(), paletteNavigator::showLocation);
} else {
paletteNavigator.showUntracked();
}
}
SourceItem initial = findInitialSource();
@@ -512,7 +522,7 @@ public final class NoiseExplorerGUI extends JPanel {
}
long revision = catalogLoadRevision.incrementAndGet();
catalogLoading = true;
catalogLabel.setForeground(SECONDARY_TEXT);
catalogLabel.setForeground(StudioTheme.SECONDARY_TEXT);
catalogLabel.setText("Loading pack catalog...");
submitLatest(catalogExecutor, () -> {
try {
@@ -664,10 +674,10 @@ public final class NoiseExplorerGUI extends JPanel {
paletteNavigator.showCatalog(paletteCatalog);
}
if (error != null) {
catalogLabel.setForeground(ERROR);
catalogLabel.setForeground(StudioTheme.ERROR);
catalogLabel.setText("Pack catalog unavailable");
} else {
catalogLabel.setForeground(SECONDARY_TEXT);
catalogLabel.setForeground(StudioTheme.SECONDARY_TEXT);
catalogLabel.setText(catalogSummary(sources));
}
boolean reloadSelectedPack = false;
@@ -693,7 +703,7 @@ public final class NoiseExplorerGUI extends JPanel {
}
private void selectInitialPackSource(List<SourceItem> sources) {
String initialSourceId = paletteStart == null || paletteStart.biomeKey() == null ? null : paletteSourceId(paletteStart.biomeKey(), 0);
String initialSourceId = requestedPaletteBiome == null ? null : paletteSourceId(requestedPaletteBiome, 0);
SourceItem requested = initialSourceId == null ? null : findSourceById(initialSourceId, sources);
SourceItem initial = requested == null ? sources.get(0) : requested;
if (!selectSourceInList(initial.id())) {
@@ -724,9 +734,8 @@ public final class NoiseExplorerGUI extends JPanel {
rendering = false;
errorMessage = "Pack source removed during hotload: " + removedSource.text();
selectedLabel.setText("No source selected");
progressLabel.setForeground(ERROR);
progressLabel.setText("Selected pack source was removed");
sourceList.clearSelection();
refreshStatus();
repaint();
}
@@ -817,8 +826,7 @@ public final class NoiseExplorerGUI extends JPanel {
sourceLoading = true;
rendering = false;
errorMessage = null;
progressLabel.setForeground(SECONDARY_TEXT);
progressLabel.setText("Loading source...");
refreshStatus();
repaint();
submitLatest(sourceExecutor, () -> {
try {
@@ -844,8 +852,7 @@ public final class NoiseExplorerGUI extends JPanel {
sourceLoading = false;
errorMessage = null;
renderResult = null;
progressLabel.setForeground(SECONDARY_TEXT);
progressLabel.setText(source.usesSeed() ? "Ready · seed " + seed : "Ready");
refreshStatus();
requestPreview();
}
@@ -857,8 +864,7 @@ public final class NoiseExplorerGUI extends JPanel {
sourceLoading = false;
rendering = false;
errorMessage = readableError(error);
progressLabel.setForeground(ERROR);
progressLabel.setText("Source failed to load");
refreshStatus();
repaint();
}
@@ -892,7 +898,7 @@ public final class NoiseExplorerGUI extends JPanel {
String rawSeed = seedField.getText().trim();
try {
long nextSeed = Long.parseLong(rawSeed);
seedField.setForeground(PRIMARY_TEXT);
seedField.putClientProperty(FlatClientProperties.OUTLINE, null);
if (nextSeed == seed) {
return;
}
@@ -901,9 +907,8 @@ public final class NoiseExplorerGUI extends JPanel {
loadSelectedSource();
}
} catch (NumberFormatException error) {
seedField.setForeground(ERROR);
progressLabel.setForeground(ERROR);
progressLabel.setText("Seed must be a whole number");
seedField.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR);
statusBar.showError("Seed must be a whole number");
}
}
@@ -966,7 +971,7 @@ public final class NoiseExplorerGUI extends JPanel {
requestRender(sampleStep);
}
// Smooth noise looks fine upscaled, but blocks and their textures only read correctly at full resolution.
// Blocks and their textures are slower to sample, so palettes get longer refinement passes than plain noise.
private long refinedSampleBudget(NoiseRenderCoordinator.Result currentResult) {
double targetMilliseconds = mode == ExplorerMode.PALETTES ? PALETTE_REFINEMENT_TARGET_MILLIS : REFINEMENT_TARGET_MILLIS;
return timedSampleBudget(currentResult, targetMilliseconds);
@@ -988,10 +993,7 @@ public final class NoiseExplorerGUI extends JPanel {
}
private long maximumSampleBudget() {
if (mode == ExplorerMode.PALETTES) {
return Math.max(COARSE_SAMPLE_BUDGET, (long) getWidth() * renderHeight());
}
return MAX_REFINED_SAMPLE_BUDGET;
return Math.max(COARSE_SAMPLE_BUDGET, (long) getWidth() * renderHeight());
}
private NoiseRenderCoordinator.Request requestRender(int sampleStep) {
@@ -1042,13 +1044,13 @@ public final class NoiseExplorerGUI extends JPanel {
@Override
public void mouseMoved(MouseEvent event) {
pointer = event.getPoint();
repaint();
refreshPointerStatus();
}
@Override
public void mouseExited(MouseEvent event) {
pointer = null;
repaint();
refreshPointerStatus();
}
@Override
@@ -1075,16 +1077,9 @@ public final class NoiseExplorerGUI extends JPanel {
resizeTimer.restart();
}
});
getInputMap(WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke('R'), "reset-view");
getActionMap().put("reset-view", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent event) {
resetViewport();
}
});
}
private void resetViewport() {
void resetViewport() {
viewport = initialViewport;
requestPreview();
}
@@ -1137,7 +1132,6 @@ public final class NoiseExplorerGUI extends JPanel {
}
drawCrosshair(canvas, getWidth(), renderHeight);
drawLegend(canvas, getWidth());
drawStatus(canvas, getWidth(), renderHeight);
} finally {
canvas.dispose();
}
@@ -1176,7 +1170,7 @@ public final class NoiseExplorerGUI extends JPanel {
}
private static void drawEmptyState(Graphics2D canvas, int width, int height) {
canvas.setColor(BACKGROUND);
canvas.setColor(StudioTheme.EDITOR_BACKGROUND);
canvas.fillRect(0, 0, width, height);
canvas.setColor(GRID);
for (int x = 0; x < width; x += 48) {
@@ -1198,101 +1192,110 @@ public final class NoiseExplorerGUI extends JPanel {
private void drawLegend(Graphics2D canvas, int width) {
NoiseColorScale colorScale = activeColorScale();
int legendWidth = 190;
int legendHeight = 40;
int legendX = Math.max(10, width - legendWidth - 12);
int legendX = Math.max(10, width - LEGEND_WIDTH - 12);
int legendY = 12;
canvas.setColor(LEGEND_BACKGROUND);
canvas.fillRoundRect(legendX, legendY, legendWidth, legendHeight, 10, 10);
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
StudioOverlay.drawCard(canvas, legendX, legendY, LEGEND_WIDTH, LEGEND_HEIGHT);
int gradientX = legendX + 9;
int gradientY = legendY + 8;
int gradientWidth = legendWidth - 18;
int gradientWidth = LEGEND_WIDTH - 18;
for (int x = 0; x < gradientWidth; x++) {
double normalized = x / (double) Math.max(1, gradientWidth - 1);
canvas.setColor(colorScale.displayColorNormalized(normalized));
canvas.drawLine(gradientX + x, gradientY, gradientX + x, gradientY + 8);
}
canvas.setFont(LEGEND_FONT);
canvas.setColor(PRIMARY_TEXT);
canvas.setFont(StudioTheme.smallFont());
canvas.setColor(StudioTheme.TEXT);
canvas.drawString(formatNumber(colorScale.minimum()), gradientX, legendY + 31);
String maximum = formatNumber(colorScale.maximum());
int maximumWidth = canvas.getFontMetrics().stringWidth(maximum);
canvas.drawString(maximum, gradientX + gradientWidth - maximumWidth, legendY + 31);
String label = colorScale.label();
int labelWidth = canvas.getFontMetrics().stringWidth(label);
canvas.drawString(label, legendX + ((legendWidth - labelWidth) / 2), legendY + 31);
canvas.drawString(label, legendX + ((LEGEND_WIDTH - labelWidth) / 2), legendY + 31);
}
private void drawStatus(Graphics2D canvas, int width, int renderHeight) {
canvas.setColor(PANEL_BACKGROUND);
canvas.fillRect(0, renderHeight, width, STATUS_HEIGHT);
canvas.setColor(BORDER);
canvas.drawLine(0, renderHeight, width, renderHeight);
canvas.setFont(MONOSPACED_FONT);
canvas.setColor(errorMessage == null ? PRIMARY_TEXT : ERROR);
canvas.drawString(primaryStatus(), 10, renderHeight + 19);
canvas.setColor(SECONDARY_TEXT);
canvas.drawString(secondaryStatus(width, renderHeight), 10, renderHeight + 38);
}
private String primaryStatus() {
private void refreshStatus() {
if (errorMessage != null) {
return "Error: " + errorMessage;
statusBar.showError("Error: " + errorMessage);
} else {
statusBar.showSummary(primaryStatus());
}
String sourceName = selectedSource == null ? "No source" : selectedSource.text();
if (sourceLoading) {
return sourceName + " · loading source";
}
if (rendering) {
return sourceName + " · rendering preview";
}
if (renderResult == null) {
return sourceName + " · waiting for preview";
refreshPointerStatus();
}
// Only the status bar changes when the pointer moves, so moving the mouse never redraws the map.
private void refreshPointerStatus() {
int renderHeight = renderHeight();
Point currentPointer = pointer;
double screenX = currentPointer == null ? getWidth() / 2D : currentPointer.getX();
double screenZ = currentPointer == null ? renderHeight / 2D : Math.min(currentPointer.getY(), renderHeight);
double worldX = viewport.worldX(screenX, getWidth());
double worldZ = viewport.worldZ(screenZ, renderHeight);
statusBar.showPosition(String.format(Locale.ROOT, "X %.1f Z %.1f %s blocks/px", worldX, worldZ,
formatScale(viewport.blocksPerPixel())));
statusBar.showActivity(pointerDetail(worldX, worldZ));
}
private String pointerDetail(double worldX, double worldZ) {
if (catalogLoading && renderResult == null) {
return "Loading catalog...";
}
NoiseRenderCoordinator.Result result = renderResult;
String quality = result.request().sampleStep() == 1
? "full"
: "sample 1:" + result.request().sampleStep();
return sourceName + " · " + quality + " · " + formatNumber(result.centerValue())
+ " center · " + String.format(Locale.ROOT, "%.1f ms", result.milliseconds());
}
private String secondaryStatus(int width, int renderHeight) {
Point currentPointer = pointer;
double screenX = currentPointer == null ? width / 2D : currentPointer.getX();
double screenZ = currentPointer == null ? renderHeight / 2D : Math.min(currentPointer.getY(), renderHeight);
double worldX = viewport.worldX(screenX, width);
double worldZ = viewport.worldZ(screenZ, renderHeight);
String coordinates = String.format(
Locale.ROOT,
"X %.2f Z %.2f · %.5f blocks/px",
worldX,
worldZ,
viewport.blocksPerPixel()
);
if (renderResult == null) {
return coordinates + (catalogLoading ? " · loading catalog" : "");
if (result == null) {
return "";
}
PaletteLayerPreview preview = palettePreview;
if (preview != null) {
return coordinates + " · " + describePaletteAt(preview, worldX, worldZ);
return describePaletteAt(preview, worldX, worldZ);
}
long clipped = result.underflow() + result.overflow();
return "range " + formatNumber(result.minimum()) + " to " + formatNumber(result.maximum())
+ " clipped " + clipped + " invalid " + result.invalid();
}
private String primaryStatus() {
String sourceName = selectedSource == null ? "No source" : sourceTitle(selectedSource);
if (sourceLoading) {
return sourceName + " · loading";
}
if (rendering && renderResult == null) {
return sourceName + " · rendering";
}
if (renderResult == null) {
return sourceName;
}
NoiseRenderCoordinator.Result result = renderResult;
long clipped = result.underflow() + result.overflow();
return coordinates + " · range " + formatNumber(result.minimum()) + ".." + formatNumber(result.maximum())
+ " · clipped " + clipped + " invalid " + result.invalid();
String quality = result.request().sampleStep() == 1
? "full detail"
: "1 sample per " + result.request().sampleStep() + " px";
String seedText = selectedSource != null && selectedSource.usesSeed() ? " · seed " + seed : "";
return sourceName + seedText + " · " + quality + " · " + String.format(Locale.ROOT, "%.0f ms", result.milliseconds());
}
private static String sourceTitle(SourceItem source) {
return source.category() == NoiseCategory.PACK_PALETTES
? source.rawName() + " · layer " + paletteLayerOf(source)
: source.text();
}
private static String describePaletteAt(PaletteLayerPreview preview, double worldX, double worldZ) {
double noise = preview.sample(worldX, worldZ);
if (Double.isNaN(noise)) {
return "layer doesn't reach this depth here";
return "The layer doesn't reach this depth here";
}
return preview.entryForNoise(noise).block() + " · noise " + formatNumber(noise);
return preview.entryForNoise(noise).block() + " noise " + formatNumber(noise);
}
private int renderHeight() {
return Math.max(1, getHeight() - STATUS_HEIGHT);
return Math.max(1, getHeight());
}
private static String formatScale(double blocksPerPixel) {
if (blocksPerPixel >= 1D) {
return String.format(Locale.ROOT, "%.2f", blocksPerPixel);
}
return "1/" + String.format(Locale.ROOT, "%.0f", 1D / blocksPerPixel);
}
private static String formatNumber(double value) {
@@ -1445,7 +1448,7 @@ public final class NoiseExplorerGUI extends JPanel {
EventQueue.invokeLater(() -> {
if (!disposed && request.revision() == viewRevision) {
rendering = true;
repaint();
refreshStatus();
}
});
}
@@ -1460,6 +1463,7 @@ public final class NoiseExplorerGUI extends JPanel {
errorMessage = null;
renderResult = result;
repaint();
refreshStatus();
if (finishInteractiveFrame(result.request())) {
return;
}
@@ -1493,6 +1497,7 @@ public final class NoiseExplorerGUI extends JPanel {
rendering = false;
errorMessage = readableError(error);
repaint();
refreshStatus();
});
}
}
@@ -1503,18 +1508,15 @@ public final class NoiseExplorerGUI extends JPanel {
boolean selected, boolean focus) {
ListItem item = (ListItem) value;
boolean header = item.source() == null;
super.getListCellRendererComponent(list, item.text(), index, selected && !header, false);
setOpaque(true);
super.getListCellRendererComponent(list, header ? item.text().toUpperCase(Locale.ROOT) : item.text(), index,
selected && !header, false);
if (header) {
setFont(HEADER_FONT);
setForeground(ACCENT);
setBackground(SIDEBAR_BACKGROUND);
setBorder(BorderFactory.createEmptyBorder(11, 11, 4, 11));
setFont(StudioTheme.sectionFont());
setForeground(StudioTheme.MUTED_TEXT);
setBorder(BorderFactory.createEmptyBorder(12, 12, 4, 12));
} else {
setFont(ITEM_FONT);
setForeground(selected ? Color.WHITE : SECONDARY_TEXT);
setBackground(selected ? SELECTED_BACKGROUND : SIDEBAR_BACKGROUND);
setBorder(BorderFactory.createEmptyBorder(4, 20, 4, 10));
setFont(StudioTheme.font());
setBorder(BorderFactory.createEmptyBorder(3, 20, 3, 12));
}
setHorizontalAlignment(SwingConstants.LEFT);
return this;
@@ -21,10 +21,8 @@ package com.volmit.iris.core.gui;
import com.volmit.iris.util.function.NoiseProvider;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
import java.util.Objects;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
@@ -35,10 +33,11 @@ import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
final class NoiseRenderCoordinator implements AutoCloseable {
private static final int BANDS_PER_WORKER = 4;
private final int workerCount;
private final Listener listener;
private final ThreadFactory workerThreadFactory;
private final ThreadFactory coordinatorThreadFactory;
private final ThreadPoolExecutor workers;
private final AtomicReference<RenderGeneration> latestRequested = new AtomicReference<>();
private final AtomicLong latestRevision = new AtomicLong();
private final AtomicBoolean closed = new AtomicBoolean();
@@ -51,18 +50,16 @@ final class NoiseRenderCoordinator implements AutoCloseable {
this.workerCount = Math.max(1, workerCount);
this.listener = Objects.requireNonNull(listener, "listener");
AtomicInteger threadIds = new AtomicInteger();
workerThreadFactory = runnable -> {
ThreadFactory workerThreadFactory = runnable -> {
Thread thread = new Thread(runnable, "Iris Noise Renderer " + threadIds.incrementAndGet());
thread.setDaemon(true);
thread.setPriority(Thread.MIN_PRIORITY);
return thread;
};
coordinatorThreadFactory = runnable -> {
Thread thread = new Thread(runnable, "Iris Noise Coordinator " + threadIds.incrementAndGet());
thread.setDaemon(true);
thread.setPriority(Thread.MIN_PRIORITY);
return thread;
};
workers = new ThreadPoolExecutor(this.workerCount, this.workerCount, 30L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(), workerThreadFactory);
workers.allowCoreThreadTimeOut(true);
GuiHost.interruptOnPluginShutdown(workers);
}
void request(Request request) {
@@ -71,15 +68,23 @@ final class NoiseRenderCoordinator implements AutoCloseable {
return;
}
latestRevision.accumulateAndGet(request.revision(), Math::max);
cancelActiveRender();
RenderGeneration generation = createGeneration(request);
RenderGeneration generation = new RenderGeneration(request, Math.min(workerCount * BANDS_PER_WORKER, outputHeight(request)));
latestRequested.set(generation);
generation.start();
listener.onRenderStarted(request);
for (int band = 0; band < generation.bandCount(); band++) {
int bandIndex = band;
try {
workers.execute(() -> renderBand(generation, bandIndex));
} catch (RejectedExecutionException rejected) {
generation.fail(rejected);
finishBand(generation);
}
}
}
void cancel(long revision) {
latestRevision.accumulateAndGet(revision, Math::max);
cancelActiveRender();
latestRequested.set(null);
}
boolean isClosed() {
@@ -136,89 +141,43 @@ final class NoiseRenderCoordinator implements AutoCloseable {
return;
}
latestRevision.incrementAndGet();
cancelActiveRender();
latestRequested.set(null);
workers.shutdownNow();
}
private void renderGeneration(RenderGeneration generation) {
Request request = generation.request();
if (!isCurrent(request)) {
generation.close();
return;
}
listener.onRenderStarted(request);
// Bands of a frame that was replaced are skipped, so a new frame only waits for the rows already being drawn.
private void renderBand(RenderGeneration generation, int bandIndex) {
try {
Result result = render(generation);
if (result != null && isCurrent(request)) {
listener.onRenderCompleted(result);
if (isCurrent(generation)) {
renderRows(generation, bandIndex);
}
} catch (Throwable error) {
if (isCurrent(request)) {
listener.onRenderFailed(request, error);
}
generation.fail(error);
} finally {
latestRequested.compareAndSet(generation, null);
generation.close();
finishBand(generation);
}
}
private Result render(RenderGeneration generation) throws InterruptedException {
private void finishBand(RenderGeneration generation) {
if (!generation.completeBand() || !isCurrent(generation)) {
return;
}
Throwable failure = generation.failure();
if (failure != null) {
listener.onRenderFailed(generation.request(), failure);
return;
}
listener.onRenderCompleted(generation.result());
}
private void renderRows(RenderGeneration generation, int bandIndex) {
Request request = generation.request();
long started = System.nanoTime();
int outputWidth = Math.max(1, (request.width() + request.sampleStep() - 1) / request.sampleStep());
int outputHeight = Math.max(1, (request.height() + request.sampleStep() - 1) / request.sampleStep());
BufferedImage image = new BufferedImage(outputWidth, outputHeight, BufferedImage.TYPE_INT_RGB);
int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
int bandCount = Math.min(workerCount, outputHeight);
BandStats[] bandStats = new BandStats[bandCount];
CountDownLatch completion = new CountDownLatch(bandCount);
AtomicReference<Throwable> failure = new AtomicReference<>();
for (int band = 0; band < bandCount; band++) {
bandStats[band] = new BandStats();
}
for (int band = 0; band < bandCount; band++) {
int bandIndex = band;
Runnable task = () -> {
try {
renderBand(request, image, pixels, bandIndex, bandCount, bandStats[bandIndex]);
} catch (Throwable error) {
failure.compareAndSet(null, error);
} finally {
completion.countDown();
}
};
try {
generation.executor().execute(task);
} catch (RejectedExecutionException exception) {
failure.compareAndSet(null, exception);
completion.countDown();
}
}
completion.await();
Throwable renderFailure = failure.get();
if (renderFailure != null) {
if (renderFailure instanceof RuntimeException runtimeException) {
throw runtimeException;
}
if (renderFailure instanceof Error error) {
throw error;
}
throw new IllegalStateException("Noise rendering failed", renderFailure);
}
if (!isCurrent(request)) {
return null;
}
RenderStats combined = combine(bandStats);
double milliseconds = (System.nanoTime() - started) / 1_000_000D;
return new Result(request, image, milliseconds, combined.samples, combined.minimum, combined.maximum,
combined.centerValue, combined.underflow, combined.overflow, combined.invalid);
}
private void renderBand(Request request, BufferedImage image, int[] pixels, int bandIndex, int bandCount,
BandStats stats) {
int outputWidth = image.getWidth();
int outputHeight = image.getHeight();
int fromY = (outputHeight * bandIndex) / bandCount;
int toY = (outputHeight * (bandIndex + 1)) / bandCount;
int outputWidth = generation.outputWidth();
int outputHeight = generation.outputHeight();
int[] pixels = generation.pixels();
BandStats stats = generation.bandStats(bandIndex);
int fromY = (outputHeight * bandIndex) / generation.bandCount();
int toY = (outputHeight * (bandIndex + 1)) / generation.bandCount();
double sampleOffset = request.sampleStep() * 0.5D;
double startWorldX = request.viewport().worldX(sampleOffset, request.width());
double worldStep = request.viewport().blocksPerPixel() * request.sampleStep();
@@ -232,7 +191,7 @@ final class NoiseRenderCoordinator implements AutoCloseable {
int pixelIndex = y * outputWidth;
boolean centerRow = y == outputHeight / 2;
for (int x = 0; x < outputWidth; x++) {
if ((x & 31) == 0 && (!isCurrent(request) || Thread.currentThread().isInterrupted())) {
if ((x & 31) == 0 && (!isCurrent(generation) || Thread.currentThread().isInterrupted())) {
return;
}
double value = request.sampler().noise(worldX, worldZ);
@@ -249,36 +208,18 @@ final class NoiseRenderCoordinator implements AutoCloseable {
}
}
private boolean isCurrent(Request request) {
private boolean isCurrent(RenderGeneration generation) {
return !closed.get()
&& latestRequested.get() != null
&& latestRequested.get().request() == request
&& request.revision() >= latestRevision.get();
&& latestRequested.get() == generation
&& generation.request().revision() >= latestRevision.get();
}
private void cancelActiveRender() {
RenderGeneration generation = latestRequested.getAndSet(null);
if (generation != null) {
generation.close();
}
private static int outputWidth(Request request) {
return Math.max(1, (request.width() + request.sampleStep() - 1) / request.sampleStep());
}
private RenderGeneration createGeneration(Request request) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
workerCount,
workerCount,
0L,
TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(Math.max(1, workerCount)),
workerThreadFactory,
new ThreadPoolExecutor.AbortPolicy()
);
GuiHost.interruptOnPluginShutdown(executor);
RenderGeneration generation = new RenderGeneration(request, executor);
Thread actualRunner = coordinatorThreadFactory.newThread(() -> renderGeneration(generation));
generation.setRunner(actualRunner);
GuiHost.interruptOnPluginShutdown(actualRunner);
return generation;
private static int outputHeight(Request request) {
return Math.max(1, (request.height() + request.sampleStep() - 1) / request.sampleStep());
}
private static RenderStats combine(BandStats[] bands) {
@@ -333,36 +274,71 @@ final class NoiseRenderCoordinator implements AutoCloseable {
private static final class RenderGeneration {
private final Request request;
private final ThreadPoolExecutor executor;
private volatile Thread runner;
private final long startedNanos = System.nanoTime();
private final int outputWidth;
private final int outputHeight;
private final int[] pixels;
private final BandStats[] bandStats;
private final AtomicInteger remainingBands;
private final AtomicReference<Throwable> failure = new AtomicReference<>();
private RenderGeneration(Request request, ThreadPoolExecutor executor) {
this.request = Objects.requireNonNull(request, "request");
this.executor = Objects.requireNonNull(executor, "executor");
private RenderGeneration(Request request, int bandCount) {
this.request = request;
this.outputWidth = NoiseRenderCoordinator.outputWidth(request);
this.outputHeight = NoiseRenderCoordinator.outputHeight(request);
this.pixels = new int[outputWidth * outputHeight];
this.bandStats = new BandStats[Math.max(1, bandCount)];
for (int band = 0; band < bandStats.length; band++) {
bandStats[band] = new BandStats();
}
this.remainingBands = new AtomicInteger(bandStats.length);
}
private Request request() {
return request;
}
private ThreadPoolExecutor executor() {
return executor;
private int bandCount() {
return bandStats.length;
}
private void setRunner(Thread runner) {
this.runner = Objects.requireNonNull(runner, "runner");
private int outputWidth() {
return outputWidth;
}
private void start() {
runner.start();
private int outputHeight() {
return outputHeight;
}
private void close() {
Thread activeRunner = runner;
if (activeRunner != null) {
activeRunner.interrupt();
}
executor.shutdownNow();
private int[] pixels() {
return pixels;
}
private BandStats bandStats(int band) {
return bandStats[band];
}
private void fail(Throwable error) {
failure.compareAndSet(null, error);
}
private Throwable failure() {
return failure.get();
}
// True for the band that finishes last, which is the one that publishes the frame.
private boolean completeBand() {
return remainingBands.decrementAndGet() == 0;
}
// Copied into a fresh image instead of drawing into its buffer, so Java2D can keep the finished frame on the GPU.
private Result result() {
BufferedImage image = new BufferedImage(outputWidth, outputHeight, BufferedImage.TYPE_INT_RGB);
image.getRaster().setDataElements(0, 0, outputWidth, outputHeight, pixels);
RenderStats combined = combine(bandStats);
double milliseconds = (System.nanoTime() - startedNanos) / 1_000_000D;
return new Result(request, image, milliseconds, combined.samples, combined.minimum, combined.maximum,
combined.centerValue, combined.underflow, combined.overflow, combined.invalid);
}
}
@@ -24,13 +24,11 @@ import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JToggleButton;
import javax.swing.SwingConstants;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
@@ -40,30 +38,19 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import static com.volmit.iris.core.gui.NoiseExplorerGUI.ACCENT;
import static com.volmit.iris.core.gui.NoiseExplorerGUI.BORDER;
import static com.volmit.iris.core.gui.NoiseExplorerGUI.FIELD_BACKGROUND;
import static com.volmit.iris.core.gui.NoiseExplorerGUI.HEADER_FONT;
import static com.volmit.iris.core.gui.NoiseExplorerGUI.ITEM_FONT;
import static com.volmit.iris.core.gui.NoiseExplorerGUI.LEGEND_FONT;
import static com.volmit.iris.core.gui.NoiseExplorerGUI.PANEL_BACKGROUND;
import static com.volmit.iris.core.gui.NoiseExplorerGUI.PRIMARY_TEXT;
import static com.volmit.iris.core.gui.NoiseExplorerGUI.SECONDARY_TEXT;
import static com.volmit.iris.core.gui.NoiseExplorerGUI.SIDEBAR_BACKGROUND;
final class PaletteInspectorPanel extends JPanel {
private static final long serialVersionUID = 4473102938518712001L;
private static final int PANEL_WIDTH = 310;
private static final Color WARNING = new Color(255, 196, 92);
private static final Color HISTOGRAM_BAR = new Color(151, 160, 178, 170);
private static final Color HISTOGRAM_BAR = new Color(0x9D, 0xA0, 0xA8, 150);
private static final Color BAND_DIVIDER = new Color(255, 255, 255, 70);
private final Listener listener;
private final JLabel titleLabel = new JLabel();
private final JLabel detailLabel = new JLabel();
private final JLabel depthLabel = new JLabel("", SwingConstants.CENTER);
private final JButton shallowerButton = ExplorerStyle.button("‹");
private final JButton deeperButton = ExplorerStyle.button("›");
private final JButton shallowerButton = new JButton("‹");
private final JButton deeperButton = new JButton("›");
private final JPanel depthRow = new JPanel(new BorderLayout(6, 0));
private final StatisticsView statisticsView = new StatisticsView();
private PaletteLayerScale.View selectedView = PaletteLayerScale.View.BLOCKS;
@@ -74,13 +61,9 @@ final class PaletteInspectorPanel extends JPanel {
super(new BorderLayout());
this.listener = listener;
setPreferredSize(new Dimension(PANEL_WIDTH, 0));
setBackground(SIDEBAR_BACKGROUND);
setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, BORDER));
setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, StudioTheme.SEPARATOR));
add(buildControls(), BorderLayout.NORTH);
JScrollPane statisticsScroll = new JScrollPane(statisticsView);
statisticsScroll.setBorder(BorderFactory.createEmptyBorder());
statisticsScroll.getVerticalScrollBar().setUnitIncrement(18);
add(statisticsScroll, BorderLayout.CENTER);
add(StudioToolbar.scroll(statisticsView), BorderLayout.CENTER);
setVisible(false);
}
@@ -109,15 +92,13 @@ final class PaletteInspectorPanel extends JPanel {
private JPanel buildControls() {
JPanel controls = new JPanel(new GridLayout(0, 1, 0, 6));
controls.setBackground(PANEL_BACKGROUND);
controls.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createMatteBorder(0, 0, 1, 0, BORDER),
BorderFactory.createMatteBorder(0, 0, 1, 0, StudioTheme.SEPARATOR),
BorderFactory.createEmptyBorder(12, 14, 12, 14)
));
titleLabel.setForeground(PRIMARY_TEXT);
titleLabel.setFont(ITEM_FONT.deriveFont(Font.BOLD, 14F));
detailLabel.setForeground(SECONDARY_TEXT);
detailLabel.setFont(ITEM_FONT);
titleLabel.setFont(StudioTheme.titleFont());
detailLabel.setForeground(StudioTheme.SECONDARY_TEXT);
detailLabel.setFont(StudioTheme.smallFont());
controls.add(titleLabel);
controls.add(detailLabel);
@@ -133,7 +114,8 @@ final class PaletteInspectorPanel extends JPanel {
row.setOpaque(false);
ButtonGroup group = new ButtonGroup();
for (PaletteLayerScale.View view : PaletteLayerScale.View.values()) {
JToggleButton button = ExplorerStyle.toggleButton(view.toString());
JToggleButton button = new JToggleButton(view.toString());
button.setFocusable(false);
button.setSelected(view == selectedView);
button.addActionListener(event -> {
selectedView = view;
@@ -147,8 +129,8 @@ final class PaletteInspectorPanel extends JPanel {
private JPanel buildDepthRow() {
depthRow.setOpaque(false);
depthLabel.setForeground(PRIMARY_TEXT);
depthLabel.setFont(ITEM_FONT);
shallowerButton.setToolTipText("Show the block one higher up");
deeperButton.setToolTipText("Show the block one further down");
shallowerButton.addActionListener(event -> changeDepth(-1));
deeperButton.addActionListener(event -> changeDepth(1));
depthRow.add(shallowerButton, BorderLayout.WEST);
@@ -174,9 +156,9 @@ final class PaletteInspectorPanel extends JPanel {
}
private static JLabel sectionLabel(String text) {
JLabel label = new JLabel(text);
label.setForeground(SECONDARY_TEXT);
label.setFont(HEADER_FONT);
JLabel label = new JLabel(text.toUpperCase(Locale.ROOT));
label.setForeground(StudioTheme.MUTED_TEXT);
label.setFont(StudioTheme.sectionFont());
return label;
}
@@ -227,7 +209,7 @@ final class PaletteInspectorPanel extends JPanel {
private StatisticsView() {
setOpaque(true);
setBackground(SIDEBAR_BACKGROUND);
setBackground(StudioTheme.PANEL_BACKGROUND);
}
private void show(PaletteLayerPreview nextPreview) {
@@ -248,7 +230,7 @@ final class PaletteInspectorPanel extends JPanel {
return new Dimension(PANEL_WIDTH - 20, 0);
}
int width = Math.max(getWidth(), PANEL_WIDTH - 20) - MARGIN * 2;
FontMetrics noteMetrics = getFontMetrics(LEGEND_FONT);
FontMetrics noteMetrics = getFontMetrics(StudioTheme.smallFont());
int noteLines = 0;
for (String note : notes()) {
noteLines += wrap(note, noteMetrics, width).size() + 1;
@@ -265,7 +247,7 @@ final class PaletteInspectorPanel extends JPanel {
try {
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
canvas.setColor(SIDEBAR_BACKGROUND);
canvas.setColor(StudioTheme.PANEL_BACKGROUND);
canvas.fillRect(0, 0, getWidth(), getHeight());
if (preview == null) {
return;
@@ -280,7 +262,7 @@ final class PaletteInspectorPanel extends JPanel {
}
private int drawValueSpread(Graphics2D canvas, int x, int y, int width) {
drawSectionTitle(canvas, "NOISE VALUES THIS LAYER PRODUCES", x, y + 10);
drawSectionTitle(canvas, "Noise values this layer produces", x, y + 10);
int histogramTop = y + 18;
long[] histogram = preview.histogram();
long peak = 1L;
@@ -310,8 +292,8 @@ final class PaletteInspectorPanel extends JPanel {
}
}
canvas.setFont(LEGEND_FONT);
canvas.setColor(SECONDARY_TEXT);
canvas.setFont(StudioTheme.smallFont());
canvas.setColor(StudioTheme.SECONDARY_TEXT);
FontMetrics metrics = canvas.getFontMetrics();
int labelY = bandTop + BAND_HEIGHT + 13;
canvas.drawString("0", x, labelY);
@@ -323,19 +305,19 @@ final class PaletteInspectorPanel extends JPanel {
private int drawBlockTable(Graphics2D canvas, int x, int y, int width) {
int actualRight = x + width;
int weightRight = actualRight - ACTUAL_COLUMN_WIDTH;
drawSectionTitle(canvas, "BLOCKS", x, y);
drawSectionTitle(canvas, "Blocks", x, y);
drawRightAligned(canvas, "weight", weightRight, y);
drawRightAligned(canvas, "actual", actualRight, y);
int rowY = y + ROW_HEIGHT;
for (PaletteLayerPreview.Entry entry : preview.entries()) {
drawSwatch(canvas, entry, x, rowY - SWATCH_SIZE + 2);
canvas.setFont(ITEM_FONT);
canvas.setColor(entry.found() ? PRIMARY_TEXT : NoiseExplorerGUI.ERROR);
canvas.setFont(StudioTheme.font());
canvas.setColor(entry.found() ? StudioTheme.TEXT : StudioTheme.ERROR);
String name = entry.found() ? shortBlockName(entry.block()) : shortBlockName(entry.block()) + " (not found)";
int nameLeft = x + SWATCH_SIZE + 7;
canvas.drawString(fit(canvas, name, weightRight - WEIGHT_COLUMN_WIDTH - nameLeft), nameLeft, rowY);
canvas.setColor(SECONDARY_TEXT);
canvas.setColor(StudioTheme.SECONDARY_TEXT);
drawRightAligned(canvas, percent(entry.weightShare()), weightRight, rowY);
canvas.setColor(actualColor(entry));
drawRightAligned(canvas, actualText(entry), actualRight, rowY);
@@ -354,8 +336,8 @@ final class PaletteInspectorPanel extends JPanel {
}
private void drawNotes(Graphics2D canvas, int x, int y, int width) {
canvas.setFont(LEGEND_FONT);
canvas.setColor(SECONDARY_TEXT);
canvas.setFont(StudioTheme.smallFont());
canvas.setColor(StudioTheme.SECONDARY_TEXT);
FontMetrics metrics = canvas.getFontMetrics();
int lineY = y;
for (String note : notes()) {
@@ -401,13 +383,13 @@ final class PaletteInspectorPanel extends JPanel {
}
private static Color actualColor(PaletteLayerPreview.Entry entry) {
return entry.isFarBelowWeight() || entry.isFarAboveWeight() ? WARNING : PRIMARY_TEXT;
return entry.isFarBelowWeight() || entry.isFarAboveWeight() ? StudioTheme.WARNING : StudioTheme.TEXT;
}
private static void drawSectionTitle(Graphics2D canvas, String title, int x, int y) {
canvas.setFont(HEADER_FONT);
canvas.setColor(ACCENT);
canvas.drawString(title, x, y);
canvas.setFont(StudioTheme.sectionFont());
canvas.setColor(StudioTheme.MUTED_TEXT);
canvas.drawString(title.toUpperCase(Locale.ROOT), x, y);
}
private static void drawRightAligned(Graphics2D canvas, String text, int right, int y) {
@@ -30,12 +30,10 @@ import javax.swing.DefaultListModel;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextField;
import javax.swing.JToggleButton;
import javax.swing.JTree;
import javax.swing.ListSelectionModel;
import javax.swing.ScrollPaneConstants;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.tree.DefaultMutableTreeNode;
@@ -53,21 +51,13 @@ import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import static com.volmit.iris.core.gui.NoiseExplorerGUI.ACCENT;
import static com.volmit.iris.core.gui.NoiseExplorerGUI.BORDER;
import static com.volmit.iris.core.gui.NoiseExplorerGUI.FIELD_BACKGROUND;
import static com.volmit.iris.core.gui.NoiseExplorerGUI.HEADER_FONT;
import static com.volmit.iris.core.gui.NoiseExplorerGUI.ITEM_FONT;
import static com.volmit.iris.core.gui.NoiseExplorerGUI.PANEL_BACKGROUND;
import static com.volmit.iris.core.gui.NoiseExplorerGUI.PRIMARY_TEXT;
import static com.volmit.iris.core.gui.NoiseExplorerGUI.SECONDARY_TEXT;
import static com.volmit.iris.core.gui.NoiseExplorerGUI.SIDEBAR_BACKGROUND;
final class PaletteNavigator extends JPanel {
private static final long serialVersionUID = -6402313718430917284L;
private static final String HERE_CARD = "here";
private static final String OVERVIEW_CARD = "overview";
private static final String DETAIL_COLOR = "#8890A0";
private static final String DETAIL_COLOR = StudioTheme.SECONDARY_TEXT_HTML;
private static final int MESSAGE_WRAP_WIDTH_PIXELS = 190;
private final Listener listener;
private final CardLayout cards = new CardLayout();
@@ -84,11 +74,11 @@ final class PaletteNavigator extends JPanel {
private String selectedBiome;
private int selectedLayer;
private boolean updatingSelection;
private boolean untracked;
PaletteNavigator(Listener listener) {
super(new BorderLayout());
this.listener = listener;
setBackground(SIDEBAR_BACKGROUND);
cardPanel.add(buildHereCard(), HERE_CARD);
cardPanel.add(buildOverviewCard(), OVERVIEW_CARD);
add(buildTabBar(), BorderLayout.NORTH);
@@ -107,6 +97,12 @@ final class PaletteNavigator extends JPanel {
refreshHere();
}
// Without a player there is nowhere to call "here", so the Here tab explains how to get one instead.
void showUntracked() {
untracked = true;
refreshHere();
}
void markSelected(String biomeKey, int layer) {
selectedBiome = biomeKey;
selectedLayer = layer;
@@ -120,9 +116,8 @@ final class PaletteNavigator extends JPanel {
}
private JPanel buildTabBar() {
JPanel bar = new JPanel(new GridLayout(1, 2, 6, 0));
bar.setBackground(SIDEBAR_BACKGROUND);
bar.setBorder(BorderFactory.createEmptyBorder(0, 12, 8, 12));
JPanel bar = new JPanel(new GridLayout(1, 2, 0, 0));
bar.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, StudioTheme.SEPARATOR));
ButtonGroup group = new ButtonGroup();
JToggleButton here = createTab("Here", HERE_CARD, group);
JToggleButton overview = createTab("Overview", OVERVIEW_CARD, group);
@@ -133,7 +128,7 @@ final class PaletteNavigator extends JPanel {
}
private JToggleButton createTab(String text, String card, ButtonGroup group) {
JToggleButton tab = ExplorerStyle.toggleButton(text);
JToggleButton tab = StudioToolbar.tab(text);
tab.addActionListener(event -> cards.show(cardPanel, card));
group.add(tab);
return tab;
@@ -141,18 +136,10 @@ final class PaletteNavigator extends JPanel {
private JPanel buildHereCard() {
JPanel card = new JPanel(new BorderLayout());
card.setBackground(SIDEBAR_BACKGROUND);
JPanel location = new JPanel(new GridLayout(0, 1, 0, 3));
location.setBackground(PANEL_BACKGROUND);
location.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createMatteBorder(1, 0, 1, 0, BORDER),
BorderFactory.createEmptyBorder(9, 12, 9, 12)
));
regionLabel.setForeground(PRIMARY_TEXT);
regionLabel.setFont(ITEM_FONT);
location.setBorder(BorderFactory.createEmptyBorder(9, 12, 5, 12));
location.add(regionLabel);
hereList.setBackground(SIDEBAR_BACKGROUND);
hereList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
hereList.setCellRenderer(new HereRowRenderer());
hereList.addListSelectionListener(event -> {
@@ -162,23 +149,13 @@ final class PaletteNavigator extends JPanel {
}
});
card.add(location, BorderLayout.NORTH);
card.add(scroll(hereList), BorderLayout.CENTER);
card.add(StudioToolbar.scroll(hereList), BorderLayout.CENTER);
return card;
}
private JPanel buildOverviewCard() {
JPanel card = new JPanel(new BorderLayout());
card.setBackground(SIDEBAR_BACKGROUND);
searchField.setBackground(FIELD_BACKGROUND);
searchField.setForeground(PRIMARY_TEXT);
searchField.setCaretColor(PRIMARY_TEXT);
searchField.setFont(ITEM_FONT);
searchField.putClientProperty("JTextField.placeholderText", "Search biomes or blocks...");
searchField.setToolTipText("Search biomes or blocks");
searchField.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createMatteBorder(1, 0, 1, 0, BORDER),
BorderFactory.createEmptyBorder(8, 12, 8, 12)
));
StudioToolbar.makeSearchField(searchField, "Search biomes or blocks...");
searchField.getDocument().addDocumentListener(new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent event) {
@@ -198,8 +175,7 @@ final class PaletteNavigator extends JPanel {
overviewTree.setRootVisible(false);
overviewTree.setShowsRootHandles(true);
overviewTree.setBackground(SIDEBAR_BACKGROUND);
overviewTree.setRowHeight(22);
overviewTree.setRowHeight(24);
overviewTree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION);
overviewTree.setCellRenderer(new OverviewRenderer());
overviewTree.addTreeSelectionListener(event -> {
@@ -207,20 +183,11 @@ final class PaletteNavigator extends JPanel {
chooseBiome(node.biomeKey(), Math.max(0, node.layer()));
}
});
card.add(searchField, BorderLayout.NORTH);
card.add(scroll(overviewTree), BorderLayout.CENTER);
card.add(StudioToolbar.padded(searchField, 8, 8, 8, 8), BorderLayout.NORTH);
card.add(StudioToolbar.scroll(overviewTree), BorderLayout.CENTER);
return card;
}
private static JScrollPane scroll(Component content) {
JScrollPane scrollPane = new JScrollPane(content);
scrollPane.setBorder(BorderFactory.createEmptyBorder());
scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
scrollPane.getViewport().setBackground(SIDEBAR_BACKGROUND);
scrollPane.getVerticalScrollBar().setUnitIncrement(18);
return scrollPane;
}
private void chooseBiome(String biomeKey, int preferredLayer) {
BiomeEntry biome = catalog == null ? null : catalog.biome(biomeKey);
if (biome == null || biome.layerSummaries().isEmpty()) {
@@ -233,6 +200,10 @@ final class PaletteNavigator extends JPanel {
updatingSelection = true;
try {
hereRows.clear();
if (untracked) {
showHereMessage("Run /iris std pal in-game to see the palettes where you stand.");
return;
}
if (location == null || catalog == null) {
showHereMessage("Finding where you are...");
return;
@@ -249,7 +220,7 @@ final class PaletteNavigator extends JPanel {
}
private void showHereMessage(String message) {
regionLabel.setText(message);
regionLabel.setText("<html><body style='width:" + MESSAGE_WRAP_WIDTH_PIXELS + "px'>" + message + "</body></html>");
}
private void showHereRows(PlayerLocation here) {
@@ -445,11 +416,12 @@ final class PaletteNavigator extends JPanel {
public Component getListCellRendererComponent(JList<?> list, Object value, int index, boolean selected, boolean focus) {
HereRow row = (HereRow) value;
boolean selectable = row.biomeKey() != null;
super.getListCellRendererComponent(list, row.text(), index, selected && selectable, false);
setOpaque(true);
setFont(row.header() ? HEADER_FONT : ITEM_FONT);
setForeground(row.header() ? ACCENT : selected ? PRIMARY_TEXT : SECONDARY_TEXT);
setBackground(selected && selectable ? FIELD_BACKGROUND.brighter() : SIDEBAR_BACKGROUND);
super.getListCellRendererComponent(list, row.header() ? row.text().toUpperCase(Locale.ROOT) : row.text(), index,
selected && selectable, false);
setFont(row.header() ? StudioTheme.sectionFont() : StudioTheme.font());
if (row.header()) {
setForeground(StudioTheme.MUTED_TEXT);
}
int indent = row.header() ? HEADER_INDENT : row.layer() >= 0 ? LAYER_INDENT : BIOME_INDENT;
setBorder(BorderFactory.createEmptyBorder(row.header() ? 11 : 4, indent, 4, 10));
return this;
@@ -463,11 +435,6 @@ final class PaletteNavigator extends JPanel {
setLeafIcon(null);
setOpenIcon(null);
setClosedIcon(null);
setBackgroundNonSelectionColor(SIDEBAR_BACKGROUND);
setBackgroundSelectionColor(FIELD_BACKGROUND.brighter());
setBorderSelectionColor(null);
setTextNonSelectionColor(SECONDARY_TEXT);
setTextSelectionColor(PRIMARY_TEXT);
}
@Override
@@ -476,7 +443,7 @@ final class PaletteNavigator extends JPanel {
Object userObject = ((DefaultMutableTreeNode) value).getUserObject();
String text = userObject instanceof OverviewNode node ? node.html() : "";
super.getTreeCellRendererComponent(tree, text, selected, expanded, leaf, row, false);
setFont(userObject instanceof OverviewNode node && node.biomeKey() == null ? HEADER_FONT : ITEM_FONT);
setFont(userObject instanceof OverviewNode node && node.biomeKey() == null ? StudioTheme.boldFont() : StudioTheme.font());
return this;
}
}
File diff suppressed because it is too large. Load diff
@@ -26,6 +26,8 @@ import java.awt.EventQueue;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
@@ -35,9 +37,7 @@ import java.util.Set;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.Callable;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Future;
import java.util.concurrent.FutureTask;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
@@ -47,50 +47,49 @@ import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
/**
* Renders the map as tiles on fixed zoom levels, so zooming within a level and panning reuse tiles that are already drawn.
* Tiles keep rendering while the view moves, as long as they are still on screen.
*/
final class VisionRenderController implements AutoCloseable {
static final int TILE_PIXELS = 64;
static final double MINIMUM_BLOCKS_PER_PIXEL = 1D;
static final double MINIMUM_VIEW_BLOCKS_PER_PIXEL = 1D / 8D;
static final double MAXIMUM_BLOCKS_PER_PIXEL = 4_096D;
private static final int MAXIMUM_WORKERS = 3;
private static final double MINIMUM_RENDER_BLOCKS_PER_PIXEL = 1D;
private static final int MAXIMUM_VISIBLE_TILES = 32_768;
private static final int RENDER_QUEUE_CAPACITY = 12;
private static final int PROBE_QUEUE_CAPACITY = 1;
private static final long CACHE_BYTES = 64L * 1024L * 1024L;
private static final int COARSER_FALLBACK_LEVELS = 6;
private static final long CACHE_BYTES = 128L * 1024L * 1024L;
private final Runnable listener;
private final int renderWorkerCount;
private final int workerCount;
private final ThreadPoolExecutor renderExecutor;
private final ThreadPoolExecutor probeExecutor;
private final WeightedTileCache cache;
private final AtomicLong viewSequence;
private final AtomicLong probeSequence;
private final AtomicBoolean closed;
private final AtomicBoolean publicationQueued;
private final AtomicBoolean publicationDirty;
private final Set<Future<?>> activeRenderTasks;
private final Set<TileKey> tilesInFlight = new HashSet<>();
private final AtomicInteger activeWorkers = new AtomicInteger();
private final AtomicLong viewSequence = new AtomicLong();
private final AtomicLong probeSequence = new AtomicLong();
private final AtomicBoolean closed = new AtomicBoolean();
private final AtomicBoolean publicationQueued = new AtomicBoolean();
private final AtomicBoolean publicationDirty = new AtomicBoolean();
private volatile Frame currentFrame;
private volatile WorkState currentWork;
private volatile CancellationToken currentToken;
VisionRenderController(Runnable listener) {
this(listener, RuntimeOptions.production());
}
VisionRenderController(Runnable listener, RuntimeOptions options) {
this.listener = Objects.requireNonNull(listener, "listener");
Objects.requireNonNull(options, "options");
int processors = Math.max(1, Runtime.getRuntime().availableProcessors());
this.workerCount = Math.max(1, Math.min(6, processors / 2));
AtomicInteger threadSequence = new AtomicInteger();
this.renderWorkerCount = options.workers();
this.renderExecutor = new ThreadPoolExecutor(
options.workers(),
options.workers(),
0L,
TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(options.renderQueueCapacity()),
daemonFactory("Iris Vision Render", Thread.NORM_PRIORITY, threadSequence),
new ThreadPoolExecutor.AbortPolicy()
workerCount,
workerCount,
30L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(),
daemonFactory("Iris Vision Render", Thread.NORM_PRIORITY, threadSequence)
);
this.renderExecutor.allowCoreThreadTimeOut(true);
this.probeExecutor = new ThreadPoolExecutor(
1,
1,
@@ -101,60 +100,46 @@ final class VisionRenderController implements AutoCloseable {
new ThreadPoolExecutor.DiscardOldestPolicy()
);
this.cache = new WeightedTileCache(CACHE_BYTES);
this.viewSequence = new AtomicLong();
this.probeSequence = new AtomicLong();
this.closed = new AtomicBoolean();
this.publicationQueued = new AtomicBoolean();
this.publicationDirty = new AtomicBoolean();
this.activeRenderTasks = ConcurrentHashMap.newKeySet();
GuiHost.interruptOnPluginShutdown(renderExecutor);
GuiHost.interruptOnPluginShutdown(probeExecutor);
}
synchronized Frame request(RenderSpec spec) {
// Snapping to powers of two keeps every zoom within a level on the same tiles; nearest keeps tiles within 1.41x of screen size.
static double renderBlocksPerPixel(double viewBlocksPerPixel) {
double level = Math.pow(2D, Math.round(Math.log(viewBlocksPerPixel) / Math.log(2D)));
return Math.max(MINIMUM_RENDER_BLOCKS_PER_PIXEL, Math.min(MAXIMUM_BLOCKS_PER_PIXEL, level));
}
Frame request(RenderSpec spec) {
Objects.requireNonNull(spec, "spec");
if (closed.get()) {
throw new IllegalStateException("Vision render controller is closed");
}
CancellationToken previousToken = currentToken;
if (previousToken != null) {
previousToken.cancel();
double renderScale = renderBlocksPerPixel(spec.blocksPerPixel());
List<VisibleTile> tiles = visibleTiles(spec, renderScale);
Frame frame = new Frame(viewSequence.incrementAndGet(), spec, renderScale, tiles);
for (VisibleTile tile : tiles) {
tile.setImage(cache.get(tile.key()));
}
renderExecutor.getQueue().clear();
cancelActiveRenderTasks();
currentFrame = frame;
probeSequence.incrementAndGet();
probeExecutor.getQueue().clear();
List<VisibleTile> tiles = visibleTiles(spec);
Frame frame = new Frame(viewSequence.incrementAndGet(), spec, tiles);
for (VisibleTile tile : tiles) {
tile.setImage(cache.get(frame.key(tile)));
}
CancellationToken token = new CancellationToken();
WorkState work = new WorkState(frame, token);
currentFrame = frame;
currentToken = token;
currentWork = work;
schedule(work);
publish(frame, token);
startWorkers();
publish();
return frame;
}
Frame currentFrame() {
return currentFrame;
}
BufferedImage image(Frame frame, VisibleTile tile) {
if (frame == null || tile == null) {
return null;
}
return tile.image();
/**
* A tile from another zoom level covering the given one, to draw while the real tile renders.
*/
Fallback fallback(Frame frame, VisibleTile tile) {
Fallback coarser = coarserFallback(frame, tile);
return coarser != null ? coarser : finerFallback(frame, tile);
}
Progress progress(Frame frame) {
if (frame == null) {
return new Progress(0, 0, 0, 0);
return new Progress(0, 0, 0);
}
int ready = 0;
for (VisibleTile tile : frame.tiles()) {
@@ -162,7 +147,7 @@ final class VisionRenderController implements AutoCloseable {
ready++;
}
}
return new Progress(frame.tiles().size(), ready, renderExecutor.getActiveCount(), renderExecutor.getQueue().size());
return new Progress(frame.tiles().size(), ready, activeWorkers.get());
}
<T> void submitProbe(Frame frame, Callable<T> probe, Consumer<T> consumer) {
@@ -174,35 +159,27 @@ final class VisionRenderController implements AutoCloseable {
long probeRevision = probeSequence.incrementAndGet();
probeExecutor.getQueue().clear();
try {
probeExecutor.execute(() -> runProbe(frame, probeRevision, probe, consumer));
probeExecutor.execute(() -> runProbe(probeRevision, probe, consumer));
} catch (RejectedExecutionException ignored) {
probeExecutor.getQueue().clear();
}
}
@Override
public synchronized void close() {
public void close() {
if (!closed.compareAndSet(false, true)) {
return;
}
CancellationToken token = currentToken;
if (token != null) {
token.cancel();
}
currentWork = null;
currentFrame = null;
renderExecutor.getQueue().clear();
cancelActiveRenderTasks();
probeExecutor.getQueue().clear();
renderExecutor.shutdownNow();
probeExecutor.shutdownNow();
cache.clear();
}
static List<VisibleTile> visibleTiles(RenderSpec spec) {
static List<VisibleTile> visibleTiles(RenderSpec spec, double renderScale) {
Objects.requireNonNull(spec, "spec");
double blocksPerPixel = spec.blocksPerPixel();
double tileSpan = TILE_PIXELS * blocksPerPixel;
double tileSpan = TILE_PIXELS * renderScale;
double halfWidth = spec.width() * blocksPerPixel * 0.5D;
double halfHeight = spec.height() * blocksPerPixel * 0.5D;
long minimumX = floorTile(spec.centerX() - halfWidth, tileSpan);
@@ -217,19 +194,12 @@ final class VisionRenderController implements AutoCloseable {
ArrayList<VisibleTile> tiles = new ArrayList<>((int) tileCount);
double centerTileX = spec.centerX() / tileSpan;
double centerTileZ = spec.centerZ() / tileSpan;
for (long tileZ = minimumZ; ; tileZ++) {
for (long tileX = minimumX; ; tileX++) {
int screenX = (int) Math.round(spec.width() * 0.5D + (tileX * tileSpan - spec.centerX()) / blocksPerPixel);
int screenY = (int) Math.round(spec.height() * 0.5D + (tileZ * tileSpan - spec.centerZ()) / blocksPerPixel);
for (long tileZ = minimumZ; tileZ <= maximumZ; tileZ++) {
for (long tileX = minimumX; tileX <= maximumX; tileX++) {
double deltaX = tileX + 0.5D - centerTileX;
double deltaZ = tileZ + 0.5D - centerTileZ;
tiles.add(new VisibleTile(tileX, tileZ, screenX, screenY, deltaX * deltaX + deltaZ * deltaZ));
if (tileX == maximumX) {
break;
}
}
if (tileZ == maximumZ) {
break;
TileKey key = new TileKey(spec.contentRevision(), spec.type(), renderScale, tileX, tileZ);
tiles.add(new VisibleTile(key, deltaX * deltaX + deltaZ * deltaZ));
}
}
tiles.sort(Comparator.comparingDouble(VisibleTile::distanceSquared)
@@ -238,13 +208,6 @@ final class VisionRenderController implements AutoCloseable {
return List.copyOf(tiles);
}
static long sampleCount(int tileCount) {
if (tileCount < 1) {
throw new IllegalArgumentException("Vision tile count must be positive");
}
return Math.multiplyExact((long) tileCount, (long) TILE_PIXELS * TILE_PIXELS);
}
private static long floorTile(double coordinate, double tileSpan) {
double tile = Math.floor(coordinate / tileSpan);
if (tile < Long.MIN_VALUE || tile > Long.MAX_VALUE) {
@@ -263,81 +226,142 @@ final class VisionRenderController implements AutoCloseable {
};
}
private void schedule(WorkState work) {
synchronized (work) {
if (!isCurrent(work)) {
private void startWorkers() {
while (!closed.get()) {
int active = activeWorkers.get();
if (active >= workerCount || !hasWork()) {
return;
}
while (work.inFlight() < renderWorkerCount) {
VisibleTile tile = work.nextMissing();
if (tile == null) {
return;
}
work.incrementInFlight();
TrackedRenderTask task = new TrackedRenderTask(() -> render(work, tile), activeRenderTasks);
activeRenderTasks.add(task);
try {
renderExecutor.execute(task);
} catch (RejectedExecutionException ignored) {
task.cancel(false);
work.decrementInFlight();
return;
}
if (!activeWorkers.compareAndSet(active, active + 1)) {
continue;
}
try {
renderExecutor.execute(this::renderUntilIdle);
} catch (RejectedExecutionException ignored) {
activeWorkers.decrementAndGet();
return;
}
}
}
private void render(WorkState work, VisibleTile tile) {
Frame frame = work.frame();
CancellationToken token = work.token();
// Each worker keeps taking the most central missing tile of whatever frame is current, until the screen is covered.
private void renderUntilIdle() {
try {
if (!isCurrent(frame, token)) {
return;
TileJob job;
while ((job = claimNextTile()) != null) {
render(job);
}
double tileSpan = TILE_PIXELS * frame.spec().blocksPerPixel();
BufferedImage image = frame.spec().renderer().renderStudio(
tile.tileX() * tileSpan,
tile.tileZ() * tileSpan,
} finally {
activeWorkers.decrementAndGet();
}
startWorkers();
}
private synchronized boolean hasWork() {
Frame frame = currentFrame;
return frame != null && frame.nextMissing(tilesInFlight, false) != null;
}
private synchronized TileJob claimNextTile() {
Frame frame = currentFrame;
if (frame == null || closed.get()) {
return null;
}
VisibleTile tile = frame.nextMissing(tilesInFlight, true);
if (tile == null) {
return null;
}
tilesInFlight.add(tile.key());
return new TileJob(frame.spec().renderer(), tile.key());
}
private synchronized void release(TileKey key) {
tilesInFlight.remove(key);
}
private void render(TileJob job) {
TileKey key = job.key();
try {
double tileSpan = TILE_PIXELS * key.blocksPerPixel();
BufferedImage image = job.renderer().renderStudio(
key.tileX() * tileSpan,
key.tileZ() * tileSpan,
tileSpan,
TILE_PIXELS,
frame.spec().type(),
() -> !isCurrent(frame, token)
key.type(),
() -> !isWanted(key)
);
if (!isCurrent(frame, token)) {
return;
cache.put(key, image);
Frame frame = currentFrame;
VisibleTile visible = frame == null ? null : frame.tile(key);
if (visible != null) {
visible.setImage(image);
publish();
}
cache.put(frame.key(tile), image);
tile.setImage(image);
publish(frame, token);
} catch (CancellationException ignored) {
} catch (Throwable error) {
Iris.debug("Vision tile render failed: " + error.getClass().getSimpleName() + ": " + error.getMessage());
} finally {
complete(work);
release(key);
}
}
private void complete(WorkState work) {
synchronized (work) {
work.decrementInFlight();
if (!isCurrent(work)) {
return;
private boolean isWanted(TileKey key) {
Frame frame = currentFrame;
return !closed.get() && frame != null && frame.tile(key) != null;
}
private Fallback coarserFallback(Frame frame, VisibleTile tile) {
TileKey key = tile.key();
for (int level = 1; level <= COARSER_FALLBACK_LEVELS; level++) {
long scale = 1L << level;
int sourceSize = TILE_PIXELS >> level;
double parentScale = key.blocksPerPixel() * scale;
if (sourceSize < 1 || parentScale > MAXIMUM_BLOCKS_PER_PIXEL) {
return null;
}
long parentX = Math.floorDiv(key.tileX(), scale);
long parentZ = Math.floorDiv(key.tileZ(), scale);
BufferedImage parent = cache.get(new TileKey(key.contentRevision(), key.type(), parentScale, parentX, parentZ));
if (parent != null) {
int sourceX = (int) (key.tileX() - parentX * scale) * sourceSize;
int sourceZ = (int) (key.tileZ() - parentZ * scale) * sourceSize;
return new Fallback(List.of(new FallbackPiece(parent, sourceX, sourceZ, sourceSize, 0D, 0D, 1D)));
}
}
schedule(work);
return null;
}
private <T> void runProbe(Frame frame, long probeRevision, Callable<T> probe, Consumer<T> consumer) {
private Fallback finerFallback(Frame frame, VisibleTile tile) {
TileKey key = tile.key();
double childScale = key.blocksPerPixel() / 2D;
if (childScale < MINIMUM_RENDER_BLOCKS_PER_PIXEL) {
return null;
}
List<FallbackPiece> pieces = new ArrayList<>(4);
for (int childZ = 0; childZ < 2; childZ++) {
for (int childX = 0; childX < 2; childX++) {
BufferedImage child = cache.get(new TileKey(key.contentRevision(), key.type(), childScale,
key.tileX() * 2L + childX, key.tileZ() * 2L + childZ));
if (child != null) {
pieces.add(new FallbackPiece(child, 0, 0, TILE_PIXELS, childX * 0.5D, childZ * 0.5D, 0.5D));
}
}
}
return pieces.isEmpty() ? null : new Fallback(List.copyOf(pieces));
}
private <T> void runProbe(long probeRevision, Callable<T> probe, Consumer<T> consumer) {
try {
if (!isProbeCurrent(frame, probeRevision)) {
if (!isProbeCurrent(probeRevision)) {
return;
}
T result = probe.call();
if (!isProbeCurrent(frame, probeRevision)) {
if (!isProbeCurrent(probeRevision)) {
return;
}
EventQueue.invokeLater(() -> {
if (isProbeCurrent(frame, probeRevision)) {
if (isProbeCurrent(probeRevision)) {
consumer.accept(result);
}
});
@@ -348,20 +372,12 @@ final class VisionRenderController implements AutoCloseable {
}
}
private boolean isProbeCurrent(Frame frame, long probeRevision) {
return !closed.get() && currentFrame == frame && probeSequence.get() == probeRevision;
private boolean isProbeCurrent(long probeRevision) {
return !closed.get() && probeSequence.get() == probeRevision;
}
private boolean isCurrent(Frame frame, CancellationToken token) {
return !closed.get() && !token.cancelled() && currentFrame == frame;
}
private boolean isCurrent(WorkState work) {
return currentWork == work && isCurrent(work.frame(), work.token());
}
private void publish(Frame frame, CancellationToken token) {
if (!isCurrent(frame, token)) {
private void publish() {
if (closed.get()) {
return;
}
publicationDirty.set(true);
@@ -386,13 +402,6 @@ final class VisionRenderController implements AutoCloseable {
});
}
private void cancelActiveRenderTasks() {
for (Future<?> task : activeRenderTasks) {
task.cancel(true);
}
activeRenderTasks.clear();
}
record RenderSpec(
IrisRenderer renderer,
RenderType type,
@@ -410,7 +419,7 @@ final class VisionRenderController implements AutoCloseable {
throw new IllegalArgumentException("Vision center must be finite");
}
if (!Double.isFinite(blocksPerPixel)
|| blocksPerPixel < MINIMUM_BLOCKS_PER_PIXEL
|| blocksPerPixel < MINIMUM_VIEW_BLOCKS_PER_PIXEL
|| blocksPerPixel > MAXIMUM_BLOCKS_PER_PIXEL) {
throw new IllegalArgumentException("Vision scale is outside the supported range");
}
@@ -418,50 +427,85 @@ final class VisionRenderController implements AutoCloseable {
throw new IllegalArgumentException("Vision viewport must be positive");
}
}
}
record Frame(long viewRevision, RenderSpec spec, List<VisibleTile> tiles) {
Frame {
Objects.requireNonNull(spec, "spec");
tiles = List.copyOf(tiles);
static final class Frame {
private final long viewRevision;
private final RenderSpec spec;
private final double renderBlocksPerPixel;
private final List<VisibleTile> tiles;
private final Map<TileKey, VisibleTile> tilesByKey;
private int nextCandidate;
private Frame(long viewRevision, RenderSpec spec, double renderBlocksPerPixel, List<VisibleTile> tiles) {
this.viewRevision = viewRevision;
this.spec = Objects.requireNonNull(spec, "spec");
this.renderBlocksPerPixel = renderBlocksPerPixel;
this.tiles = tiles;
this.tilesByKey = new HashMap<>(tiles.size() * 2);
for (VisibleTile tile : tiles) {
tilesByKey.put(tile.key(), tile);
}
}
TileKey key(VisibleTile tile) {
return new TileKey(spec.contentRevision(), spec.type(), spec.blocksPerPixel(), tile.tileX(), tile.tileZ());
long viewRevision() {
return viewRevision;
}
RenderSpec spec() {
return spec;
}
double renderBlocksPerPixel() {
return renderBlocksPerPixel;
}
List<VisibleTile> tiles() {
return tiles;
}
private VisibleTile tile(TileKey key) {
return tilesByKey.get(key);
}
// 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) {
for (int index = nextCandidate; index < tiles.size(); index++) {
VisibleTile tile = tiles.get(index);
if (tile.image() == null && !inFlight.contains(tile.key())) {
if (advance) {
nextCandidate = index + 1;
}
return tile;
}
}
if (advance) {
nextCandidate = tiles.size();
}
return null;
}
}
static final class VisibleTile {
private final long tileX;
private final long tileZ;
private final int screenX;
private final int screenY;
private final TileKey key;
private final double distanceSquared;
private volatile BufferedImage image;
private VisibleTile(long tileX, long tileZ, int screenX, int screenY, double distanceSquared) {
this.tileX = tileX;
this.tileZ = tileZ;
this.screenX = screenX;
this.screenY = screenY;
private VisibleTile(TileKey key, double distanceSquared) {
this.key = key;
this.distanceSquared = distanceSquared;
}
TileKey key() {
return key;
}
long tileX() {
return tileX;
return key.tileX();
}
long tileZ() {
return tileZ;
}
int screenX() {
return screenX;
}
int screenY() {
return screenY;
return key.tileZ();
}
double distanceSquared() {
@@ -472,7 +516,7 @@ final class VisionRenderController implements AutoCloseable {
return image;
}
void setImage(BufferedImage image) {
private void setImage(BufferedImage image) {
this.image = image;
}
}
@@ -486,7 +530,17 @@ final class VisionRenderController implements AutoCloseable {
}
}
record Progress(int total, int ready, int active, int queued) {
/**
* Pieces of cached tiles that together cover a missing tile; offsets and size are fractions of the tile.
*/
record Fallback(List<FallbackPiece> pieces) {
}
record FallbackPiece(BufferedImage image, int sourceX, int sourceZ, int sourceSize, double offsetX, double offsetZ,
double size) {
}
record Progress(int total, int ready, int activeWorkers) {
double completion() {
if (total == 0) {
return 1D;
@@ -495,86 +549,7 @@ final class VisionRenderController implements AutoCloseable {
}
}
record RuntimeOptions(int workers, int renderQueueCapacity) {
RuntimeOptions {
if (workers < 1 || renderQueueCapacity < 1) {
throw new IllegalArgumentException("Vision render runtime options are invalid");
}
}
static RuntimeOptions production() {
int processors = Math.max(1, Runtime.getRuntime().availableProcessors());
int workers = Math.max(1, Math.min(MAXIMUM_WORKERS, processors));
return new RuntimeOptions(workers, RENDER_QUEUE_CAPACITY);
}
}
private static final class CancellationToken {
private final AtomicBoolean cancelled = new AtomicBoolean();
void cancel() {
cancelled.set(true);
}
boolean cancelled() {
return cancelled.get();
}
}
private static final class TrackedRenderTask extends FutureTask<Void> {
private final Set<Future<?>> tasks;
private TrackedRenderTask(Runnable task, Set<Future<?>> tasks) {
super(task, null);
this.tasks = tasks;
}
@Override
protected void done() {
tasks.remove(this);
}
}
private static final class WorkState {
private final Frame frame;
private final CancellationToken token;
private int index;
private int inFlight;
private WorkState(Frame frame, CancellationToken token) {
this.frame = frame;
this.token = token;
}
Frame frame() {
return frame;
}
CancellationToken token() {
return token;
}
VisibleTile nextMissing() {
while (index < frame.tiles().size()) {
VisibleTile tile = frame.tiles().get(index++);
if (tile.image() == null) {
return tile;
}
}
return null;
}
int inFlight() {
return inFlight;
}
void incrementInFlight() {
inFlight++;
}
void decrementInFlight() {
inFlight--;
}
private record TileJob(IrisRenderer renderer, TileKey key) {
}
private static final class WeightedTileCache {
@@ -28,7 +28,6 @@ import com.volmit.iris.util.stream.ProceduralStream;
import java.awt.Color;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
import java.util.Arrays;
import java.util.IdentityHashMap;
import java.util.List;
@@ -98,17 +97,36 @@ public final class IrisRenderer {
Objects.requireNonNull(cancelled, "cancelled");
checkCancelled(cancelled);
int[] pixels = renderPixels(sx, sz, size, resolution, currentType, cancelled, studio);
return managedImage(pixels, resolution);
}
// Filled through the raster rather than its backing array, so Java2D may keep the image on the GPU between paints.
private static BufferedImage managedImage(int[] pixels, int resolution) {
BufferedImage image = new BufferedImage(resolution, resolution, BufferedImage.TYPE_INT_RGB);
int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
image.getRaster().setDataElements(0, 0, resolution, resolution, pixels);
return image;
}
private int[] renderPixels(
double sx,
double sz,
double size,
int resolution,
RenderType currentType,
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 image;
return pixels;
}
PixelShader shader = shader(currentType, studio);
if (studio && adaptiveStudioType(currentType)) {
renderAdaptiveAtlas(pixels, resolution, sx, sz, step, shader, cancelled);
return image;
return pixels;
}
int groupSize = sampleGroup(step, resolution);
@@ -130,7 +148,7 @@ public final class IrisRenderer {
}
}
return image;
return pixels;
}
public static int heightColor(double height, double maximumHeight, double fluidHeight) {