Show the noise that picks regions and biomes

Noise view, rarity bands, hover picks.
This commit is contained in:
piexel committed 2026-10-21 20:56:01 +02:00
1 parent 97dfb63741
commit 0b5b75e936
14 files changed
+1022 -109

No files matched your search

@@ -23,6 +23,7 @@ import com.volmit.iris.core.gui.components.IrisRenderer;
import com.volmit.iris.core.gui.components.MapLabels;
import com.volmit.iris.core.gui.components.MapLayer;
import com.volmit.iris.core.gui.components.MapStyle;
import com.volmit.iris.core.gui.components.PickDisplay;
import com.volmit.iris.core.gui.components.RenderType;
import com.volmit.iris.engine.framework.Engine;
import com.volmit.iris.engine.object.IrisBiome;
@@ -97,6 +98,7 @@ final class MapCanvas extends JPanel {
private MapStyle style = MapStyle.PLAIN;
private RenderType currentType = RenderType.BIOME;
private String previewRegionKey;
private PickDisplay pickDisplay = PickDisplay.PICKS;
private String viewLabel = MapView.BIOMES.label();
private List<GuiMarker> players = List.of();
private List<GuiMarker> entities = List.of();
@@ -219,19 +221,36 @@ final class MapCanvas extends JPanel {
return;
}
previewRegionKey = regionKey;
if (currentType == RenderType.REGION || currentType == RenderType.REGION_PREVIEW) {
if (currentType.isRegionView()) {
currentType = regionKey == null ? RenderType.REGION : RenderType.REGION_PREVIEW;
requestRender();
}
}
/**
* Draws the region view's picks, the noise that picks them, or both.
*/
void showPicksAs(PickDisplay display) {
if (display == pickDisplay) {
return;
}
pickDisplay = display;
if (currentType.isRegionView()) {
requestRender();
}
}
MapCensus.ViewArea viewArea() {
return new MapCensus.ViewArea(screenToWorldX(0D), screenToWorldZ(0D), screenToWorldX(getWidth()),
screenToWorldZ(getHeight()));
}
private MapLayer currentLayer() {
return currentType == RenderType.REGION_PREVIEW ? MapLayer.regionPreview(previewRegionKey) : MapLayer.of(currentType);
return switch (currentType) {
case REGION -> MapLayer.regions(pickDisplay);
case REGION_PREVIEW -> MapLayer.regionPreview(previewRegionKey, pickDisplay);
default -> MapLayer.of(currentType);
};
}
String legendTitle() {
@@ -1079,7 +1098,7 @@ final class MapCanvas extends JPanel {
double worldZ = screenToWorldZ(point.y);
controller.submitProbe(
frame,
() -> MapHoverProbe.sample(probeEngine, worldX, worldZ, previewedRegion()),
() -> MapHoverProbe.sample(probeEngine, worldX, worldZ, previewedRegion(), currentType.isRegionView()),
details -> {
QuickHover quick = quickHover;
if (quick != null && quick.worldX() == details.worldX() && quick.worldZ() == details.worldZ()) {
@@ -27,6 +27,7 @@ import com.volmit.iris.engine.object.IrisBiome;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* The text of the card that follows the cursor: what the drawn map shows there at once, and the rest once probed.
@@ -95,6 +96,32 @@ final class MapHoverCard {
if (details.placement() != null) {
lines.add(placementText(details.placement()));
}
if (details.pickNoise() != null) {
addPickNoise(lines, details.pickNoise(), surface);
}
}
private static void addPickNoise(List<String> lines, MapHoverProbe.PickNoise noise, IrisBiome surface) {
if (noise.regionNoise() != null) {
lines.add("Region noise " + noiseText(noise.regionNoise()));
}
if (noise.biomeType() == null) {
return;
}
String biomeNoise = capitalize(noise.biomeType().name().toLowerCase(Locale.ROOT)) + " biome noise "
+ noiseText(noise.biomeNoise());
IrisBiome picked = noise.noisePick();
if (picked == null) {
lines.add(biomeNoise);
} else if (surface == null || picked.getLoadKey().equals(surface.getLoadKey())) {
lines.add(biomeNoise + " picks " + picked.getName());
} else {
lines.add(biomeNoise + " picks " + picked.getName() + ", then " + surface.getName() + " takes over");
}
}
private static String noiseText(double noise) {
return String.format(Locale.ROOT, "%.3f", noise);
}
// How hybrid terrain chose the base biome; a child biome or a coast can still replace it afterwards.
@@ -41,8 +41,9 @@ final class MapHoverProbe {
/**
* @param previewRegionKey the region the region preview treats every spot as part of, or null for the real world
* @param explainPicks also look up the noise values that picked the region and biome, for the region view
*/
static Details sample(Engine engine, double worldX, double worldZ, String previewRegionKey) {
static Details sample(Engine engine, double worldX, double worldZ, String previewRegionKey, boolean explainPicks) {
IrisComplex complex = engine.getComplex();
IrisRegion previewRegion = previewRegionKey == null ? null : engine.getData().getRegionLoader().load(previewRegionKey);
IrisRegion region = previewRegion != null ? previewRegion : complex.getRegionStream().get(worldX, worldZ);
@@ -66,10 +67,22 @@ final class MapHoverProbe {
height,
dimension == null ? 0D : dimension.getFluidHeight(),
Math.toDegrees(Math.atan(slope / SLOPE_STREAM_RANGE_BLOCKS)),
complex.explainHybridChoice(region, worldX, worldZ)
complex.explainHybridChoice(region, worldX, worldZ),
explainPicks ? pickNoise(complex, region, previewRegion == null, worldX, worldZ) : null
);
}
// HYBRID terrain picks biomes by height, which the placement line already tells, so only the region noise is left.
private static PickNoise pickNoise(IrisComplex complex, IrisRegion region, boolean regionPicked, double x, double z) {
Double regionNoise = regionPicked ? complex.getRegionStyleStream().get(x, z) : null;
if (complex.isHybridTerrain() || region == null) {
return new PickNoise(regionNoise, null, Double.NaN, null);
}
InferredType biomeType = complex.surfacePickAsIfIn(region, x, z).type();
return new PickNoise(regionNoise, biomeType, complex.biomePickNoise(region, biomeType, x, z),
complex.biomePickedByNoise(region, biomeType, x, z));
}
// A shore biome sits on land the continent stream calls land or sea, so the biome's own type says it best.
private static String terrainKindName(IrisBiome surfaceBiome, InferredType continentKind) {
InferredType kind = surfaceBiome != null && surfaceBiome.getInferredType() != null
@@ -93,7 +106,16 @@ final class MapHoverProbe {
double height,
double fluidHeight,
double slopeDegrees,
HybridBiomeSelector.Choice placement
HybridBiomeSelector.Choice placement,
PickNoise pickNoise
) {
}
/**
* @param regionNoise the region noise value, or null when the region preview chose the region
* @param biomeType the biome list the biome noise picked from, or null when no noise picked the biome
* @param noisePick the biome the noise picked, which a child biome can still replace
*/
record PickNoise(Double regionNoise, InferredType biomeType, double biomeNoise, IrisBiome noisePick) {
}
}
@@ -108,7 +108,7 @@ public final class MapWindow implements MapCanvas.Listener {
this.viewList = new MapViewList(this::showView);
this.viewListScroll = StudioToolbar.scroll(viewList);
this.legendPanel = new MapLegendPanel(share -> canvas.toggleHighlight(share.label()));
this.regionExplorer = new RegionExplorer(canvas::engine, canvas::previewRegion);
this.regionExplorer = new RegionExplorer(canvas::engine, canvas::previewRegion, canvas::showPicksAs);
canvas.centerOn(start.centerX(), start.centerZ());
buildFrame();
}
@@ -0,0 +1,238 @@
/*
* 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.JComponent;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.util.List;
import java.util.Locale;
/**
* One noise that picks regions or biomes: the spread of its values in view above the bands that pick each option,
* and a table of how much of the noise each option is given and how much it gets in view.
*/
final class NoisePickChart extends JComponent {
private static final long serialVersionUID = 1873365510472853316L;
private static final int PREFERRED_WIDTH = 300;
private static final int MARGIN = 16;
private static final int HISTOGRAM_HEIGHT = 64;
private static final int BAND_HEIGHT = 12;
private static final int TITLE_GAP = 6;
private static final int SECTION_GAP = 12;
private static final int BOTTOM_GAP = 14;
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 static final String WIDEST_SHARE = "▲ 100.0%";
private final NoisePickSpread spread;
private final String optionNoun;
/**
* @param optionNoun what the noise picks, as a table heading: "Regions" or "Biomes"
*/
NoisePickChart(NoisePickSpread spread, String optionNoun) {
this.spread = spread;
this.optionNoun = optionNoun;
setAlignmentX(LEFT_ALIGNMENT);
}
@Override
public Dimension getPreferredSize() {
int height = StudioTheme.scale(SECTION_GAP) + spreadHeight() + StudioTheme.scale(SECTION_GAP) + tableHeight()
+ StudioTheme.scale(BOTTOM_GAP);
return new Dimension(StudioTheme.scale(PREFERRED_WIDTH), height);
}
@Override
public Dimension getMaximumSize() {
return new Dimension(Integer.MAX_VALUE, getPreferredSize().height);
}
@Override
protected void paintComponent(Graphics graphics) {
Graphics2D canvas = (Graphics2D) graphics.create();
try {
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
int margin = StudioTheme.scale(MARGIN);
int width = getWidth() - margin * 2;
int top = StudioTheme.scale(SECTION_GAP);
drawSpread(canvas, margin, top, width);
drawTable(canvas, margin, top + spreadHeight() + StudioTheme.scale(SECTION_GAP), width);
} finally {
canvas.dispose();
}
}
private int spreadHeight() {
return sectionMetrics().getHeight() + StudioTheme.scale(TITLE_GAP) + StudioTheme.scale(HISTOGRAM_HEIGHT)
+ StudioTheme.scale(2) + StudioTheme.scale(BAND_HEIGHT) + StudioTheme.scale(3) + smallMetrics().getHeight();
}
private int tableHeight() {
return sectionMetrics().getHeight() + StudioTheme.scale(TITLE_GAP) + rowHeight() * spread.rows().size();
}
private void drawSpread(Graphics2D canvas, int x, int top, int width) {
drawSectionTitle(canvas, spread.title(), x, top + sectionMetrics().getAscent());
int histogramTop = top + sectionMetrics().getHeight() + StudioTheme.scale(TITLE_GAP);
int histogramHeight = StudioTheme.scale(HISTOGRAM_HEIGHT);
drawHistogram(canvas, x, histogramTop, width, histogramHeight);
int bandTop = histogramTop + histogramHeight + StudioTheme.scale(2);
drawBands(canvas, x, width, histogramTop, bandTop);
canvas.setFont(StudioTheme.smallFont());
canvas.setColor(StudioTheme.SECONDARY_TEXT);
FontMetrics metrics = canvas.getFontMetrics();
int labelY = bandTop + StudioTheme.scale(BAND_HEIGHT) + StudioTheme.scale(3) + metrics.getAscent();
canvas.drawString("0", x, labelY);
canvas.drawString("0.5", x + (width - metrics.stringWidth("0.5")) / 2, labelY);
canvas.drawString("1", x + width - metrics.stringWidth("1"), labelY);
}
private void drawHistogram(Graphics2D canvas, int x, int top, int width, int height) {
long[] histogram = spread.histogram();
long peak = 1L;
for (long count : histogram) {
peak = Math.max(peak, count);
}
canvas.setColor(HISTOGRAM_BAR);
for (int bin = 0; bin < histogram.length; bin++) {
int barLeft = x + (bin * width) / histogram.length;
int barRight = x + ((bin + 1) * width) / histogram.length;
int barHeight = (int) Math.round(height * (histogram[bin] / (double) peak));
canvas.fillRect(barLeft, top + height - barHeight, Math.max(1, barRight - barLeft - 1), barHeight);
}
}
private void drawBands(Graphics2D canvas, int x, int width, int histogramTop, int bandTop) {
int bandHeight = StudioTheme.scale(BAND_HEIGHT);
for (NoisePickSpread.Band band : spread.bands()) {
int bandLeft = x + (int) Math.round(band.fromNoise() * width);
int bandRight = x + (int) Math.round(band.toNoise() * width);
canvas.setColor(new Color(band.option().color()));
canvas.fillRect(bandLeft, bandTop, Math.max(1, bandRight - bandLeft), bandHeight);
if (band.toNoise() < 1D) {
canvas.setColor(BAND_DIVIDER);
canvas.drawLine(bandRight, histogramTop, bandRight, bandTop + bandHeight - 1);
}
}
}
private void drawTable(Graphics2D canvas, int x, int top, int width) {
int columnGap = StudioTheme.scale(10);
int actualRight = x + width;
int weightRight = actualRight - textMetrics().stringWidth(WIDEST_SHARE) - columnGap;
int nameRight = weightRight - textMetrics().stringWidth(WIDEST_SHARE) - columnGap;
int titleBaseline = top + sectionMetrics().getAscent();
drawSectionTitle(canvas, optionNoun, x, titleBaseline);
canvas.setFont(StudioTheme.sectionFont());
drawRightAligned(canvas, "WEIGHT", weightRight, titleBaseline);
drawRightAligned(canvas, "IN VIEW", actualRight, titleBaseline);
FontMetrics text = textMetrics();
int rowHeight = rowHeight();
int swatchSize = text.getAscent() - text.getDescent();
int rowTop = top + sectionMetrics().getHeight() + StudioTheme.scale(TITLE_GAP);
List<NoisePickSpread.Row> rows = spread.rows();
for (NoisePickSpread.Row row : rows) {
int baseline = rowTop + (rowHeight + text.getAscent() - text.getDescent()) / 2;
int arc = StudioTheme.scale(3);
canvas.setColor(new Color(row.option().color()));
canvas.fillRoundRect(x, rowTop + (rowHeight - swatchSize) / 2, swatchSize, swatchSize, arc, arc);
canvas.setFont(StudioTheme.font());
canvas.setColor(StudioTheme.TEXT);
int nameLeft = x + swatchSize + StudioTheme.scale(7);
canvas.drawString(fit(canvas, distinctName(row, rows), nameRight - nameLeft), nameLeft, baseline);
canvas.setColor(StudioTheme.SECONDARY_TEXT);
drawRightAligned(canvas, percent(row.weightShare()), weightRight, baseline);
boolean farFromWeight = row.isFarBelowWeight() || row.isFarAboveWeight();
canvas.setColor(farFromWeight ? StudioTheme.WARNING : StudioTheme.TEXT);
drawRightAligned(canvas, actualText(row), actualRight, baseline);
rowTop += rowHeight;
}
}
private int rowHeight() {
return textMetrics().getHeight() + StudioTheme.scale(4);
}
private FontMetrics textMetrics() {
return getFontMetrics(StudioTheme.font());
}
private FontMetrics smallMetrics() {
return getFontMetrics(StudioTheme.smallFont());
}
private FontMetrics sectionMetrics() {
return getFontMetrics(StudioTheme.sectionFont());
}
private static void drawSectionTitle(Graphics2D canvas, String title, int x, int 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) {
canvas.drawString(text, right - canvas.getFontMetrics().stringWidth(text), y);
}
// Two biomes in different folders can share a name, so their keys tell them apart.
private static String distinctName(NoisePickSpread.Row row, List<NoisePickSpread.Row> rows) {
String name = row.option().name();
boolean shared = rows.stream().anyMatch(other -> other != row && other.option().name().equals(name));
return shared ? name + " (" + row.option().key() + ")" : name;
}
private static String actualText(NoisePickSpread.Row row) {
if (row.isFarBelowWeight()) {
return "▼ " + percent(row.actualShare());
}
if (row.isFarAboveWeight()) {
return "▲ " + percent(row.actualShare());
}
return percent(row.actualShare());
}
static String percent(double share) {
if (share > 0D && share < 0.001D) {
return "<0.1%";
}
return String.format(Locale.ROOT, "%.1f%%", share * 100D);
}
private static String fit(Graphics2D canvas, String text, int maxWidth) {
FontMetrics metrics = canvas.getFontMetrics();
if (metrics.stringWidth(text) <= maxWidth) {
return text;
}
String shortened = text;
while (shortened.length() > 1 && metrics.stringWidth(shortened + "…") > maxWidth) {
shortened = shortened.substring(0, shortened.length() - 1);
}
return shortened + "…";
}
}
@@ -0,0 +1,191 @@
/*
* 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 java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* How a noise picks one of several regions or biomes, measured against the noise values in view: rarity splits the
* noise from 0 to 1 into one band per option, and each spot gets the option whose band its noise value falls in.
*/
final class NoisePickSpread {
static final int HISTOGRAM_BINS = 50;
private final String title;
private final List<Band> bands;
private final long[] histogram = new long[HISTOGRAM_BINS];
private final long[] samplesByBand;
private long sampleCount;
private long belowZeroCount;
private long aboveOneCount;
private NoisePickSpread(String title, List<Band> bands) {
this.title = title;
this.bands = List.copyOf(bands);
this.samplesByBand = new long[bands.size()];
}
/**
* @param legacyRarity whether the dimension splits the noise the legacy way
*/
static NoisePickSpread of(String title, List<Option> options, boolean legacyRarity) {
return new NoisePickSpread(title, legacyRarity ? legacyBands(options) : bands(options));
}
void add(double noise) {
if (bands.isEmpty() || !Double.isFinite(noise)) {
return;
}
sampleCount++;
if (noise < 0D) {
belowZeroCount++;
} else if (noise > 1D) {
aboveOneCount++;
}
histogram[Math.clamp((int) (noise * HISTOGRAM_BINS), 0, HISTOGRAM_BINS - 1)]++;
samplesByBand[bandIndexOf(noise)]++;
}
String title() {
return title;
}
List<Band> bands() {
return bands;
}
long[] histogram() {
return histogram;
}
long sampleCount() {
return sampleCount;
}
double belowZeroShare() {
return share(belowZeroCount);
}
double aboveOneShare() {
return share(aboveOneCount);
}
/**
* Every option once, with its bands' widths and samples added up, in the order the noise meets them.
*/
List<Row> rows() {
Map<String, Row> rows = new LinkedHashMap<>();
for (int index = 0; index < bands.size(); index++) {
Band band = bands.get(index);
Row row = new Row(band.option(), band.width(), share(samplesByBand[index]));
rows.merge(band.option().key(), row, Row::plus);
}
return new ArrayList<>(rows.values());
}
private double share(long count) {
return sampleCount == 0 ? 0D : count / (double) sampleCount;
}
// The engine gives values below 0 the first option and values above 1 the last, the same as these bands.
private int bandIndexOf(double noise) {
for (int index = 0; index < bands.size(); index++) {
if (noise <= bands.get(index).toNoise()) {
return index;
}
}
return bands.size() - 1;
}
// Mirrors IRare.pick: each option gets a share of the noise in proportion to one over its rarity.
private static List<Band> bands(List<Option> options) {
double total = 0D;
for (Option option : options) {
total += 1D / option.rarity();
}
List<Band> bands = new ArrayList<>();
double from = 0D;
for (Option option : options) {
double to = options.size() == 1 ? 1D : from + (1D / option.rarity()) / total;
bands.add(new Band(option, from, to));
from = to;
}
return bands;
}
// Mirrors IRare.pickLegacy: each option gets a share in proportion to the total rarity minus its own.
private static List<Band> legacyBands(List<Option> options) {
if (options.size() <= 1) {
return bands(options);
}
double totalRarity = 0D;
for (Option option : options) {
totalRarity += option.rarity();
}
double whole = totalRarity * (options.size() - 1);
List<Band> bands = new ArrayList<>();
double from = 0D;
double buffer = 0D;
for (Option option : options) {
buffer += totalRarity - option.rarity();
double to = buffer / whole;
bands.add(new Band(option, from, to));
from = to;
}
return bands;
}
/**
* A region or biome the noise can pick.
*/
record Option(String key, String name, int color, int rarity) {
}
/**
* The noise values from {@code fromNoise} to {@code toNoise} that pick the option.
*/
record Band(Option option, double fromNoise, double toNoise) {
double width() {
return toNoise - fromNoise;
}
}
/**
* @param weightShare how much of the noise from 0 to 1 picks the option
* @param actualShare how much of the noise in view picks the option
*/
record Row(Option option, double weightShare, double actualShare) {
private static final double FAR_FROM_WEIGHT_FACTOR = 2D;
Row plus(Row other) {
return new Row(option, weightShare + other.weightShare, actualShare + other.actualShare);
}
boolean isFarBelowWeight() {
return actualShare * FAR_FROM_WEIGHT_FACTOR < weightShare;
}
boolean isFarAboveWeight() {
return actualShare > weightShare * FAR_FROM_WEIGHT_FACTOR;
}
}
}
@@ -19,8 +19,10 @@
package com.volmit.iris.core.gui;
import com.volmit.iris.Iris;
import com.volmit.iris.core.gui.NoisePickSpread.Option;
import com.volmit.iris.core.gui.RegionHeightCoverage.BiomeRange;
import com.volmit.iris.core.gui.RegionNavigator.RegionRow;
import com.volmit.iris.core.gui.components.PickDisplay;
import com.volmit.iris.core.gui.components.RenderType;
import com.volmit.iris.engine.IrisComplex;
import com.volmit.iris.engine.framework.Engine;
@@ -30,7 +32,11 @@ import com.volmit.iris.engine.object.IrisRegion;
import java.awt.EventQueue;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
@@ -38,19 +44,21 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.stream.Collectors;
/**
* The region view's explorer: choosing a region fills the map with its biomes and measures which of them covers which
* ground height in view. Measuring runs off the Swing thread and only the newest request counts.
* The region view's explorer: shows how the region noise picks the regions, and for a chosen region fills the map with
* its biomes and shows how they are picked. Measuring what is in view runs off the Swing thread and only the newest
* request counts.
*/
final class RegionExplorer {
private static final int GROUND_SAMPLES_PER_SIDE = 96;
private static final int SAMPLES_PER_SIDE = 96;
private static final List<InferredType> PLACED_TYPES = List.of(InferredType.LAND, InferredType.SHORE, InferredType.SEA);
private final Supplier<Engine> engineSource;
private final Consumer<String> onPreviewChanged;
private final RegionNavigator navigator = new RegionNavigator(this::choose);
private final RegionInspectorPanel inspector = new RegionInspectorPanel();
private final RegionInspectorPanel inspector;
private final ThreadPoolExecutor measurer = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(1), RegionExplorer::daemonThread, new ThreadPoolExecutor.DiscardOldestPolicy());
private final AtomicLong measurementGeneration = new AtomicLong();
@@ -60,10 +68,12 @@ final class RegionExplorer {
/**
* @param onPreviewChanged receives the region to fill the map with, or null to show every region again
* @param onDisplayChosen receives how the map should draw the picks
*/
RegionExplorer(Supplier<Engine> engineSource, Consumer<String> onPreviewChanged) {
RegionExplorer(Supplier<Engine> engineSource, Consumer<String> onPreviewChanged, Consumer<PickDisplay> onDisplayChosen) {
this.engineSource = engineSource;
this.onPreviewChanged = onPreviewChanged;
this.inspector = new RegionInspectorPanel(onDisplayChosen);
}
RegionNavigator navigator() {
@@ -81,18 +91,17 @@ final class RegionExplorer {
}
List<RegionRow> rows = new ArrayList<>();
for (IrisRegion region : engine.getData().getRegionLoader().loadAll(engine.getDimension().getRegions())) {
rows.add(new RegionRow(region.getLoadKey(), region.getName(),
region.getColor(engine.getComplex(), RenderType.REGION).getRGB() & 0xFFFFFF));
rows.add(new RegionRow(region.getLoadKey(), region.getName(), regionColor(engine, region)));
}
navigator.showRegions(rows);
}
/**
* Measures the chosen region against the ground in this area, unless that was already done.
* Measures the regions, or the chosen region's biomes, against this area, unless that was already done.
*/
void measure(MapCensus.ViewArea area) {
latestArea = area;
if (chosenRegionKey == null || area == null || area.equals(measuredArea)) {
if (area == null || area.equals(measuredArea)) {
return;
}
measuredArea = area;
@@ -126,11 +135,7 @@ final class RegionExplorer {
measuredArea = null;
measurementGeneration.incrementAndGet();
onPreviewChanged.accept(regionKey);
if (regionKey == null) {
inspector.showNothingChosen();
return;
}
inspector.showMeasuring(regionName(regionKey));
inspector.showMeasuring(regionKey == null ? "Regions" : regionName(regionKey));
measure(latestArea);
}
@@ -144,34 +149,144 @@ final class RegionExplorer {
if (!Objects.equals(measurement.regionKey(), chosenRegionKey)) {
return;
}
if (measurement.coverage() == null) {
inspector.showBiomeTerrain(measurement.regionName());
} else {
inspector.showCoverage(measurement.regionName(), measurement.coverage());
switch (measurement) {
case RegionPicks picks -> inspector.showRegionPicks(picks.spread(), picks.notes());
case BiomePicks picks -> inspector.showBiomePicks(picks.regionName(), picks.spreads(), picks.notes());
case HeightPicks picks -> inspector.showCoverage(picks.regionName(), picks.coverage(), picks.notes());
}
}
private Measurement measureSafely(String regionKey, MapCensus.ViewArea area) {
try {
return measureRegion(regionKey, area);
return measurePicks(regionKey, area);
} catch (Throwable error) {
Iris.reportError(error);
return null;
}
}
private Measurement measureRegion(String regionKey, MapCensus.ViewArea area) {
private Measurement measurePicks(String regionKey, MapCensus.ViewArea area) {
Engine engine = engineSource.get();
IrisComplex complex = engine == null ? null : engine.getComplex();
IrisRegion region = complex == null ? null : engine.getData().getRegionLoader().load(regionKey);
if (complex == null) {
return null;
}
if (regionKey == null) {
return measureRegionPicks(engine, complex, area);
}
IrisRegion region = engine.getData().getRegionLoader().load(regionKey);
if (region == null) {
return null;
}
if (!complex.isHybridTerrain()) {
return new Measurement(regionKey, region.getName(), null);
return complex.isHybridTerrain()
? measureHeightPicks(engine, complex, region, area)
: measureBiomePicks(engine, complex, region, area);
}
private static RegionPicks measureRegionPicks(Engine engine, IrisComplex complex, MapCensus.ViewArea area) {
List<Option> options = complex.noisePickedRegions().stream()
.map(region -> new Option(region.getLoadKey(), region.getName(), regionColor(engine, region), region.getRarity()))
.toList();
NoisePickSpread spread = NoisePickSpread.of("Region noise in view", options, complex.isLegacyRarity());
forEachSample(area, (x, z) -> spread.add(complex.getRegionStyleStream().get(x, z)));
List<String> notes = new ArrayList<>();
addSpreadNotes(notes, spread, "region");
addRarityNotes(notes, complex, List.of(spread));
return new RegionPicks(spread, notes);
}
private static BiomePicks measureBiomePicks(Engine engine, IrisComplex complex, IrisRegion region, MapCensus.ViewArea area) {
Map<InferredType, NoisePickSpread> spreads = new LinkedHashMap<>();
for (InferredType type : PLACED_TYPES) {
List<Option> options = complex.noisePickedBiomes(region, type).stream()
.map(biome -> biomeOption(engine, biome))
.toList();
if (!options.isEmpty()) {
spreads.put(type, NoisePickSpread.of(typeName(type) + " biome noise in view", options, complex.isLegacyRarity()));
}
}
RegionHeightCoverage coverage = RegionHeightCoverage.measure(biomeRanges(engine, region), groundHeights(complex, area));
return new Measurement(regionKey, region.getName(), coverage);
forEachSample(area, (x, z) -> {
IrisComplex.BiomePick pick = complex.surfacePickAsIfIn(region, x, z);
NoisePickSpread spread = spreads.get(pick.type());
if (spread != null) {
spread.add(complex.biomePickNoise(region, pick.type(), x, z));
}
});
List<String> notes = new ArrayList<>();
spreads.forEach((type, spread) -> addSpreadNotes(notes, spread, typeName(type).toLowerCase(Locale.ROOT) + " biome"));
addChildBiomeNote(notes, complex, region);
addRarityNotes(notes, complex, spreads.values());
return new BiomePicks(region.getLoadKey(), region.getName(), List.copyOf(spreads.values()), notes);
}
private static HeightPicks measureHeightPicks(Engine engine, IrisComplex complex, IrisRegion region, MapCensus.ViewArea area) {
RegionHeightCoverage coverage = RegionHeightCoverage.measure(biomeRanges(engine, region), pickingHeights(complex, area));
List<String> notes = new ArrayList<>(coverage.notes());
notes.add("Noise shows the height biomes are picked at: the overlay noise plus the dimension's biome placement "
+ "jitter. Where ranges share a height, each biome's competition noise, divided by its rarity, decides.");
return new HeightPicks(region.getLoadKey(), region.getName(), coverage, notes);
}
private static void addSpreadNotes(List<String> notes, NoisePickSpread spread, String noun) {
List<NoisePickSpread.Row> rows = spread.rows();
if (spread.sampleCount() == 0) {
notes.add("No " + noun + " is picked in view.");
return;
}
if (rows.size() == 1) {
notes.add(rows.getFirst().option().name() + " is the only " + noun + ", so the " + noun + " noise has nothing to pick.");
return;
}
if (spread.belowZeroShare() > 0D) {
notes.add(NoisePickChart.percent(spread.belowZeroShare()) + " of the " + noun + " noise in view is below 0, so "
+ rows.getFirst().option().name() + " gets it.");
}
if (spread.aboveOneShare() > 0D) {
notes.add(NoisePickChart.percent(spread.aboveOneShare()) + " of the " + noun + " noise in view is above 1, so "
+ rows.getLast().option().name() + " gets it.");
}
List<String> starved = rows.stream().filter(NoisePickSpread.Row::isFarBelowWeight).map(row -> row.option().name()).toList();
if (!starved.isEmpty()) {
notes.add(String.join(", ", starved) + (starved.size() == 1 ? " gets" : " get")
+ " under half " + (starved.size() == 1 ? "its" : "their") + " weight in view (▼): the "
+ noun + " noise rarely reaches " + (starved.size() == 1 ? "its band." : "their bands."));
}
}
private static void addChildBiomeNote(List<String> notes, IrisComplex complex, IrisRegion region) {
String parents = PLACED_TYPES.stream()
.flatMap(type -> complex.noisePickedBiomes(region, type).stream())
.filter(biome -> !biome.getChildren().isEmpty())
.map(IrisBiome::getName)
.distinct()
.collect(Collectors.joining(", "));
if (!parents.isEmpty()) {
notes.add(parents + " then hand some spots to child biomes by a noise of their own.");
}
}
private static void addRarityNotes(List<String> notes, IrisComplex complex, Collection<NoisePickSpread> spreads) {
if (spreads.stream().allMatch(spread -> spread.bands().size() <= 1)) {
return;
}
notes.add(complex.isLegacyRarity()
? "Legacy rarity: each weight is the total rarity minus the option's own."
: "Each weight is one over the rarity, so rarity 2 gets half the noise of rarity 1.");
notes.add("Measured on a " + SAMPLES_PER_SIDE + " × " + SAMPLES_PER_SIDE + " grid over the map in view.");
}
private static Option biomeOption(Engine engine, IrisBiome biome) {
return new Option(biome.getLoadKey(), biome.getName(), biome.getColor(engine, RenderType.BIOME).getRGB() & 0xFFFFFF,
biome.getRarity());
}
private static int regionColor(Engine engine, IrisRegion region) {
return region.getColor(engine.getComplex(), RenderType.REGION).getRGB() & 0xFFFFFF;
}
private static String typeName(InferredType type) {
String name = type.name().toLowerCase(Locale.ROOT);
return Character.toUpperCase(name.charAt(0)) + name.substring(1);
}
private static List<BiomeRange> biomeRanges(Engine engine, IrisRegion region) {
@@ -189,19 +304,22 @@ final class RegionExplorer {
return ranges;
}
// In hybrid terrain the overlay noise is the ground's height above the fluid, before any biome adds its own terrain.
private static int[] groundHeights(IrisComplex complex, MapCensus.ViewArea area) {
int[] heights = new int[GROUND_SAMPLES_PER_SIDE * GROUND_SAMPLES_PER_SIDE];
double stepX = (area.maximumX() - area.minimumX()) / GROUND_SAMPLES_PER_SIDE;
double stepZ = (area.maximumZ() - area.minimumZ()) / GROUND_SAMPLES_PER_SIDE;
for (int row = 0; row < GROUND_SAMPLES_PER_SIDE; row++) {
for (int column = 0; column < GROUND_SAMPLES_PER_SIDE; column++) {
double x = area.minimumX() + (column + 0.5D) * stepX;
double z = area.minimumZ() + (row + 0.5D) * stepZ;
heights[row * GROUND_SAMPLES_PER_SIDE + column] = (int) Math.round(complex.getOverlayStream().get(x, z));
// HYBRID terrain picks biomes by the overlay noise plus the placement jitter, in blocks above the fluid.
private static int[] pickingHeights(IrisComplex complex, MapCensus.ViewArea area) {
int[] heights = new int[SAMPLES_PER_SIDE * SAMPLES_PER_SIDE];
int[] next = {0};
forEachSample(area, (x, z) -> heights[next[0]++] = (int) Math.round(complex.getPlacementHeightStream().get(x, z)));
return heights;
}
private static void forEachSample(MapCensus.ViewArea area, SampleVisitor visitor) {
double stepX = (area.maximumX() - area.minimumX()) / SAMPLES_PER_SIDE;
double stepZ = (area.maximumZ() - area.minimumZ()) / SAMPLES_PER_SIDE;
for (int row = 0; row < SAMPLES_PER_SIDE; row++) {
for (int column = 0; column < SAMPLES_PER_SIDE; column++) {
visitor.visit(area.minimumX() + (column + 0.5D) * stepX, area.minimumZ() + (row + 0.5D) * stepZ);
}
}
return heights;
}
private static Thread daemonThread(Runnable task) {
@@ -210,9 +328,30 @@ final class RegionExplorer {
return thread;
}
@FunctionalInterface
private interface SampleVisitor {
void visit(double x, double z);
}
/**
* @param coverage how the region's biomes cover the ground in view, or null when the dimension uses biome terrain
* What was measured for the region view; the region key is null when every region was measured.
*/
private record Measurement(String regionKey, String regionName, RegionHeightCoverage coverage) {
private sealed interface Measurement permits RegionPicks, BiomePicks, HeightPicks {
String regionKey();
}
private record RegionPicks(NoisePickSpread spread, List<String> notes) implements Measurement {
@Override
public String regionKey() {
return null;
}
}
private record BiomePicks(String regionKey, String regionName, List<NoisePickSpread> spreads, List<String> notes)
implements Measurement {
}
private record HeightPicks(String regionKey, String regionName, RegionHeightCoverage coverage, List<String> notes)
implements Measurement {
}
}
@@ -208,7 +208,7 @@ final class RegionHeightChart extends JComponent {
canvas.drawString(label, x - metrics.stringWidth(label) / 2, axisTop + StudioTheme.scale(4) + metrics.getAscent());
}
canvas.setColor(StudioTheme.MUTED_TEXT);
canvas.drawString("Ground in view", StudioTheme.scale(PADDING), axisTop - StudioTheme.scale(4));
canvas.drawString("Heights in view", StudioTheme.scale(PADDING), axisTop - StudioTheme.scale(4));
}
private int tickStep() {
@@ -18,88 +18,136 @@
package com.volmit.iris.core.gui;
import com.volmit.iris.core.gui.components.PickDisplay;
import javax.swing.BorderFactory;
import javax.swing.BoxLayout;
import javax.swing.ButtonGroup;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JTextArea;
import javax.swing.JToggleButton;
import javax.swing.Scrollable;
import javax.swing.SwingConstants;
import javax.swing.text.DefaultCaret;
import java.awt.BorderLayout;
import java.awt.CardLayout;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.GridLayout;
import java.awt.Rectangle;
import java.util.EnumMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Consumer;
/**
* The region view's inspector: for the chosen region, which of its biomes covers which ground height, and what that
* means in words.
* The region view's inspector: how the noise picks the regions, or for a chosen region how its biomes are picked,
* measured against what is in view and put in words.
*/
final class RegionInspectorPanel extends JPanel {
private static final long serialVersionUID = -2874105263188240317L;
private static final String MESSAGE_CARD = "message";
private static final String COVERAGE_CARD = "coverage";
private static final String DETAILS_CARD = "details";
private static final int PANEL_WIDTH = 330;
private static final String PARAGRAPH_BREAK = "\n\n";
private final JLabel title = new JLabel();
private final JLabel detail = new JLabel();
private final Map<PickDisplay, JToggleButton> displayButtons = new EnumMap<>(PickDisplay.class);
private final CardLayout cards = new CardLayout();
private final JPanel cardPanel = new JPanel(cards);
private final JTextArea message = wrappingText();
private final RegionHeightChart chart = new RegionHeightChart();
private final JTextArea notes = wrappingText();
private final JPanel details = new ViewportWidthPanel();
private final RegionHeightChart heightChart = new RegionHeightChart();
RegionInspectorPanel() {
/**
* @param onDisplayChosen receives how the map should draw the picks
*/
RegionInspectorPanel(Consumer<PickDisplay> onDisplayChosen) {
super(new BorderLayout());
setPreferredSize(StudioTheme.scaledSize(PANEL_WIDTH, 0));
setBackground(StudioTheme.PANEL_BACKGROUND);
add(buildHeader(), BorderLayout.NORTH);
add(buildHeader(onDisplayChosen), BorderLayout.NORTH);
cardPanel.setOpaque(false);
cardPanel.add(buildMessageCard(), MESSAGE_CARD);
cardPanel.add(StudioToolbar.scroll(buildCoverageCard()), COVERAGE_CARD);
details.setLayout(new BoxLayout(details, BoxLayout.Y_AXIS));
details.setBackground(StudioTheme.PANEL_BACKGROUND);
heightChart.setAlignmentX(LEFT_ALIGNMENT);
cardPanel.add(StudioToolbar.scroll(details), DETAILS_CARD);
add(cardPanel, BorderLayout.CENTER);
showNothingChosen();
showMeasuring("Regions");
}
void showNothingChosen() {
showHeader("Regions", "Every region in its own colour");
showMessage("Pick a region on the left to fill the map with its biomes and see which biome covers which height.");
void showMeasuring(String name) {
showHeader(name, "Measuring what is in view…");
showMessage("");
}
void showMeasuring(String regionName) {
showHeader(regionName, "Measuring the ground in view…");
void showRegionPicks(NoisePickSpread spread, List<String> notes) {
showHeader("Regions", "Region noise, split by rarity");
nameFirstDisplay("Regions");
showDetails(List.of(new NoisePickChart(spread, "Regions")), notes);
}
void showBiomeTerrain(String regionName) {
showHeader(regionName, "Biome terrain");
showMessage("This dimension uses BIOME terrain, where biomes are placed by noise and ignore height. "
+ "Set the dimension's terrainMode to HYBRID to choose biomes by the height of the ground.");
void showBiomePicks(String regionName, List<NoisePickSpread> spreads, List<String> notes) {
showHeader(regionName, "Biome terrain · biome noise, split by rarity");
nameFirstDisplay("Biomes");
showDetails(spreads.stream().map(spread -> (JComponent) new NoisePickChart(spread, "Biomes")).toList(), notes);
}
void showCoverage(String regionName, RegionHeightCoverage coverage) {
showHeader(regionName, "Hybrid terrain · biomes by ground height");
chart.show(coverage);
notes.setText(notesText(coverage.notes()));
cards.show(cardPanel, COVERAGE_CARD);
void showCoverage(String regionName, RegionHeightCoverage coverage, List<String> notes) {
showHeader(regionName, "Hybrid terrain · biomes by height");
nameFirstDisplay("Biomes");
heightChart.show(coverage);
showDetails(List.of(heightChart), notes);
}
private JPanel buildHeader() {
JPanel header = new JPanel(new GridLayout(0, 1, 0, StudioTheme.scale(2)));
private JPanel buildHeader(Consumer<PickDisplay> onDisplayChosen) {
JPanel header = new JPanel(new GridLayout(0, 1, 0, StudioTheme.scale(6)));
header.setOpaque(false);
header.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createMatteBorder(0, 0, 1, 0, StudioTheme.SEPARATOR),
StudioTheme.emptyBorder(10, 16, 9, 16)));
StudioTheme.emptyBorder(10, 16, 12, 16)));
title.setFont(StudioTheme.titleFont());
detail.setFont(StudioTheme.smallFont());
detail.setForeground(StudioTheme.MUTED_TEXT);
header.add(title);
header.add(detail);
header.add(sectionLabel("Show"));
header.add(buildDisplayButtons(onDisplayChosen));
return header;
}
private JPanel buildDisplayButtons(Consumer<PickDisplay> onDisplayChosen) {
JPanel row = new JPanel(new GridLayout(1, 0, StudioTheme.scale(4), 0));
row.setOpaque(false);
ButtonGroup group = new ButtonGroup();
for (PickDisplay display : PickDisplay.values()) {
JToggleButton button = new JToggleButton(displayName(display));
button.setFocusable(false);
button.setSelected(display == PickDisplay.PICKS);
button.addActionListener(event -> onDisplayChosen.accept(display));
group.add(button);
row.add(button);
displayButtons.put(display, button);
}
return row;
}
private static String displayName(PickDisplay display) {
return switch (display) {
case PICKS -> "Regions";
case NOISE -> "Noise";
case BOTH -> "Both";
};
}
private void nameFirstDisplay(String name) {
displayButtons.get(PickDisplay.PICKS).setText(name);
}
private JPanel buildMessageCard() {
JPanel card = new JPanel(new BorderLayout());
card.setOpaque(false);
@@ -108,20 +156,8 @@ final class RegionInspectorPanel extends JPanel {
return card;
}
private JPanel buildCoverageCard() {
JPanel card = new ViewportWidthPanel();
card.setLayout(new BoxLayout(card, BoxLayout.Y_AXIS));
card.setBackground(StudioTheme.PANEL_BACKGROUND);
chart.setAlignmentX(Component.LEFT_ALIGNMENT);
notes.setAlignmentX(Component.LEFT_ALIGNMENT);
notes.setBorder(StudioTheme.emptyBorder(0, 16, 12, 16));
card.add(chart);
card.add(notes);
return card;
}
private void showHeader(String regionName, String detailText) {
title.setText(regionName);
private void showHeader(String name, String detailText) {
title.setText(name);
detail.setText(detailText);
}
@@ -130,11 +166,27 @@ final class RegionInspectorPanel extends JPanel {
cards.show(cardPanel, MESSAGE_CARD);
}
private static String notesText(List<String> lines) {
if (lines.isEmpty()) {
return "Every ground height in view has a biome, and no two ranged biomes share one.";
private void showDetails(List<JComponent> charts, List<String> notes) {
details.removeAll();
charts.forEach(details::add);
if (!notes.isEmpty()) {
JTextArea notesText = wrappingText();
notesText.setAlignmentX(LEFT_ALIGNMENT);
notesText.setBorder(StudioTheme.emptyBorder(4, 16, 12, 16));
notesText.setText(String.join(PARAGRAPH_BREAK, notes));
details.add(notesText);
}
return String.join(PARAGRAPH_BREAK, lines);
details.revalidate();
details.repaint();
details.scrollRectToVisible(new Rectangle());
cards.show(cardPanel, DETAILS_CARD);
}
private static JLabel sectionLabel(String text) {
JLabel label = new JLabel(text.toUpperCase(Locale.ROOT));
label.setForeground(StudioTheme.MUTED_TEXT);
label.setFont(StudioTheme.sectionFont());
return label;
}
private static JTextArea wrappingText() {
@@ -142,6 +194,7 @@ final class RegionInspectorPanel extends JPanel {
text.setEditable(false);
text.setFocusable(false);
text.setOpaque(false);
((DefaultCaret) text.getCaret()).setUpdatePolicy(DefaultCaret.NEVER_UPDATE);
text.setLineWrap(true);
text.setWrapStyleWord(true);
text.setFont(StudioTheme.font());
@@ -150,7 +203,7 @@ final class RegionInspectorPanel extends JPanel {
}
/**
* Follows the scroll pane's width, so the chart stretches to it and the notes wrap inside it.
* Follows the scroll pane's width, so the charts stretch to it and the notes wrap inside it.
*/
private static final class ViewportWidthPanel extends JPanel implements Scrollable {
private static final long serialVersionUID = 6086542174870301352L;
@@ -20,6 +20,7 @@ package com.volmit.iris.core.gui.components;
import com.volmit.iris.engine.IrisComplex;
import com.volmit.iris.engine.framework.Engine;
import com.volmit.iris.engine.object.InferredType;
import com.volmit.iris.engine.object.IrisBiome;
import com.volmit.iris.engine.object.IrisBiomeGeneratorLink;
import com.volmit.iris.engine.object.IrisDimension;
@@ -28,8 +29,10 @@ import com.volmit.iris.util.stream.ProceduralStream;
import java.awt.Color;
import java.util.Arrays;
import java.util.EnumMap;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ConcurrentHashMap;
@@ -53,6 +56,9 @@ public final class IrisRenderer {
private static final double LARGEST_SAMPLE_BLOCK_BLOCKS = 64D;
private static final double DEFAULT_DEPTH_BELOW_SEA_BLOCKS = 48D;
private static final double DEFAULT_HEIGHT_ABOVE_SEA_BLOCKS = 160D;
private static final int NOISE_LOW = rgb(5, 5, 5);
private static final int NOISE_HIGH = rgb(255, 255, 255);
private static final double DARKEST_PICK_SHADE = 0.3D;
private final Engine engine;
private final MapLabels labels = new MapLabels();
@@ -101,6 +107,9 @@ public final class IrisRenderer {
regions[index] = regionOf(identities[index]);
}
int[] colors = type == RenderType.HEIGHT ? null : tileColors(size, type, subjects);
if (layer.display() != PickDisplay.PICKS) {
drawPickNoise(colors, grid, size, layer);
}
MapLabels.Kind subjectKind = type == RenderType.REGION ? MapLabels.Kind.REGION : MapLabels.Kind.BIOME;
return new MapTile(size, blocksPerPixel, labels, subjectKind, colors, defaultHeightRange(), subjects, regions, heights);
}
@@ -183,6 +192,51 @@ public final class IrisRenderer {
return (double x, double z) -> identity(lookup.idOf(complex.surfaceBiomeAsIfIn(region, x, z)), regionId);
}
private void drawPickNoise(int[] colors, TileGrid grid, int size, MapLayer layer) {
PickNoise noise = pickNoise(grid, layer);
for (int pixelZ = 0; pixelZ < size; pixelZ++) {
checkCancelled(grid.cancelled());
double worldZ = grid.worldZ(pixelZ + 1);
for (int pixelX = 0; pixelX < size; pixelX++) {
int index = pixelZ * size + pixelX;
double value = noise.valueAt((pixelZ + 1) * grid.size() + pixelX + 1, grid.worldX(pixelX + 1), worldZ);
colors[index] = layer.display() == PickDisplay.NOISE
? noise.color(value)
: shade(colors[index], DARKEST_PICK_SHADE + (1D - DARKEST_PICK_SHADE) * noise.brightness(value));
}
}
}
// Regions and BIOME terrain biomes are picked by splitting a noise by rarity; HYBRID terrain biomes by a height.
private PickNoise pickNoise(TileGrid grid, MapLayer layer) {
IrisComplex complex = engine.getComplex();
if (layer.type() == RenderType.REGION) {
ProceduralStream<Double> regionNoise = complex.getRegionStyleStream();
return new UnitNoise((gridIndex, x, z) -> regionNoise.get(x, z));
}
IrisRegion region = engine.getData().getRegionLoader().load(layer.previewRegionKey());
if (region == null) {
return new UnitNoise((gridIndex, x, z) -> 0D);
}
if (complex.isHybridTerrain()) {
ProceduralStream<Double> placementHeight = complex.getPlacementHeightStream();
return new PlacementHeightNoise((gridIndex, x, z) -> placementHeight.get(x, z), fluidHeight(), defaultHeightRange());
}
return new UnitNoise(biomePickNoise(complex, region, grid));
}
// Finding which biome list picked a spot is slow, so it is sampled in squares like the biomes; the noise is cheap.
private static NoiseField biomePickNoise(IrisComplex complex, IrisRegion region, TileGrid grid) {
IdentitySampler typeSampler = new IdentitySampler(grid, (x, z) -> complex.surfacePickAsIfIn(region, x, z).type().ordinal());
typeSampler.renderAll(sampleBlockPixels(grid.step()));
int[] typeByGridIndex = typeSampler.identities;
InferredType[] types = InferredType.values();
Map<InferredType, ProceduralStream<Double>> noiseByType = new EnumMap<>(InferredType.class);
return (gridIndex, x, z) -> noiseByType
.computeIfAbsent(types[typeByGridIndex[gridIndex]], type -> complex.biomePickNoiseStream(region, type))
.get(x, z);
}
// Hybrid terrain decides land, coast and sea by height after the biomes are chosen, so the final biome tells.
private static ProceduralStream<IrisBiome> subjectBiomeStream(IrisComplex complex, RenderType type) {
return switch (type) {
@@ -276,6 +330,13 @@ public final class IrisRenderer {
return new Color(red, green, blue).getRGB() & 0xFFFFFF;
}
private static int shade(int color, double brightness) {
int red = (int) Math.round(((color >> 16) & 0xFF) * brightness);
int green = (int) Math.round(((color >> 8) & 0xFF) * brightness);
int blue = (int) Math.round((color & 0xFF) * brightness);
return (red << 16) | (green << 8) | blue;
}
private static int blend(int first, int second, double progress) {
double bounded = clamp(progress, 0D, 1D);
int red = (int) Math.round(((first >> 16) & 0xFF) * (1D - bounded) + ((second >> 16) & 0xFF) * bounded);
@@ -302,6 +363,62 @@ public final class IrisRenderer {
private record LabelColorKey(RenderType type, short id) {
}
@FunctionalInterface
private interface NoiseField {
double at(int gridIndex, double x, double z);
}
/**
* A noise that picks regions or biomes, drawn on its own or as how bright the picks are.
*/
private interface PickNoise {
double valueAt(int gridIndex, double x, double z);
int color(double value);
double brightness(double value);
}
/**
* A noise whose values run from 0 to 1, split into one band per region or biome by their rarities.
*/
private record UnitNoise(NoiseField field) implements PickNoise {
@Override
public double valueAt(int gridIndex, double x, double z) {
return field.at(gridIndex, x, z);
}
@Override
public int color(double value) {
return blend(NOISE_LOW, NOISE_HIGH, value);
}
@Override
public double brightness(double value) {
return clamp(value, 0D, 1D);
}
}
/**
* The height above the fluid that HYBRID terrain picks biomes by, coloured like the height view.
*/
private record PlacementHeightNoise(NoiseField field, double fluidHeight, MapStyle.HeightRange range) implements PickNoise {
@Override
public double valueAt(int gridIndex, double x, double z) {
return field.at(gridIndex, x, z);
}
@Override
public int color(double value) {
return heightColor(fluidHeight + value, range);
}
@Override
public double brightness(double value) {
return clamp((fluidHeight + value - range.lowest()) / Math.max(1D, range.highest() - range.lowest()), 0D, 1D);
}
}
/**
* The square of world positions one tile samples, including the one pixel ring borrowed from its neighbours.
*/
@@ -21,23 +21,33 @@ package com.volmit.iris.core.gui.components;
import java.util.Objects;
/**
* What a map tile shows: one of the render types, and for a region preview which region every spot is treated as.
* What a map tile shows: one of the render types, for a region preview which region every spot is treated as, and in
* the region view whether the noise that picks is drawn too.
*
* @param previewRegionKey the region a {@link RenderType#REGION_PREVIEW} tile shows everywhere, null for every other type
* @param display how the picking noise is drawn; only the region view draws anything but the picks
*/
public record MapLayer(RenderType type, String previewRegionKey) {
public record MapLayer(RenderType type, String previewRegionKey, PickDisplay display) {
public MapLayer {
Objects.requireNonNull(type, "type");
Objects.requireNonNull(display, "display");
if ((type == RenderType.REGION_PREVIEW) != (previewRegionKey != null)) {
throw new IllegalArgumentException("Only a region preview names a region");
}
if (display != PickDisplay.PICKS && !type.isRegionView()) {
throw new IllegalArgumentException("Only the region view draws its picking noise");
}
}
public static MapLayer of(RenderType type) {
return new MapLayer(type, null);
return new MapLayer(type, null, PickDisplay.PICKS);
}
public static MapLayer regionPreview(String regionKey) {
return new MapLayer(RenderType.REGION_PREVIEW, Objects.requireNonNull(regionKey, "regionKey"));
public static MapLayer regions(PickDisplay display) {
return new MapLayer(RenderType.REGION, null, display);
}
public static MapLayer regionPreview(String regionKey, PickDisplay display) {
return new MapLayer(RenderType.REGION_PREVIEW, Objects.requireNonNull(regionKey, "regionKey"), display);
}
}
@@ -0,0 +1,29 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.volmit.iris.core.gui.components;
/**
* How the region view draws the noise that picks its regions or biomes: the picks, the noise itself, or the picks
* darkened where the noise is low.
*/
public enum PickDisplay {
PICKS,
NOISE,
BOTH
}
@@ -19,5 +19,9 @@
package com.volmit.iris.core.gui.components;
public enum RenderType {
BIOME, BIOME_LAND, BIOME_SEA, REGION, REGION_PREVIEW, CAVE_LAND, HEIGHT, CONTINENT
BIOME, BIOME_LAND, BIOME_SEA, REGION, REGION_PREVIEW, CAVE_LAND, HEIGHT, CONTINENT;
public boolean isRegionView() {
return this == REGION || this == REGION_PREVIEW;
}
}
@@ -270,12 +270,61 @@ public class IrisComplex implements DataProvider {
* The surface biome a spot would get if it belonged to the given region, for previewing one region across the map.
*/
public IrisBiome surfaceBiomeAsIfIn(IrisRegion region, double x, double z) {
return surfacePickAsIfIn(region, x, z).biome();
}
/**
* Like {@link #surfaceBiomeAsIfIn}, also telling which of the region's biome lists the biome was picked from.
*/
public BiomePick surfacePickAsIfIn(IrisRegion region, double x, double z) {
if (focusBiome != null) {
return focusBiome;
return new BiomePick(focusBiome, focusBiome.getInferredType());
}
IrisBiome base = implode(biomeInRegion(region, bridgeStream.get(x, z), x, z), x, z);
return fixBiomeType(heightStream.get(x, z), base, region, x, z, (type, xx, zz) -> biomeInRegion(region, type, xx, zz));
InferredType baseType = bridgeStream.get(x, z);
IrisBiome base = implode(biomeInRegion(region, baseType, x, z), x, z);
InferredType correctedType = correctedType(heightStream.get(x, z), base, region, x, z);
if (correctedType == null) {
return new BiomePick(base, baseType);
}
return new BiomePick(biomeInRegion(region, correctedType, x, z), correctedType);
}
/**
* The noise value that picks one of the region's biomes of this type in BIOME terrain, before rarity splits it.
*/
public double biomePickNoise(IrisRegion region, InferredType type, double x, double z) {
return biomeNoiseStream(region, type).get(x, z);
}
/**
* The biome the noise picks from the region's biomes of this type in BIOME terrain, before child biomes or the
* coast replace it.
*/
public IrisBiome biomePickedByNoise(IrisRegion region, InferredType type, double x, double z) {
return noiseBiomeStream(region, type).get(x, z);
}
/**
* {@link #biomePickNoise} as a stream, for sampling it many times.
*/
public ProceduralStream<Double> biomePickNoiseStream(IrisRegion region, InferredType type) {
return biomeNoiseStream(region, type);
}
/**
* The region's biomes of this type in the order their rarities split the picking noise.
*/
public KList<IrisBiome> noisePickedBiomes(IrisRegion region, InferredType type) {
return data.getBiomeLoader().loadAll(biomeKeys(region, type), biome -> biome.setInferredType(type));
}
/**
* The dimension's regions in the order their rarities split the region noise.
*/
public KList<IrisRegion> noisePickedRegions() {
return data.getRegionLoader().loadAll(dimension.getRegions());
}
/**
@@ -302,8 +351,12 @@ public class IrisComplex implements DataProvider {
return hybridBiomeSelector.choose(region.getBiomes(this, type), placementHeightStream.get(x, z), x / zoom, z / zoom);
}
// The zooms stay chained one by one as they always were, so BIOME terrain keeps placing exactly the same biomes.
private ProceduralStream<IrisBiome> noiseBiomeStream(IrisRegion region, InferredType type) {
return biomeNoiseStream(region, type).selectRarity(noisePickedBiomes(region, type), legacyRarity);
}
// The zooms stay chained one by one as they always were, so BIOME terrain keeps placing exactly the same biomes.
private ProceduralStream<Double> biomeNoiseStream(IrisRegion region, InferredType type) {
ProceduralStream<Double> noise = dimension.getBiomeStyle(type).create(rng.nextParallelRNG(type.ordinal()), getData()).stream()
.zoom(dimension.getBiomeZoom());
if (type == InferredType.LAND) {
@@ -312,8 +365,7 @@ public class IrisComplex implements DataProvider {
noise = noise.zoom(dimension.getSeaZoom());
}
return noise.zoom(region.getBiomeZoom(type))
.selectRarity(data.getBiomeLoader().loadAll(biomeKeys(region, type), biome -> biome.setInferredType(type)), legacyRarity);
return noise.zoom(region.getBiomeZoom(type));
}
private static KList<String> biomeKeys(IrisRegion region, InferredType type) {
@@ -376,25 +428,31 @@ public class IrisComplex implements DataProvider {
}
private IrisBiome fixBiomeType(double height, IrisBiome biome, IrisRegion region, double x, double z, BiomeOfType biomeOfType) {
InferredType correctedType = correctedType(height, biome, region, x, z);
return correctedType == null ? biome : biomeOfType.at(correctedType, x, z);
}
// The type the terrain height asks for when the biome doesn't fit it, or null when it does.
private InferredType correctedType(double height, IrisBiome biome, IrisRegion region, double x, double z) {
double sh = region.getShoreHeight(x, z);
if (height >= fluidHeight - 1 && height <= fluidHeight + sh && !biome.isShore()) {
return biomeOfType.at(InferredType.SHORE, x, z);
return InferredType.SHORE;
}
if (height > fluidHeight + sh && !biome.isLand()) {
return biomeOfType.at(InferredType.LAND, x, z);
return InferredType.LAND;
}
if (height < fluidHeight && !biome.isAquatic()) {
return biomeOfType.at(InferredType.SEA, x, z);
return InferredType.SEA;
}
if (height == fluidHeight && !biome.isShore()) {
return biomeOfType.at(InferredType.SHORE, x, z);
return InferredType.SHORE;
}
return biome;
return null;
}
private double interpolateGenerators(Engine engine, IrisInterpolator interpolator, Set<IrisGenerator> generators, double x, double z, long seed) {
@@ -506,6 +564,12 @@ public class IrisComplex implements DataProvider {
}
/**
* A biome and the type of biome list it was picked from.
*/
public record BiomePick(IrisBiome biome, InferredType type) {
}
@FunctionalInterface
private interface BiomeOfType {
IrisBiome at(InferredType type, double x, double z);