Port the v4 noise explorer and Vision map, and fix blank windows on Wayland

Brings over v4's Noise Explorer and Vision viewers: a tiled, cancellable
renderer, viewport math, palettes, player and entity markers, and
click-to-teleport.

On Wayland desktops whose Xwayland window manager does not reparent
windows (niri, sway, Hyprland), Java's X11 toolkit waits for a reparent
that never comes, so it keeps its guessed window size and insets and
leaves most of the window white. Iris now sets
_JAVA_AWT_WM_NONREPARENTING natively before opening its first window
when the desktop needs it. GNOME and KDE are left alone because Java
already recognises their window managers.

All Iris windows, including the pregen view, now come from GuiHost, and
the studio commands report a headless server instead of failing.

Model used: Claude Opus 5.5
This commit is contained in:
piexel committed 2026-10-15 14:42:28 +02:00
1 parent d35fe93f7f
commit 87dc78934f
14 files changed
+4531 -1130

No files matched your search

@@ -20,6 +20,7 @@ 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.NoiseExplorerGUI;
import com.volmit.iris.core.gui.VisionGUI;
import com.volmit.iris.core.loader.IrisData;
@@ -40,7 +41,6 @@ import com.volmit.iris.util.decree.annotations.Decree;
import com.volmit.iris.util.decree.annotations.Param;
import com.volmit.iris.util.format.C;
import com.volmit.iris.util.format.Form;
import com.volmit.iris.util.function.Function2;
import com.volmit.iris.util.function.NoiseProvider;
import com.volmit.iris.util.interpolation.InterpolationMethod;
import com.volmit.iris.util.io.IO;
@@ -79,7 +79,6 @@ import java.util.Objects;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
@Decree(name = "studio", aliases = {"std", "s"}, description = "Studio Commands", studio = true)
public class CommandStudio implements DecreeExecutor {
@@ -272,16 +271,7 @@ public class CommandStudio implements DecreeExecutor {
) {
if (noGUI()) return;
sender().sendMessage(C.GREEN + "Opening Noise Explorer!");
Supplier<Function2<Double, Double, Double>> l = () -> {
if (generator == null) {
return (x, z) -> 0D;
}
return (x, z) -> generator.getHeight(x, z, new RNG(seed).nextParallelRNG(3245).lmax());
};
NoiseExplorerGUI.launch(l, "Custom Generator");
NoiseExplorerGUI.launchGeneratorKey(generator == null ? null : generator.getLoadKey(), generator, seed);
}
@Decree(description = "Hotload a studio", aliases = {"reload", "h"})
@@ -456,7 +446,7 @@ public class CommandStudio implements DecreeExecutor {
return;
}
VisionGUI.launch(IrisToolbelt.access(world).getEngine(), 0);
VisionGUI.launch(IrisToolbelt.access(world).getEngine(), sender().isPlayer() ? player().getUniqueId() : null);
sender().sendMessage(C.GREEN + "Opening map!");
}
@@ -877,6 +867,10 @@ public class CommandStudio implements DecreeExecutor {
sender().sendMessage(C.RED + "You must have server launched GUIs enabled in the settings!");
return true;
}
if (!GuiHost.isAvailable()) {
sender().sendMessage(C.RED + "This server runs without a desktop, so it cannot open windows!");
return true;
}
return false;
}
@@ -0,0 +1,169 @@
/*
* 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.events.IrisEngineHotloadEvent;
import com.volmit.iris.core.service.PreservationSVC;
import com.volmit.iris.core.tools.IrisToolbelt;
import com.volmit.iris.engine.framework.Engine;
import com.volmit.iris.engine.platform.PlatformChunkGenerator;
import org.bukkit.Bukkit;
import org.bukkit.World;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import javax.swing.JFrame;
import java.awt.Desktop;
import java.awt.EventQueue;
import java.awt.GraphicsEnvironment;
import java.awt.desktop.QuitResponse;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.util.ArrayList;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.atomic.AtomicBoolean;
public final class GuiHost {
private static final AtomicBoolean DESKTOP_QUIT_GUARD_INSTALLED = new AtomicBoolean(false);
private static final Set<JFrame> MANAGED_FRAMES = ConcurrentHashMap.newKeySet();
private static final Map<Runnable, Listener> HOTLOAD_HOOKS = new ConcurrentHashMap<>();
private GuiHost() {
}
public static boolean isAvailable() {
return !GraphicsEnvironment.isHeadless();
}
public static JFrame createFrame(String title) {
XwaylandWindowManagerHint.apply();
JFrame frame = new JFrame(title);
frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
MANAGED_FRAMES.add(frame);
frame.addWindowListener(new WindowAdapter() {
@Override
public void windowClosed(WindowEvent event) {
MANAGED_FRAMES.remove(frame);
}
});
installMacQuitGuard();
return frame;
}
public static Engine findActiveEngine() {
try {
for (World world : new ArrayList<>(Bukkit.getWorlds())) {
Engine engine = engineOf(world);
if (engine != null) {
return engine;
}
}
} catch (Throwable ignored) {
}
return null;
}
public static void registerHotloadHook(Runnable onHotload) {
Listener listener = new HotloadListener(onHotload);
HOTLOAD_HOOKS.put(onHotload, listener);
Iris.instance.registerListener(listener);
}
public static void unregisterHotloadHook(Runnable onHotload) {
Listener listener = HOTLOAD_HOOKS.remove(onHotload);
if (listener != null) {
Iris.instance.unregisterListener(listener);
}
}
static void interruptOnPluginShutdown(ExecutorService executor) {
PreservationSVC preservation = Iris.service(PreservationSVC.class);
if (preservation != null) {
preservation.register(executor);
}
}
static void interruptOnPluginShutdown(Thread thread) {
PreservationSVC preservation = Iris.service(PreservationSVC.class);
if (preservation != null) {
preservation.register(thread);
}
}
private static Engine engineOf(World world) {
try {
PlatformChunkGenerator access = IrisToolbelt.access(world);
if (access != null && access.getEngine() != null && !access.getEngine().isClosed()) {
return access.getEngine();
}
} catch (Throwable ignored) {
}
return null;
}
// macOS Quit in the dock menu would otherwise stop the whole server.
private static void installMacQuitGuard() {
if (!isAvailable()
|| !System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("mac")
|| !DESKTOP_QUIT_GUARD_INSTALLED.compareAndSet(false, true)) {
return;
}
try {
if (!Desktop.isDesktopSupported()) {
return;
}
Desktop desktop = Desktop.getDesktop();
if (!desktop.isSupported(Desktop.Action.APP_QUIT_HANDLER)) {
return;
}
desktop.setQuitHandler((event, response) -> closeWindowsAndCancelQuit(response));
} catch (Throwable e) {
Iris.reportError(e);
Iris.info("Unable to install the Iris desktop quit guard; use the server stop command instead of macOS Quit");
}
}
private static void closeWindowsAndCancelQuit(QuitResponse response) {
response.cancelQuit();
EventQueue.invokeLater(() -> {
for (JFrame frame : MANAGED_FRAMES) {
frame.dispose();
}
});
}
private static final class HotloadListener implements Listener {
private final Runnable onHotload;
private HotloadListener(Runnable onHotload) {
this.onHotload = onHotload;
}
@EventHandler
public void on(IrisEngineHotloadEvent event) {
onHotload.run();
}
}
}
@@ -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;
public record GuiMarker(String label, double worldX, double worldY, double worldZ, double health, double maxHealth) {
public static GuiMarker player(String name, double worldX, double worldZ) {
return new GuiMarker(name, worldX, 0, worldZ, 0, 0);
}
public static GuiMarker entity(String label, double worldX, double worldY, double worldZ, double health, double maxHealth) {
return new GuiMarker(label, worldX, worldY, worldZ, health, maxHealth);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,114 @@
/*
* 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.awt.Color;
enum NoisePalette {
TERRAIN("Terrain", 0D, 1D, new int[]{0x071A2F, 0x155E75, 0x2A9D8F, 0xE9C46A, 0xF4F1DE}),
SIGNED("Signed", -1D, 1D, new int[]{0x173B66, 0x4F86C6, 0xE7EDF3, 0xE89555, 0x9D3B35}),
GRAYSCALE("Grayscale", 0D, 1D, new int[]{0x050505, 0xFFFFFF});
static final int INVALID_COLOR = 0xFF2DAA;
private final String label;
private final double minimum;
private final double maximum;
private final int[] lookup;
private final Color[] displayColors;
NoisePalette(String label, double minimum, double maximum, int[] stops) {
this.label = label;
this.minimum = minimum;
this.maximum = maximum;
this.lookup = buildLookup(stops);
this.displayColors = buildDisplayColors(lookup);
}
int color(double value) {
if (!Double.isFinite(value)) {
return INVALID_COLOR;
}
return colorFinite(value);
}
int colorFinite(double value) {
double normalized = (value - minimum) / (maximum - minimum);
return colorNormalized(normalized);
}
int colorNormalized(double normalized) {
double clipped = Math.max(0D, Math.min(1D, normalized));
return lookup[(int) Math.round(clipped * (lookup.length - 1))];
}
Color displayColorNormalized(double normalized) {
double clipped = Math.max(0D, Math.min(1D, normalized));
return displayColors[(int) Math.round(clipped * (displayColors.length - 1))];
}
String label() {
return label;
}
double minimum() {
return minimum;
}
double maximum() {
return maximum;
}
@Override
public String toString() {
return label;
}
private static int[] buildLookup(int[] stops) {
int[] values = new int[256];
for (int index = 0; index < values.length; index++) {
double position = (index / 255D) * (stops.length - 1);
int lowerIndex = Math.min(stops.length - 2, (int) position);
double fraction = position - lowerIndex;
values[index] = interpolate(stops[lowerIndex], stops[lowerIndex + 1], fraction);
}
return values;
}
private static Color[] buildDisplayColors(int[] lookup) {
Color[] colors = new Color[lookup.length];
for (int index = 0; index < lookup.length; index++) {
colors[index] = new Color(lookup[index]);
}
return colors;
}
private static int interpolate(int from, int to, double fraction) {
int red = channel(from, 16, to, fraction);
int green = channel(from, 8, to, fraction);
int blue = channel(from, 0, to, fraction);
return (red << 16) | (green << 8) | blue;
}
private static int channel(int from, int shift, int to, double fraction) {
int fromChannel = (from >>> shift) & 0xFF;
int toChannel = (to >>> shift) & 0xFF;
return (int) Math.round(fromChannel + ((toChannel - fromChannel) * fraction));
}
}
@@ -0,0 +1,404 @@
/*
* 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.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.RejectedExecutionException;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
final class NoiseRenderCoordinator implements AutoCloseable {
private final int workerCount;
private final Listener listener;
private final ThreadFactory workerThreadFactory;
private final ThreadFactory coordinatorThreadFactory;
private final AtomicReference<RenderGeneration> latestRequested = new AtomicReference<>();
private final AtomicLong latestRevision = new AtomicLong();
private final AtomicBoolean closed = new AtomicBoolean();
NoiseRenderCoordinator(Listener listener) {
this(Math.min(4, Math.max(1, Runtime.getRuntime().availableProcessors() / 2)), listener);
}
NoiseRenderCoordinator(int workerCount, Listener listener) {
this.workerCount = Math.max(1, workerCount);
this.listener = Objects.requireNonNull(listener, "listener");
AtomicInteger threadIds = new AtomicInteger();
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;
};
}
void request(Request request) {
Objects.requireNonNull(request, "request");
if (closed.get()) {
return;
}
latestRevision.accumulateAndGet(request.revision(), Math::max);
cancelActiveRender();
RenderGeneration generation = createGeneration(request);
latestRequested.set(generation);
generation.start();
}
void cancel(long revision) {
latestRevision.accumulateAndGet(revision, Math::max);
cancelActiveRender();
}
boolean isClosed() {
return closed.get();
}
static int sampleStepForBudget(int width, int height, long sampleBudget) {
if (width < 1 || height < 1 || sampleBudget < 1L) {
throw new IllegalArgumentException("Invalid noise sample budget");
}
long totalSamples = (long) width * height;
int sampleStep = Math.max(1, (int) Math.ceil(Math.sqrt(totalSamples / (double) sampleBudget)));
while (sampleCount(width, height, sampleStep) > sampleBudget) {
sampleStep++;
}
return sampleStep;
}
static long sampleCount(int width, int height, int sampleStep) {
if (width < 1 || height < 1 || sampleStep < 1) {
throw new IllegalArgumentException("Invalid noise sample dimensions");
}
long outputWidth = ((long) width + sampleStep - 1L) / sampleStep;
long outputHeight = ((long) height + sampleStep - 1L) / sampleStep;
return outputWidth * outputHeight;
}
static int nextRefinementStep(int width, int height, int currentStep, long sampleBudget) {
if (currentStep <= 1) {
throw new IllegalArgumentException("Noise refinement requires a coarse input");
}
return Math.min(currentStep, sampleStepForBudget(width, height, sampleBudget));
}
static long timeBoundSampleBudget(long completedSamples, double milliseconds, double targetMilliseconds,
long minimumBudget, long maximumBudget) {
if (completedSamples < 1L
|| !Double.isFinite(milliseconds)
|| milliseconds <= 0D
|| !Double.isFinite(targetMilliseconds)
|| targetMilliseconds <= 0D
|| minimumBudget < 1L
|| maximumBudget < minimumBudget) {
throw new IllegalArgumentException("Invalid timed noise sample budget");
}
double projectedSamples = Math.ceil((completedSamples / milliseconds) * targetMilliseconds);
long timeBoundBudget = (long) Math.min(maximumBudget, projectedSamples);
return Math.max(minimumBudget, timeBoundBudget);
}
@Override
public void close() {
if (!closed.compareAndSet(false, true)) {
return;
}
latestRevision.incrementAndGet();
cancelActiveRender();
}
private void renderGeneration(RenderGeneration generation) {
Request request = generation.request();
if (!isCurrent(request)) {
generation.close();
return;
}
listener.onRenderStarted(request);
try {
Result result = render(generation);
if (result != null && isCurrent(request)) {
listener.onRenderCompleted(result);
}
} catch (Throwable error) {
if (isCurrent(request)) {
listener.onRenderFailed(request, error);
}
} finally {
latestRequested.compareAndSet(generation, null);
generation.close();
}
}
private Result render(RenderGeneration generation) throws InterruptedException {
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;
double sampleOffset = request.sampleStep() * 0.5D;
double startWorldX = request.viewport().worldX(sampleOffset, request.width());
double worldStep = request.viewport().blocksPerPixel() * request.sampleStep();
NoisePalette palette = request.palette();
double paletteMinimum = palette.minimum();
double paletteMaximum = palette.maximum();
for (int y = fromY; y < toY; y++) {
double screenY = (y * (double) request.sampleStep()) + sampleOffset;
double worldZ = request.viewport().worldZ(screenY, request.height());
double worldX = startWorldX;
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())) {
return;
}
double value = request.sampler().noise(worldX, worldZ);
boolean center = centerRow && x == outputWidth / 2;
if (Double.isFinite(value)) {
pixels[pixelIndex++] = palette.colorFinite(value);
stats.acceptFinite(value, center, paletteMinimum, paletteMaximum);
} else {
pixels[pixelIndex++] = NoisePalette.INVALID_COLOR;
stats.acceptInvalid(value, center);
}
worldX += worldStep;
}
}
}
private boolean isCurrent(Request request) {
return !closed.get()
&& latestRequested.get() != null
&& latestRequested.get().request() == request
&& request.revision() >= latestRevision.get();
}
private void cancelActiveRender() {
RenderGeneration generation = latestRequested.getAndSet(null);
if (generation != null) {
generation.close();
}
}
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 RenderStats combine(BandStats[] bands) {
long samples = 0L;
long underflow = 0L;
long overflow = 0L;
long invalid = 0L;
double minimum = Double.POSITIVE_INFINITY;
double maximum = Double.NEGATIVE_INFINITY;
double centerValue = Double.NaN;
for (BandStats band : bands) {
samples += band.samples;
underflow += band.underflow;
overflow += band.overflow;
invalid += band.invalid;
minimum = Math.min(minimum, band.minimum);
maximum = Math.max(maximum, band.maximum);
if (!Double.isNaN(band.centerValue)) {
centerValue = band.centerValue;
}
}
if (minimum == Double.POSITIVE_INFINITY) {
minimum = Double.NaN;
maximum = Double.NaN;
}
return new RenderStats(samples, minimum, maximum, centerValue, underflow, overflow, invalid);
}
interface Listener {
void onRenderStarted(Request request);
void onRenderCompleted(Result result);
void onRenderFailed(Request request, Throwable error);
}
record Request(long revision, NoiseProvider sampler, NoiseViewport viewport, NoisePalette palette,
int width, int height, int sampleStep) {
Request {
Objects.requireNonNull(sampler, "sampler");
Objects.requireNonNull(viewport, "viewport");
Objects.requireNonNull(palette, "palette");
if (revision < 0L || width < 1 || height < 1 || sampleStep < 1) {
throw new IllegalArgumentException("Invalid noise render request");
}
}
}
record Result(Request request, BufferedImage image, double milliseconds, long samples, double minimum,
double maximum, double centerValue, long underflow, long overflow, long invalid) {
}
private static final class RenderGeneration {
private final Request request;
private final ThreadPoolExecutor executor;
private volatile Thread runner;
private RenderGeneration(Request request, ThreadPoolExecutor executor) {
this.request = Objects.requireNonNull(request, "request");
this.executor = Objects.requireNonNull(executor, "executor");
}
private Request request() {
return request;
}
private ThreadPoolExecutor executor() {
return executor;
}
private void setRunner(Thread runner) {
this.runner = Objects.requireNonNull(runner, "runner");
}
private void start() {
runner.start();
}
private void close() {
Thread activeRunner = runner;
if (activeRunner != null) {
activeRunner.interrupt();
}
executor.shutdownNow();
}
}
private static final class BandStats {
private long samples;
private long underflow;
private long overflow;
private long invalid;
private double minimum = Double.POSITIVE_INFINITY;
private double maximum = Double.NEGATIVE_INFINITY;
private double centerValue = Double.NaN;
private void acceptFinite(double value, boolean center, double paletteMinimum, double paletteMaximum) {
samples++;
if (center) {
centerValue = value;
}
minimum = Math.min(minimum, value);
maximum = Math.max(maximum, value);
if (value < paletteMinimum) {
underflow++;
} else if (value > paletteMaximum) {
overflow++;
}
}
private void acceptInvalid(double value, boolean center) {
samples++;
invalid++;
if (center) {
centerValue = value;
}
}
}
private record RenderStats(long samples, double minimum, double maximum, double centerValue,
long underflow, long overflow, long invalid) {
}
}
@@ -0,0 +1,62 @@
/*
* 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;
record NoiseViewport(double centerX, double centerZ, double blocksPerPixel) {
static final double MIN_BLOCKS_PER_PIXEL = 0.0001D;
static final double MAX_BLOCKS_PER_PIXEL = 1_000_000D;
NoiseViewport {
if (!Double.isFinite(centerX) || !Double.isFinite(centerZ)) {
throw new IllegalArgumentException("Viewport center must be finite");
}
if (!Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) {
throw new IllegalArgumentException("Viewport scale must be finite and positive");
}
}
double worldX(double screenX, int width) {
return centerX + ((screenX - (width / 2D)) * blocksPerPixel);
}
double worldZ(double screenZ, int height) {
return centerZ + ((screenZ - (height / 2D)) * blocksPerPixel);
}
NoiseViewport panPixels(double deltaX, double deltaZ) {
return new NoiseViewport(
centerX - (deltaX * blocksPerPixel),
centerZ - (deltaZ * blocksPerPixel),
blocksPerPixel
);
}
NoiseViewport zoomAt(double screenX, double screenZ, int width, int height, double factor) {
if (!Double.isFinite(factor) || factor <= 0D) {
throw new IllegalArgumentException("Zoom factor must be finite and positive");
}
double anchorX = worldX(screenX, width);
double anchorZ = worldZ(screenZ, height);
double nextScale = Math.max(MIN_BLOCKS_PER_PIXEL,
Math.min(MAX_BLOCKS_PER_PIXEL, blocksPerPixel * factor));
double nextCenterX = anchorX - ((screenX - (width / 2D)) * nextScale);
double nextCenterZ = anchorZ - ((screenZ - (height / 2D)) * nextScale);
return new NoiseViewport(nextCenterX, nextCenterZ, nextScale);
}
}
@@ -212,7 +212,7 @@ public class PregeneratorJob implements PregenListener {
public void open() {
J.a(() -> {
try {
frame = new JFrame("Pregen View");
frame = GuiHost.createFrame("Pregen View");
renderer = new PregenRenderer();
frame.addKeyListener(renderer);
renderer.l = new ReentrantLock();
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,173 @@
/*
* 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.RenderType;
import com.volmit.iris.engine.IrisComplex;
import com.volmit.iris.engine.framework.Engine;
import com.volmit.iris.engine.object.IrisWorld;
import com.volmit.iris.util.format.Form;
import com.volmit.iris.util.scheduling.J;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import static com.volmit.iris.util.data.registry.Attributes.MAX_HEALTH;
final class VisionOverlay {
private final Engine engine;
private final UUID openerId;
private final AtomicBoolean playerRefreshQueued = new AtomicBoolean();
private volatile List<GuiMarker> playerMarkers = List.of();
VisionOverlay(Engine engine, UUID openerId) {
this.engine = engine;
this.openerId = openerId;
}
List<GuiMarker> lastKnownPlayers() {
queuePlayerRefresh();
return playerMarkers;
}
void requestEntities(Consumer<List<GuiMarker>> sink) {
runOnServerThread(() -> sink.accept(collectEntities()));
}
void teleport(int blockX, int blockZ) {
runOnServerThread(() -> teleportOpener(blockX, blockZ));
}
Optional<String> openInEditor(double worldX, double worldZ, RenderType type) {
IrisComplex complex = engine.getComplex();
File file = switch (type) {
case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT ->
complex.getTrueBiomeStream().get(worldX, worldZ).openInVSCode();
case BIOME_LAND -> complex.getLandBiomeStream().get(worldX, worldZ).openInVSCode();
case BIOME_SEA -> complex.getSeaBiomeStream().get(worldX, worldZ).openInVSCode();
case REGION -> complex.getRegionStream().get(worldX, worldZ).openInVSCode();
case CAVE_LAND -> complex.getCaveBiomeStream().get(worldX, worldZ).openInVSCode();
case CONTINENT -> null;
};
return Optional.ofNullable(file).map(File::getName);
}
private void queuePlayerRefresh() {
if (!playerRefreshQueued.compareAndSet(false, true)) {
return;
}
boolean scheduled = runOnServerThread(() -> {
try {
playerMarkers = collectPlayers();
} finally {
playerRefreshQueued.set(false);
}
});
if (!scheduled) {
playerRefreshQueued.set(false);
}
}
private List<GuiMarker> collectPlayers() {
List<GuiMarker> markers = new ArrayList<>();
for (Player player : engine.getWorld().getPlayers()) {
Location at = player.getLocation();
markers.add(GuiMarker.player(player.getName(), at.getX(), at.getZ()));
}
return List.copyOf(markers);
}
private List<GuiMarker> collectEntities() {
IrisWorld world = engine.getWorld();
if (!world.hasRealWorld()) {
return List.of();
}
List<GuiMarker> markers = new ArrayList<>();
for (LivingEntity entity : world.getEntitiesByClass(LivingEntity.class)) {
if (!(entity instanceof Player)) {
markers.add(marker(entity));
}
}
return List.copyOf(markers);
}
private static GuiMarker marker(LivingEntity entity) {
Location at = entity.getLocation();
String label = Form.capitalizeWords(entity.getType().name().toLowerCase(Locale.ROOT).replace('_', ' '));
double maxHealth = 0D;
try {
maxHealth = entity.getAttribute(MAX_HEALTH).getValue();
} catch (Throwable ignored) {
}
return GuiMarker.entity(label, at.getX(), at.getY(), at.getZ(), entity.getHealth(), maxHealth);
}
private void teleportOpener(int blockX, int blockZ) {
IrisWorld target = engine.getWorld();
Player player = selectPlayer(target.getPlayers());
if (player == null || !target.hasRealWorld() || engine.isClosed()) {
return;
}
World world = target.realWorld();
int blockY = engine.getMinHeight() + engine.getHeight(blockX, blockZ, false) + 2;
Location destination = new Location(world, blockX + 0.5D, blockY, blockZ + 0.5D);
player.teleportAsync(destination).whenComplete((success, failure) -> {
if (failure != null) {
Iris.reportError(failure);
Iris.warn("Vision could not teleport %s to %d, %d.", player.getName(), blockX, blockZ);
}
});
}
private Player selectPlayer(List<Player> players) {
if (openerId == null) {
return players.isEmpty() ? null : players.get(0);
}
for (Player player : players) {
if (openerId.equals(player.getUniqueId())) {
return player;
}
}
return null;
}
private static boolean runOnServerThread(Runnable task) {
try {
J.s(task);
return true;
} catch (Throwable e) {
Iris.reportError(e);
return false;
}
}
}
@@ -0,0 +1,618 @@
/*
* 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 java.awt.EventQueue;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
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.RejectedExecutionException;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
final class VisionRenderController implements AutoCloseable {
static final int TILE_PIXELS = 64;
static final double MINIMUM_BLOCKS_PER_PIXEL = 1D;
static final double MAXIMUM_BLOCKS_PER_PIXEL = 4_096D;
private static final int MAXIMUM_WORKERS = 3;
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 final Runnable listener;
private final int renderWorkerCount;
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 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");
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()
);
this.probeExecutor = new ThreadPoolExecutor(
1,
1,
0L,
TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(PROBE_QUEUE_CAPACITY),
daemonFactory("Iris Vision Probe", Thread.MIN_PRIORITY, threadSequence),
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) {
Objects.requireNonNull(spec, "spec");
if (closed.get()) {
throw new IllegalStateException("Vision render controller is closed");
}
CancellationToken previousToken = currentToken;
if (previousToken != null) {
previousToken.cancel();
}
renderExecutor.getQueue().clear();
cancelActiveRenderTasks();
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);
return frame;
}
Frame currentFrame() {
return currentFrame;
}
BufferedImage image(Frame frame, VisibleTile tile) {
if (frame == null || tile == null) {
return null;
}
return tile.image();
}
Progress progress(Frame frame) {
if (frame == null) {
return new Progress(0, 0, 0, 0);
}
int ready = 0;
for (VisibleTile tile : frame.tiles()) {
if (tile.image() != null) {
ready++;
}
}
return new Progress(frame.tiles().size(), ready, renderExecutor.getActiveCount(), renderExecutor.getQueue().size());
}
<T> void submitProbe(Frame frame, Callable<T> probe, Consumer<T> consumer) {
Objects.requireNonNull(probe, "probe");
Objects.requireNonNull(consumer, "consumer");
if (frame == null || closed.get() || currentFrame != frame) {
return;
}
long probeRevision = probeSequence.incrementAndGet();
probeExecutor.getQueue().clear();
try {
probeExecutor.execute(() -> runProbe(frame, probeRevision, probe, consumer));
} catch (RejectedExecutionException ignored) {
probeExecutor.getQueue().clear();
}
}
@Override
public synchronized 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) {
Objects.requireNonNull(spec, "spec");
double blocksPerPixel = spec.blocksPerPixel();
double tileSpan = TILE_PIXELS * blocksPerPixel;
double halfWidth = spec.width() * blocksPerPixel * 0.5D;
double halfHeight = spec.height() * blocksPerPixel * 0.5D;
long minimumX = floorTile(spec.centerX() - halfWidth, tileSpan);
long maximumX = floorTile(Math.nextDown(spec.centerX() + halfWidth), tileSpan);
long minimumZ = floorTile(spec.centerZ() - halfHeight, tileSpan);
long maximumZ = floorTile(Math.nextDown(spec.centerZ() + halfHeight), tileSpan);
long tileCount = Math.multiplyExact(maximumX - minimumX + 1L, maximumZ - minimumZ + 1L);
if (tileCount > MAXIMUM_VISIBLE_TILES) {
throw new IllegalArgumentException("Vision viewport contains too many tiles");
}
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);
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;
}
}
tiles.sort(Comparator.comparingDouble(VisibleTile::distanceSquared)
.thenComparingLong(VisibleTile::tileZ)
.thenComparingLong(VisibleTile::tileX));
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) {
throw new IllegalArgumentException("Vision viewport exceeds tile coordinate range");
}
return (long) tile;
}
private static ThreadFactory daemonFactory(String name, int priority, AtomicInteger sequence) {
return (Runnable runnable) -> {
Thread thread = new Thread(runnable, name + " " + sequence.incrementAndGet());
thread.setDaemon(true);
thread.setPriority(priority);
thread.setUncaughtExceptionHandler((Thread failedThread, Throwable error) -> Iris.reportError(error));
return thread;
};
}
private void schedule(WorkState work) {
synchronized (work) {
if (!isCurrent(work)) {
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;
}
}
}
}
private void render(WorkState work, VisibleTile tile) {
Frame frame = work.frame();
CancellationToken token = work.token();
try {
if (!isCurrent(frame, token)) {
return;
}
double tileSpan = TILE_PIXELS * frame.spec().blocksPerPixel();
BufferedImage image = frame.spec().renderer().renderStudio(
tile.tileX() * tileSpan,
tile.tileZ() * tileSpan,
tileSpan,
TILE_PIXELS,
frame.spec().type(),
() -> !isCurrent(frame, token)
);
if (!isCurrent(frame, token)) {
return;
}
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);
}
}
private void complete(WorkState work) {
synchronized (work) {
work.decrementInFlight();
if (!isCurrent(work)) {
return;
}
}
schedule(work);
}
private <T> void runProbe(Frame frame, long probeRevision, Callable<T> probe, Consumer<T> consumer) {
try {
if (!isProbeCurrent(frame, probeRevision)) {
return;
}
T result = probe.call();
if (!isProbeCurrent(frame, probeRevision)) {
return;
}
EventQueue.invokeLater(() -> {
if (isProbeCurrent(frame, probeRevision)) {
consumer.accept(result);
}
});
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
} catch (Throwable error) {
Iris.debug("Vision probe failed: " + error.getClass().getSimpleName() + ": " + error.getMessage());
}
}
private boolean isProbeCurrent(Frame frame, long probeRevision) {
return !closed.get() && currentFrame == frame && 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)) {
return;
}
publicationDirty.set(true);
queuePublication();
}
private void queuePublication() {
if (!publicationQueued.compareAndSet(false, true)) {
return;
}
EventQueue.invokeLater(() -> {
try {
if (publicationDirty.getAndSet(false) && !closed.get()) {
listener.run();
}
} finally {
publicationQueued.set(false);
if (publicationDirty.get() && !closed.get()) {
queuePublication();
}
}
});
}
private void cancelActiveRenderTasks() {
for (Future<?> task : activeRenderTasks) {
task.cancel(true);
}
activeRenderTasks.clear();
}
record RenderSpec(
IrisRenderer renderer,
RenderType type,
long contentRevision,
double centerX,
double centerZ,
double blocksPerPixel,
int width,
int height
) {
RenderSpec {
Objects.requireNonNull(renderer, "renderer");
Objects.requireNonNull(type, "type");
if (!Double.isFinite(centerX) || !Double.isFinite(centerZ)) {
throw new IllegalArgumentException("Vision center must be finite");
}
if (!Double.isFinite(blocksPerPixel)
|| blocksPerPixel < MINIMUM_BLOCKS_PER_PIXEL
|| blocksPerPixel > MAXIMUM_BLOCKS_PER_PIXEL) {
throw new IllegalArgumentException("Vision scale is outside the supported range");
}
if (width < 1 || height < 1) {
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);
}
TileKey key(VisibleTile tile) {
return new TileKey(spec.contentRevision(), spec.type(), spec.blocksPerPixel(), tile.tileX(), tile.tileZ());
}
}
static final class VisibleTile {
private final long tileX;
private final long tileZ;
private final int screenX;
private final int screenY;
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;
this.distanceSquared = distanceSquared;
}
long tileX() {
return tileX;
}
long tileZ() {
return tileZ;
}
int screenX() {
return screenX;
}
int screenY() {
return screenY;
}
double distanceSquared() {
return distanceSquared;
}
BufferedImage image() {
return image;
}
void setImage(BufferedImage image) {
this.image = image;
}
}
record TileKey(long contentRevision, RenderType type, double blocksPerPixel, long tileX, long tileZ) {
TileKey {
Objects.requireNonNull(type, "type");
if (!Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) {
throw new IllegalArgumentException("Vision cache scale must be finite and positive");
}
}
}
record Progress(int total, int ready, int active, int queued) {
double completion() {
if (total == 0) {
return 1D;
}
return Math.min(1D, ready / (double) total);
}
}
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 static final class WeightedTileCache {
private final long maximumBytes;
private final LinkedHashMap<TileKey, CacheEntry> entries;
private long bytes;
private WeightedTileCache(long maximumBytes) {
this.maximumBytes = maximumBytes;
this.entries = new LinkedHashMap<>(128, 0.75F, true);
}
synchronized BufferedImage get(TileKey key) {
CacheEntry entry = entries.get(key);
return entry == null ? null : entry.image();
}
synchronized void put(TileKey key, BufferedImage image) {
long imageBytes = (long) image.getWidth() * image.getHeight() * Integer.BYTES;
CacheEntry previous = entries.put(key, new CacheEntry(image, imageBytes));
if (previous != null) {
bytes -= previous.bytes();
}
bytes += imageBytes;
while (bytes > maximumBytes && !entries.isEmpty()) {
Iterator<Map.Entry<TileKey, CacheEntry>> iterator = entries.entrySet().iterator();
Map.Entry<TileKey, CacheEntry> eldest = iterator.next();
bytes -= eldest.getValue().bytes();
iterator.remove();
}
}
synchronized void clear() {
entries.clear();
bytes = 0L;
}
}
private record CacheEntry(BufferedImage image, long bytes) {
}
}
@@ -0,0 +1,60 @@
/*
* 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;
record VisionViewport(double centerX, double centerZ, double blocksPerPixel) {
VisionViewport {
if (!Double.isFinite(centerX) || !Double.isFinite(centerZ)) {
throw new IllegalArgumentException("Vision viewport center must be finite");
}
if (!Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) {
throw new IllegalArgumentException("Vision viewport scale must be finite and positive");
}
}
double worldX(double screenX, int width) {
return centerX + (screenX - width * 0.5D) * blocksPerPixel;
}
double worldZ(double screenZ, int height) {
return centerZ + (screenZ - height * 0.5D) * blocksPerPixel;
}
VisionViewport zoomAt(
double screenX,
double screenZ,
int width,
int height,
double factor,
double minimumScale,
double maximumScale
) {
if (!Double.isFinite(factor) || factor <= 0D) {
throw new IllegalArgumentException("Vision zoom factor must be finite and positive");
}
double anchorX = worldX(screenX, width);
double anchorZ = worldZ(screenZ, height);
double nextScale = Math.max(minimumScale, Math.min(maximumScale, blocksPerPixel * factor));
return new VisionViewport(
anchorX - (screenX - width * 0.5D) * nextScale,
anchorZ - (screenZ - height * 0.5D) * nextScale,
nextScale
);
}
}
@@ -0,0 +1,86 @@
/*
* 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.sun.jna.platform.linux.LibC;
import com.volmit.iris.Iris;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Java does not recognise the non-reparenting Xwayland window managers of compositors such as niri, sway and Hyprland.
* It waits for a reparent that never comes and paints only a corner of each window. Java reads this hint from the
* native environment once, at the first decorated window, so it is set through libc before Iris opens any window.
*/
final class XwaylandWindowManagerHint {
private static final String NON_REPARENTING_VARIABLE = "_JAVA_AWT_WM_NONREPARENTING";
private static final int KEEP_EXISTING_VALUE = 0;
private static final List<String> REPARENTING_DESKTOPS = List.of("GNOME", "KDE");
private static final AtomicBoolean APPLIED = new AtomicBoolean();
private XwaylandWindowManagerHint() {
}
static void apply() {
if (!APPLIED.compareAndSet(false, true) || !isNeeded(System.getenv(), System.getProperty("os.name", ""))) {
return;
}
try {
if (LibC.INSTANCE.setenv(NON_REPARENTING_VARIABLE, "1", KEEP_EXISTING_VALUE) != 0) {
warnUnapplied();
}
} catch (Throwable e) {
Iris.reportError(e);
warnUnapplied();
}
}
static boolean isNeeded(Map<String, String> environment, String osName) {
return osName.toLowerCase(Locale.ROOT).contains("linux")
&& isSet(environment, "WAYLAND_DISPLAY")
&& isSet(environment, "DISPLAY")
&& !isSet(environment, NON_REPARENTING_VARIABLE)
&& !isReparentingDesktop(environment.get("XDG_CURRENT_DESKTOP"));
}
private static boolean isReparentingDesktop(String currentDesktop) {
if (currentDesktop == null) {
return false;
}
return Arrays.stream(currentDesktop.split(":"))
.map(desktop -> desktop.trim().toUpperCase(Locale.ROOT))
.anyMatch(REPARENTING_DESKTOPS::contains);
}
private static boolean isSet(Map<String, String> environment, String variable) {
String value = environment.get(variable);
return value != null && !value.isBlank();
}
private static void warnUnapplied() {
Iris.warn("Iris could not adjust Java for this Wayland desktop, so its windows may stay blank. "
+ "Start the server with %s=1 set to fix that.", NON_REPARENTING_VARIABLE);
}
}
@@ -1,85 +1,586 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.volmit.iris.core.gui.components;
import com.volmit.iris.engine.framework.Engine;
import com.volmit.iris.engine.object.IrisBiome;
import com.volmit.iris.engine.object.IrisBiomeGeneratorLink;
import com.volmit.iris.util.interpolation.IrisInterpolation;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.util.function.BiFunction;
public class IrisRenderer {
private final Engine renderer;
public IrisRenderer(Engine renderer) {
this.renderer = renderer;
}
public BufferedImage render(double sx, double sz, double size, int resolution, RenderType currentType) {
BufferedImage image = new BufferedImage(resolution, resolution, BufferedImage.TYPE_INT_RGB);
BiFunction<Double, Double, Integer> colorFunction = (d, dx) -> Color.black.getRGB();
switch (currentType) {
case BIOME, DECORATOR_LOAD, OBJECT_LOAD, LAYER_LOAD ->
colorFunction = (x, z) -> renderer.getComplex().getTrueBiomeStream().get(x, z).getColor(renderer, currentType).getRGB();
case BIOME_LAND ->
colorFunction = (x, z) -> renderer.getComplex().getLandBiomeStream().get(x, z).getColor(renderer, currentType).getRGB();
case BIOME_SEA ->
colorFunction = (x, z) -> renderer.getComplex().getSeaBiomeStream().get(x, z).getColor(renderer, currentType).getRGB();
case REGION ->
colorFunction = (x, z) -> renderer.getComplex().getRegionStream().get(x, z).getColor(renderer.getComplex(), currentType).getRGB();
case CAVE_LAND ->
colorFunction = (x, z) -> renderer.getComplex().getCaveBiomeStream().get(x, z).getColor(renderer, currentType).getRGB();
case HEIGHT ->
colorFunction = (x, z) -> Color.getHSBColor(renderer.getComplex().getHeightStream().get(x, z).floatValue(), 100, 100).getRGB();
case CONTINENT -> colorFunction = (x, z) -> {
IrisBiome b = renderer.getBiome((int) Math.round(x), renderer.getMaxHeight() - 1, (int) Math.round(z));
IrisBiomeGeneratorLink g = b.getGenerators().get(0);
Color c;
if (g.getMax() <= 0) {
// Max is below water level, so it is most likely an ocean biome
c = Color.BLUE;
} else if (g.getMin() < 0) {
// Min is below water level, but max is not, so it is most likely a shore biome
c = Color.YELLOW;
} else {
// Both min and max are above water level, so it is most likely a land biome
c = Color.GREEN;
}
return c.getRGB();
};
}
double x, z;
int i, j;
for (i = 0; i < resolution; i++) {
x = IrisInterpolation.lerp(sx, sx + size, (double) i / (double) (resolution));
for (j = 0; j < resolution; j++) {
z = IrisInterpolation.lerp(sz, sz + size, (double) j / (double) (resolution));
image.setRGB(i, j, colorFunction.apply(x, z));
}
}
return image;
}
}
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.volmit.iris.core.gui.components;
import com.volmit.iris.engine.IrisComplex;
import com.volmit.iris.engine.framework.Engine;
import com.volmit.iris.engine.object.IrisBiome;
import com.volmit.iris.engine.object.IrisBiomeGeneratorLink;
import com.volmit.iris.engine.object.IrisDimension;
import com.volmit.iris.engine.object.IrisRegion;
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;
import java.util.Objects;
import java.util.concurrent.CancellationException;
import java.util.function.BooleanSupplier;
public final class IrisRenderer {
private static final int BLUE = new Color(45, 91, 156).getRGB();
private static final int YELLOW = new Color(211, 164, 67).getRGB();
private static final int GREEN = new Color(78, 137, 83).getRGB();
private static final int DEEP_WATER = new Color(20, 48, 92).getRGB();
private static final int SHALLOW_WATER = new Color(50, 112, 154).getRGB();
private static final int LOWLAND = new Color(78, 128, 76).getRGB();
private static final int HIGHLAND = new Color(151, 139, 92).getRGB();
private static final int ROCK = new Color(126, 119, 112).getRGB();
private static final int SNOW = new Color(226, 230, 232).getRGB();
private final Engine renderer;
public IrisRenderer(Engine renderer) {
this.renderer = Objects.requireNonNull(renderer, "renderer");
}
public BufferedImage render(double sx, double sz, double size, int resolution, RenderType currentType) {
return render(sx, sz, size, resolution, currentType, () -> false);
}
public BufferedImage render(
double sx,
double sz,
double size,
int resolution,
RenderType currentType,
BooleanSupplier cancelled
) {
return render(sx, sz, size, resolution, currentType, cancelled, false);
}
public BufferedImage renderStudio(
double sx,
double sz,
double size,
int resolution,
RenderType currentType,
BooleanSupplier cancelled
) {
return render(sx, sz, size, resolution, currentType, cancelled, true);
}
private BufferedImage render(
double sx,
double sz,
double size,
int resolution,
RenderType currentType,
BooleanSupplier cancelled,
boolean studio
) {
if (!Double.isFinite(sx) || !Double.isFinite(sz) || !Double.isFinite(size) || size <= 0D) {
throw new IllegalArgumentException("Vision render coordinates and size must be finite");
}
if (resolution < 1) {
throw new IllegalArgumentException("Vision render resolution must be positive");
}
Objects.requireNonNull(currentType, "currentType");
Objects.requireNonNull(cancelled, "cancelled");
checkCancelled(cancelled);
BufferedImage image = new BufferedImage(resolution, resolution, BufferedImage.TYPE_INT_RGB);
int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
double step = size / resolution;
if (studio && currentType == RenderType.HEIGHT) {
renderHeightAtlas(pixels, resolution, sx, sz, step, renderer, cancelled);
return image;
}
PixelShader shader = shader(currentType, studio);
if (studio && adaptiveStudioType(currentType)) {
renderAdaptiveAtlas(pixels, resolution, sx, sz, step, shader, cancelled);
return image;
}
int groupSize = sampleGroup(step, resolution);
for (int groupZ = 0; groupZ < resolution; groupZ += groupSize) {
checkCancelled(cancelled);
int maximumZ = Math.min(resolution, groupZ + groupSize);
for (int groupX = 0; groupX < resolution; groupX += groupSize) {
checkCancelled(cancelled);
int maximumX = Math.min(resolution, groupX + groupSize);
for (int pixelZ = groupZ; pixelZ < maximumZ; pixelZ++) {
double z = sz + step * pixelZ;
int row = pixelZ * resolution;
for (int pixelX = groupX; pixelX < maximumX; pixelX++) {
checkCancelled(cancelled);
double x = sx + step * pixelX;
pixels[row + pixelX] = shader.color(x, z);
}
}
}
}
return image;
}
public static int heightColor(double height, double maximumHeight, double fluidHeight) {
double boundedMaximum = Math.max(1D, maximumHeight);
double boundedHeight = clamp(height, 0D, boundedMaximum);
double boundedFluid = clamp(fluidHeight, 0D, boundedMaximum);
if (boundedHeight <= boundedFluid && boundedFluid > 0D) {
return blend(DEEP_WATER, SHALLOW_WATER, boundedHeight / boundedFluid);
}
double landRange = Math.max(1D, boundedMaximum - boundedFluid);
double land = clamp((boundedHeight - boundedFluid) / landRange, 0D, 1D);
if (land < 0.45D) {
return blend(LOWLAND, HIGHLAND, land / 0.45D);
}
if (land < 0.78D) {
return blend(HIGHLAND, ROCK, (land - 0.45D) / 0.33D);
}
return blend(ROCK, SNOW, (land - 0.78D) / 0.22D);
}
static int sampleGroup(double step, int resolution) {
double absoluteStep = Math.abs(step);
if (!Double.isFinite(absoluteStep) || absoluteStep <= 0D) {
return 1;
}
return Math.max(1, Math.min(resolution, (int) Math.floor(16D / absoluteStep)));
}
private PixelShader shader(RenderType currentType, boolean studio) {
IrisComplex complex = renderer.getComplex();
return switch (currentType) {
case BIOME, DECORATOR_LOAD, OBJECT_LOAD, LAYER_LOAD -> biomeShader(
studio ? complex.getBaseBiomeStream() : complex.getTrueBiomeStream(), currentType);
case BIOME_LAND -> biomeShader(complex.getLandBiomeStream(), currentType);
case BIOME_SEA -> biomeShader(complex.getSeaBiomeStream(), currentType);
case REGION -> regionShader(complex, currentType);
case CAVE_LAND -> biomeShader(complex.getCaveBiomeStream(), currentType);
case HEIGHT -> heightShader(complex.getHeightStream());
case CONTINENT -> studio
? continentShader(complex.getBaseBiomeStream())
: this::continentColor;
};
}
private static boolean adaptiveStudioType(RenderType type) {
return switch (type) {
case BIOME, DECORATOR_LOAD, OBJECT_LOAD, LAYER_LOAD, BIOME_LAND, BIOME_SEA, REGION, CAVE_LAND,
CONTINENT -> true;
case HEIGHT -> false;
};
}
private static void renderAdaptiveAtlas(
int[] pixels,
int resolution,
double startX,
double startZ,
double step,
PixelShader shader,
BooleanSupplier cancelled
) {
int maximumPixels = Math.max(1, Math.min(16, (int) Math.floor(64D / step)));
int blockPixels = Integer.highestOneBit(maximumPixels);
AdaptiveSampler sampler = new AdaptiveSampler(pixels, resolution, startX, startZ, step, shader, cancelled);
for (int pixelZ = 0; pixelZ < resolution; pixelZ += blockPixels) {
int height = Math.min(blockPixels, resolution - pixelZ);
for (int pixelX = 0; pixelX < resolution; pixelX += blockPixels) {
sampler.render(pixelX, pixelZ, Math.min(blockPixels, resolution - pixelX), height);
}
}
}
private static void renderHeightAtlas(
int[] pixels,
int resolution,
double startX,
double startZ,
double step,
Engine engine,
BooleanSupplier cancelled
) {
IrisComplex complex = engine.getComplex();
IrisDimension dimension = engine.getDimension();
double fluidHeight = dimension == null ? 0D : dimension.getFluidHeight();
int maximumPixels = Math.max(1, Math.min(16, (int) Math.floor(64D / step)));
int blockPixels = Integer.highestOneBit(maximumPixels);
HeightSampler sampler = new HeightSampler(
pixels,
resolution,
startX,
startZ,
step,
complex.getHeightStream(),
engine.getHeight(),
fluidHeight,
cancelled
);
for (int pixelZ = 0; pixelZ < resolution; pixelZ += blockPixels) {
int height = Math.min(blockPixels, resolution - pixelZ);
for (int pixelX = 0; pixelX < resolution; pixelX += blockPixels) {
sampler.render(pixelX, pixelZ, Math.min(blockPixels, resolution - pixelX), height);
}
}
}
private PixelShader biomeShader(ProceduralStream<IrisBiome> stream, RenderType currentType) {
IdentityHashMap<IrisBiome, Integer> colors = new IdentityHashMap<>();
return (double x, double z) -> {
IrisBiome biome = stream.get(x, z);
Integer color = colors.get(biome);
if (color == null) {
color = biome.getColor(renderer, currentType).getRGB();
colors.put(biome, color);
}
return color;
};
}
private PixelShader regionShader(IrisComplex complex, RenderType currentType) {
ProceduralStream<IrisRegion> stream = complex.getRegionStream();
IdentityHashMap<IrisRegion, Integer> colors = new IdentityHashMap<>();
return (double x, double z) -> {
IrisRegion region = stream.get(x, z);
Integer color = colors.get(region);
if (color == null) {
color = region.getColor(complex, currentType).getRGB();
colors.put(region, color);
}
return color;
};
}
private PixelShader heightShader(ProceduralStream<Double> stream) {
double maximumHeight = renderer.getHeight();
IrisDimension dimension = renderer.getDimension();
double fluidHeight = dimension == null ? 0D : dimension.getFluidHeight();
return (double x, double z) -> heightColor(stream.getDouble(x, z), maximumHeight, fluidHeight);
}
private int continentColor(double x, double z) {
IrisBiome biome = renderer.getBiome(
(int) Math.round(x),
renderer.getMaxHeight() - 1,
(int) Math.round(z)
);
return continentColor(biome);
}
private PixelShader continentShader(ProceduralStream<IrisBiome> stream) {
IdentityHashMap<IrisBiome, Integer> colors = new IdentityHashMap<>();
return (double x, double z) -> {
IrisBiome biome = stream.get(x, z);
Integer color = colors.get(biome);
if (color == null) {
color = continentColor(biome);
colors.put(biome, color);
}
return color;
};
}
private int continentColor(IrisBiome biome) {
if (biome == null) {
return GREEN;
}
List<IrisBiomeGeneratorLink> generators = biome.getGenerators();
if (generators.isEmpty()) {
return GREEN;
}
IrisBiomeGeneratorLink generator = generators.get(0);
if (generator.getMax() <= 0D) {
return BLUE;
}
if (generator.getMin() < 0D) {
return YELLOW;
}
return GREEN;
}
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);
int green = (int) Math.round(((first >> 8) & 0xFF) * (1D - bounded) + ((second >> 8) & 0xFF) * bounded);
int blue = (int) Math.round((first & 0xFF) * (1D - bounded) + (second & 0xFF) * bounded);
return (red << 16) | (green << 8) | blue;
}
private static double clamp(double value, double minimum, double maximum) {
return Math.max(minimum, Math.min(maximum, value));
}
private static void checkCancelled(BooleanSupplier cancelled) {
if (cancelled.getAsBoolean() || Thread.currentThread().isInterrupted()) {
throw new CancellationException("Vision render cancelled");
}
}
@FunctionalInterface
private interface PixelShader {
int color(double x, double z);
}
private static final class AdaptiveSampler {
private final int[] pixels;
private final boolean[] sampled;
private final int resolution;
private final double startX;
private final double startZ;
private final double step;
private final PixelShader shader;
private final BooleanSupplier cancelled;
private AdaptiveSampler(
int[] pixels,
int resolution,
double startX,
double startZ,
double step,
PixelShader shader,
BooleanSupplier cancelled
) {
this.pixels = pixels;
this.sampled = new boolean[pixels.length];
this.resolution = resolution;
this.startX = startX;
this.startZ = startZ;
this.step = step;
this.shader = shader;
this.cancelled = cancelled;
}
private void render(int pixelX, int pixelZ, int width, int height) {
checkCancelled(cancelled);
if (width == 1 && height == 1) {
sample(pixelX, pixelZ);
return;
}
int maximumX = pixelX + width - 1;
int maximumZ = pixelZ + height - 1;
int centerX = pixelX + width / 2;
int centerZ = pixelZ + height / 2;
int color = sample(pixelX, pixelZ);
if (sample(maximumX, pixelZ) == color
&& sample(pixelX, maximumZ) == color
&& sample(maximumX, maximumZ) == color
&& sample(centerX, centerZ) == color) {
fill(pixelX, pixelZ, width, height, color);
return;
}
int leftWidth = Math.max(1, width / 2);
int rightWidth = width - leftWidth;
int topHeight = Math.max(1, height / 2);
int bottomHeight = height - topHeight;
render(pixelX, pixelZ, leftWidth, topHeight);
if (rightWidth > 0) {
render(pixelX + leftWidth, pixelZ, rightWidth, topHeight);
}
if (bottomHeight > 0) {
render(pixelX, pixelZ + topHeight, leftWidth, bottomHeight);
if (rightWidth > 0) {
render(pixelX + leftWidth, pixelZ + topHeight, rightWidth, bottomHeight);
}
}
}
private int sample(int pixelX, int pixelZ) {
int index = pixelZ * resolution + pixelX;
if (!sampled[index]) {
pixels[index] = shader.color(startX + pixelX * step, startZ + pixelZ * step);
sampled[index] = true;
}
return pixels[index];
}
private void fill(int pixelX, int pixelZ, int width, int height, int color) {
for (int row = pixelZ; row < pixelZ + height; row++) {
int start = row * resolution + pixelX;
Arrays.fill(pixels, start, start + width, color);
Arrays.fill(sampled, start, start + width, true);
}
}
}
private static final class HeightSampler {
private static final double MAXIMUM_INTERPOLATION_ERROR = 1.5D;
private final int[] pixels;
private final double[] heights;
private final boolean[] sampled;
private final int resolution;
private final double startX;
private final double startZ;
private final double step;
private final ProceduralStream<Double> stream;
private final double maximumHeight;
private final double fluidHeight;
private final BooleanSupplier cancelled;
private HeightSampler(
int[] pixels,
int resolution,
double startX,
double startZ,
double step,
ProceduralStream<Double> stream,
double maximumHeight,
double fluidHeight,
BooleanSupplier cancelled
) {
this.pixels = pixels;
this.heights = new double[pixels.length];
this.sampled = new boolean[pixels.length];
this.resolution = resolution;
this.startX = startX;
this.startZ = startZ;
this.step = step;
this.stream = stream;
this.maximumHeight = maximumHeight;
this.fluidHeight = fluidHeight;
this.cancelled = cancelled;
}
private void render(int pixelX, int pixelZ, int width, int height) {
checkCancelled(cancelled);
if (width == 1 && height == 1) {
pixels[pixelZ * resolution + pixelX] = heightColor(
sample(pixelX, pixelZ), maximumHeight, fluidHeight);
return;
}
int maximumX = pixelX + width - 1;
int maximumZ = pixelZ + height - 1;
int centerX = pixelX + width / 2;
int centerZ = pixelZ + height / 2;
double topLeft = sample(pixelX, pixelZ);
double topRight = sample(maximumX, pixelZ);
double bottomLeft = sample(pixelX, maximumZ);
double bottomRight = sample(maximumX, maximumZ);
if (matchesPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight,
centerX, pixelZ)
&& matchesPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight,
pixelX, centerZ)
&& matchesPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight,
maximumX, centerZ)
&& matchesPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight,
centerX, maximumZ)
&& matchesPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight,
centerX, centerZ)) {
fillPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight);
return;
}
int leftWidth = Math.max(1, width / 2);
int rightWidth = width - leftWidth;
int topHeight = Math.max(1, height / 2);
int bottomHeight = height - topHeight;
render(pixelX, pixelZ, leftWidth, topHeight);
if (rightWidth > 0) {
render(pixelX + leftWidth, pixelZ, rightWidth, topHeight);
}
if (bottomHeight > 0) {
render(pixelX, pixelZ + topHeight, leftWidth, bottomHeight);
if (rightWidth > 0) {
render(pixelX + leftWidth, pixelZ + topHeight, rightWidth, bottomHeight);
}
}
}
private boolean matchesPlane(
int pixelX,
int pixelZ,
int width,
int height,
double topLeft,
double topRight,
double bottomLeft,
double bottomRight,
int sampleX,
int sampleZ
) {
double predicted = interpolate(
pixelX,
pixelZ,
width,
height,
topLeft,
topRight,
bottomLeft,
bottomRight,
sampleX,
sampleZ
);
return Math.abs(sample(sampleX, sampleZ) - predicted) <= MAXIMUM_INTERPOLATION_ERROR;
}
private void fillPlane(
int pixelX,
int pixelZ,
int width,
int height,
double topLeft,
double topRight,
double bottomLeft,
double bottomRight
) {
for (int row = pixelZ; row < pixelZ + height; row++) {
int offset = row * resolution;
for (int column = pixelX; column < pixelX + width; column++) {
double value = interpolate(
pixelX,
pixelZ,
width,
height,
topLeft,
topRight,
bottomLeft,
bottomRight,
column,
row
);
pixels[offset + column] = heightColor(value, maximumHeight, fluidHeight);
}
}
}
private double sample(int pixelX, int pixelZ) {
int index = pixelZ * resolution + pixelX;
if (!sampled[index]) {
heights[index] = stream.getDouble(startX + pixelX * step, startZ + pixelZ * step);
sampled[index] = true;
}
return heights[index];
}
private static double interpolate(
int pixelX,
int pixelZ,
int width,
int height,
double topLeft,
double topRight,
double bottomLeft,
double bottomRight,
int sampleX,
int sampleZ
) {
double x = width <= 1 ? 0D : (sampleX - pixelX) / (double) (width - 1);
double z = height <= 1 ? 0D : (sampleZ - pixelZ) / (double) (height - 1);
double top = topLeft + (topRight - topLeft) * x;
double bottom = bottomLeft + (bottomRight - bottomLeft) * x;
return top + (bottom - top) * z;
}
}
}