Integrate Iris Studio

- Merge the unified Studio interface and command entry points.
- Adapt water views and provisional sampling to basin hydrology.
- Restore diagnostic estimates and the biome water overlay.
- Fix render cancellation, preview closure, and platform position callbacks.
- Fall back to software rendering when native bindings are unavailable.
- Cover renderer, lifecycle, and scheduler regressions.
This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-25 16:56:49 -04:00
commit 9f49d29e2b
225 files changed
+29666 -4395

No files matched your search

@@ -60,6 +60,7 @@ import art.arcane.iris.integration.MultiverseCoreLink;
import art.arcane.iris.platform.bukkit.nms.INMS;
import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary;
import art.arcane.iris.studio.view.BukkitGuiHost;
import art.arcane.iris.studio.view.GuiHost;
import art.arcane.iris.studio.view.PregeneratorJob;
import art.arcane.iris.platform.bukkit.BoardSVC;
import art.arcane.iris.command.CommandSVC;
@@ -988,6 +989,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
public void onDisable() {
GuiHost.shutdown();
preservePendingBlockDrops();
papiLifecycleActive = false;
if (updateNotifier != null) {
@@ -21,10 +21,9 @@ package art.arcane.iris.command;
import art.arcane.iris.Iris;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.studio.map.MapWindow;
import art.arcane.iris.studio.map.PaletteExplorerStart;
import art.arcane.iris.studio.view.GuiHost;
import art.arcane.iris.studio.view.ImageMapStudioGUI;
import art.arcane.iris.studio.view.NoiseExplorerGUI;
import art.arcane.iris.studio.view.VisionGUI;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.studio.workspace.IrisProject;
import art.arcane.iris.studio.workspace.IrisCodeWorkspace;
@@ -98,7 +97,9 @@ import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.localization.BukkitCommandMessages;
@@ -255,15 +256,10 @@ public class CommandStudio implements DirectorExecutor {
long seed
) {
if (noGUI()) return;
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_NOISE_EXPLORER));
if (generator == null) {
NoiseExplorerGUI.launch();
return;
}
String generatorKey = generator.getLoadKey();
NoiseExplorerGUI.launchGeneratorKey(generatorKey, generator, seed);
VolmitSender commandSender = sender();
UUID openerId = commandSender.isPlayer() ? commandSender.player().getUniqueId() : null;
MapWindow.NoiseSelection selection = new MapWindow.NoiseSelection(generator == null ? null : generator.getLoadKey(), generator, seed);
withPlayerContext(opener -> openNoiseAtPlayer(commandSender, openerId, opener, selection));
}
@Director(description = "Open the image-map studio (External GUI)", descriptionKey = "iris.director.commandstudio.director.open_image_map_studio_external_gui", aliases = {"imap"})
@@ -279,8 +275,7 @@ public class CommandStudio implements DirectorExecutor {
return;
}
Engine activeEngine = IrisToolbelt.access(world).getEngine();
ImageMapStudioGUI.launch(activeEngine);
requestMapStart(world, MapWindow::openImageMaps);
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_IMAGE_MAP_STUDIO));
}
@@ -420,11 +415,61 @@ public class CommandStudio implements DirectorExecutor {
return;
}
UUID openerId = sender().isPlayer() ? player().getUniqueId() : null;
VisionGUI.launch(IrisToolbelt.access(world).getEngine(), openerId);
requestMapStart(world, MapWindow::openMap);
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_MAP));
}
// The map opens where the player stands when they are in that world, otherwise at the world origin.
private PaletteExplorerStart mapStartIn(World world, Player opener, UUID openerId) {
Location center = opener != null && opener.getWorld().equals(world) ? opener.getLocation() : null;
return new PaletteExplorerStart(
IrisToolbelt.access(world).getEngine(),
world.getUID(),
openerId,
null,
center == null ? 0D : center.getX(),
center == null ? 0D : center.getZ()
);
}
private void requestMapStart(World world, Consumer<PaletteExplorerStart> launch) {
UUID openerId = sender().isPlayer() ? player().getUniqueId() : null;
withPlayerContext(opener -> launch.accept(mapStartIn(world, opener, openerId)));
}
private void openNoiseAtPlayer(VolmitSender commandSender, UUID openerId, Player opener, MapWindow.NoiseSelection selection) {
World world = opener == null ? null : opener.getWorld();
PlatformChunkGenerator worldGenerator = world == null ? null : IrisToolbelt.access(world);
Engine engine = worldGenerator == null ? GuiHost.get().findActiveEngine() : worldGenerator.getEngine();
if (engine == null) {
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_YOU_NEED_BE_SPECIFY_IRIS_GENERATED_WORLD));
return;
}
PaletteExplorerStart start = worldGenerator == null
? new PaletteExplorerStart(engine, null, openerId, null, 0D, 0D)
: mapStartIn(world, opener, openerId);
MapWindow.openNoise(start, selection);
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_NOISE_EXPLORER));
}
private void withPlayerContext(Consumer<Player> action) {
Player opener = sender().isPlayer() ? player() : null;
if (opener == null) {
action.accept(null);
return;
}
AtomicBoolean completed = new AtomicBoolean();
Consumer<Player> complete = player -> {
if (completed.compareAndSet(false, true)) {
action.accept(player);
}
};
Runnable retired = () -> complete.accept(null);
if (!J.runEntity(opener, () -> complete.accept(opener.isOnline() ? opener : null), 0, retired)) {
retired.run();
}
}
@Director(description = "Profiles the performance of a dimension", descriptionKey = "iris.director.commandstudio.director.profiles_performance_dimension", origin = DirectorOrigin.PLAYER)
public void profile(
@Param(description = "The dimension to profile", descriptionKey = "iris.director.commandstudio.param.dimension_profile", contextual = true, contextualOverride = true, defaultValue = "default", customHandler = DimensionHandler.class)
@@ -23,6 +23,8 @@ import art.arcane.iris.world.event.IrisEngineHotloadEvent;
import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.studio.StudioSVC;
import art.arcane.iris.studio.workspace.IrisProject;
import org.bukkit.Bukkit;
import org.bukkit.World;
import org.bukkit.event.EventHandler;
@@ -40,16 +42,21 @@ public final class BukkitGuiHost implements GuiHost.Provider {
GuiHost.set(new BukkitGuiHost());
}
/**
* The engine of the open studio project, which reads the pack folder being edited, or else the first
* Iris world. A normal Iris world runs on its own copy of the pack, made when the world was created.
*/
@Override
public Engine findActiveEngine() {
Engine studioEngine = findStudioEngine();
if (studioEngine != null) {
return studioEngine;
}
try {
for (World world : new ArrayList<>(Bukkit.getWorlds())) {
try {
PlatformChunkGenerator access = IrisToolbelt.access(world);
if (access != null && access.getEngine() != null && !access.getEngine().isClosed()) {
return access.getEngine();
}
} catch (Throwable ignored) {
Engine engine = engineOf(world);
if (engine != null) {
return engine;
}
}
} catch (Throwable ignored) {
@@ -57,6 +64,35 @@ public final class BukkitGuiHost implements GuiHost.Provider {
return null;
}
@Override
public Engine findEngine(UUID worldId) {
World world = worldId == null ? null : Bukkit.getWorld(worldId);
return world == null ? null : engineOf(world);
}
private static Engine findStudioEngine() {
try {
StudioSVC studio = Iris.service(StudioSVC.class);
IrisProject project = studio == null || !studio.isProjectOpen() ? null : studio.getActiveProject();
PlatformChunkGenerator provider = project == null ? null : project.getActiveProvider();
Engine engine = provider == null ? null : provider.getEngine();
return engine == null || engine.isClosed() ? null : engine;
} catch (Throwable ignored) {
return null;
}
}
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;
}
@Override
public void registerHotloadHook(Runnable onHotload) {
Listener listener = new HotloadListener(onHotload);
@@ -18,23 +18,19 @@
package art.arcane.iris.studio.view;
import art.arcane.volmlib.nativelib.view.WorldMarker;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.studio.render.RenderType;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.world.task.J;
import art.arcane.volmlib.util.format.Form;
import org.bukkit.Bukkit;
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.Collections;
import java.util.List;
@@ -56,7 +52,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
private final AtomicBoolean playerRefreshQueued = new AtomicBoolean();
private final AtomicLong teleportSequence = new AtomicLong();
private final AtomicReference<VisionTeleportRequest> latestTeleport = new AtomicReference<>();
private volatile List<WorldMarker> playerMarkers = List.of();
private volatile List<GuiMarker> playerMarkers = List.of();
public BukkitVisionOverlay(Engine engine, UUID openerId) {
this.engine = engine;
@@ -68,7 +64,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
* built by a server thread.
*/
@Override
public List<WorldMarker> players() {
public List<GuiMarker> players() {
queuePlayerRefresh();
return playerMarkers;
}
@@ -78,26 +74,48 @@ public final class BukkitVisionOverlay implements GuiOverlay {
return;
}
boolean scheduled = J.runGlobal(() -> {
try {
List<WorldMarker> markers = new ArrayList<>();
for (Player player : BukkitWorldBinding.players(engine.getWorld())) {
Location at = player.getLocation();
markers.add(WorldMarker.player(player.getName(), at.getX(), at.getZ()));
}
playerMarkers = List.copyOf(markers);
} finally {
playerRefreshQueued.set(false);
}
});
boolean scheduled = J.runGlobal(this::refreshPlayers);
if (!scheduled) {
playerRefreshQueued.set(false);
}
}
private void refreshPlayers() {
World world = BukkitWorldBinding.world(engine.getWorld());
List<Player> players = BukkitWorldBinding.players(engine.getWorld());
if (players.isEmpty() || world == null) {
playerMarkers = List.of();
playerRefreshQueued.set(false);
return;
}
List<GuiMarker> markers = Collections.synchronizedList(new ArrayList<>(players.size()));
AtomicInteger pending = new AtomicInteger(players.size());
for (Player player : players) {
AtomicBoolean completed = new AtomicBoolean();
Runnable complete = () -> {
if (completed.compareAndSet(false, true) && pending.decrementAndGet() == 0) {
playerMarkers = List.copyOf(markers);
playerRefreshQueued.set(false);
}
};
if (!J.runEntity(player, () -> {
try {
if (player.isOnline() && player.getWorld() == world) {
Location at = player.getLocation();
markers.add(GuiMarker.player(player.getName(), at.getX(), at.getZ(), at.getYaw()));
}
} finally {
complete.run();
}
}, 0, complete)) {
complete.run();
}
}
}
@Override
public void requestEntities(Consumer<List<WorldMarker>> sink) {
public void requestEntities(Consumer<List<GuiMarker>> sink) {
J.runGlobal(() -> {
IrisWorld target = engine.getWorld();
World world = BukkitWorldBinding.world(target);
@@ -118,7 +136,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
return;
}
List<WorldMarker> collected = Collections.synchronizedList(new ArrayList<>(living.size()));
List<GuiMarker> collected = Collections.synchronizedList(new ArrayList<>(living.size()));
AtomicInteger pending = new AtomicInteger(living.size());
Runnable complete = () -> {
if (pending.decrementAndGet() == 0) {
@@ -144,22 +162,22 @@ public final class BukkitVisionOverlay implements GuiOverlay {
});
}
private WorldMarker marker(LivingEntity entity, Location at) {
private GuiMarker marker(LivingEntity entity, Location at) {
String label = Form.capitalizeWords(entity.getType().name().toLowerCase(Locale.ROOT).replaceAll("\\Q_\\E", " "));
double maxHealth = 0;
try {
maxHealth = entity.getAttribute(MAX_HEALTH).getValue();
} catch (Throwable ignored) {
}
return WorldMarker.entity(label, at.getX(), at.getY(), at.getZ(), entity.getHealth(), maxHealth);
return GuiMarker.entity(label, at.getX(), at.getY(), at.getZ(), entity.getHealth(), maxHealth);
}
@Override
public void teleport(double worldX, double worldZ) {
VisionTeleportRequest request = new VisionTeleportRequest(
teleportSequence.incrementAndGet(),
VisionGUI.floorWorldCoordinate(worldX),
VisionGUI.floorWorldCoordinate(worldZ));
(int) StrictMath.floor(worldX),
(int) StrictMath.floor(worldZ));
latestTeleport.set(request);
startTeleport(request);
}
@@ -212,6 +230,46 @@ public final class BukkitVisionOverlay implements GuiOverlay {
}
}
@Override
public void requestOpenerPosition(Consumer<OpenerPosition> sink) {
AtomicBoolean completed = new AtomicBoolean();
Consumer<OpenerPosition> complete = position -> {
if (completed.compareAndSet(false, true)) {
sink.accept(position);
}
};
if (openerId == null) {
complete.accept(OpenerPosition.OFFLINE);
return;
}
boolean scheduled = J.runGlobal(() -> {
Player player = Bukkit.getPlayer(openerId);
if (player == null) {
complete.accept(OpenerPosition.OFFLINE);
return;
}
Runnable retired = () -> complete.accept(OpenerPosition.OFFLINE);
if (!J.runEntity(player, () -> complete.accept(positionOf(player,
BukkitWorldBinding.world(engine.getWorld()))), 0, retired)) {
retired.run();
}
});
if (!scheduled) {
complete.accept(OpenerPosition.OFFLINE);
}
}
private static OpenerPosition positionOf(Player player, World world) {
if (!player.isOnline()) {
return OpenerPosition.OFFLINE;
}
if (world == null || player.getWorld() != world) {
return OpenerPosition.IN_OTHER_WORLD;
}
Location at = player.getLocation();
return OpenerPosition.inWorld(at.getX(), at.getY(), at.getZ());
}
private Player selectPlayer(List<Player> players) {
if (openerId == null) {
return players.isEmpty() ? null : players.get(0);
@@ -316,21 +374,6 @@ public final class BukkitVisionOverlay implements GuiOverlay {
+ blockX + "," + blockZ + ".", failure);
}
@Override
public 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, RIVER ->
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();
default -> null;
};
return file == null ? null : file.getName();
}
private static final class VisionTeleportRequest {
private final long sequence;
private final int blockX;
@@ -0,0 +1,99 @@
package art.arcane.iris.command;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.studio.map.MapWindow;
import art.arcane.iris.studio.map.PaletteExplorerStart;
import art.arcane.iris.studio.view.GuiHost;
import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.world.task.J;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class StudioMapPositionTest {
@Test
public void mapCapturesItsStartingPositionOnThePlayerScheduler() {
IrisSettings previous = IrisSettings.settings;
IrisSettings.settings = new IrisSettings();
IrisSettings.settings.getGui().setUseServerLaunchedGuis(true);
World world = mock(World.class);
Player player = mock(Player.class);
VolmitSender sender = mock(VolmitSender.class);
PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class);
Engine engine = mock(Engine.class);
UUID playerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
AtomicBoolean owned = new AtomicBoolean();
AtomicReference<Runnable> task = new AtomicReference<>();
AtomicReference<PaletteExplorerStart> start = new AtomicReference<>();
when(world.getUID()).thenReturn(worldId);
when(player.getUniqueId()).thenReturn(playerId);
when(player.isOnline()).thenReturn(true);
when(player.getWorld()).thenAnswer(invocation -> {
assertTrue("world read must use the player scheduler", owned.get());
return world;
});
when(player.getLocation()).thenAnswer(invocation -> {
assertTrue("location read must use the player scheduler", owned.get());
return new Location(world, 33.25D, 91D, -72.5D);
});
when(sender.isPlayer()).thenReturn(true);
when(sender.player()).thenReturn(player);
when(generator.getEngine()).thenReturn(engine);
CommandStudio command = new CommandStudio() {
@Override
public VolmitSender sender() {
return sender;
}
};
try (MockedStatic<GuiHost> host = mockStatic(GuiHost.class);
MockedStatic<IrisToolbelt> toolbelt = mockStatic(IrisToolbelt.class);
MockedStatic<J> scheduling = mockStatic(J.class);
MockedStatic<MapWindow> windows = mockStatic(MapWindow.class)) {
host.when(GuiHost::isAvailable).thenReturn(true);
toolbelt.when(() -> IrisToolbelt.isIrisWorld(world)).thenReturn(true);
toolbelt.when(() -> IrisToolbelt.access(world)).thenReturn(generator);
scheduling.when(() -> J.runEntity(same(player), any(Runnable.class), eq(0), any(Runnable.class)))
.thenAnswer(invocation -> {
task.set(invocation.getArgument(1));
return true;
});
windows.when(() -> MapWindow.openMap(any(PaletteExplorerStart.class))).thenAnswer(invocation -> {
start.set(invocation.getArgument(0));
return null;
});
command.map(world);
assertNull(start.get());
owned.set(true);
task.get().run();
assertEquals(engine, start.get().engine());
assertEquals(playerId, start.get().playerId());
assertEquals(worldId, start.get().worldId());
assertEquals(33.25D, start.get().centerX(), 0D);
assertEquals(-72.5D, start.get().centerZ(), 0D);
} finally {
IrisSettings.settings = previous;
}
}
}
@@ -0,0 +1,165 @@
package art.arcane.iris.studio.view;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.world.task.J;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class BukkitOpenerPositionTest {
@Test
public void playerMarkersAreCapturedOnTheirEntitySchedulers() {
try (Harness harness = new Harness()) {
harness.binding.when(() -> BukkitWorldBinding.players(harness.target)).thenReturn(List.of(harness.player));
when(harness.player.getName()).thenReturn("Explorer");
assertTrue(harness.overlay.players().isEmpty());
harness.runEntity();
List<GuiMarker> markers = harness.overlay.players();
assertEquals(1, markers.size());
assertEquals("Explorer", markers.getFirst().label());
assertEquals(18.5D, markers.getFirst().worldX(), 0D);
assertEquals(-4.25D, markers.getFirst().worldZ(), 0D);
}
}
@Test
public void positionIsCapturedOnlyOnTheOwningEntityTask() {
try (Harness harness = new Harness()) {
harness.overlay.requestOpenerPosition(harness.results::add);
assertTrue(harness.results.isEmpty());
harness.runEntity();
assertEquals(List.of(OpenerPosition.inWorld(18.5D, 72D, -4.25D)), harness.results);
}
}
@Test
public void retiredEntityCompletesTheRequestOnce() {
try (Harness harness = new Harness()) {
harness.overlay.requestOpenerPosition(harness.results::add);
assertTrue("retirement callback must be registered", harness.retired != null);
harness.retired.run();
harness.retired.run();
assertEquals(List.of(OpenerPosition.OFFLINE), harness.results);
}
}
@Test
public void disconnectBeforeCaptureCompletesAsOfflineAndReconnectCanRefresh() {
try (Harness harness = new Harness()) {
harness.overlay.requestOpenerPosition(harness.results::add);
when(harness.player.isOnline()).thenReturn(false);
harness.runEntity();
assertEquals(List.of(OpenerPosition.OFFLINE), harness.results);
when(harness.player.isOnline()).thenReturn(true);
harness.overlay.requestOpenerPosition(harness.results::add);
harness.runEntity();
assertEquals(List.of(OpenerPosition.OFFLINE, OpenerPosition.inWorld(18.5D, 72D, -4.25D)), harness.results);
}
}
@Test
public void rejectedEntityTaskCompletesOnceEvenIfRetirementAlreadyRan() {
try (Harness harness = new Harness()) {
harness.reject = true;
harness.overlay.requestOpenerPosition(harness.results::add);
assertEquals(List.of(OpenerPosition.OFFLINE), harness.results);
}
}
@Test
public void changingWorldBeforeCaptureReportsOtherWorld() {
try (Harness harness = new Harness()) {
harness.overlay.requestOpenerPosition(harness.results::add);
when(harness.player.getWorld()).thenReturn(mock(World.class));
harness.runEntity();
assertEquals(List.of(OpenerPosition.IN_OTHER_WORLD), harness.results);
}
}
private static final class Harness implements AutoCloseable {
private final Player player = mock(Player.class);
private final World world = mock(World.class);
private final Engine engine = mock(Engine.class);
private final IrisWorld target = mock(IrisWorld.class);
private final UUID opener = UUID.randomUUID();
private final List<OpenerPosition> results = new ArrayList<>();
private final MockedStatic<J> scheduling = mockStatic(J.class);
private final MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
private final MockedStatic<BukkitWorldBinding> binding = mockStatic(BukkitWorldBinding.class);
private final BukkitVisionOverlay overlay = new BukkitVisionOverlay(engine, opener);
private Runnable task;
private Runnable retired;
private boolean entityOwned;
private boolean reject;
private Harness() {
when(engine.getWorld()).thenReturn(target);
when(player.isOnline()).thenReturn(true);
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenAnswer(invocation -> {
assertTrue("location read outside owning entity task", entityOwned);
return new Location(world, 18.5D, 72D, -4.25D);
});
bukkit.when(() -> Bukkit.getPlayer(opener)).thenReturn(player);
binding.when(() -> BukkitWorldBinding.world(target)).thenReturn(world);
scheduling.when(() -> J.runGlobal(any(Runnable.class))).thenAnswer(invocation -> {
invocation.getArgument(0, Runnable.class).run();
return true;
});
scheduling.when(() -> J.runEntity(same(player), any(Runnable.class))).thenAnswer(invocation -> {
task = invocation.getArgument(1);
return !reject;
});
scheduling.when(() -> J.runEntity(same(player), any(Runnable.class), eq(0), any(Runnable.class)))
.thenAnswer(invocation -> {
task = invocation.getArgument(1);
retired = invocation.getArgument(3);
if (reject) {
retired.run();
}
return !reject;
});
}
private void runEntity() {
entityOwned = true;
try {
task.run();
} finally {
entityOwned = false;
}
}
@Override
public void close() {
binding.close();
bukkit.close();
scheduling.close();
}
}
}
+5 -1
View File
@@ -131,6 +131,8 @@ configurations.named('bundle').configure {
exclude(group: 'de.crazydev22.slimjar.helper')
exclude(group: 'de.crazydev22.slimjar')
exclude(group: 'io.github.slimjar')
// The game ships its own LWJGL; without the modules it leaves out, the map's 3D views draw in software.
exclude(group: 'org.lwjgl')
}
configurations.compileClasspath.extendsFrom(configurations.devBundle)
@@ -216,7 +218,9 @@ dependencies {
libs.caffeine,
'com.fulcrumgenomics:jlibdeflate:0.2.0',
libs.dom4j,
libs.jaxen
libs.jaxen,
libs.flatlaf,
libs.flatlaf.extras
]
shared.each { Object notation ->
['bundle', 'devBundle'].each { String configuration ->
+7 -1
View File
@@ -152,12 +152,16 @@ configurations.named('bundle').configure {
exclude(group: 'de.crazydev22.slimjar.helper')
exclude(group: 'de.crazydev22.slimjar')
exclude(group: 'io.github.slimjar')
// The game ships its own LWJGL; without the modules it leaves out, the map's 3D views draw in software.
exclude(group: 'org.lwjgl')
}
configurations.named('runBundle').configure {
exclude(group: 'de.crazydev22.slimjar.helper')
exclude(group: 'de.crazydev22.slimjar')
exclude(group: 'io.github.slimjar')
// The game ships its own LWJGL; without the modules it leaves out, the map's 3D views draw in software.
exclude(group: 'org.lwjgl')
}
configurations.compileClasspath.extendsFrom(configurations.devBundle)
@@ -215,7 +219,9 @@ dependencies {
libs.caffeine,
'com.fulcrumgenomics:jlibdeflate:0.2.0',
libs.dom4j,
libs.jaxen
libs.jaxen,
libs.flatlaf,
libs.flatlaf.extras
]
shared.each { Object notation ->
['bundle', 'devBundle', 'runBundle'].each { String configuration ->
@@ -227,6 +227,7 @@ public final class ModdedEngineBootstrap {
failure = runStopStage(failure, "wand service", ModdedWandService::clearAll);
failure = runStopStage(failure, "block break handler", ModdedBlockBreakHandler::clear);
failure = runStopStage(failure, "studio commands", ModdedStudioCommands::clear);
failure = runStopStage(failure, "gui windows", GuiHost::shutdown);
failure = runStopStage(failure, "gui host", ModdedGuiHost::clear);
failure = runStopStage(failure, "services", () -> services().disableAll());
reportStopFailure("stopping", failure);
@@ -23,10 +23,9 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldView;
import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.studio.map.MapWindow;
import art.arcane.iris.studio.map.PaletteExplorerStart;
import art.arcane.iris.studio.view.GuiHost;
import art.arcane.iris.studio.view.ImageMapStudioGUI;
import art.arcane.iris.studio.view.NoiseExplorerGUI;
import art.arcane.iris.studio.view.VisionGUI;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.pack.StructurePackageClosure;
import art.arcane.iris.pack.ImageMapPackageClosure;
@@ -38,6 +37,7 @@ import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.biome.IrisBiomeGeneratorLink;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.world.entity.IrisEntitySpawn;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.generation.noise.IrisGenerator;
import art.arcane.iris.pack.PackExportClosure;
import art.arcane.iris.world.entity.IrisEntity;
@@ -247,8 +247,9 @@ public final class ModdedStudioCommands {
NativeProtocolPlayer player = source.player();
ModdedGuiHost.bindContext(worldView(source.server(), level), engine, player == null ? null : player.id());
}
if (generatorKey == null || generatorKey.isBlank()) {
NoiseExplorerGUI.launch();
Engine mapEngine = engine != null ? engine : GuiHost.get().findActiveEngine();
if ((generatorKey == null || generatorKey.isBlank()) && mapEngine != null) {
MapWindow.openNoise(mapStart(mapEngine, source.player()), new MapWindow.NoiseSelection(null, null, seed));
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_NOISE_EXPLORER_ON_SERVER_DISPLAY));
return 1;
}
@@ -262,7 +263,7 @@ public final class ModdedStudioCommands {
return 0;
}
String selectedGeneratorKey = generatorKey.trim();
NoiseExplorerGUI.launchGeneratorKey(selectedGeneratorKey, generator, seed);
MapWindow.openNoise(mapStart(engine, source.player()), new MapWindow.NoiseSelection(selectedGeneratorKey, generator, seed));
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_NOISE_EXPLORER_GENERATOR_SEED, MessageArgument.untrusted("value", generatorKey.trim()), MessageArgument.untrusted("seed", seed)));
return 1;
}
@@ -280,11 +281,20 @@ public final class ModdedStudioCommands {
}
NativeProtocolPlayer player = source.player();
ModdedGuiHost.bindContext(worldView(source.server(), level), engine, player == null ? null : player.id());
VisionGUI.launch(engine, player == null ? null : player.id());
MapWindow.openMap(mapStart(engine, player));
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_VISION_MAP_ON_SERVER_DISPLAY, MessageArgument.untrusted("value", level.name())));
return 1;
}
// Modded worlds have no Bukkit world id; the map finds the engine again through the bound GUI context.
private static PaletteExplorerStart mapStart(Engine engine, NativeProtocolPlayer player) {
IrisWorld world = engine.getWorld();
NativeWorld level = world == null ? null : world.platformWorld();
boolean inWorld = player != null && player.connected() && level != null && player.inWorld(level);
return new PaletteExplorerStart(engine, null, player == null ? null : player.id(), null,
inWorld ? player.x() : 0D, inWorld ? player.z() : 0D);
}
private static int imageMap(NativeCommandSource source) {
NativeWorld level = source.world();
Engine engine = IrisModdedCommands.engineFor(level);
@@ -294,13 +304,13 @@ public final class ModdedStudioCommands {
));
return 0;
}
if (!ImageMapStudioGUI.isAvailable()) {
if (!GuiHost.isAvailable() || !IrisSettings.get().getGui().isUseServerLaunchedGuis()) {
IrisModdedCommands.fail(source, guiUnavailableMessage());
return 0;
}
NativeProtocolPlayer player = source.player();
ModdedGuiHost.bindContext(worldView(source.server(), level), engine, player == null ? null : player.id());
ImageMapStudioGUI.launch(engine);
MapWindow.openImageMaps(mapStart(engine, player));
IrisModdedCommands.ok(source, IrisLanguage.plain(
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_IMAGE_MAP_STUDIO_ON_SERVER_DISPLAY,
MessageArgument.untrusted("value", level.name())
@@ -18,21 +18,22 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.studio.view.GuiHost;
import art.arcane.volmlib.nativelib.view.WorldMarker;
import art.arcane.iris.studio.view.GuiMarker;
import art.arcane.iris.studio.view.GuiOverlay;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.studio.view.OpenerPosition;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.studio.render.RenderType;
import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.iris.modded.ModdedTeleportDeadline;
import art.arcane.iris.world.IrisWorld;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.nativelib.view.WorldView;
import java.awt.Desktop;
import java.io.File;
import java.util.List;
import java.util.Optional;
import art.arcane.volmlib.nativelib.view.WorldView;
import java.util.UUID;
import java.util.concurrent.RejectedExecutionException;
import java.util.function.Consumer;
public final class ModdedVisionOverlay implements GuiOverlay {
@@ -46,14 +47,33 @@ public final class ModdedVisionOverlay implements GuiOverlay {
opener = context.opener();
}
// WorldView doesn't report which way a player faces, so their markers all face south.
@Override
public List<WorldMarker> players() {
return world.players();
public List<GuiMarker> players() {
return world.players().stream()
.map(player -> GuiMarker.player(player.label(), player.worldX(), player.worldZ(), 0F))
.toList();
}
@Override
public void requestEntities(Consumer<List<WorldMarker>> sink) {
world.requestEntities(sink);
public void requestEntities(Consumer<List<GuiMarker>> sink) {
world.requestEntities(entities -> sink.accept(entities.stream()
.map(entity -> GuiMarker.entity(entity.label(), entity.worldX(), entity.worldY(), entity.worldZ(),
entity.health(), entity.maxHealth()))
.toList()));
}
@Override
public void requestOpenerPosition(Consumer<OpenerPosition> sink) {
if (opener == null) {
sink.accept(OpenerPosition.OFFLINE);
return;
}
try {
world.execute(() -> sink.accept(openerPosition()));
} catch (RejectedExecutionException failure) {
sink.accept(OpenerPosition.OFFLINE);
}
}
@Override
@@ -80,23 +100,25 @@ public final class ModdedVisionOverlay implements GuiOverlay {
});
}
@Override
public String openInEditor(double worldX, double worldZ, RenderType type) {
if (!GuiHost.isAvailable() || !Desktop.isDesktopSupported()) {
return null;
private OpenerPosition openerPosition() {
IrisWorld target = engine.getWorld();
if (target == null || engine.isClosing() || engine.isClosed()) {
return OpenerPosition.OFFLINE;
}
IrisComplex complex = engine.getComplex();
File file = switch (type) {
case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT, RIVER ->
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();
default -> null;
};
return file == null ? null : file.getName();
NativeWorld level = target.platformWorld();
if (level == null) {
return OpenerPosition.OFFLINE;
}
NativeProtocolPlayer player = NativeModdedServer.forWorld(level).player(opener);
if (player == null || !player.connected()) {
return OpenerPosition.OFFLINE;
}
if (!player.inWorld(level)) {
return OpenerPosition.IN_OTHER_WORLD;
}
return OpenerPosition.inWorld(player.x(), player.y(), player.z());
}
public record Context(WorldView world, Engine engine, UUID opener) {
}
@@ -1,25 +1,98 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.studio.view.OpenerPosition;
import art.arcane.iris.world.IrisWorld;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.nativelib.view.WorldView;
import org.junit.Test;
import org.mockito.MockedStatic;
import org.mockito.invocation.InvocationOnMock;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class ModdedVisionOverlayTest {
@Test
public void rejectedServerTaskCompletesThePositionRequestAsOffline() {
WorldView world = mock(WorldView.class);
doThrow(new RejectedExecutionException("Server stopped")).when(world).execute(any(Runnable.class));
ModdedVisionOverlay overlay = new ModdedVisionOverlay(new ModdedVisionOverlay.Context(world,
mock(Engine.class), UUID.randomUUID()));
AtomicReference<OpenerPosition> position = new AtomicReference<>();
overlay.requestOpenerPosition(position::set);
assertEquals(OpenerPosition.OFFLINE, position.get());
}
@Test
public void openerPositionRunsOnTheServerAndTracksDisconnectAndWorldChanges() {
WorldView world = mock(WorldView.class);
AtomicReference<Runnable> scheduled = new AtomicReference<>();
doAnswer(invocation -> {
scheduled.set(invocation.getArgument(0));
return null;
}).when(world).execute(any(Runnable.class));
Engine engine = mock(Engine.class);
IrisWorld irisWorld = mock(IrisWorld.class);
NativeWorld level = mock(NativeWorld.class);
NativeModdedServer server = mock(NativeModdedServer.class);
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
UUID opener = UUID.randomUUID();
when(engine.getWorld()).thenReturn(irisWorld);
when(irisWorld.platformWorld()).thenReturn(level);
when(server.player(opener)).thenReturn(player);
when(player.connected()).thenReturn(true);
when(player.inWorld(level)).thenReturn(true);
when(player.x()).thenReturn(21.5D);
when(player.y()).thenReturn(85.25D);
when(player.z()).thenReturn(-39.5D);
ModdedVisionOverlay overlay = new ModdedVisionOverlay(new ModdedVisionOverlay.Context(world, engine, opener));
AtomicReference<OpenerPosition> position = new AtomicReference<>();
try (MockedStatic<NativeModdedServer> nativeServer = mockStatic(NativeModdedServer.class)) {
nativeServer.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server);
overlay.requestOpenerPosition(position::set);
assertNull("position reads must wait for the server task", position.get());
scheduled.get().run();
assertEquals(OpenerPosition.inWorld(21.5D, 85.25D, -39.5D), position.get());
overlay.requestOpenerPosition(position::set);
when(player.connected()).thenReturn(false);
scheduled.get().run();
assertEquals(OpenerPosition.OFFLINE, position.get());
when(player.connected()).thenReturn(true);
when(player.inWorld(level)).thenReturn(false);
overlay.requestOpenerPosition(position::set);
scheduled.get().run();
assertEquals(OpenerPosition.IN_OTHER_WORLD, position.get());
when(player.inWorld(level)).thenReturn(true);
overlay.requestOpenerPosition(position::set);
scheduled.get().run();
assertEquals(OpenerPosition.inWorld(21.5D, 85.25D, -39.5D), position.get());
}
}
/**
* A Vision click can land anywhere in the world, so the destination chunk is usually cold and gets the same
* bounded deadline as /iris tp.
+5 -1
View File
@@ -132,6 +132,8 @@ configurations.named('bundle').configure {
exclude(group: 'de.crazydev22.slimjar.helper')
exclude(group: 'de.crazydev22.slimjar')
exclude(group: 'io.github.slimjar')
// The game ships its own LWJGL; without the modules it leaves out, the map's 3D views draw in software.
exclude(group: 'org.lwjgl')
}
configurations.compileClasspath.extendsFrom(configurations.devBundle)
@@ -173,7 +175,9 @@ dependencies {
libs.caffeine,
'com.fulcrumgenomics:jlibdeflate:0.2.0',
libs.dom4j,
libs.jaxen
libs.jaxen,
libs.flatlaf,
libs.flatlaf.extras
]
add('bundle', libs.asm)
add('devBundle', 'org.ow2.asm:asm') {
+9
View File
@@ -136,6 +136,13 @@ dependencies {
implementation('com.moandjiezana.toml:toml4j:0.7.2') {
transitive = false
}
// Shaded: the runtime downloader can neither fetch the natives classifiers nor relocate lwjgl's own jar.
implementation(libs.lwjgl)
implementation(libs.lwjgl.opengl)
implementation(libs.lwjgl.egl)
implementation(libs.lwjgl.jawt)
runtimeOnly(variantOf(libs.lwjgl) { classifier('natives-linux') })
runtimeOnly(variantOf(libs.lwjgl.opengl) { classifier('natives-linux') })
// Dynamically Loaded
slim(libs.gson)
slim(libs.lru)
@@ -161,6 +168,8 @@ dependencies {
slim(libs.byteBuddy.agent)
slim(libs.dom4j)
slim(libs.jaxen)
slim(libs.flatlaf)
slim(libs.flatlaf.extras)
testFixturesImplementation('junit:junit:4.13.2')
testFixturesImplementation(libs.mockito.core)
@@ -373,6 +373,7 @@ public class IrisSettings {
public boolean useServerLaunchedGuis = true;
public boolean maximumPregenGuiFPS = false;
public boolean colorMode = true;
public String theme = "auto";
}
@Data
@@ -580,6 +580,10 @@ public class IrisBiome extends IrisRegistrant implements Rarity {
return IrisBiomeLayerGenerator.getLayerSeaHeightGenerators(this, rng, data);
}
public KList<CNG> getCaveCeilingLayerHeightGenerators(RNG rng, IrisData data) {
return IrisBiomeLayerGenerator.getCaveCeilingLayerHeightGenerators(this, rng, data);
}
public NativeBlockState getSurfaceBlock(int x, int z, RNG rng, IrisData idm) {
return IrisBiomeLayerGenerator.getSurfaceBlock(this, x, z, rng, idm);
}
@@ -148,8 +148,7 @@ final class IrisBiomeLayerGenerator {
return data;
}
KList<CNG> heightGenerators = getHeightGenerators(
biome.getLayerCeilingHeightGenerators(), ceilingLayers, 7235, random, rdata);
KList<CNG> heightGenerators = getCaveCeilingLayerHeightGenerators(biome, random, rdata);
for (int i = 0; i < layerCount; i++) {
IrisBiomePaletteLayer layer = ceilingLayers.get(i);
@@ -317,6 +316,10 @@ final class IrisBiomeLayerGenerator {
return getHeightGenerators(biome.getLayerSeaHeightGenerators(), biome.getSeaLayers(), 7735, rng, data);
}
static KList<CNG> getCaveCeilingLayerHeightGenerators(IrisBiome biome, RNG rng, IrisData data) {
return getHeightGenerators(biome.getLayerCeilingHeightGenerators(), biome.getCaveCeilingLayers(), 7235, rng, data);
}
private static KList<CNG> getHeightGenerators(IrisBiomeLayerHeightCache cache,
KList<IrisBiomePaletteLayer> layers,
int seedOffset, RNG rng, IrisData data) {
@@ -1112,33 +1112,118 @@ public class IrisComplex implements DataProvider {
InferredType inferredType,
IrisBiome emptyBiome
) {
ProceduralStream<IrisBiome> picked = new RarityStream<>(biomePickNoiseStream(engine.getDimension(), region, inferredType),
loadInferredBiomes(biomeKeys(region, inferredType), inferredType));
return inferredType == InferredType.CAVE ? picked.onNull(emptyBiome) : picked;
}
private ProceduralStream<Double> biomePickNoiseStream(IrisDimension dimension, IrisRegion region, InferredType inferredType) {
return switch (inferredType) {
case CAVE -> new RarityStream<>(engine.getDimension().getCaveBiomeStyle()
case CAVE -> dimension.getCaveBiomeStyle()
.create(rng.nextParallelRNG(InferredType.CAVE.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(region.getCaveBiomeZoom()),
loadInferredBiomes(region.getCaveBiomes(), InferredType.CAVE))
.onNull(emptyBiome);
case LAND -> new RarityStream<>(engine.getDimension().getLandBiomeStyle()
.zoom(dimension.getBiomeZoom())
.zoom(region.getCaveBiomeZoom());
case LAND -> dimension.getLandBiomeStyle()
.create(rng.nextParallelRNG(InferredType.LAND.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(engine.getDimension().getLandZoom())
.zoom(region.getLandBiomeZoom()),
loadInferredBiomes(region.getLandBiomes(), InferredType.LAND));
case SEA -> new RarityStream<>(engine.getDimension().getSeaBiomeStyle()
.zoom(dimension.getBiomeZoom())
.zoom(dimension.getLandZoom())
.zoom(region.getLandBiomeZoom());
case SEA -> dimension.getSeaBiomeStyle()
.create(rng.nextParallelRNG(InferredType.SEA.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(engine.getDimension().getSeaZoom())
.zoom(region.getSeaBiomeZoom()),
loadInferredBiomes(region.getSeaBiomes(), InferredType.SEA));
case SHORE -> new RarityStream<>(engine.getDimension().getShoreBiomeStyle()
.zoom(dimension.getBiomeZoom())
.zoom(dimension.getSeaZoom())
.zoom(region.getSeaBiomeZoom());
case SHORE -> dimension.getShoreBiomeStyle()
.create(rng.nextParallelRNG(InferredType.SHORE.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(region.getShoreBiomeZoom()),
loadInferredBiomes(region.getShoreBiomes(), InferredType.SHORE));
.zoom(dimension.getBiomeZoom())
.zoom(region.getShoreBiomeZoom());
};
}
private static KList<String> biomeKeys(IrisRegion region, InferredType inferredType) {
return switch (inferredType) {
case CAVE -> region.getCaveBiomes();
case LAND -> region.getLandBiomes();
case SEA -> region.getSeaBiomes();
case SHORE -> region.getShoreBiomes();
};
}
/**
* The noise that picks one of the region's biomes of this type, before rarity splits it. Image maps and the
* studio biome buffet bypass it.
*/
public ProceduralStream<Double> biomePickNoiseStream(IrisRegion region, InferredType inferredType) {
return biomePickNoiseStream(terrainEngine.getDimension(), region, inferredType);
}
public double biomePickNoise(IrisRegion region, InferredType inferredType, double x, double z) {
return biomePickNoiseStream(region, inferredType).getDouble(x, z);
}
/**
* The region's biomes of this type in the order their rarities split the picking noise.
*/
public KList<IrisBiome> noisePickedBiomes(IrisRegion region, InferredType inferredType) {
return loadInferredBiomes(biomeKeys(region, inferredType), inferredType);
}
/**
* The biome the noise picks from the region's biomes of this type, before child biomes or the coast replace it.
*/
public IrisBiome biomePickedByNoise(IrisRegion region, InferredType inferredType, double x, double z) {
return createInferredBiomeStream(region, inferredType).get(x, z);
}
/**
* The dimension's regions in the order their rarities split the region noise.
*/
public KList<IrisRegion> noisePickedRegions() {
KList<IrisRegion> regions = new KList<>();
for (IrisRegion region : data.getRegionLoader().loadAll(terrainEngine.getDimension().getRegions())) {
if (!region.isCompatExcluded()) {
regions.add(region);
}
}
return regions;
}
/**
* The base biome a spot gets in the given region, before the terrain height can move it to another biome list,
* and the list it was picked from.
*/
public BiomePick basePickIn(IrisRegion region, double x, double z) {
if (focusBiome != null) {
return new BiomePick(focusBiome, focusBiome.getInferredType());
}
InferredType type = bridgeStream.get(x, z);
return new BiomePick(implode(createInferredBiomeStream(region, type).get(x, z), x, z), type);
}
/**
* The surface biome a spot would get if it belonged to the given region, and the biome list it came from, for
* previewing one region across the map.
*/
public BiomePick surfacePickAsIfIn(IrisRegion region, double x, double z) {
BiomePick base = basePickIn(region, x, z);
if (focusBiome != null) {
return base;
}
IrisBiome surface = resolveSurfaceBiome(heightStream.getDouble(x, z), base.biome(), region, x, z, fluidHeight,
createInferredBiomeStream(region, InferredType.LAND),
createInferredBiomeStream(region, InferredType.SEA),
createInferredBiomeStream(region, InferredType.SHORE),
shoreSlopeStream);
return surface == base.biome() ? base : new BiomePick(implode(surface, x, z), surface.getInferredType());
}
public IrisBiome surfaceBiomeAsIfIn(IrisRegion region, double x, double z) {
return surfacePickAsIfIn(region, x, z).biome();
}
public record BiomePick(IrisBiome biome, InferredType type) {
}
private Map<String, ProceduralStream<NativeBlockState>> createWaterFluidStreams(Map<String, IrisMaterialPalette> fluids) {
Map<String, ProceduralStream<NativeBlockState>> streams = new HashMap<>();
int index = 0;
@@ -0,0 +1,154 @@
/*
* 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 art.arcane.iris.studio.map;
import com.formdev.flatlaf.extras.FlatSVGIcon;
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;
/**
* IntelliJ's profiler gauge, how heavy the measured bake is, a meter and the time it took, all in the weight's colour.
*/
final class BakeMeter extends JComponent {
private static final long serialVersionUID = 4120925066373218716L;
private static final String PROFILER_ICON = "iris/icons/profiler.svg";
private static final String TIMING_LABEL = "Timing";
private static final String WIDEST_TIME = "999 ms";
private static final int ICON_SIZE = 16;
private static final int GAP = 6;
private static final int BAR_HEIGHT = 4;
private static final int HEIGHT = 20;
private final FlatSVGIcon icon = new FlatSVGIcon(PROFILER_ICON, ICON_SIZE, ICON_SIZE, BakeMeter.class.getClassLoader());
private BakeTiming timing;
private boolean timingUnderWay;
BakeMeter() {
icon.setColorFilter(new FlatSVGIcon.ColorFilter(drawn -> color()));
}
void showTiming() {
timing = null;
timingUnderWay = true;
setToolTipText("Baking every variant five times over");
repaint();
}
void show(BakeTiming measured) {
timing = measured;
timingUnderWay = false;
setToolTipText(measured.explain());
repaint();
}
void clear() {
timing = null;
timingUnderWay = false;
setToolTipText(null);
repaint();
}
@Override
public Dimension getPreferredSize() {
return new Dimension(0, StudioTheme.scale(HEIGHT));
}
@Override
protected void paintComponent(Graphics graphics) {
Graphics2D canvas = (Graphics2D) graphics.create();
try {
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
icon.paintIcon(this, canvas, 0, (getHeight() - icon.getIconHeight()) / 2);
int labelX = icon.getIconWidth() + StudioTheme.scale(GAP);
int barX = drawLabel(canvas, labelX) + StudioTheme.scale(GAP);
int timeX = getWidth() - timeWidth(canvas);
drawBar(canvas, barX, timeX - StudioTheme.scale(GAP) - barX);
drawTime(canvas, timeX);
} finally {
canvas.dispose();
}
}
private Color color() {
return timing == null ? StudioTheme.MUTED_TEXT : weight().color();
}
private BakeWeight weight() {
return BakeWeight.of(timing.totalMillis());
}
// Returns where the label ends.
private int drawLabel(Graphics2D canvas, int x) {
canvas.setFont(StudioTheme.boldFont());
FontMetrics metrics = canvas.getFontMetrics();
canvas.setColor(color());
canvas.drawString(labelText(), x, baseline(metrics));
int widestLabelWidth = metrics.stringWidth(TIMING_LABEL);
for (BakeWeight each : BakeWeight.values()) {
widestLabelWidth = Math.max(widestLabelWidth, metrics.stringWidth(each.label()));
}
return x + widestLabelWidth;
}
private String labelText() {
if (timing != null) {
return weight().label();
}
return timingUnderWay ? TIMING_LABEL : "—";
}
private void drawBar(Graphics2D canvas, int x, int width) {
int barHeight = StudioTheme.scale(BAR_HEIGHT);
int y = (getHeight() - barHeight) / 2;
canvas.setColor(StudioTheme.HOVER_BACKGROUND);
canvas.fillRoundRect(x, y, width, barHeight, barHeight, barHeight);
if (timing == null) {
return;
}
int filledWidth = (int) Math.round(width * BakeWeight.meterFill(timing.totalMillis()));
canvas.setColor(color());
canvas.fillRoundRect(x, y, Math.max(barHeight, filledWidth), barHeight, barHeight, barHeight);
}
private int timeWidth(Graphics2D canvas) {
return canvas.getFontMetrics(StudioTheme.smallFont()).stringWidth(WIDEST_TIME);
}
private void drawTime(Graphics2D canvas, int x) {
if (timing == null) {
return;
}
canvas.setFont(StudioTheme.smallFont());
FontMetrics metrics = canvas.getFontMetrics();
String time = BakeTiming.formatMillis(timing.totalMillis());
canvas.setColor(StudioTheme.SECONDARY_TEXT);
canvas.drawString(time, x + timeWidth(canvas) - metrics.stringWidth(time), baseline(metrics));
}
private int baseline(FontMetrics metrics) {
return (getHeight() + metrics.getAscent() - metrics.getDescent()) / 2;
}
}
@@ -0,0 +1,25 @@
/*
* 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 art.arcane.iris.studio.map;
/**
* How long one bake of a procedural object's variants took.
*/
record BakeRun(long totalNanos, int variantCount) {
}
@@ -0,0 +1,71 @@
/*
* 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 art.arcane.iris.studio.map;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.function.BooleanSupplier;
import java.util.function.Supplier;
/**
* A procedural object's variants baked five times over, keeping the median run. The first run is slow while the JIT
* compiles the baker, and the garbage collector or chunk generation can slow any other, so neither ends up in the middle.
*/
record BakeTiming(BakeRun median) {
private static final int RUN_COUNT = 5;
private static final double NANOS_PER_MILLI = 1_000_000D;
/** Null when {@code bake} finds nothing to bake, or once the timing is no longer wanted. */
static BakeTiming measure(Supplier<BakeRun> bake, BooleanSupplier stillWanted) {
List<BakeRun> runs = new ArrayList<>();
for (int run = 0; run < RUN_COUNT; run++) {
if (!stillWanted.getAsBoolean()) {
return null;
}
BakeRun measured = bake.get();
if (measured == null) {
return null;
}
runs.add(measured);
}
runs.sort(Comparator.comparingLong(BakeRun::totalNanos));
return new BakeTiming(runs.get(runs.size() / 2));
}
double totalMillis() {
return median.totalNanos() / NANOS_PER_MILLI;
}
/** How long the bake took and when world generation pays for it. */
String explain() {
String variants = median.variantCount() == 1 ? "1 variant" : median.variantCount() + " variants";
return "<html>" + variants + " in " + formatMillis(totalMillis()) + " · median of " + RUN_COUNT + " runs<br>"
+ "Paid once per pack load, by the first chunk placing it<br>"
+ BakeWeight.scale() + "</html>";
}
static String formatMillis(double millis) {
if (millis >= 1000D) {
return String.format(Locale.ROOT, "%.1f s", millis / 1000D);
}
return String.format(Locale.ROOT, millis < 10D ? "%.1f ms" : "%.0f ms", millis);
}
}
@@ -0,0 +1,74 @@
/*
* 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 art.arcane.iris.studio.map;
import java.awt.Color;
/**
* How long a procedural object's bake stalls the chunk that first places it, against a server tick of 50 ms.
*/
enum BakeWeight {
LIGHT("Light", 1D, 50D, StudioTheme.SUCCESS),
MODERATE("Moderate", 50D, 500D, StudioTheme.WARNING),
HEAVY("Heavy", 500D, 5_000D, StudioTheme.ERROR);
private final String label;
private final double fromMillis;
private final double meterFullAtMillis;
private final Color color;
BakeWeight(String label, double fromMillis, double meterFullAtMillis, Color color) {
this.label = label;
this.fromMillis = fromMillis;
this.meterFullAtMillis = meterFullAtMillis;
this.color = color;
}
static BakeWeight of(double millis) {
for (BakeWeight weight : values()) {
if (millis < weight.meterFullAtMillis) {
return weight;
}
}
return HEAVY;
}
/**
* How full the meter is for a bake of {@code millis}: each weight takes an equal share of the meter, filled on a
* logarithmic scale, since bakes span thousands of times.
*/
static double meterFill(double millis) {
BakeWeight weight = of(millis);
double clamped = Math.clamp(millis, weight.fromMillis, weight.meterFullAtMillis);
double withinWeight = Math.log(clamped / weight.fromMillis) / Math.log(weight.meterFullAtMillis / weight.fromMillis);
return (weight.ordinal() + withinWeight) / values().length;
}
static String scale() {
return "Light under " + BakeTiming.formatMillis(MODERATE.fromMillis) + ", heavy from " + BakeTiming.formatMillis(HEAVY.fromMillis);
}
String label() {
return label;
}
Color color() {
return color;
}
}
@@ -0,0 +1,177 @@
/*
* 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 art.arcane.iris.studio.map;
import art.arcane.iris.studio.map.PickMeasuring.TypedSpread;
import art.arcane.iris.studio.map.components.PickDisplay;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.terrain.InferredType;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.volmlib.util.stream.ProceduralStream;
import javax.swing.JComponent;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.EnumMap;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.function.Supplier;
/**
* The biome view's explorer: every region picks the biomes on its ground from its own biome lists, so for each region
* in view it shows how its noise picks them.
*/
final class BiomeExplorer {
private final Supplier<Engine> engineSource;
private final PickInspectorPanel inspector;
private final LatestMeasurement<Measurement> measurer = new LatestMeasurement<>("Iris Biome Explorer");
private MapCensus.ViewArea latestArea;
private MapCensus.ViewArea measuredArea;
/**
* @param onDisplayChosen receives how the map should draw the picks
*/
BiomeExplorer(Supplier<Engine> engineSource, Consumer<PickDisplay> onDisplayChosen) {
this.engineSource = engineSource;
this.inspector = new PickInspectorPanel("Biomes", onDisplayChosen);
}
PickInspectorPanel inspector() {
return inspector;
}
/**
* Measures the regions in this area and how they pick their biomes, unless that was already done.
*/
void measure(MapCensus.ViewArea area) {
latestArea = area;
if (area == null || area.equals(measuredArea)) {
return;
}
measuredArea = area;
measurer.measure(() -> measurePicks(area), this::show);
}
/**
* The pack changed, so the regions and their biomes are read again.
*/
void refresh() {
measuredArea = null;
measure(latestArea);
}
void close() {
measurer.close();
}
private void show(Measurement measurement) {
List<PickInspectorPanel.Section> sections = new ArrayList<>();
for (RegionPicks region : measurement.regions()) {
List<JComponent> charts = region.spreads().stream()
.map(spread -> (JComponent) inspector.noiseChart(spread, "Biomes", region.key() + "/" + spread.title()))
.toList();
sections.add(new PickInspectorPanel.Section(region.key(), region.name(), region.color(), region.share(),
biomeCountText(region.biomeCount()), charts));
}
inspector.showRegionSections("Each region splits its own biome noise by rarity", sections);
}
private static String biomeCountText(int count) {
return count == 1 ? "1 biome" : count + " biomes";
}
private Measurement measurePicks(MapCensus.ViewArea area) {
Engine engine = engineSource.get();
IrisComplex complex = engine == null ? null : engine.getComplex();
if (complex == null) {
return null;
}
Map<IrisRegion, RegionSamples> samplesByRegion = new IdentityHashMap<>();
int[] total = {0};
PickMeasuring.forEachSample(area, (x, z) -> {
IrisRegion region = complex.getRegionStream().get(x, z);
if (region == null) {
return;
}
total[0]++;
samplesByRegion.computeIfAbsent(region, subject -> new RegionSamples(engine, complex, subject)).add(x, z);
});
List<RegionPicks> regions = samplesByRegion.values().stream()
.sorted(Comparator.comparingInt(RegionSamples::count).reversed())
.map(samples -> samples.picks(engine, total[0]))
.toList();
return new Measurement(regions);
}
/**
* The spots in view one region owns. The biome view shows each spot's base biome, which the region picks from the
* biome list of the spot's continent type.
*/
private static final class RegionSamples {
private final IrisComplex complex;
private final IrisRegion region;
private final List<TypedSpread> spreads;
private final Map<InferredType, ProceduralStream<Double>> noiseByType = new EnumMap<>(InferredType.class);
private int count;
private RegionSamples(Engine engine, IrisComplex complex, IrisRegion region) {
this.complex = complex;
this.region = region;
this.spreads = PickMeasuring.biomeSpreads(engine, complex, region);
}
private void add(double x, double z) {
count++;
InferredType type = complex.getBridgeStream().get(x, z);
for (TypedSpread typed : spreads) {
if (typed.type() == type) {
typed.spread().add(noiseByType.computeIfAbsent(type, shown -> complex.biomePickNoiseStream(region, shown)).get(x, z));
}
}
}
private int count() {
return count;
}
private RegionPicks picks(Engine engine, int totalCount) {
double share = totalCount == 0 ? 0D : count / (double) totalCount;
String key = region.getLoadKey();
String name = region.getName();
int color = PickMeasuring.regionColor(engine, region);
List<TypedSpread> inView = spreads.stream().filter(typed -> typed.spread().sampleCount() > 0).toList();
int biomeCount = (int) inView.stream()
.flatMap(typed -> typed.spread().rows().stream())
.map(row -> row.option().key())
.distinct()
.count();
return new RegionPicks(key, name, color, share, biomeCount, inView.stream().map(TypedSpread::spread).toList());
}
}
private record RegionPicks(String key, String name, int color, double share, int biomeCount,
List<NoisePickSpread> spreads) {
}
private record Measurement(List<RegionPicks> regions) {
}
}
@@ -0,0 +1,96 @@
/*
* 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 art.arcane.iris.studio.map;
import java.util.Random;
// A copy of Minecraft's Biome.BIOME_INFO_NOISE, the 2D simplex noise swamps use to pick between their two grass colours.
// Minecraft seeds it through a LegacyRandomSource, which draws the same numbers as java.util.Random.
final class BiomeInfoNoise {
private static final long SEED = 2345L;
private static final int PERMUTATION_SIZE = 256;
private static final int GRADIENT_COUNT = 12;
private static final double SKEW = 0.5D * (Math.sqrt(3D) - 1D);
private static final double UNSKEW = (3D - Math.sqrt(3D)) / 6D;
private static final double CORNER_RADIUS = 0.5D;
private static final double OUTPUT_SCALE = 70D;
private static final int[][] GRADIENTS = {
{1, 1}, {-1, 1}, {1, -1}, {-1, -1},
{1, 0}, {-1, 0}, {1, 0}, {-1, 0},
{0, 1}, {0, -1}, {0, 1}, {0, -1}
};
private static final int[] PERMUTATION = createPermutation();
private BiomeInfoNoise() {
}
static float at(double x, double z) {
double skewOffset = (x + z) * SKEW;
int cellX = (int) Math.floor(x + skewOffset);
int cellZ = (int) Math.floor(z + skewOffset);
double unskewOffset = (cellX + cellZ) * UNSKEW;
double x0 = x - (cellX - unskewOffset);
double z0 = z - (cellZ - unskewOffset);
int stepX = x0 > z0 ? 1 : 0;
int stepZ = 1 - stepX;
double x1 = x0 - stepX + UNSKEW;
double z1 = z0 - stepZ + UNSKEW;
double x2 = x0 - 1D + 2D * UNSKEW;
double z2 = z0 - 1D + 2D * UNSKEW;
int wrappedX = cellX & 0xFF;
int wrappedZ = cellZ & 0xFF;
int gradient0 = permute(wrappedX + permute(wrappedZ)) % GRADIENT_COUNT;
int gradient1 = permute(wrappedX + stepX + permute(wrappedZ + stepZ)) % GRADIENT_COUNT;
int gradient2 = permute(wrappedX + 1 + permute(wrappedZ + 1)) % GRADIENT_COUNT;
return (float) (OUTPUT_SCALE * (corner(gradient0, x0, z0) + corner(gradient1, x1, z1) + corner(gradient2, x2, z2)));
}
private static double corner(int gradient, double x, double z) {
double falloff = CORNER_RADIUS - x * x - z * z;
if (falloff < 0D) {
return 0D;
}
falloff *= falloff;
return falloff * falloff * (GRADIENTS[gradient][0] * x + GRADIENTS[gradient][1] * z);
}
private static int permute(int index) {
return PERMUTATION[index & 0xFF];
}
// Minecraft draws three offsets first and discards them for this noise, so they are drawn here too to stay in step.
private static int[] createPermutation() {
Random random = new Random(SEED);
for (int discardedOffset = 0; discardedOffset < 3; discardedOffset++) {
random.nextDouble();
}
int[] permutation = new int[PERMUTATION_SIZE];
for (int i = 0; i < PERMUTATION_SIZE; i++) {
permutation[i] = i;
}
for (int i = 0; i < PERMUTATION_SIZE; i++) {
int swap = i + random.nextInt(PERMUTATION_SIZE - i);
int previous = permutation[i];
permutation[i] = permutation[swap];
permutation[swap] = previous;
}
return permutation;
}
}
@@ -0,0 +1,195 @@
/*
* 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 art.arcane.iris.studio.map;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.biome.IrisBiomePaletteLayer;
import art.arcane.iris.generation.block.IrisBlockData;
import art.arcane.iris.generation.decoration.IrisDecorator;
import art.arcane.volmlib.util.collection.KList;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* One of the block palettes a biome places: a surface, sea or cave ceiling layer, its wall or slab blocks, or a
* decorator.
*/
record BiomePalette(String biomeKey, Kind kind, int index) {
private static final String ID_PREFIX = "palette:";
private static final char ID_SEPARATOR = '#';
private static final String SNIPPET_PREFIX = "snippet/";
enum Kind {
LAYER("Layer", "layers", SnippetFolder.BLOCK_PALETTES, true, true),
SEA_LAYER("Sea layer", "seaLayers", SnippetFolder.BLOCK_PALETTES, true, true),
CAVE_CEILING_LAYER("Cave ceiling layer", "caveCeilingLayers", SnippetFolder.BLOCK_PALETTES, true, true),
WALL("Wall", "wall", SnippetFolder.BLOCK_PALETTES, false, false),
SLAB("Slab", "slab", SnippetFolder.BLOCK_PALETTES, false, false),
DECORATOR("Decorator", "decorators", SnippetFolder.DECORATORS, false, true);
private final String label;
private final String jsonKey;
private final SnippetFolder snippetFolder;
private final boolean stacked;
private final boolean numbered;
Kind(String label, String jsonKey, SnippetFolder snippetFolder, boolean stacked, boolean numbered) {
this.label = label;
this.jsonKey = jsonKey;
this.snippetFolder = snippetFolder;
this.stacked = stacked;
this.numbered = numbered;
}
SnippetFolder snippetFolder() {
return snippetFolder;
}
/**
* Whether the palette is a stack of blocks with a thickness, rather than single blocks on walls or slopes.
*/
boolean isStacked() {
return stacked;
}
/**
* The biome file's key holding these palettes: a list of them, or a single one for walls and slabs.
*/
String jsonKey() {
return jsonKey;
}
// A biome without wall or slab blocks still has those palettes, just empty.
List<IrisBiomePaletteLayer> layersOf(IrisBiome biome) {
return switch (this) {
case LAYER -> biome.getLayers();
case SEA_LAYER -> biome.getSeaLayers();
case CAVE_CEILING_LAYER -> biome.getCaveCeilingLayers();
case WALL -> biome.getWall().getPalette().isEmpty() ? List.of() : List.of(biome.getWall());
case SLAB -> biome.getSlab().getPalette().isEmpty() ? List.of() : List.of(biome.getSlab());
case DECORATOR -> List.of();
};
}
// A decorator snippet that couldn't be found loads as an empty entry, which places nothing.
List<KList<IrisBlockData>> blockListsOf(IrisBiome biome) {
if (this != DECORATOR) {
return layersOf(biome).stream().map(IrisBiomePaletteLayer::getPalette).toList();
}
return biome.getDecorators().stream()
.map(decorator -> decorator == null ? new KList<IrisBlockData>() : decorator.getPalette())
.toList();
}
}
/**
* The folders under snippet/ that palettes can come from, named after the Iris type they hold.
*/
enum SnippetFolder {
BLOCK_PALETTES("biome-palette", "Block palettes"),
DECORATORS("decorator", "Decorators");
private final String folder;
private final String label;
SnippetFolder(String folder, String label) {
this.folder = folder;
this.label = label;
}
String folder() {
return folder;
}
String label() {
return label;
}
/**
* The snippet's path inside this folder. Packs may leave the folder out, the way the snippet loader allows.
*/
String pathOf(String reference) {
String path = reference.startsWith(SNIPPET_PREFIX) ? reference.substring(SNIPPET_PREFIX.length()) : reference;
return path.startsWith(folder + "/") ? path.substring(folder.length() + 1) : path;
}
}
static List<BiomePalette> allOf(String biomeKey, IrisBiome biome) {
List<BiomePalette> palettes = new ArrayList<>();
for (Kind kind : Kind.values()) {
List<KList<IrisBlockData>> blockLists = kind.blockListsOf(biome);
for (int index = 0; index < blockLists.size(); index++) {
if (!blockLists.get(index).isEmpty()) {
palettes.add(new BiomePalette(biomeKey, kind, index));
}
}
}
return List.copyOf(palettes);
}
static BiomePalette fromId(String id) {
if (id == null || !id.startsWith(ID_PREFIX)) {
return null;
}
int indexSeparator = id.lastIndexOf(ID_SEPARATOR);
int kindSeparator = id.lastIndexOf(ID_SEPARATOR, indexSeparator - 1);
if (kindSeparator < ID_PREFIX.length()) {
return null;
}
return new BiomePalette(
id.substring(ID_PREFIX.length(), kindSeparator),
Kind.valueOf(id.substring(kindSeparator + 1, indexSeparator)),
Integer.parseInt(id.substring(indexSeparator + 1))
);
}
static boolean belongsTo(String id, String biomeKey) {
return id != null && id.startsWith(ID_PREFIX + biomeKey + ID_SEPARATOR);
}
String id() {
return ID_PREFIX + biomeKey + ID_SEPARATOR + kind.name() + ID_SEPARATOR + index;
}
String label() {
return kind.numbered ? kind.label + " " + index : kind.label;
}
String lowercaseLabel() {
return label().toLowerCase(Locale.ROOT);
}
/**
* The palette's blocks in the given copy of the biome, or null when a hotload removed it.
*/
KList<IrisBlockData> blocksIn(IrisBiome biome) {
List<KList<IrisBlockData>> blockLists = kind.blockListsOf(biome);
return index < blockLists.size() ? blockLists.get(index) : null;
}
IrisBiomePaletteLayer layerIn(IrisBiome biome) {
return kind.layersOf(biome).get(index);
}
IrisDecorator decoratorIn(IrisBiome biome) {
return biome.getDecorators().get(index);
}
}
@@ -0,0 +1,221 @@
/*
* 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 art.arcane.iris.studio.map;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.generation.decoration.IrisDecorationPart;
import art.arcane.iris.generation.decoration.IrisEngineDecorator;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.biome.IrisBiomePaletteLayer;
import art.arcane.iris.generation.block.IrisBlockData;
import art.arcane.iris.generation.decoration.IrisDecorator;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.volmlib.util.noise.CNG;
/**
* How thick one biome palette is at a spot and which noise picks its block there, sampled exactly the way the
* generator does for that kind of palette, with the same random sources the terrain, carving, post and decorator
* passes use.
*/
abstract class BiomePalettePlacement {
protected final IrisData data;
private BiomePalettePlacement(IrisData data) {
this.data = data;
}
static BiomePalettePlacement of(Engine engine, IrisBiome biome, BiomePalette palette) {
IrisData data = engine.getData();
RNG terrainRandom = new RNG(engine.getSeedManager().getTerrain());
RNG carveRandom = new RNG(engine.getSeedManager().getCarve());
RNG postRandom = new RNG(engine.getSeedManager().getPost());
int seaLevel = seaLevel(engine.getDimension());
int index = palette.index();
return switch (palette.kind()) {
case LAYER -> new StackedLayer(palette.layerIn(biome), index, biome.getLayerHeightGenerators(terrainRandom, data),
terrainRandom, data);
case SEA_LAYER -> new StackedLayer(palette.layerIn(biome), index, biome.getLayerSeaHeightGenerators(terrainRandom, data),
terrainRandom, data);
case CAVE_CEILING_LAYER -> new StackedLayer(palette.layerIn(biome), index,
biome.getCaveCeilingLayerHeightGenerators(carveRandom, data), carveRandom, data);
case WALL -> new SingleBlocks(palette.layerIn(biome), data, carveRandom, seaLevel);
case SLAB -> new SingleBlocks(palette.layerIn(biome), data, postRandom, seaLevel);
case DECORATOR -> new Decorations(palette.decoratorIn(biome), engine.getSeedManager().getComponent(), seaLevel, data);
};
}
private static int seaLevel(IrisDimension dimension) {
return dimension == null ? 0 : dimension.getFluidHeight();
}
abstract KList<IrisBlockData> blocks();
abstract double zoom();
int minThickness() {
return 1;
}
int maxThickness() {
return 1;
}
/**
* How many blocks of this palette are stacked at the spot.
*/
abstract int thickness(int blockX, int blockZ);
abstract double noise(int blockX, int blockZ, int depth);
private abstract static class LayerPlacement extends BiomePalettePlacement {
protected final IrisBiomePaletteLayer layer;
private LayerPlacement(IrisBiomePaletteLayer layer, IrisData data) {
super(data);
this.layer = layer;
}
@Override
KList<IrisBlockData> blocks() {
return layer.getPalette();
}
@Override
double zoom() {
return layer.getZoom();
}
}
/**
* Surface, sea and cave ceiling layers. Each layer's thickness comes from its own noise in the list the generator
* builds, and every depth reseeds the block noise, sampled through the zoom twice.
*/
private static final class StackedLayer extends LayerPlacement {
private final int index;
private final RNG random;
private final CNG thicknessNoise;
private StackedLayer(IrisBiomePaletteLayer layer, int index, KList<CNG> thicknessNoises, RNG random, IrisData data) {
super(layer, data);
this.index = index;
this.random = random;
this.thicknessNoise = index < thicknessNoises.size() ? thicknessNoises.get(index) : null;
}
@Override
int minThickness() {
return layer.getMinHeight();
}
@Override
int maxThickness() {
return layer.getMaxHeight();
}
@Override
int thickness(int blockX, int blockZ) {
if (thicknessNoise == null) {
return 0;
}
double zoom = layer.getZoom();
return Math.max(0, thicknessNoise.fit(layer.getMinHeight(), layer.getMaxHeight(), blockX / zoom, blockZ / zoom));
}
@Override
double noise(int blockX, int blockZ, int depth) {
double zoom = layer.getZoom();
return layer.getLayerGenerator(random, index + depth, data)
.noise((blockX + depth) / zoom / zoom, depth / zoom, (blockZ - depth) / zoom / zoom);
}
}
/**
* Wall and slab blocks are picked one at a time with noise that also changes with height.
*/
private static final class SingleBlocks extends LayerPlacement {
private final RNG random;
private final int sampleHeight;
private SingleBlocks(IrisBiomePaletteLayer layer, IrisData data, RNG random, int sampleHeight) {
super(layer, data);
this.random = random;
this.sampleHeight = sampleHeight;
}
@Override
int thickness(int blockX, int blockZ) {
return 1;
}
@Override
double noise(int blockX, int blockZ, int depth) {
double zoom = layer.getZoom();
return layer.getLayerGenerator(random, data).noise(blockX / zoom, sampleHeight / zoom, blockZ / zoom);
}
}
/**
* A decorator places where its style noise stays under its chance, and its variance noise picks the block there.
* Surface and ceiling decorators pick single blocks from flat noise; sea and shore decorators pick from noise that
* also changes with height.
*/
private static final class Decorations extends BiomePalettePlacement {
private final IrisDecorator decorator;
private final CNG placementNoise;
private final CNG blockNoise;
private final boolean picksFromFlatNoise;
private final int sampleHeight;
private Decorations(IrisDecorator decorator, long componentSeed, int sampleHeight, IrisData data) {
super(data);
this.decorator = decorator;
RNG partRandom = new RNG(IrisEngineDecorator.seedForPart(componentSeed, decorator.getPartOf()));
this.placementNoise = decorator.getGenerator(partRandom, data);
this.blockNoise = decorator.getVarianceGenerator(partRandom, data);
this.picksFromFlatNoise = decorator.getPartOf() == IrisDecorationPart.NONE
|| decorator.getPartOf() == IrisDecorationPart.CEILING;
this.sampleHeight = sampleHeight;
}
@Override
KList<IrisBlockData> blocks() {
return decorator.getPalette();
}
@Override
double zoom() {
return decorator.getStyle().getZoom();
}
@Override
int thickness(int blockX, int blockZ) {
double zoom = zoom();
return placementNoise.fitDouble(0D, 1D, blockX / zoom, blockZ / zoom) <= decorator.getChance() ? 1 : 0;
}
// The generator swaps X and Z when it picks the block.
@Override
double noise(int blockX, int blockZ, int depth) {
return picksFromFlatNoise ? blockNoise.noiseFast2D(blockZ, blockX) : blockNoise.noise(blockZ, sampleHeight, blockX);
}
}
}
@@ -0,0 +1,27 @@
/*
* 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 art.arcane.iris.studio.map;
enum BiomeTint {
GRASS,
FOLIAGE,
DRY_FOLIAGE,
WATER
}
@@ -0,0 +1,68 @@
/*
* 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 art.arcane.iris.studio.map;
import java.util.Locale;
// The colours one Minecraft biome multiplies onto tinted blocks, as worked out by Biome#getGrassColor and friends.
record BiomeTintColors(int grass, GrassColorModifier grassModifier, int foliage, int dryFoliage, int water) {
int colorAt(BiomeTint tint, int blockX, int blockZ) {
return switch (tint) {
case GRASS -> grassModifier.modify(blockX, blockZ, grass);
case FOLIAGE -> foliage;
case DRY_FOLIAGE -> dryFoliage;
case WATER -> water;
};
}
boolean variesByPosition() {
return grassModifier == GrassColorModifier.SWAMP;
}
enum GrassColorModifier {
NONE,
DARK_FOREST,
SWAMP;
private static final double SWAMP_NOISE_SCALE = 0.0225D;
private static final double SWAMP_WET_THRESHOLD = -0.1D;
private static final int SWAMP_WET_GRASS = 0x4C763C;
private static final int SWAMP_DRY_GRASS = 0x6A7039;
private static final int DARK_FOREST_SHIFT = 0x28340A;
static GrassColorModifier named(String name) {
return switch (name.toLowerCase(Locale.ROOT)) {
case "dark_forest" -> DARK_FOREST;
case "swamp" -> SWAMP;
default -> NONE;
};
}
int modify(int blockX, int blockZ, int baseColor) {
return switch (this) {
case NONE -> baseColor;
case DARK_FOREST -> ((baseColor & 0xFEFEFE) + DARK_FOREST_SHIFT) >> 1;
case SWAMP -> BiomeInfoNoise.at(blockX * SWAMP_NOISE_SCALE, blockZ * SWAMP_NOISE_SCALE) < SWAMP_WET_THRESHOLD
? SWAMP_WET_GRASS
: SWAMP_DRY_GRASS;
};
}
}
}
@@ -0,0 +1,138 @@
/*
* 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 art.arcane.iris.studio.map;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.biome.IrisBiomeCustom;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
// The tint Minecraft draws on each column of one Iris biome. Iris picks the Minecraft biome once per chunk
// (see IrisBiomeActuator), and the client averages the colours of the columns around each block.
final class BiomeTintField {
private static final int MINECRAFT_DEFAULT_BLEND_RADIUS = 2;
private static final int BLENDED_COLUMN_COUNT = (MINECRAFT_DEFAULT_BLEND_RADIUS * 2 + 1) * (MINECRAFT_DEFAULT_BLEND_RADIUS * 2 + 1);
private static final int BLENDED_TINT_CACHE_SIZE = 1 << 16;
private static final int CHUNK_COLORS_CACHE_SIZE = 1 << 10;
private final IrisBiome biome;
private final RNG biomeRandom;
private final MinecraftBiomeColors minecraftColors;
private final BiomeTintColors sameColorsEverywhere;
private final Map<IrisBiomeCustom, BiomeTintColors> colorsByCustomBiome = new ConcurrentHashMap<>();
private final BlendedTint[] blendedTintCache = new BlendedTint[BLENDED_TINT_CACHE_SIZE];
private final ChunkColors[] chunkColorsCache = new ChunkColors[CHUNK_COLORS_CACHE_SIZE];
BiomeTintField(IrisBiome biome, RNG biomeRandom, MinecraftBiomeColors minecraftColors) {
this.biome = biome;
this.biomeRandom = biomeRandom;
this.minecraftColors = minecraftColors;
BiomeTintColors onlyColors = onlyPossibleColors();
this.sameColorsEverywhere = onlyColors == null || onlyColors.variesByPosition() ? null : onlyColors;
}
int colorAt(BiomeTint tint, int blockX, int blockZ) {
if (sameColorsEverywhere != null) {
return sameColorsEverywhere.colorAt(tint, blockX, blockZ);
}
int cacheSlot = cacheSlot(blockX, blockZ, tint.ordinal(), BLENDED_TINT_CACHE_SIZE);
BlendedTint cached = blendedTintCache[cacheSlot];
if (cached != null && cached.x() == blockX && cached.z() == blockZ && cached.tint() == tint) {
return cached.color();
}
int color = blendedColor(tint, blockX, blockZ);
blendedTintCache[cacheSlot] = new BlendedTint(blockX, blockZ, tint, color);
return color;
}
// Minecraft's ClientLevel#calculateBlockTint at the default biome blend radius.
private int blendedColor(BiomeTint tint, int blockX, int blockZ) {
int red = 0;
int green = 0;
int blue = 0;
for (int x = blockX - MINECRAFT_DEFAULT_BLEND_RADIUS; x <= blockX + MINECRAFT_DEFAULT_BLEND_RADIUS; x++) {
for (int z = blockZ - MINECRAFT_DEFAULT_BLEND_RADIUS; z <= blockZ + MINECRAFT_DEFAULT_BLEND_RADIUS; z++) {
int color = chunkColors(x >> 4, z >> 4).colorAt(tint, x, z);
red += (color >>> 16) & 0xFF;
green += (color >>> 8) & 0xFF;
blue += color & 0xFF;
}
}
return (red / BLENDED_COLUMN_COUNT) << 16 | (green / BLENDED_COLUMN_COUNT) << 8 | (blue / BLENDED_COLUMN_COUNT);
}
private BiomeTintColors chunkColors(int chunkX, int chunkZ) {
int cacheSlot = cacheSlot(chunkX, chunkZ, 0, CHUNK_COLORS_CACHE_SIZE);
ChunkColors cached = chunkColorsCache[cacheSlot];
if (cached != null && cached.chunkX() == chunkX && cached.chunkZ() == chunkZ) {
return cached.colors();
}
BiomeTintColors colors = colorsForChunkAt(chunkX << 4, chunkZ << 4);
chunkColorsCache[cacheSlot] = new ChunkColors(chunkX, chunkZ, colors);
return colors;
}
private BiomeTintColors colorsForChunkAt(int originX, int originZ) {
if (biome.isCustom()) {
return customColors(biome.getCustomBiome(biomeRandom, originX, 0, originZ));
}
return vanillaColors(biome.getSkyBiomeKey(biomeRandom, originX, 0, originZ));
}
// Follows the same choices as IrisBiome#getSkyBiomeKey and #getCustomBiome, returning null when chunks can differ.
private BiomeTintColors onlyPossibleColors() {
if (biome.isCustom()) {
KList<IrisBiomeCustom> variants = biome.getCustomDerivitives();
return variants.size() == 1 ? customColors(variants.get(0)) : null;
}
KList<String> skyBiomes = biome.getBiomeSkyScatter();
if (!skyBiomes.isEmpty()) {
return skyBiomes.size() == 1 ? vanillaColors(skyBiomes.get(0)) : null;
}
KList<String> groundBiomes = biome.getBiomeScatter();
if (groundBiomes.isEmpty()) {
return vanillaColors(biome.getDerivativeKey());
}
return groundBiomes.size() == 1 ? vanillaColors(groundBiomes.get(0)) : null;
}
private BiomeTintColors customColors(IrisBiomeCustom customBiome) {
return colorsByCustomBiome.computeIfAbsent(customBiome, minecraftColors::custom);
}
private BiomeTintColors vanillaColors(String vanillaBiomeKey) {
return minecraftColors.vanilla(vanillaBiomeKey.trim().toLowerCase(Locale.ROOT));
}
private static int cacheSlot(int x, int z, int salt, int cacheSize) {
long hash = x * 0x9E3779B97F4A7C15L ^ z * 0xC2B2AE3D27D4EB4FL ^ salt * 0x165667B19E3779F9L;
return (int) ((hash ^ (hash >>> 29)) & (cacheSize - 1));
}
private record BlendedTint(int x, int z, BiomeTint tint, int color) {
}
private record ChunkColors(int chunkX, int chunkZ, BiomeTintColors colors) {
}
}
@@ -0,0 +1,320 @@
/*
* 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 art.arcane.iris.studio.map;
import java.util.Arrays;
import java.util.function.IntUnaryOperator;
/**
* The visible faces of noise terrain as columns, with neighbouring columns merged into one face where they show the
* same stretch: tops of one height along X, and sides of one height range along the side. A face runs {@code columns}
* columns from its first one and reaches {@code height} across: one column along Z for tops, blocks up for sides.
* <p>
* Hole walls follow the surface: a side from the floor up for each column next to where a {@link TerrainHole} may end,
* shown in place of the usual side while the column beside it is left out. No face crosses a hole's cell edge, so one
* set of faces serves every hole.
* <p>
* Faces depend on neither the camera nor the hole, so they are found once per terrain rather than every frame.
*/
final class BlockFaces {
private static final int INITIAL_CAPACITY = 1024;
private static final Facing[] FACINGS = Facing.values();
private static final Kind[] KINDS = Kind.values();
private final NoiseTerrain terrain;
private final int floorY;
// Faces are kept kind by kind, and facing by facing within a kind.
private final int[] firstOfGroup = new int[KINDS.length * FACINGS.length + 1];
private int count;
private int[] firstColumn = new int[INITIAL_CAPACITY];
private int[] firstRow = new int[INITIAL_CAPACITY];
private int[] bottomY = new int[INITIAL_CAPACITY];
private int[] columns = new int[INITIAL_CAPACITY];
private int[] height = new int[INITIAL_CAPACITY];
private byte[] facing = new byte[INITIAL_CAPACITY];
private BlockFaces(NoiseTerrain terrain, int floorY) {
this.terrain = terrain;
this.floorY = floorY;
}
/**
* @param floorY where sides on the edge, next to a gap or into a hole end
*/
static BlockFaces of(NoiseTerrain terrain, int floorY) {
BlockFaces faces = new BlockFaces(terrain, floorY);
for (Kind kind : KINDS) {
for (Facing facing : FACINGS) {
faces.firstOfGroup[group(kind, facing)] = faces.count;
faces.add(kind, facing);
}
}
faces.firstOfGroup[KINDS.length * FACINGS.length] = faces.count;
faces.trimToCount();
return faces;
}
private static int group(Kind kind, Facing facing) {
return kind.ordinal() * FACINGS.length + facing.ordinal();
}
private void add(Kind kind, Facing facing) {
if (kind == Kind.HOLE_WALL) {
addHoleWalls(facing);
} else if (facing == Facing.TOP) {
addTops();
} else {
addSides(facing);
}
}
// A height of 64 places blocks up to y 64, so the column's top face is at 65.
static int blockTop(double height) {
return (int) Math.floor(height) + 1;
}
int count() {
return count;
}
// All faces of one kind and facing follow on from the first.
int first(Kind kind, Facing facing) {
return firstOfGroup[group(kind, facing)];
}
int count(Kind kind, Facing facing) {
return firstOfGroup[group(kind, facing) + 1] - firstOfGroup[group(kind, facing)];
}
boolean isHoleWall(int face) {
return face >= first(Kind.HOLE_WALL, FACINGS[0]);
}
int bottomY(int face) {
return bottomY[face];
}
Facing facing(int face) {
return FACINGS[facing[face]];
}
int columns(int face) {
return columns[face];
}
int height(int face) {
return height[face];
}
// The column and row of the face's first column; the others follow along its run.
int firstColumn(int face) {
return firstColumn[face];
}
int firstRow(int face) {
return firstRow[face];
}
// The corner the face runs and reaches from; east and south sides lie on the far edge of their column.
double originX(int face) {
return terrain.worldX(firstColumn[face] + (facing(face).normalX > 0 ? 1 : 0));
}
double originY(int face) {
return bottomY[face];
}
double originZ(int face) {
return terrain.worldZ(firstRow[face] + (facing(face).normalZ > 0 ? 1 : 0));
}
double runX(int face) {
return facing(face).runsAlongX() ? (double) columns[face] * terrain.step() : 0D;
}
double runZ(int face) {
return facing(face).runsAlongX() ? 0D : (double) columns[face] * terrain.step();
}
double reachY(int face) {
return facing(face) == Facing.TOP ? 0D : height[face];
}
double reachZ(int face) {
return facing(face) == Facing.TOP ? (double) height[face] * terrain.step() : 0D;
}
private boolean hasColumn(int column, int row) {
return terrain.hasHeight(column, row);
}
private static boolean startsCell(int columnOrRow) {
return columnOrRow % TerrainHole.CELL_COLUMNS == 0;
}
private void addTops() {
for (int row = 0; row < terrain.rows(); row++) {
int column = 0;
while (column < terrain.columns()) {
if (!hasColumn(column, row)) {
column++;
continue;
}
int top = blockTop(terrain.height(column, row));
int runEnd = column + 1;
while (runEnd < terrain.columns() && !startsCell(runEnd) && hasColumn(runEnd, row)
&& blockTop(terrain.height(runEnd, row)) == top) {
runEnd++;
}
add(Facing.TOP, column, row, top, runEnd - column, 1);
column = runEnd;
}
}
}
private void addSides(Facing side) {
for (int lane = 0; lane < lanes(side); lane++) {
int sideLane = lane;
addSideRuns(side, lane, step -> sideBottom(side, sideLane, step));
}
}
// A hole only ends on the lines between cells, so walls only stand along those inside the terrain.
private void addHoleWalls(Facing side) {
if (side == Facing.TOP) {
return;
}
int towardNeighbour = side.runsAlongX() ? side.normalZ : side.normalX;
for (int lane = 0; lane < lanes(side); lane++) {
int neighbourLane = lane + towardNeighbour;
boolean facesNextCell = lane / TerrainHole.CELL_COLUMNS != neighbourLane / TerrainHole.CELL_COLUMNS;
if (neighbourLane >= 0 && neighbourLane < lanes(side) && facesNextCell) {
addSideRuns(side, lane, step -> floorY);
}
}
}
private int lanes(Facing side) {
return side.runsAlongX() ? terrain.rows() : terrain.columns();
}
private void addSideRuns(Facing side, int lane, IntUnaryOperator bottomAt) {
int steps = side.runsAlongX() ? terrain.columns() : terrain.rows();
int step = 0;
while (step < steps) {
int top = sideTop(side, lane, step);
int bottom = bottomAt.applyAsInt(step);
if (bottom >= top) {
step++;
continue;
}
int runEnd = step + 1;
while (runEnd < steps && !startsCell(runEnd) && sideTop(side, lane, runEnd) == top && bottomAt.applyAsInt(runEnd) == bottom) {
runEnd++;
}
add(side, side.column(lane, step), side.row(lane, step), bottom, runEnd - step, top - bottom);
step = runEnd;
}
}
// No column means no side, which the caller sees as a side with no height.
private int sideTop(Facing side, int lane, int step) {
int column = side.column(lane, step);
int row = side.row(lane, step);
return hasColumn(column, row) ? blockTop(terrain.height(column, row)) : Integer.MIN_VALUE;
}
// Columns on the edge, or next to a gap, show their side all the way down to the floor.
private int sideBottom(Facing side, int lane, int step) {
int column = side.column(lane, step) + side.normalX;
int row = side.row(lane, step) + side.normalZ;
return hasColumn(column, row) ? blockTop(terrain.height(column, row)) : floorY;
}
private void add(Facing faceFacing, int column, int row, int bottom, int runColumns, int reach) {
if (count == facing.length) {
grow();
}
firstColumn[count] = column;
firstRow[count] = row;
bottomY[count] = bottom;
columns[count] = runColumns;
height[count] = reach;
facing[count] = (byte) faceFacing.ordinal();
count++;
}
private void grow() {
resizeTo(facing.length * 2);
}
// Large terrain keeps millions of faces, so the room left over from growing would be a lot of memory.
private void trimToCount() {
resizeTo(count);
}
private void resizeTo(int capacity) {
firstColumn = Arrays.copyOf(firstColumn, capacity);
firstRow = Arrays.copyOf(firstRow, capacity);
bottomY = Arrays.copyOf(bottomY, capacity);
columns = Arrays.copyOf(columns, capacity);
height = Arrays.copyOf(height, capacity);
facing = Arrays.copyOf(facing, capacity);
}
/**
* Which way a face looks. Tops and north and south sides run along X; east and west sides run along Z.
*/
enum Facing {
TOP(0, 1, 0),
NORTH(0, 0, -1),
SOUTH(0, 0, 1),
WEST(-1, 0, 0),
EAST(1, 0, 0);
final int normalX;
final int normalY;
final int normalZ;
Facing(int normalX, int normalY, int normalZ) {
this.normalX = normalX;
this.normalY = normalY;
this.normalZ = normalZ;
}
boolean runsAlongX() {
return normalX == 0;
}
// A lane is a row for faces running along X and a column for the others; steps go along the lane.
int column(int lane, int step) {
return runsAlongX() ? step : lane;
}
int row(int lane, int step) {
return runsAlongX() ? lane : step;
}
}
// Surface faces show unless their column is in a hole; hole walls only beside one.
enum Kind {
SURFACE,
HOLE_WALL
}
}
@@ -0,0 +1,47 @@
/*
* 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 art.arcane.iris.studio.map;
import java.awt.Color;
/**
* Colours noise that is already a height in blocks, spreading a palette over the heights it can reach.
*/
record BlockHeightColorScale(NoisePalette palette, double minimum, double maximum) implements NoiseColorScale {
@Override
public int colorFinite(double value) {
double span = maximum - minimum;
return palette.colorNormalized(span <= 0D ? 0.5D : (value - minimum) / span);
}
@Override
public int colorInvalid() {
return palette.colorInvalid();
}
@Override
public Color displayColorNormalized(double normalized) {
return palette.displayColorNormalized(normalized);
}
@Override
public String label() {
return "Blocks";
}
}
@@ -0,0 +1,213 @@
/*
* 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 art.arcane.iris.studio.map;
import art.arcane.iris.studio.map.BlockTextures.BlockTexture;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.UnaryOperator;
/**
* A block as the object viewer draws it: the textured faces of its model, in block units, already turned the way the
* blockstate faces.
*
* @param faces every face of every box in the model
* @param coveredSides the block's sides that a solid face fills completely, which hide whatever touches them
* @param hidesSameBlockSides whether the model fills the whole block, so like glass it hides the sides between two of
* the same blocks
*/
record BlockModel(List<Face> faces, Set<CubeFace> coveredSides, boolean hidesSameBlockSides) {
static final BlockModel EMPTY = new BlockModel(List.of(), Set.of(), false);
private static final int SIDE_CELLS = 16;
private static final double ON_SIDE_TOLERANCE = 1e-4D;
static BlockModel of(List<Face> faces) {
Set<CubeFace> coveredSides = EnumSet.noneOf(CubeFace.class);
boolean fillsEverySide = true;
for (CubeFace side : CubeFace.values()) {
if (sideIsFilled(faces, side, true)) {
coveredSides.add(side);
}
fillsEverySide &= sideIsFilled(faces, side, false);
}
return new BlockModel(List.copyOf(faces), Set.copyOf(coveredSides), fillsEverySide);
}
static BlockModel solidColor(int color) {
BlockTexture texture = BlockTexture.solid(color);
List<Face> faces = new ArrayList<>();
for (CubeFace side : CubeFace.values()) {
faces.add(Face.boxSide(Vec3.ZERO, new Vec3(1D, 1D, 1D), side, texture, side));
}
return of(faces);
}
boolean isEmpty() {
return faces.isEmpty();
}
BlockModel withTextures(UnaryOperator<BlockTexture> change) {
Map<BlockTexture, BlockTexture> changed = new IdentityHashMap<>();
List<Face> changedFaces = new ArrayList<>(faces.size());
for (Face face : faces) {
changedFaces.add(face.withTexture(changed.computeIfAbsent(face.texture(), change)));
}
return new BlockModel(List.copyOf(changedFaces), coveredSides, hidesSameBlockSides);
}
// The side is split into a grid, and every cell has to lie under a face pressed flat against it.
private static boolean sideIsFilled(List<Face> faces, CubeFace side, boolean onlySolidFaces) {
boolean[] filled = new boolean[SIDE_CELLS * SIDE_CELLS];
for (Face face : faces) {
if (face.liesOn(side) && (!onlySolidFaces || face.texture().isFullyOpaque())) {
face.fillCells(side, filled, SIDE_CELLS);
}
}
for (boolean cell : filled) {
if (!cell) {
return false;
}
}
return true;
}
/**
* A parallelogram from {@code origin} along {@code edgeU} and {@code edgeV}. The texture point at the origin and
* how it moves along each edge are given as fractions of the texture.
*
* @param normal the way the face looks out; it is hidden when seen from behind
* @param cullFace the block side whose neighbour hides this face when it covers that side, or null
*/
record Face(Vec3 origin, Vec3 edgeU, Vec3 edgeV, Vec3 normal, BlockTexture texture, TextureMapping mapping,
double shade, CubeFace cullFace) {
// Laid out as seen from outside: u to the right and v downwards, matching Minecraft's UVs for a whole texture.
static Face boxSide(Vec3 from, Vec3 to, CubeFace side, BlockTexture texture, CubeFace cullFace) {
Vec3[] corners = BoxCorners.of(from, to, side);
return new Face(corners[0], corners[3].minus(corners[0]), corners[1].minus(corners[0]), side.direction(),
texture, TextureMapping.WHOLE, side.shade(), cullFace);
}
Face withTexture(BlockTexture changedTexture) {
return new Face(origin, edgeU, edgeV, normal, changedTexture, mapping, shade, cullFace);
}
Face transformed(Transform transform) {
Vec3 turnedNormal = transform.direction(normal);
double length = Math.sqrt(turnedNormal.x() * turnedNormal.x() + turnedNormal.y() * turnedNormal.y() + turnedNormal.z() * turnedNormal.z());
return new Face(transform.point(origin), transform.direction(edgeU), transform.direction(edgeV),
length == 0D ? turnedNormal : turnedNormal.times(1D / length), texture, mapping, shade,
cullFace == null ? null : CubeFace.nearest(transform.direction(cullFace.direction())));
}
Face withCullFace(CubeFace side) {
return new Face(origin, edgeU, edgeV, normal, texture, mapping, shade, side);
}
Face withShade(double changedShade) {
return new Face(origin, edgeU, edgeV, normal, texture, mapping, changedShade, cullFace);
}
private boolean liesOn(CubeFace side) {
int axis = side.axis();
double plane = side.pointsPositive() ? 1D : 0D;
return CubeFace.nearest(normal) == side
&& Math.abs(normal.component(axis)) > 1D - ON_SIDE_TOLERANCE
&& Math.abs(origin.component(axis) - plane) < ON_SIDE_TOLERANCE
&& Math.abs(edgeU.component(axis)) < ON_SIDE_TOLERANCE
&& Math.abs(edgeV.component(axis)) < ON_SIDE_TOLERANCE;
}
private void fillCells(CubeFace side, boolean[] filled, int cells) {
int firstAxis = side.axis() == 0 ? 1 : 0;
int secondAxis = side.axis() == 2 ? 1 : 2;
double[] firstRange = range(firstAxis);
double[] secondRange = range(secondAxis);
for (int first = 0; first < cells; first++) {
double firstCenter = (first + 0.5D) / cells;
if (firstCenter < firstRange[0] || firstCenter > firstRange[1]) {
continue;
}
for (int second = 0; second < cells; second++) {
double secondCenter = (second + 0.5D) / cells;
if (secondCenter >= secondRange[0] && secondCenter <= secondRange[1]) {
filled[first * cells + second] = true;
}
}
}
}
private double[] range(int axis) {
double start = origin.component(axis);
double alongU = edgeU.component(axis);
double alongV = edgeV.component(axis);
double low = start + Math.min(0D, alongU) + Math.min(0D, alongV);
double high = start + Math.max(0D, alongU) + Math.max(0D, alongV);
return new double[]{low, high};
}
}
/**
* Where a face's corner sits in its texture and how the texture point moves along the face's two edges, all as
* fractions of the texture's width and height.
*/
record TextureMapping(double u, double v, double uAlongU, double vAlongU, double uAlongV, double vAlongV) {
static final TextureMapping WHOLE = new TextureMapping(0D, 0D, 1D, 0D, 0D, 1D);
}
/**
* A box side's corners in Minecraft's order: top left, bottom left, bottom right and top right, as seen from
* outside the box.
*/
static final class BoxCorners {
private BoxCorners() {
}
static Vec3[] of(Vec3 from, Vec3 to, CubeFace side) {
double minX = from.x();
double minY = from.y();
double minZ = from.z();
double maxX = to.x();
double maxY = to.y();
double maxZ = to.z();
return switch (side) {
case DOWN -> corners(minX, minY, maxZ, minX, minY, minZ, maxX, minY, minZ, maxX, minY, maxZ);
case UP -> corners(minX, maxY, minZ, minX, maxY, maxZ, maxX, maxY, maxZ, maxX, maxY, minZ);
case NORTH -> corners(maxX, maxY, minZ, maxX, minY, minZ, minX, minY, minZ, minX, maxY, minZ);
case SOUTH -> corners(minX, maxY, maxZ, minX, minY, maxZ, maxX, minY, maxZ, maxX, maxY, maxZ);
case WEST -> corners(minX, maxY, minZ, minX, minY, minZ, minX, minY, maxZ, minX, maxY, maxZ);
case EAST -> corners(maxX, maxY, maxZ, maxX, minY, maxZ, maxX, minY, minZ, maxX, maxY, minZ);
};
}
private static Vec3[] corners(double... coordinates) {
Vec3[] corners = new Vec3[4];
for (int corner = 0; corner < 4; corner++) {
corners[corner] = new Vec3(coordinates[corner * 3], coordinates[corner * 3 + 1], coordinates[corner * 3 + 2]);
}
return corners;
}
}
}
@@ -0,0 +1,351 @@
/*
* 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 art.arcane.iris.studio.map;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import art.arcane.iris.studio.map.BlockModel.BoxCorners;
import art.arcane.iris.studio.map.BlockModel.Face;
import art.arcane.iris.studio.map.BlockModel.TextureMapping;
import art.arcane.iris.studio.map.BlockTextures.BlockStateId;
import art.arcane.iris.studio.map.BlockTextures.BlockTexture;
import art.arcane.iris.studio.map.BlockTextures.ModelDefinition;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Reads a block's look the way Minecraft does: every model part the blockstate picks, every box with its own texture
* and UVs, tilted boxes, the blockstate's turn with or without locked textures, and which faces a neighbour hides.
*/
final class BlockModelBuilder {
private static final double MODEL_UNITS = 16D;
private static final double BLOCK_CENTER = 0.5D;
private static final int QUARTER_TURN = 90;
private final BlockTextures textures;
private final EntityBlockShapes entityShapes;
BlockModelBuilder(BlockTextures textures) {
this.textures = textures;
this.entityShapes = new EntityBlockShapes(textures);
}
Optional<BlockModel> build(String blockState) {
try {
BlockStateId state = BlockStateId.parse(blockState);
List<JsonObject> placements = placementsOf(state);
if (placements == null) {
return Optional.empty();
}
List<Face> faces = new ArrayList<>();
for (JsonObject placement : placements) {
faces.addAll(placedFaces(state, placement));
}
faces.addAll(entityShapes.facesAddedTo(state));
return Optional.of(BlockModel.of(faces));
} catch (IOException | RuntimeException error) {
return Optional.empty();
}
}
/**
* A box with Minecraft's default UVs on each side, for shapes the game draws in code.
*/
static List<Face> box(Vec3 fromPixels, Vec3 toPixels, BlockTexture texture, boolean culledBySides) {
List<Face> faces = new ArrayList<>();
for (CubeFace side : CubeFace.values()) {
FaceUv uv = FaceUv.byDefault(side, fromPixels, toPixels);
Face face = boxFace(fromPixels, toPixels, side, texture, uv).withShade(side.shade());
faces.add(culledBySides ? face.withCullFace(side) : face);
}
return faces;
}
// A variant names one model; a multipart blockstate, like a fence's, adds a part for every condition that holds.
private List<JsonObject> placementsOf(BlockStateId state) throws IOException {
JsonObject definition = textures.readJson(BlockTextures.assetPath(state.namespace() + ":" + state.name(), "blockstates", ".json"));
if (definition == null) {
return null;
}
List<JsonObject> placements = new ArrayList<>();
if (definition.has("variants")) {
for (Map.Entry<String, JsonElement> variant : definition.getAsJsonObject("variants").entrySet()) {
if (BlockTextures.variantMatches(variant.getKey(), state.properties())) {
addPlacement(placements, variant.getValue());
break;
}
}
}
if (definition.has("multipart")) {
for (JsonElement part : definition.getAsJsonArray("multipart")) {
JsonObject rule = part.getAsJsonObject();
if (!rule.has("when") || BlockTextures.conditionMatches(rule.getAsJsonObject("when"), state.properties())) {
addPlacement(placements, rule.get("apply"));
}
}
}
return placements;
}
private static void addPlacement(List<JsonObject> placements, JsonElement models) {
JsonObject first = BlockTextures.firstModel(models);
if (first != null && first.has("model")) {
placements.add(first);
}
}
private List<Face> placedFaces(BlockStateId state, JsonObject placement) throws IOException {
ModelDefinition model = textures.readModel(placement.get("model").getAsString());
if (model.elements() == null) {
return entityShapes.facesOf(state, model);
}
BlockRotation rotation = BlockRotation.of(placement);
List<Face> faces = new ArrayList<>();
for (JsonElement element : model.elements()) {
addElementFaces(faces, state, model, element.getAsJsonObject(), rotation);
}
return faces;
}
private void addElementFaces(List<Face> faces, BlockStateId state, ModelDefinition model, JsonObject element, BlockRotation rotation) {
JsonObject elementFaces = element.getAsJsonObject("faces");
if (elementFaces == null) {
return;
}
Vec3 from = vector(element.getAsJsonArray("from"));
Vec3 to = vector(element.getAsJsonArray("to"));
Transform tilt = tiltOf(element);
boolean shaded = !element.has("shade") || element.get("shade").getAsBoolean();
CubeFace shadeSide = element.has("shade_direction_override")
? CubeFace.named(element.get("shade_direction_override").getAsString()) : null;
for (Map.Entry<String, JsonElement> entry : elementFaces.entrySet()) {
CubeFace side = CubeFace.named(entry.getKey());
JsonObject faceJson = entry.getValue().getAsJsonObject();
if (side == null || !faceJson.has("texture")) {
continue;
}
String textureName = BlockTextures.dereference(faceJson.get("texture").getAsString(), model.textures());
if (textureName == null) {
continue;
}
Optional<BlockTexture> texture = textures.faceTexture(state, textureName, BlockTextures.tintIndexOf(faceJson));
if (texture.isEmpty()) {
continue;
}
FaceUv uv = FaceUv.of(faceJson, side, from, to);
if (rotation.locksTextures()) {
uv = uv.lockedTo(side, rotation);
}
Face face = boxFace(from, to, side, texture.get(), uv).transformed(tilt);
if (faceJson.has("cullface")) {
face = face.withCullFace(CubeFace.named(faceJson.get("cullface").getAsString()));
}
face = face.transformed(rotation.transform());
faces.add(face.withShade(shadeOf(face, shaded, shadeSide, rotation)));
}
}
private static Face boxFace(Vec3 fromPixels, Vec3 toPixels, CubeFace side, BlockTexture texture, FaceUv uv) {
Vec3[] corners = BoxCorners.of(fromPixels.times(1D / MODEL_UNITS), toPixels.times(1D / MODEL_UNITS), side);
return new Face(corners[0], corners[3].minus(corners[0]), corners[1].minus(corners[0]), side.direction(),
texture, uv.mapping(), side.shade(), null);
}
private static double shadeOf(Face face, boolean shaded, CubeFace shadeSide, BlockRotation rotation) {
if (!shaded) {
return CubeFace.UP.shade();
}
if (shadeSide != null) {
return CubeFace.nearest(rotation.transform().direction(shadeSide.direction())).shade();
}
return CubeFace.nearest(face.normal()).shade();
}
// A box can be tilted around a point, on one axis or, since 1.21.?, on all three; rescaling stretches a tilted
// plant back to the full block width.
private static Transform tiltOf(JsonObject element) {
if (!element.has("rotation")) {
return Transform.IDENTITY;
}
JsonObject rotation = element.getAsJsonObject("rotation");
Vec3 origin = rotation.has("origin") ? vector(rotation.getAsJsonArray("origin")).times(1D / MODEL_UNITS) : new Vec3(BLOCK_CENTER, BLOCK_CENTER, BLOCK_CENTER);
Transform around = Transform.IDENTITY.translate(origin.x(), origin.y(), origin.z());
Transform back = Transform.IDENTITY.translate(-origin.x(), -origin.y(), -origin.z());
if (!rotation.has("axis")) {
return around
.rotateX(numberOr(rotation, "x", 0D))
.rotateY(numberOr(rotation, "y", 0D))
.rotateZ(numberOr(rotation, "z", 0D))
.then(back);
}
String axis = rotation.get("axis").getAsString();
double angle = numberOr(rotation, "angle", 0D);
boolean rescale = rotation.has("rescale") && rotation.get("rescale").getAsBoolean();
double stretch = rescale ? 1D / Math.cos(Math.toRadians(angle)) : 1D;
Transform stretched = around.scale(axis.equals("x") ? 1D : stretch, axis.equals("y") ? 1D : stretch, axis.equals("z") ? 1D : stretch);
Transform tilted = switch (axis) {
case "x" -> stretched.rotateX(angle);
case "y" -> stretched.rotateY(angle);
default -> stretched.rotateZ(angle);
};
return tilted.then(back);
}
private static double numberOr(JsonObject object, String key, double fallback) {
return object.has(key) ? object.get(key).getAsDouble() : fallback;
}
private static Vec3 vector(JsonArray values) {
return new Vec3(values.get(0).getAsDouble(), values.get(1).getAsDouble(), values.get(2).getAsDouble());
}
/**
* How a blockstate turns its model: around the x axis first, then the y axis, both in quarter turns against the
* right-hand rule. With uvlock the textures stay put while the shape turns.
*/
private record BlockRotation(int x, int y, boolean uvlock) {
static BlockRotation of(JsonObject placement) {
int x = placement.has("x") ? Math.floorMod(placement.get("x").getAsInt(), 360) : 0;
int y = placement.has("y") ? Math.floorMod(placement.get("y").getAsInt(), 360) : 0;
return new BlockRotation(x, y, placement.has("uvlock") && placement.get("uvlock").getAsBoolean());
}
boolean locksTextures() {
return uvlock && (x != 0 || y != 0);
}
Transform transform() {
return Transform.IDENTITY
.translate(BLOCK_CENTER, BLOCK_CENTER, BLOCK_CENTER)
.then(turn())
.translate(-BLOCK_CENTER, -BLOCK_CENTER, -BLOCK_CENTER);
}
Transform turn() {
return Transform.IDENTITY.rotateY(-y).rotateX(-x);
}
Transform inverseTurn() {
return Transform.IDENTITY.rotateX(x).rotateY(y);
}
}
/**
* A face's corner UVs in model pixels and the quarter turn of the texture on it.
*/
private record FaceUv(double u1, double v1, double u2, double v2, int rotation) {
static FaceUv of(JsonObject face, CubeFace side, Vec3 from, Vec3 to) {
int rotation = face.has("rotation") ? Math.floorMod(face.get("rotation").getAsInt(), 360) : 0;
if (!face.has("uv")) {
return byDefault(side, from, to).withRotation(rotation);
}
JsonArray uv = face.getAsJsonArray("uv");
return new FaceUv(uv.get(0).getAsDouble(), uv.get(1).getAsDouble(), uv.get(2).getAsDouble(), uv.get(3).getAsDouble(), rotation);
}
// The part of the texture the face would cover if the texture were laid over the whole block side.
static FaceUv byDefault(CubeFace side, Vec3 from, Vec3 to) {
double full = MODEL_UNITS;
return switch (side) {
case DOWN -> new FaceUv(from.x(), full - to.z(), to.x(), full - from.z(), 0);
case UP -> new FaceUv(from.x(), from.z(), to.x(), to.z(), 0);
case NORTH -> new FaceUv(full - to.x(), full - to.y(), full - from.x(), full - from.y(), 0);
case SOUTH -> new FaceUv(from.x(), full - to.y(), to.x(), full - from.y(), 0);
case WEST -> new FaceUv(from.z(), full - to.y(), to.z(), full - from.y(), 0);
case EAST -> new FaceUv(full - to.z(), full - to.y(), full - from.z(), full - from.y(), 0);
};
}
FaceUv withRotation(int changedRotation) {
return new FaceUv(u1, v1, u2, v2, changedRotation);
}
// Each corner takes the UV a quarter turn further along for every quarter turn of the texture.
TextureMapping mapping() {
double[] origin = cornerUv(0);
double[] alongU = cornerUv(3);
double[] alongV = cornerUv(1);
return new TextureMapping(origin[0] / MODEL_UNITS, origin[1] / MODEL_UNITS,
(alongU[0] - origin[0]) / MODEL_UNITS, (alongU[1] - origin[1]) / MODEL_UNITS,
(alongV[0] - origin[0]) / MODEL_UNITS, (alongV[1] - origin[1]) / MODEL_UNITS);
}
private double[] cornerUv(int corner) {
return switch ((corner + rotation / QUARTER_TURN) % 4) {
case 0 -> new double[]{u1, v1};
case 1 -> new double[]{u1, v2};
case 2 -> new double[]{u2, v2};
default -> new double[]{u2, v1};
};
}
/**
* Minecraft's uvlock: the UVs are carried through the reverse of the blockstate's turn, so the texture keeps
* its place in the world while the face moves to another side.
*/
FaceUv lockedTo(CubeFace side, BlockRotation blockRotation) {
CubeFace turnedSide = CubeFace.nearest(blockRotation.turn().direction(side.direction()));
Transform uvTurn = Transform.IDENTITY
.translate(BLOCK_CENTER, BLOCK_CENTER, BLOCK_CENTER)
.then(faceFromWorld(side))
.then(blockRotation.inverseTurn())
.then(worldFromFace(turnedSide))
.translate(-BLOCK_CENTER, -BLOCK_CENTER, -BLOCK_CENTER);
Vec3 first = uvTurn.point(new Vec3(u1 / MODEL_UNITS, v1 / MODEL_UNITS, 0D)).times(MODEL_UNITS);
Vec3 second = uvTurn.point(new Vec3(u2 / MODEL_UNITS, v2 / MODEL_UNITS, 0D)).times(MODEL_UNITS);
boolean keepsUOrder = Math.signum(u2 - u1) == Math.signum(second.x() - first.x());
boolean keepsVOrder = Math.signum(v2 - v1) == Math.signum(second.y() - first.y());
Vec3 textureRight = uvTurn.direction(new Vec3(Math.cos(Math.toRadians(rotation)), Math.sin(Math.toRadians(rotation)), 0D));
long quarterTurns = Math.round(Math.toDegrees(Math.atan2(textureRight.y(), textureRight.x())) / QUARTER_TURN);
return new FaceUv(
keepsUOrder ? first.x() : second.x(), keepsVOrder ? first.y() : second.y(),
keepsUOrder ? second.x() : first.x(), keepsVOrder ? second.y() : first.y(),
(int) Math.floorMod(-quarterTurns * QUARTER_TURN, 360L));
}
// Minecraft lays every side's texture out as if it were the south side, turned into place.
private static Transform worldFromFace(CubeFace side) {
return switch (side) {
case SOUTH -> Transform.IDENTITY;
case EAST -> Transform.IDENTITY.rotateY(90D);
case WEST -> Transform.IDENTITY.rotateY(-90D);
case NORTH -> Transform.IDENTITY.rotateY(180D);
case UP -> Transform.IDENTITY.rotateX(-90D);
case DOWN -> Transform.IDENTITY.rotateX(90D);
};
}
private static Transform faceFromWorld(CubeFace side) {
return switch (side) {
case SOUTH -> Transform.IDENTITY;
case EAST -> Transform.IDENTITY.rotateY(-90D);
case WEST -> Transform.IDENTITY.rotateY(90D);
case NORTH -> Transform.IDENTITY.rotateY(180D);
case UP -> Transform.IDENTITY.rotateX(90D);
case DOWN -> Transform.IDENTITY.rotateX(-90D);
};
}
}
}
@@ -0,0 +1,35 @@
/*
* 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 art.arcane.iris.studio.map;
import art.arcane.iris.generation.decoration.IrisProceduralBlocks;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
final class BlockNames {
private BlockNames() {
}
/**
* @return the block's type without namespace or properties, like {@code oak_log}
*/
static String typeOf(NativeBlockState block) {
String material = IrisProceduralBlocks.materialKey(block);
return material.substring(material.indexOf(':') + 1);
}
}
@@ -0,0 +1,74 @@
/*
* 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 art.arcane.iris.studio.map;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import java.awt.Color;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
// Colours for blocks without a texture, such as when the client jar can't be read.
final class BlockPreviewColors {
private static final double[] MAP_SHADES = {1D, 0.86D, 0.71D, 0.53D};
private BlockPreviewColors() {
}
static Map<String, Integer> colorsByBlock(List<NativeBlockState> blocks) {
Map<String, Integer> colors = new HashMap<>();
List<Integer> usedColors = new ArrayList<>();
for (NativeBlockState block : blocks) {
String key = block.key();
if (colors.containsKey(key)) {
continue;
}
int baseColor = colorFromName(BlockNames.typeOf(block));
int color = firstUnusedShade(baseColor, usedColors);
usedColors.add(color);
colors.put(key, color);
}
return colors;
}
// Blocks that share a map colour, like sand and sandstone, get darker shades the way Minecraft maps shade them.
private static int firstUnusedShade(int baseColor, List<Integer> usedColors) {
for (double shade : MAP_SHADES) {
int shaded = shade(baseColor, shade);
if (!usedColors.contains(shaded)) {
return shaded;
}
}
return baseColor;
}
private static int colorFromName(String name) {
float hue = (name.hashCode() & 0xFFFF) / (float) 0xFFFF;
return Color.HSBtoRGB(hue, 0.45F, 0.75F) & 0xFFFFFF;
}
static int shade(int color, double shade) {
int red = (int) Math.min(255, ((color >>> 16) & 0xFF) * shade);
int green = (int) Math.min(255, ((color >>> 8) & 0xFF) * shade);
int blue = (int) Math.min(255, (color & 0xFF) * shade);
return (red << 16) | (green << 8) | blue;
}
}
@@ -0,0 +1,623 @@
/*
* 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 art.arcane.iris.studio.map;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import art.arcane.iris.generation.decoration.IrisProceduralBlocks;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.zip.ZipFile;
final class BlockTextures {
private static final String DEFAULT_NAMESPACE = "minecraft";
private static final int MAX_MODEL_PARENTS = 16;
private static final int MAX_TEXTURE_REFERENCES = 16;
static final int NO_TINT_INDEX = -1;
private static final Object SHARED_LOCK = new Object();
private static CompletableFuture<BlockTextures> shared;
private final ZipFile clientJar;
private final MinecraftBiomeColors biomeColors;
private final Map<String, Optional<ResolvedTexture>> resolvedTextures = new ConcurrentHashMap<>();
private final Map<String, Optional<BlockModel>> resolvedModels = new ConcurrentHashMap<>();
private final Map<String, Optional<BlockTexture>> faceTextures = new ConcurrentHashMap<>();
private final BlockModelBuilder modelBuilder = new BlockModelBuilder(this);
private BlockTextures(ZipFile clientJar, MinecraftBiomeColors biomeColors) {
this.clientJar = clientJar;
this.biomeColors = biomeColors;
}
static CompletableFuture<BlockTextures> shared() {
synchronized (SHARED_LOCK) {
if (shared == null || shared.isCompletedExceptionally()) {
shared = MinecraftClientJar.open().thenApply(BlockTextures::load);
}
return shared;
}
}
private static BlockTextures load(ZipFile clientJar) {
try {
return new BlockTextures(clientJar, MinecraftBiomeColors.load(clientJar));
} catch (IOException error) {
throw new IllegalStateException("Could not read Minecraft's biome colour maps", error);
}
}
MinecraftBiomeColors biomeColors() {
return biomeColors;
}
/**
* What the block looks like from above. Plants and other side-on sprites become their average colour, because
* their sprite would read as stripes across a stack of ground blocks.
*/
Optional<BlockTexture> topTexture(NativeBlockState block) {
return resolve(block).map(resolved -> resolved.isSideOnSprite() ? resolved.texture().flattened() : resolved.texture());
}
/**
* Like {@link #topTexture}, but plants keep their sprite, so single decorations on the ground stay recognisable.
* Two block tall plants give their bottom half, the part standing on the ground.
*/
Optional<BlockTexture> iconTexture(NativeBlockState block) {
return resolve(withHalf(block, BlockHalf.LOWER).orElse(block)).map(ResolvedTexture::texture);
}
/**
* The sprite of a two block tall plant's top half, or nothing for any other block.
*/
Optional<BlockTexture> upperHalfIcon(NativeBlockState block) {
return withHalf(block, BlockHalf.UPPER).flatMap(this::resolve)
.filter(ResolvedTexture::isSideOnSprite).map(ResolvedTexture::texture);
}
/**
* What the block looks like from every side, for drawing it in 3D.
*/
Optional<BlockModel> model(NativeBlockState block) {
return resolvedModels.computeIfAbsent(block.key(), modelBuilder::build);
}
private static Optional<NativeBlockState> withHalf(NativeBlockState block, BlockHalf half) {
String current = IrisProceduralBlocks.propertyValue(block, BlockHalf.PROPERTY);
if (current == null) {
return Optional.empty();
}
return Optional.of(block.withProperty(BlockHalf.PROPERTY, half.valueLike(current)));
}
// Blockstate files match on every property, so defaults the pack left out have to be spelled out too.
private Optional<ResolvedTexture> resolve(NativeBlockState block) {
return resolvedTextures.computeIfAbsent(block.key(), this::resolveTopTexture);
}
// Two block tall blocks call their halves lower and upper; stairs and trapdoors call them bottom and top.
private enum BlockHalf {
LOWER("lower", "bottom"),
UPPER("upper", "top");
static final String PROPERTY = "half";
private final String tallBlockValue;
private final String slabLikeValue;
BlockHalf(String tallBlockValue, String slabLikeValue) {
this.tallBlockValue = tallBlockValue;
this.slabLikeValue = slabLikeValue;
}
String valueLike(String currentValue) {
boolean isTallBlock = currentValue.equals(LOWER.tallBlockValue) || currentValue.equals(UPPER.tallBlockValue);
return isTallBlock ? tallBlockValue : slabLikeValue;
}
}
private Optional<ResolvedTexture> resolveTopTexture(String blockState) {
try {
BlockStateId state = BlockStateId.parse(blockState);
JsonObject variant = variantOf(state);
if (variant == null || !variant.has("model")) {
return Optional.empty();
}
ModelFace face = readModel(variant.get("model").getAsString()).visibleFace(faceSeenFromAbove(variant));
if (face == null) {
return Optional.empty();
}
return readTexture(state, face).map(texture -> new ResolvedTexture(texture, face.isSideOnSprite()));
} catch (IOException | RuntimeException error) {
return Optional.empty();
}
}
private JsonObject variantOf(BlockStateId state) throws IOException {
JsonObject definition = readJson(assetPath(state.namespace() + ":" + state.name(), "blockstates", ".json"));
return definition == null ? null : pickVariant(definition, state.properties());
}
private static int rotationOf(JsonObject variant, String axis) {
return variant.has(axis) ? Math.floorMod(variant.get(axis).getAsInt(), 360) : 0;
}
/**
* One model face's texture with the block's tint, shared by every block state that uses it.
*/
Optional<BlockTexture> faceTexture(BlockStateId state, String texture, int tintIndex) {
int fixedTint = MinecraftBlockTints.fixedTint(state.name(), state.properties(), tintIndex);
BiomeTint biomeTint = MinecraftBlockTints.biomeTint(state.name(), tintIndex).orElse(null);
return faceTextures.computeIfAbsent(texture + "|" + fixedTint + "|" + biomeTint, key -> {
try {
return readTexture(state, new ModelFace(List.of(texture), tintIndex, false));
} catch (IOException | RuntimeException error) {
return Optional.empty();
}
});
}
/**
* A texture drawn on a block entity, like a chest, whole and tinted with a fixed colour.
*/
Optional<BlockTexture> entityTexture(String texture, int tint) {
return faceTextures.computeIfAbsent(texture + "|entity|" + tint, key -> {
try {
BufferedImage image = readImage(assetPath(texture, "textures", ".png"));
return image == null ? Optional.empty() : Optional.of(BlockTexture.fromWholeImage(image, tint));
} catch (IOException | RuntimeException error) {
return Optional.empty();
}
});
}
private Optional<BlockTexture> readTexture(BlockStateId state, ModelFace face) throws IOException {
BufferedImage image = readLayeredImage(face.textures());
if (image == null) {
return Optional.empty();
}
try {
return Optional.of(BlockTexture.fromImage(
image,
MinecraftBlockTints.fixedTint(state.name(), state.properties(), face.tintIndex()),
MinecraftBlockTints.biomeTint(state.name(), face.tintIndex()).orElse(null)
));
} catch (IllegalArgumentException invisibleTexture) {
return Optional.empty();
}
}
// A block rotated on its x axis, like a sideways log, shows a side face on top. Blockstates turn the model
// against the right-hand rule, so 90 degrees brings the south side up.
private static String faceSeenFromAbove(JsonObject variant) {
return switch (rotationOf(variant, "x")) {
case 90 -> "south";
case 270 -> "north";
case 180 -> "down";
default -> "up";
};
}
ModelDefinition readModel(String modelReference) throws IOException {
Map<String, String> textures = new HashMap<>();
JsonArray elements = null;
String current = modelReference;
for (int depth = 0; current != null && depth < MAX_MODEL_PARENTS; depth++) {
JsonObject model = readJson(assetPath(current, "models", ".json"));
if (model == null) {
break;
}
if (model.has("textures")) {
for (Map.Entry<String, JsonElement> texture : model.getAsJsonObject("textures").entrySet()) {
textures.putIfAbsent(texture.getKey(), spriteName(texture.getValue()));
}
}
if (elements == null && model.has("elements")) {
elements = model.getAsJsonArray("elements");
}
current = model.has("parent") ? model.get("parent").getAsString() : null;
}
return new ModelDefinition(textures, elements);
}
// Since 1.21.? a texture can also be an object naming its sprite, like glass's {"sprite": ..., "force_translucent": true}.
private static String spriteName(JsonElement texture) {
return texture.isJsonObject() ? texture.getAsJsonObject().get("sprite").getAsString() : texture.getAsString();
}
// Some plants draw a second sprite over the first, like the glowing fireflies on a firefly bush.
private static List<String> spriteLayers(JsonArray elements, Map<String, String> textures) {
Set<String> layers = new LinkedHashSet<>();
for (JsonElement element : elements) {
JsonObject faces = element.getAsJsonObject().getAsJsonObject("faces");
if (faces == null) {
continue;
}
for (Map.Entry<String, JsonElement> face : faces.entrySet()) {
String texture = dereference(face.getValue().getAsJsonObject().get("texture").getAsString(), textures);
if (texture != null) {
layers.add(texture);
}
}
}
return List.copyOf(layers);
}
private static int firstTintIndex(JsonArray elements) {
for (JsonElement element : elements) {
JsonObject faces = element.getAsJsonObject().getAsJsonObject("faces");
if (faces == null) {
continue;
}
for (Map.Entry<String, JsonElement> face : faces.entrySet()) {
int tintIndex = tintIndexOf(face.getValue().getAsJsonObject());
if (tintIndex != NO_TINT_INDEX) {
return tintIndex;
}
}
}
return NO_TINT_INDEX;
}
static int tintIndexOf(JsonObject face) {
return face.has("tintindex") ? face.get("tintindex").getAsInt() : NO_TINT_INDEX;
}
private static JsonObject topmostFace(JsonArray elements, String faceName) {
JsonObject best = null;
double bestHeight = Double.NEGATIVE_INFINITY;
double bestArea = -1D;
for (JsonElement element : elements) {
JsonObject cuboid = element.getAsJsonObject();
JsonObject faces = cuboid.getAsJsonObject("faces");
if (faces == null || !faces.has(faceName)) {
continue;
}
JsonArray from = cuboid.getAsJsonArray("from");
JsonArray to = cuboid.getAsJsonArray("to");
double height = to.get(1).getAsDouble();
double area = (to.get(0).getAsDouble() - from.get(0).getAsDouble()) * (to.get(2).getAsDouble() - from.get(2).getAsDouble());
if (height > bestHeight || (height == bestHeight && area > bestArea)) {
best = faces.getAsJsonObject(faceName);
bestHeight = height;
bestArea = area;
}
}
return best;
}
static String dereference(String reference, Map<String, String> textures) {
String resolved = reference;
for (int hop = 0; resolved != null && resolved.startsWith("#") && hop < MAX_TEXTURE_REFERENCES; hop++) {
resolved = textures.get(resolved.substring(1));
}
return resolved == null || resolved.startsWith("#") ? null : resolved;
}
private static JsonObject pickVariant(JsonObject definition, Map<String, String> properties) {
if (definition.has("variants")) {
for (Map.Entry<String, JsonElement> variant : definition.getAsJsonObject("variants").entrySet()) {
if (variantMatches(variant.getKey(), properties)) {
return firstModel(variant.getValue());
}
}
return null;
}
if (definition.has("multipart")) {
for (JsonElement part : definition.getAsJsonArray("multipart")) {
JsonObject rule = part.getAsJsonObject();
if (!rule.has("when") || conditionMatches(rule.getAsJsonObject("when"), properties)) {
return firstModel(rule.get("apply"));
}
}
}
return null;
}
static boolean variantMatches(String variantKey, Map<String, String> properties) {
if (variantKey.isEmpty()) {
return true;
}
for (String condition : variantKey.split(",")) {
String[] pair = condition.split("=", 2);
if (pair.length != 2 || !pair[1].equals(properties.get(pair[0]))) {
return false;
}
}
return true;
}
static boolean conditionMatches(JsonObject condition, Map<String, String> properties) {
if (condition.has("OR")) {
for (JsonElement option : condition.getAsJsonArray("OR")) {
if (conditionMatches(option.getAsJsonObject(), properties)) {
return true;
}
}
return false;
}
if (condition.has("AND")) {
for (JsonElement requirement : condition.getAsJsonArray("AND")) {
if (!conditionMatches(requirement.getAsJsonObject(), properties)) {
return false;
}
}
return true;
}
for (Map.Entry<String, JsonElement> requirement : condition.entrySet()) {
String actual = properties.get(requirement.getKey());
boolean anyAllowed = false;
for (String allowed : requirement.getValue().getAsString().split("\\|")) {
anyAllowed |= allowed.equals(actual);
}
if (!anyAllowed) {
return false;
}
}
return true;
}
static JsonObject firstModel(JsonElement models) {
if (models.isJsonArray()) {
JsonArray choices = models.getAsJsonArray();
return choices.isEmpty() ? null : choices.get(0).getAsJsonObject();
}
return models.getAsJsonObject();
}
static String assetPath(String resource, String folder, String extension) {
int separator = resource.indexOf(':');
String namespace = separator < 0 ? DEFAULT_NAMESPACE : resource.substring(0, separator);
String path = separator < 0 ? resource : resource.substring(separator + 1);
return "assets/" + namespace + "/" + folder + "/" + path + extension;
}
JsonObject readJson(String path) throws IOException {
return ClientJarFiles.readJson(clientJar, path);
}
// Each layer is drawn over the ones before it, using only its first frame when it is animated.
private BufferedImage readLayeredImage(List<String> layers) throws IOException {
BufferedImage bottom = readImage(assetPath(layers.get(0), "textures", ".png"));
if (bottom == null || layers.size() == 1) {
return bottom;
}
int size = bottom.getWidth();
BufferedImage combined = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
Graphics2D canvas = combined.createGraphics();
try {
for (String layer : layers) {
BufferedImage image = readImage(assetPath(layer, "textures", ".png"));
if (image != null) {
int frameSize = image.getWidth();
canvas.drawImage(image, 0, 0, size, size, 0, 0, frameSize, frameSize, null);
}
}
} finally {
canvas.dispose();
}
return combined;
}
private BufferedImage readImage(String path) throws IOException {
return ClientJarFiles.readImage(clientJar, path);
}
// Plants and other blocks without a top face only have a side-on sprite, which would read as stripes from above.
private record ModelFace(List<String> textures, int tintIndex, boolean isSideOnSprite) {
}
/**
* A block model with its parents' textures merged in. Models drawn in code, like chests, have no elements.
*/
record ModelDefinition(Map<String, String> textures, JsonArray elements) {
ModelFace visibleFace(String faceName) {
ModelFace face = sideFace(faceName);
if (face != null) {
return face;
}
return elements != null && !spriteLayers(elements, textures).isEmpty() ? spriteFace() : particleFace();
}
// The face's texture on the model's topmost box, the particle texture for models without boxes, or null when
// the model leaves that side open.
ModelFace sideFace(String faceName) {
if (elements == null) {
return particleFace();
}
JsonObject face = topmostFace(elements, faceName);
if (face == null) {
return null;
}
String texture = dereference(face.get("texture").getAsString(), textures);
return texture == null ? null : new ModelFace(List.of(texture), tintIndexOf(face), false);
}
ModelFace spriteFace() {
return new ModelFace(spriteLayers(elements, textures), firstTintIndex(elements), true);
}
private ModelFace particleFace() {
String particle = dereference("#particle", textures);
return particle == null ? null : new ModelFace(List.of(particle), NO_TINT_INDEX, false);
}
}
private record ResolvedTexture(BlockTexture texture, boolean isSideOnSprite) {
}
record BlockStateId(String namespace, String name, Map<String, String> properties) {
static BlockStateId parse(String blockState) {
int propertiesStart = blockState.indexOf('[');
String id = propertiesStart < 0 ? blockState : blockState.substring(0, propertiesStart);
int separator = id.indexOf(':');
String namespace = separator < 0 ? DEFAULT_NAMESPACE : id.substring(0, separator);
String name = separator < 0 ? id : id.substring(separator + 1);
Map<String, String> properties = new HashMap<>();
if (propertiesStart >= 0) {
String body = blockState.substring(propertiesStart + 1, blockState.length() - 1);
for (String pair : body.split(",")) {
String[] keyValue = pair.split("=", 2);
if (keyValue.length == 2) {
properties.put(keyValue[0], keyValue[1]);
}
}
}
return new BlockStateId(namespace, name, properties);
}
}
// Biome tinted textures keep their grey pixels, because the biome colour changes from column to column.
// See-through pixels get a darker shade of the average colour, and the opaque mask remembers which they were.
// Textures with partly see-through pixels, like stained glass, ice and water, keep each pixel's alpha too.
record BlockTexture(int width, int height, int[] pixels, boolean[] opaque, int[] alphas, int averageColor,
BiomeTint biomeTint) {
private static final double GAP_SHADE = 0.55D;
private static final int FULL_ALPHA = 255;
// Animated textures stack their frames vertically, so only the first square frame is used.
static BlockTexture fromImage(BufferedImage image, int fixedTint, BiomeTint biomeTint) {
return fromPixels(image, image.getWidth(), Math.min(image.getWidth(), image.getHeight()), fixedTint, biomeTint);
}
// Entity textures, like a chest's, are laid out for a model and can be wider than they are tall.
static BlockTexture fromWholeImage(BufferedImage image, int fixedTint) {
return fromPixels(image, image.getWidth(), image.getHeight(), fixedTint, null);
}
static BlockTexture solid(int color) {
return new BlockTexture(1, 1, new int[]{color}, new boolean[]{true}, null, color, null);
}
private static BlockTexture fromPixels(BufferedImage image, int width, int height, int fixedTint, BiomeTint biomeTint) {
int[] pixels = new int[width * height];
boolean[] opaque = new boolean[pixels.length];
int[] alphas = new int[pixels.length];
boolean translucent = false;
long red = 0;
long green = 0;
long blue = 0;
int opaqueCount = 0;
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int argb = image.getRGB(x, y);
int index = y * width + x;
int alpha = argb >>> 24;
alphas[index] = alpha;
if (alpha == 0) {
continue;
}
translucent |= alpha < FULL_ALPHA;
int color = multiply(argb & 0xFFFFFF, fixedTint);
pixels[index] = color;
opaque[index] = true;
red += (color >>> 16) & 0xFF;
green += (color >>> 8) & 0xFF;
blue += color & 0xFF;
opaqueCount++;
}
}
if (opaqueCount == 0) {
throw new IllegalArgumentException("Texture has no visible pixels");
}
int average = (int) (red / opaqueCount) << 16 | (int) (green / opaqueCount) << 8 | (int) (blue / opaqueCount);
int gapColor = BlockPreviewColors.shade(average, GAP_SHADE);
for (int index = 0; index < pixels.length; index++) {
if (!opaque[index]) {
pixels[index] = gapColor;
}
}
return new BlockTexture(width, height, pixels, opaque, translucent ? alphas : null, average, biomeTint);
}
boolean isTranslucent() {
return alphas != null;
}
boolean isFullyOpaque() {
if (isTranslucent()) {
return false;
}
for (boolean pixelOpaque : opaque) {
if (!pixelOpaque) {
return false;
}
}
return true;
}
BlockTexture flattened() {
int[] flat = new int[pixels.length];
Arrays.fill(flat, averageColor);
boolean[] allOpaque = new boolean[pixels.length];
Arrays.fill(allOpaque, true);
return new BlockTexture(width, height, flat, allOpaque, null, averageColor, biomeTint);
}
BlockTexture tinted(int tint) {
int[] tintedPixels = new int[pixels.length];
for (int index = 0; index < pixels.length; index++) {
tintedPixels[index] = multiply(pixels[index], tint);
}
return new BlockTexture(width, height, tintedPixels, opaque, alphas, multiply(averageColor, tint), null);
}
int texelAt(double worldX, double worldZ) {
return pixels[texelIndex(worldX, worldZ)];
}
boolean isOpaqueAt(double worldX, double worldZ) {
return opaque[texelIndex(worldX, worldZ)];
}
private int texelIndex(double worldX, double worldZ) {
int x = Math.min(width - 1, (int) ((worldX - Math.floor(worldX)) * width));
int z = Math.min(height - 1, (int) ((worldZ - Math.floor(worldZ)) * height));
return z * width + x;
}
BufferedImage toImage() {
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
image.setRGB(0, 0, width, height, pixels, 0, width);
return image;
}
// The GPU multiplies the texture by the tint in floating point and rounds back to 8 bits per channel.
static int multiply(int color, int tint) {
return multiplyChannel(color, tint, 16) << 16 | multiplyChannel(color, tint, 8) << 8 | multiplyChannel(color, tint, 0);
}
private static int multiplyChannel(int color, int tint, int shift) {
return (((color >>> shift) & 0xFF) * ((tint >>> shift) & 0xFF) + 127) / 255;
}
}
}
@@ -0,0 +1,82 @@
/*
* 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 art.arcane.iris.studio.map;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import javax.imageio.ImageIO;
import java.awt.color.ColorSpace;
import java.awt.image.BufferedImage;
import java.awt.image.Raster;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
final class ClientJarFiles {
private ClientJarFiles() {
}
static JsonObject readJson(ZipFile clientJar, String path) throws IOException {
ZipEntry entry = clientJar.getEntry(path);
if (entry == null) {
return null;
}
try (InputStreamReader reader = new InputStreamReader(clientJar.getInputStream(entry), StandardCharsets.UTF_8)) {
return JsonParser.parseReader(reader).getAsJsonObject();
}
}
static BufferedImage readImage(ZipFile clientJar, String path) throws IOException {
ZipEntry entry = clientJar.getEntry(path);
if (entry == null) {
return null;
}
try (InputStream input = clientJar.getInputStream(entry)) {
BufferedImage image = ImageIO.read(input);
return image != null && isGrey(image) ? withGreyKept(image) : image;
}
}
private static boolean isGrey(BufferedImage image) {
return image.getColorModel().getColorSpace().getType() == ColorSpace.TYPE_GRAY;
}
// Java treats grey PNGs, like stone's, as linear light and brightens them when converting to RGB; Minecraft reads
// the stored values as they are.
private static BufferedImage withGreyKept(BufferedImage image) {
Raster raster = image.getRaster();
boolean hasAlpha = raster.getNumBands() > 1;
int greyMax = (1 << raster.getSampleModel().getSampleSize(0)) - 1;
int alphaMax = hasAlpha ? (1 << raster.getSampleModel().getSampleSize(1)) - 1 : 1;
BufferedImage converted = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_ARGB);
for (int y = 0; y < image.getHeight(); y++) {
for (int x = 0; x < image.getWidth(); x++) {
int grey = raster.getSample(x, y, 0) * 255 / greyMax;
int alpha = hasAlpha ? raster.getSample(x, y, 1) * 255 / alphaMax : 255;
converted.setRGB(x, y, alpha << 24 | grey << 16 | grey << 8 | grey);
}
}
return converted;
}
}
@@ -0,0 +1,25 @@
/*
* 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 art.arcane.iris.studio.map;
/**
* A rectangle of a terrain's columns and rows, from the first up to but not including the last.
*/
record ColumnArea(int fromColumn, int fromRow, int toColumn, int toRow) {
}
@@ -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 art.arcane.iris.studio.map;
/**
* The six sides of a block, named as in Minecraft's model files, with the light Minecraft gives each side.
*/
enum CubeFace {
UP("up", 0, 1, 0, 1.0D),
DOWN("down", 0, -1, 0, 0.5D),
NORTH("north", 0, 0, -1, 0.8D),
SOUTH("south", 0, 0, 1, 0.8D),
EAST("east", 1, 0, 0, 0.6D),
WEST("west", -1, 0, 0, 0.6D);
private final String modelName;
private final int stepX;
private final int stepY;
private final int stepZ;
private final double shade;
CubeFace(String modelName, int stepX, int stepY, int stepZ, double shade) {
this.modelName = modelName;
this.stepX = stepX;
this.stepY = stepY;
this.stepZ = stepZ;
this.shade = shade;
}
static CubeFace named(String modelName) {
for (CubeFace face : values()) {
if (face.modelName.equals(modelName)) {
return face;
}
}
return null;
}
/**
* The side a direction points at most, like Minecraft picks for a tilted face.
*/
static CubeFace nearest(Vec3 direction) {
CubeFace nearest = UP;
double best = Double.NEGATIVE_INFINITY;
for (CubeFace face : values()) {
double alignment = face.stepX * direction.x() + face.stepY * direction.y() + face.stepZ * direction.z();
if (alignment > best) {
best = alignment;
nearest = face;
}
}
return nearest;
}
String modelName() {
return modelName;
}
int stepX() {
return stepX;
}
int stepY() {
return stepY;
}
int stepZ() {
return stepZ;
}
double shade() {
return shade;
}
Vec3 direction() {
return new Vec3(stepX, stepY, stepZ);
}
CubeFace opposite() {
return switch (this) {
case UP -> DOWN;
case DOWN -> UP;
case NORTH -> SOUTH;
case SOUTH -> NORTH;
case EAST -> WEST;
case WEST -> EAST;
};
}
int axis() {
return stepX != 0 ? 0 : stepY != 0 ? 1 : 2;
}
boolean pointsPositive() {
return stepX + stepY + stepZ > 0;
}
}
@@ -0,0 +1,102 @@
/*
* 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 art.arcane.iris.studio.map;
import javax.swing.Icon;
import java.awt.Color;
import java.awt.Component;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.RoundRectangle2D;
/**
* What an empty view says, drawn in the middle of it the way IntelliJ fills an empty tool window: a large icon, what
* is missing, and where to add it.
*
* @param location the pack folder or file the hint points to, drawn as code
*/
record EmptyState(Icon icon, String title, String hint, String location, String footer) {
private static final int ICON_GAP = 18;
private static final int TITLE_GAP = 8;
private static final int LOCATION_GAP = 10;
private static final int FOOTER_GAP = 14;
private static final int CHIP_PADDING_X = 8;
private static final int CHIP_PADDING_Y = 4;
private static final int CHIP_ARC = 8;
/**
* Draws the empty state centred on the given point.
*/
void paint(Graphics2D graphics, Component owner, int centerX, int centerY) {
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 top = centerY - height(canvas) / 2;
icon.paintIcon(owner, canvas, centerX - icon.getIconWidth() / 2, top);
top += icon.getIconHeight() + StudioTheme.scale(ICON_GAP);
top = drawCentered(canvas, title, StudioTheme.titleFont(), StudioTheme.TEXT, centerX, top);
top = drawCentered(canvas, hint, StudioTheme.font(), StudioTheme.SECONDARY_TEXT, centerX,
top + StudioTheme.scale(TITLE_GAP));
top = drawLocationChip(canvas, centerX, top + StudioTheme.scale(LOCATION_GAP));
drawCentered(canvas, footer, StudioTheme.smallFont(), StudioTheme.MUTED_TEXT, centerX, top + StudioTheme.scale(FOOTER_GAP));
} finally {
canvas.dispose();
}
}
private int height(Graphics2D canvas) {
return icon.getIconHeight() + StudioTheme.scale(ICON_GAP)
+ canvas.getFontMetrics(StudioTheme.titleFont()).getHeight()
+ StudioTheme.scale(TITLE_GAP) + canvas.getFontMetrics(StudioTheme.font()).getHeight()
+ StudioTheme.scale(LOCATION_GAP) + chipHeight(canvas)
+ StudioTheme.scale(FOOTER_GAP) + canvas.getFontMetrics(StudioTheme.smallFont()).getHeight();
}
private int chipHeight(Graphics2D canvas) {
return canvas.getFontMetrics(StudioTheme.monospacedFont()).getHeight() + StudioTheme.scale(CHIP_PADDING_Y) * 2;
}
private int drawLocationChip(Graphics2D canvas, int centerX, int top) {
canvas.setFont(StudioTheme.monospacedFont());
FontMetrics metrics = canvas.getFontMetrics();
int width = metrics.stringWidth(location) + StudioTheme.scale(CHIP_PADDING_X) * 2;
int height = chipHeight(canvas);
int left = centerX - width / 2;
RoundRectangle2D chip = new RoundRectangle2D.Double(left + 0.5D, top + 0.5D, width - 1D, height - 1D,
StudioTheme.scale(CHIP_ARC), StudioTheme.scale(CHIP_ARC));
canvas.setColor(StudioTheme.PANEL_BACKGROUND);
canvas.fill(chip);
canvas.setColor(StudioTheme.CONTROL_BORDER);
canvas.draw(chip);
canvas.setColor(StudioTheme.TEXT);
canvas.drawString(location, left + StudioTheme.scale(CHIP_PADDING_X), top + StudioTheme.scale(CHIP_PADDING_Y) + metrics.getAscent());
return top + height;
}
private static int drawCentered(Graphics2D canvas, String text, Font font, Color color, int centerX, int top) {
canvas.setFont(font);
canvas.setColor(color);
FontMetrics metrics = canvas.getFontMetrics();
canvas.drawString(text, centerX - metrics.stringWidth(text) / 2, top + metrics.getAscent());
return top + metrics.getHeight();
}
}
@@ -0,0 +1,298 @@
/*
* 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 art.arcane.iris.studio.map;
import art.arcane.iris.studio.map.BlockModel.Face;
import art.arcane.iris.studio.map.BlockModel.TextureMapping;
import art.arcane.iris.studio.map.BlockTextures.BlockStateId;
import art.arcane.iris.studio.map.BlockTextures.BlockTexture;
import art.arcane.iris.studio.map.BlockTextures.ModelDefinition;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/**
* Shapes for the blocks Minecraft draws in code instead of from a model file, like chests, heads, banners and
* fluids, which fill their block. Chests, heads, banners and the bell use their entity textures laid out the way Minecraft's model parts are;
* rarer ones are a simple box in their particle texture.
*/
final class EntityBlockShapes {
private static final Set<String> INVISIBLE = Set.of("air", "cave_air", "void_air", "barrier", "light",
"structure_void", "moving_piston", "pitcher_crop");
private static final int END_PORTAL_COLOR = 0x0E1219;
private static final double SKULL_WALL_OFFSET = 0.25D;
private static final double ROTATION_SEGMENT_DEGREES = 22.5D;
private static final double BANNER_SCALE = 2D / 3D;
private static final double MODEL_UNITS = 16D;
private static final Map<String, Integer> DYE_COLORS = Map.ofEntries(
Map.entry("white", 0xF9FFFE), Map.entry("orange", 0xF9801D), Map.entry("magenta", 0xC74EBD),
Map.entry("light_blue", 0x3AB3DA), Map.entry("yellow", 0xFED83D), Map.entry("lime", 0x80C71F),
Map.entry("pink", 0xF38BAA), Map.entry("gray", 0x474F52), Map.entry("light_gray", 0x9D9D97),
Map.entry("cyan", 0x169C9C), Map.entry("purple", 0x8932B8), Map.entry("blue", 0x3C44AA),
Map.entry("brown", 0x835432), Map.entry("green", 0x5E7C16), Map.entry("red", 0xB02E26),
Map.entry("black", 0x1D1D21));
private final BlockTextures textures;
EntityBlockShapes(BlockTextures textures) {
this.textures = textures;
}
/**
* The faces of a block whose model has no boxes of its own.
*/
List<Face> facesOf(BlockStateId state, ModelDefinition model) {
String name = state.name();
if (INVISIBLE.contains(name)) {
return List.of();
}
if (name.endsWith("chest")) {
return chest(state);
}
if (name.endsWith("_skull") || name.endsWith("_head")) {
return head(state, model);
}
if (name.endsWith("banner")) {
return banner(state);
}
return switch (name) {
case "conduit" -> particleBox(state, model, new Vec3(5, 5, 5), new Vec3(11, 11, 11), false);
case "decorated_pot" -> decoratedPot(state, model);
case "end_portal" -> BlockModelBuilder.box(new Vec3(0, 0, 0), new Vec3(16, 12, 16), BlockTexture.solid(END_PORTAL_COLOR), true);
case "end_gateway" -> BlockModelBuilder.box(new Vec3(0, 0, 0), new Vec3(16, 16, 16), BlockTexture.solid(END_PORTAL_COLOR), true);
default -> name.endsWith("copper_golem_statue")
? particleBox(state, model, new Vec3(4, 0, 4), new Vec3(12, 14, 12), false)
: particleBox(state, model, new Vec3(0, 0, 0), new Vec3(16, 16, 16), true);
};
}
/**
* Parts a block draws in code on top of its model, like the bell hanging in its frame.
*/
List<Face> facesAddedTo(BlockStateId state) {
if (!state.name().equals("bell")) {
return List.of();
}
Optional<BlockTexture> texture = textures.entityTexture("entity/bell/bell_body", MinecraftBlockTints.NO_TINT);
if (texture.isEmpty()) {
return List.of();
}
List<Face> faces = new ArrayList<>();
faces.addAll(modelPartBox(texture.get(), 0, 0, new Vec3(5, 6, 5), new Vec3(6, 7, 6), Transform.IDENTITY));
faces.addAll(modelPartBox(texture.get(), 0, 13, new Vec3(4, 4, 4), new Vec3(8, 2, 8), Transform.IDENTITY));
return faces;
}
private List<Face> particleBox(BlockStateId state, ModelDefinition model, Vec3 from, Vec3 to, boolean culledBySides) {
String particle = BlockTextures.dereference("#particle", model.textures());
if (particle == null) {
return List.of();
}
return textures.faceTexture(state, particle, BlockTextures.NO_TINT_INDEX)
.map(texture -> BlockModelBuilder.box(from, to, texture, culledBySides))
.orElse(List.of());
}
private List<Face> decoratedPot(BlockStateId state, ModelDefinition model) {
List<Face> faces = new ArrayList<>(particleBox(state, model, new Vec3(1, 0, 1), new Vec3(15, 16, 15), false));
faces.addAll(particleBox(state, model, new Vec3(4, 16, 4), new Vec3(12, 20, 12), false));
return faces;
}
// Single chests are 14 pixels wide; each half of a double chest is 15, reaching over to its partner.
private List<Face> chest(BlockStateId state) {
String type = state.properties().getOrDefault("type", "single");
String suffix = type.equals("single") ? "" : "_" + type;
Optional<BlockTexture> texture = textures.entityTexture("entity/chest/" + chestTextureName(state.name()) + suffix, MinecraftBlockTints.NO_TINT);
if (texture.isEmpty()) {
return List.of();
}
Transform place = Transform.IDENTITY
.translate(0.5D, 0.5D, 0.5D)
.rotateY(-yRotationOf(state.properties().getOrDefault("facing", "north")))
.translate(-0.5D, -0.5D, -0.5D);
double left = type.equals("left") ? 0D : 1D;
double width = type.equals("single") ? 14D : 15D;
double lockX = switch (type) {
case "left" -> 0D;
case "right" -> 15D;
default -> 7D;
};
double lockWidth = type.equals("single") ? 2D : 1D;
List<Face> faces = new ArrayList<>();
faces.addAll(modelPartBox(texture.get(), 0, 19, new Vec3(left, 0, 1), new Vec3(width, 10, 14), place));
faces.addAll(modelPartBox(texture.get(), 0, 0, new Vec3(left, 9, 1), new Vec3(width, 5, 14), place));
faces.addAll(modelPartBox(texture.get(), 0, 0, new Vec3(lockX, 7, 15), new Vec3(lockWidth, 4, 1), place));
return faces;
}
private static String chestTextureName(String blockName) {
String name = blockName.startsWith("waxed_") ? blockName.substring("waxed_".length()) : blockName;
return switch (name) {
case "trapped_chest" -> "trapped";
case "ender_chest" -> "ender";
case "copper_chest" -> "copper";
case "exposed_copper_chest" -> "copper_exposed";
case "weathered_copper_chest" -> "copper_weathered";
case "oxidized_copper_chest" -> "copper_oxidized";
default -> "normal";
};
}
// Heads are drawn upside down and mirrored in their model, and turn in sixteenths like signs.
private List<Face> head(BlockStateId state, ModelDefinition model) {
boolean onWall = state.name().contains("_wall_");
String kind = state.name().replace("_wall_", "_");
Optional<HeadLook> look = HeadLook.of(kind);
Optional<BlockTexture> texture = look.flatMap(found -> textures.entityTexture(found.texture(), MinecraftBlockTints.NO_TINT));
if (texture.isEmpty()) {
return onWall
? particleBox(state, model, new Vec3(4, 4, 8), new Vec3(12, 12, 16), false)
: particleBox(state, model, new Vec3(4, 0, 4), new Vec3(12, 8, 12), false);
}
Transform place;
if (onWall) {
CubeFace facing = CubeFace.named(state.properties().getOrDefault("facing", "north"));
place = Transform.IDENTITY
.translate(0.5D - facing.stepX() * SKULL_WALL_OFFSET, SKULL_WALL_OFFSET, 0.5D - facing.stepZ() * SKULL_WALL_OFFSET)
.scale(-1D, -1D, 1D)
.rotateY(yRotationOf(facing.opposite().modelName()));
} else {
int rotation = Integer.parseInt(state.properties().getOrDefault("rotation", "0"));
place = Transform.IDENTITY
.translate(0.5D, 0D, 0.5D)
.scale(-1D, -1D, 1D)
.rotateY(rotation * ROTATION_SEGMENT_DEGREES);
}
HeadLook found = look.get();
return modelPartBox(texture.get(), 0, 0, new Vec3(-found.width() / 2D, -8, -4), new Vec3(found.width(), 8, 8), place);
}
private List<Face> banner(BlockStateId state) {
boolean onWall = state.name().endsWith("_wall_banner");
String color = state.name().substring(0, state.name().length() - (onWall ? "_wall_banner" : "_banner").length());
Optional<BlockTexture> frame = textures.entityTexture("entity/banner/banner_base", MinecraftBlockTints.NO_TINT);
Optional<BlockTexture> cloth = textures.entityTexture("entity/banner/base", DYE_COLORS.getOrDefault(color, MinecraftBlockTints.NO_TINT));
if (frame.isEmpty() || cloth.isEmpty()) {
return List.of();
}
Transform place;
if (onWall) {
CubeFace facing = CubeFace.named(state.properties().getOrDefault("facing", "north"));
place = Transform.IDENTITY
.translate(0.5D, -1D / 6D, 0.5D)
.rotateY(-yRotationOf(facing.modelName()))
.translate(0D, -0.3125D, -0.4375D)
.scale(BANNER_SCALE, -BANNER_SCALE, -BANNER_SCALE);
} else {
int rotation = Integer.parseInt(state.properties().getOrDefault("rotation", "0"));
place = Transform.IDENTITY
.translate(0.5D, 0.5D, 0.5D)
.rotateY(-rotation * ROTATION_SEGMENT_DEGREES)
.scale(BANNER_SCALE, -BANNER_SCALE, -BANNER_SCALE);
}
List<Face> faces = new ArrayList<>();
if (!onWall) {
faces.addAll(modelPartBox(frame.get(), 44, 0, new Vec3(-1, -30, -1), new Vec3(2, 42, 2), place));
}
faces.addAll(modelPartBox(frame.get(), 0, 42, new Vec3(-10, -32, -1), new Vec3(20, 2, 2), place));
faces.addAll(modelPartBox(cloth.get(), 0, 0, new Vec3(-10, -32, -2), new Vec3(20, 40, 1), place));
return faces;
}
// Minecraft's facing angle: south is 0 and each quarter turn clockwise seen from above adds 90 degrees.
private static double yRotationOf(String facing) {
return switch (facing) {
case "west" -> 90D;
case "north" -> 180D;
case "east" -> 270D;
default -> 0D;
};
}
/**
* A box of a block entity's model, in model pixels, with its sides laid out on the texture the way Minecraft's
* model parts are: the top and bottom in a row above the four sides.
*/
static List<Face> modelPartBox(BlockTexture texture, int textureU, int textureV, Vec3 from, Vec3 size, Transform place) {
double minX = from.x() / MODEL_UNITS;
double minY = from.y() / MODEL_UNITS;
double minZ = from.z() / MODEL_UNITS;
double maxX = (from.x() + size.x()) / MODEL_UNITS;
double maxY = (from.y() + size.y()) / MODEL_UNITS;
double maxZ = (from.z() + size.z()) / MODEL_UNITS;
Vec3 v0 = new Vec3(minX, minY, minZ);
Vec3 v1 = new Vec3(maxX, minY, minZ);
Vec3 v2 = new Vec3(maxX, maxY, minZ);
Vec3 v3 = new Vec3(minX, maxY, minZ);
Vec3 v4 = new Vec3(minX, minY, maxZ);
Vec3 v5 = new Vec3(maxX, minY, maxZ);
Vec3 v6 = new Vec3(maxX, maxY, maxZ);
Vec3 v7 = new Vec3(minX, maxY, maxZ);
double u = textureU;
double v = textureV;
double width = size.x();
double height = size.y();
double depth = size.z();
List<Face> faces = new ArrayList<>(6);
faces.add(polygon(texture, place, CubeFace.DOWN, v5, v4, v0, u + depth, v, u + depth + width, v + depth));
faces.add(polygon(texture, place, CubeFace.UP, v2, v3, v7, u + depth + width, v + depth, u + depth + width + width, v));
faces.add(polygon(texture, place, CubeFace.WEST, v0, v4, v7, u, v + depth, u + depth, v + depth + height));
faces.add(polygon(texture, place, CubeFace.NORTH, v1, v0, v3, u + depth, v + depth, u + depth + width, v + depth + height));
faces.add(polygon(texture, place, CubeFace.EAST, v5, v1, v2, u + depth + width, v + depth, u + depth + width + depth, v + depth + height));
faces.add(polygon(texture, place, CubeFace.SOUTH, v4, v5, v6, u + depth + width + depth, v + depth, u + depth + width + depth + width, v + depth + height));
return faces;
}
// A model part's polygon gives its first corner (u2, v1), its second (u1, v1) and its third (u1, v2).
private static Face polygon(BlockTexture texture, Transform place, CubeFace side, Vec3 first, Vec3 second, Vec3 third,
double u1, double v1, double u2, double v2) {
double textureWidth = texture.width();
double textureHeight = texture.height();
TextureMapping mapping = new TextureMapping(u1 / textureWidth, v1 / textureHeight,
(u2 - u1) / textureWidth, 0D, 0D, (v2 - v1) / textureHeight);
Face face = new Face(second, first.minus(second), third.minus(second), side.direction(), texture, mapping, side.shade(), null)
.transformed(place);
return face.withShade(CubeFace.nearest(face.normal()).shade());
}
/**
* The entity texture of a mob head and how wide its head is.
*/
private record HeadLook(String texture, double width) {
private static final double HEAD_WIDTH = 8D;
private static final double PIGLIN_HEAD_WIDTH = 10D;
static Optional<HeadLook> of(String kind) {
return Optional.ofNullable(switch (kind) {
case "skeleton_skull" -> new HeadLook("entity/skeleton/skeleton", HEAD_WIDTH);
case "wither_skeleton_skull" -> new HeadLook("entity/skeleton/wither_skeleton", HEAD_WIDTH);
case "zombie_head" -> new HeadLook("entity/zombie/zombie", HEAD_WIDTH);
case "creeper_head" -> new HeadLook("entity/creeper/creeper", HEAD_WIDTH);
case "player_head" -> new HeadLook("entity/player/wide/steve", HEAD_WIDTH);
case "piglin_head" -> new HeadLook("entity/piglin/piglin", PIGLIN_HEAD_WIDTH);
default -> null;
});
}
}
}
@@ -0,0 +1,81 @@
/*
* 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 art.arcane.iris.studio.map;
import javax.swing.SwingUtilities;
import java.awt.Component;
import java.awt.Container;
import java.awt.Rectangle;
import java.util.ArrayList;
import java.util.List;
/**
* Finds the Swing panels floating over a native window, the way AWT does when it cuts them out of that window. Only
* for the Swing thread.
*/
final class FloatingPanels {
private FloatingPanels() {
}
/**
* A floating panel that hides what lies behind most of it, and only lets it show through some parts.
*/
interface PartlySeeThrough {
/**
* @return where what lies behind shows through, in the panel's own coordinates
*/
List<Rectangle> seeThroughParts();
}
/**
* @return where what the window shows has to show through the panels over it, in the window's coordinates
*/
static List<Rectangle> seeThroughOver(Component window) {
List<Rectangle> parts = new ArrayList<>();
Rectangle windowBounds = new Rectangle(0, 0, window.getWidth(), window.getHeight());
Component child = window;
Container parent = child.getParent();
while (parent != null) {
for (int index = parent.getComponentZOrder(child) - 1; index >= 0; index--) {
Component above = parent.getComponent(index);
if (above.isLightweight() && above.isShowing()) {
addSeeThroughParts(above, window, windowBounds, parts);
}
}
if (!parent.isLightweight()) {
break;
}
child = parent;
parent = parent.getParent();
}
return parts;
}
private static void addSeeThroughParts(Component panel, Component window, Rectangle windowBounds, List<Rectangle> parts) {
List<Rectangle> panelParts = panel instanceof PartlySeeThrough partly
? partly.seeThroughParts()
: List.of(new Rectangle(0, 0, panel.getWidth(), panel.getHeight()));
for (Rectangle part : panelParts) {
Rectangle inWindow = SwingUtilities.convertRectangle(panel, part, window).intersection(windowBounds);
if (!inWindow.isEmpty()) {
parts.add(inWindow);
}
}
}
}
@@ -0,0 +1,175 @@
/*
* 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 art.arcane.iris.studio.map;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JSlider;
import javax.swing.SwingConstants;
import java.awt.AlphaComposite;
import java.awt.BorderLayout;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.function.IntConsumer;
/**
* A slim vertical slider floating over the right edge of a 3D view, halfway down, with a short label above it. It
* stays faint so it hides little of the view, until the mouse comes near or it is told to stand out.
*/
final class FloatingSlider extends JPanel {
private static final long serialVersionUID = 4473820211915526120L;
private static final int PANEL_WIDTH = 30;
private static final int MAX_HEIGHT = 320;
private static final double HEIGHT_SHARE_OF_VIEW = 0.5D;
private static final int EDGE_MARGIN = 8;
private static final float FAINT_OPACITY = 0.28F;
private static final String BLANK_LABEL = " ";
private final JSlider slider = new JSlider(SwingConstants.VERTICAL, 0, 1, 1);
private final JLabel label = new JLabel(BLANK_LABEL, SwingConstants.CENTER);
private boolean movingQuietly;
private boolean hovered;
private boolean standingOut;
/**
* @param onMoved told the slider's value whenever the user moves it
*/
FloatingSlider(String tooltip, IntConsumer onMoved) {
super(new BorderLayout(0, StudioTheme.scale(4)));
setOpaque(false);
setToolTipText(tooltip);
label.setFont(StudioTheme.smallFont());
label.setForeground(StudioTheme.SECONDARY_TEXT);
slider.setOpaque(false);
slider.setFocusable(false);
slider.setToolTipText(tooltip);
slider.addChangeListener(event -> {
if (!movingQuietly) {
onMoved.accept(slider.getValue());
}
});
MouseAdapter hover = new MouseAdapter() {
@Override
public void mouseEntered(MouseEvent event) {
setHovered(true);
}
@Override
public void mouseExited(MouseEvent event) {
setHovered(slider.getValueIsAdjusting());
}
@Override
public void mouseReleased(MouseEvent event) {
setHovered(slider.contains(event.getPoint()));
}
};
slider.addMouseListener(hover);
addMouseListener(hover);
add(label, BorderLayout.NORTH);
add(slider, BorderLayout.CENTER);
}
/**
* The view with this slider floating over its right edge, halfway down.
*/
JComponent floatingOver(JComponent view) {
FloatingSlider floating = this;
JPanel stack = new JPanel(null) {
private static final long serialVersionUID = -1950381405658102347L;
// The slider overlaps the view, so the view may not repaint without the slider on top of it.
@Override
public boolean isOptimizedDrawingEnabled() {
return false;
}
@Override
public void doLayout() {
view.setBounds(0, 0, getWidth(), getHeight());
int width = StudioTheme.scale(PANEL_WIDTH);
int height = Math.min(StudioTheme.scale(MAX_HEIGHT), (int) (getHeight() * HEIGHT_SHARE_OF_VIEW));
floating.setBounds(getWidth() - width - StudioTheme.scale(EDGE_MARGIN), (getHeight() - height) / 2, width, height);
}
};
stack.add(floating);
stack.add(view);
return stack;
}
/**
* Shows a range and value the user didn't choose, without telling anyone the slider moved.
*/
void showQuietly(int minimum, int maximum, int value) {
movingQuietly = true;
try {
slider.getModel().setRangeProperties(value, 0, minimum, maximum, false);
} finally {
movingQuietly = false;
}
}
// Moves the slider as if the user had.
void moveBy(int steps) {
slider.setValue(slider.getValue() + steps);
}
int value() {
return slider.getValue();
}
int maximum() {
return slider.getMaximum();
}
void showLabel(String text) {
label.setText(text.isEmpty() ? BLANK_LABEL : text);
repaint();
}
void setStandingOut(boolean nowStandingOut) {
if (standingOut != nowStandingOut) {
standingOut = nowStandingOut;
repaint();
}
}
@Override
protected void paintChildren(Graphics graphics) {
Graphics2D canvas = (Graphics2D) graphics.create();
try {
if (!hovered && !standingOut) {
canvas.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, FAINT_OPACITY));
}
super.paintChildren(canvas);
} finally {
canvas.dispose();
}
}
private void setHovered(boolean nowHovered) {
if (hovered != nowHovered) {
hovered = nowHovered;
repaint();
}
}
}
@@ -0,0 +1,64 @@
/*
* 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 art.arcane.iris.studio.map;
import javax.swing.JComponent;
import java.awt.Dimension;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
/**
* How many frames a second a 3D view keeps up, right-aligned, floating over the view.
*/
final class FrameRateLabel extends JComponent {
private static final long serialVersionUID = -6402187316475095871L;
private static final String WIDEST_TEXT = "8888 fps";
private String text = "";
FrameRateLabel() {
setOpaque(false);
}
void showRate(double framesPerSecond) {
String next = Math.round(framesPerSecond) + " fps";
if (!next.equals(text)) {
text = next;
repaint();
}
}
@Override
public Dimension getPreferredSize() {
FontMetrics metrics = getFontMetrics(StudioTheme.smallFont());
return new Dimension(metrics.stringWidth(WIDEST_TEXT), metrics.getHeight());
}
@Override
protected void paintComponent(Graphics graphics) {
Graphics2D canvas = (Graphics2D) graphics;
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
canvas.setFont(StudioTheme.smallFont());
canvas.setColor(StudioTheme.MUTED_TEXT);
FontMetrics metrics = canvas.getFontMetrics();
canvas.drawString(text, getWidth() - metrics.stringWidth(text), metrics.getAscent());
}
}
@@ -0,0 +1,381 @@
/*
* 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 art.arcane.iris.studio.map;
import art.arcane.iris.spi.IrisLogging;
import org.lwjgl.PointerBuffer;
import org.lwjgl.opengl.GL;
import org.lwjgl.opengl.GLXCapabilities;
import org.lwjgl.system.Callback;
import org.lwjgl.system.CallbackI;
import org.lwjgl.system.Configuration;
import org.lwjgl.system.JNI;
import org.lwjgl.system.MemoryStack;
import org.lwjgl.system.linux.DynamicLinkLoader;
import org.lwjgl.system.linux.X11;
import org.lwjgl.system.linux.XErrorEvent;
import java.awt.GraphicsEnvironment;
import java.awt.Toolkit;
import java.lang.invoke.MethodHandles;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import static org.lwjgl.opengl.GL11.GL_RENDERER;
import static org.lwjgl.opengl.GL11.glGetString;
import static org.lwjgl.opengl.GLX.glXDestroyContext;
import static org.lwjgl.opengl.GLX.glXMakeCurrent;
import static org.lwjgl.opengl.GLX.glXSwapBuffers;
import static org.lwjgl.opengl.GLX13.GLX_BLUE_SIZE;
import static org.lwjgl.opengl.GLX13.GLX_DEPTH_SIZE;
import static org.lwjgl.opengl.GLX13.GLX_DOUBLEBUFFER;
import static org.lwjgl.opengl.GLX13.GLX_DRAWABLE_TYPE;
import static org.lwjgl.opengl.GLX13.GLX_GREEN_SIZE;
import static org.lwjgl.opengl.GLX13.GLX_RED_SIZE;
import static org.lwjgl.opengl.GLX13.GLX_RENDER_TYPE;
import static org.lwjgl.opengl.GLX13.GLX_RGBA_BIT;
import static org.lwjgl.opengl.GLX13.GLX_VISUAL_ID;
import static org.lwjgl.opengl.GLX13.GLX_WINDOW_BIT;
import static org.lwjgl.opengl.GLX13.GLX_X_RENDERABLE;
import static org.lwjgl.opengl.GLX13.glXChooseFBConfig;
import static org.lwjgl.opengl.GLX13.glXGetFBConfigAttrib;
import static org.lwjgl.opengl.GLX13.glXMakeContextCurrent;
import static org.lwjgl.opengl.GLXARBCreateContext.GLX_CONTEXT_MAJOR_VERSION_ARB;
import static org.lwjgl.opengl.GLXARBCreateContext.GLX_CONTEXT_MINOR_VERSION_ARB;
import static org.lwjgl.opengl.GLXARBCreateContext.glXCreateContextAttribsARB;
import static org.lwjgl.opengl.GLXARBCreateContextProfile.GLX_CONTEXT_CORE_PROFILE_BIT_ARB;
import static org.lwjgl.opengl.GLXARBCreateContextProfile.GLX_CONTEXT_PROFILE_MASK_ARB;
import static org.lwjgl.opengl.GLXEXTSwapControl.glXSwapIntervalEXT;
import static org.lwjgl.system.APIUtil.apiClosureRet;
import static org.lwjgl.system.APIUtil.apiCreateCIF;
import static org.lwjgl.system.MemoryUtil.NULL;
import static org.lwjgl.system.MemoryUtil.memAddress;
import static org.lwjgl.system.MemoryUtil.memGetAddress;
import static org.lwjgl.system.libffi.LibFFI.ffi_type_pointer;
import static org.lwjgl.system.libffi.LibFFI.ffi_type_sint32;
/**
* A connection of our own to the X server Swing's windows are on, with one OpenGL context that draws straight into
* those windows, so a frame never has to be copied back and painted by Swing. Only for the GPU thread.
*/
final class GlxDisplay {
private static final String X11_TOOLKIT = "sun.awt.X11.XToolkit";
private static final String NATIVE_CONTEXT_API = "native";
private static final String NVIDIA_DRIVER_VERSION_FILE = "/proc/driver/nvidia/version";
private static final Set<String> SOFTWARE_RENDERERS = Set.of("llvmpipe", "softpipe", "software rasterizer");
private static final int OPENGL_MAJOR_VERSION = 3;
private static final int OPENGL_MINOR_VERSION = 3;
private static final int COLOR_CHANNEL_BITS = 8;
private static final int DEPTH_BITS = 24;
// Frames are only drawn when something moves, and the compositor keeps them from tearing.
private static final int SWAP_EVERY_FRAME = 0;
private final long display;
private final long context;
private final long visualId;
private final boolean canSetSwapInterval;
private final Set<Long> windowsWithSwapIntervalSet = new HashSet<>();
private final String renderer;
private long currentWindow;
private GlxDisplay(long display, long context, long visualId, boolean canSetSwapInterval, String renderer) {
this.display = display;
this.context = context;
this.visualId = visualId;
this.canSetSwapInterval = canSetSwapInterval;
this.renderer = renderer;
}
/**
* Opens the connection and makes its context current on this thread, without any window yet.
*
* @throws IllegalStateException when Swing doesn't draw on X or no card there draws OpenGL 3.3 in hardware
*/
static GlxDisplay open() {
if (!isSwingOnX()) {
throw new IllegalStateException("Swing doesn't draw on X");
}
preferNvidiaCard();
// LWJGL would take EGL in a Wayland session, even though Swing's windows are on X.
Configuration.OPENGL_CONTEXT_API.set(NATIVE_CONTEXT_API);
GL.create();
long display = X11.nXOpenDisplay(NULL);
if (display == NULL) {
GL.destroy();
throw new IllegalStateException("Can't connect to the X server");
}
XErrors.watch(display);
try {
return openOn(display);
} catch (RuntimeException failure) {
XErrors.forget(display);
X11.XCloseDisplay(display);
GL.destroy();
throw failure;
}
}
static boolean isSwingOnX() {
return !GraphicsEnvironment.isHeadless() && X11_TOOLKIT.equals(Toolkit.getDefaultToolkit().getClass().getName());
}
private static GlxDisplay openOn(long display) {
int screen = X11.XDefaultScreen(display);
GLXCapabilities glx = GL.createCapabilitiesGLX(display, screen);
if (!glx.GLX13 || !glx.GLX_ARB_create_context || !glx.GLX_ARB_create_context_profile) {
throw new IllegalStateException("GLX can't make an OpenGL " + OPENGL_MAJOR_VERSION + "." + OPENGL_MINOR_VERSION + " context");
}
long visualId = Xlib.defaultVisualId(display, screen);
long config = configFor(display, screen, visualId);
long context = glXCreateContextAttribsARB(display, config, NULL, true, new int[]{
GLX_CONTEXT_MAJOR_VERSION_ARB, OPENGL_MAJOR_VERSION,
GLX_CONTEXT_MINOR_VERSION_ARB, OPENGL_MINOR_VERSION,
GLX_CONTEXT_PROFILE_MASK_ARB, GLX_CONTEXT_CORE_PROFILE_BIT_ARB,
0});
Xlib.sync(display);
if (context == NULL || XErrors.takeFailed(display)) {
throw new IllegalStateException("GLX can't make an OpenGL " + OPENGL_MAJOR_VERSION + "." + OPENGL_MINOR_VERSION + " context");
}
if (!glXMakeContextCurrent(display, NULL, NULL, context)) {
glXDestroyContext(display, context);
throw new IllegalStateException("GLX can't use a context without a window");
}
GL.createCapabilities();
String renderer = glGetString(GL_RENDERER);
if (isDrawnInSoftware(renderer)) {
glXMakeContextCurrent(display, NULL, NULL, NULL);
glXDestroyContext(display, context);
throw new IllegalStateException("GLX only draws in software, on " + renderer);
}
return new GlxDisplay(display, context, visualId, glx.GLX_EXT_swap_control, renderer);
}
/**
* Laptops show X on the built-in card and only lend the faster one out when asked to. Asked the way NVIDIA's own
* prime-run does, unless whoever started the server asked for something else already.
*/
private static void preferNvidiaCard() {
if (!Files.exists(Path.of(NVIDIA_DRIVER_VERSION_FILE))) {
return;
}
LibC.setenvUnlessSet("__NV_PRIME_RENDER_OFFLOAD", "1");
LibC.setenvUnlessSet("__GLX_VENDOR_LIBRARY_NAME", "nvidia");
}
// Windows are only drawn into with the visual they were made with; Swing makes them with the screen's default one.
private static long configFor(long display, int screen, long visualId) {
PointerBuffer configs = glXChooseFBConfig(display, screen, new int[]{
GLX_X_RENDERABLE, 1,
GLX_DRAWABLE_TYPE, GLX_WINDOW_BIT,
GLX_RENDER_TYPE, GLX_RGBA_BIT,
GLX_DOUBLEBUFFER, 1,
GLX_RED_SIZE, COLOR_CHANNEL_BITS,
GLX_GREEN_SIZE, COLOR_CHANNEL_BITS,
GLX_BLUE_SIZE, COLOR_CHANNEL_BITS,
GLX_DEPTH_SIZE, DEPTH_BITS,
0});
if (configs == null) {
throw new IllegalStateException("GLX has no double-buffered config with a depth buffer");
}
try {
int[] configVisual = new int[1];
for (int index = 0; index < configs.remaining(); index++) {
long config = configs.get(index);
glXGetFBConfigAttrib(display, config, GLX_VISUAL_ID, configVisual);
if (configVisual[0] == visualId) {
return config;
}
}
} finally {
X11.XFree(configs);
}
throw new IllegalStateException("GLX can't draw into windows of the default visual 0x" + Long.toHexString(visualId));
}
private static boolean isDrawnInSoftware(String renderer) {
String lowerCase = renderer == null ? "" : renderer.toLowerCase(Locale.ROOT);
return SOFTWARE_RENDERERS.stream().anyMatch(lowerCase::contains);
}
String renderer() {
return renderer;
}
boolean canDrawInto(long windowVisualId) {
return windowVisualId == visualId;
}
/**
* Makes the window what the default framebuffer draws into.
*
* @return false when the window can't be drawn into
*/
boolean drawInto(long window) {
if (window == currentWindow) {
return true;
}
boolean madeCurrent = glXMakeCurrent(display, window, context);
Xlib.sync(display);
if (!madeCurrent || XErrors.takeFailed(display)) {
currentWindow = NULL;
return false;
}
currentWindow = window;
if (canSetSwapInterval && windowsWithSwapIntervalSet.add(window)) {
glXSwapIntervalEXT(display, window, SWAP_EVERY_FRAME);
}
return true;
}
/**
* Puts what was drawn into the window on screen.
*
* @return false when the window couldn't take it, or something drawn into it before failed
*/
boolean show(long window) {
glXSwapBuffers(display, window);
return !XErrors.takeFailed(display);
}
/**
* Stops drawing into a window before it goes away, keeping the context for the rest.
*/
void forget(long window) {
windowsWithSwapIntervalSet.remove(window);
if (window != currentWindow) {
return;
}
glXMakeContextCurrent(display, NULL, NULL, context);
currentWindow = NULL;
Xlib.sync(display);
XErrors.takeFailed(display);
}
/**
* Destroys the context and the connection. Nothing is drawn through this display afterwards.
*/
void close() {
glXMakeContextCurrent(display, NULL, NULL, NULL);
glXDestroyContext(display, context);
currentWindow = NULL;
windowsWithSwapIntervalSet.clear();
Xlib.sync(display);
XErrors.forget(display);
X11.XCloseDisplay(display);
}
/**
* Notes X errors on our own connection instead of letting Xlib end the process over them, which is what it does
* for any connection but Swing's. Errors on other connections go wherever they went before.
*/
private static final class XErrors {
private static final Set<Long> watchedDisplays = ConcurrentHashMap.newKeySet();
private static final Set<Long> failedDisplays = ConcurrentHashMap.newKeySet();
private static long previousHandler;
private static ErrorHandler handler;
static synchronized void watch(long display) {
if (handler == null) {
handler = XErrors::handle;
previousHandler = Xlib.setErrorHandler(handler.address());
}
watchedDisplays.add(display);
}
static void forget(long display) {
watchedDisplays.remove(display);
failedDisplays.remove(display);
}
static boolean takeFailed(long display) {
return failedDisplays.remove(display);
}
private static int handle(long display, long errorEvent) {
if (watchedDisplays.contains(display)) {
failedDisplays.add(display);
IrisLogging.debug("X error " + Byte.toUnsignedInt(XErrorEvent.create(errorEvent).error_code()) + " drawing a 3D view");
return 0;
}
return previousHandler == NULL ? 0 : JNI.callPPI(display, errorEvent, previousHandler);
}
}
@FunctionalInterface
private interface ErrorHandler extends CallbackI {
Callback.Descriptor DESCRIPTOR = new Callback.Descriptor(ErrorHandler.class, MethodHandles.lookup(),
apiCreateCIF(ffi_type_sint32, ffi_type_pointer, ffi_type_pointer));
@Override
default Callback.Descriptor getDescriptor() {
return DESCRIPTOR;
}
@Override
default void callback(long ret, long args) {
apiClosureRet(ret, invoke(memGetAddress(memGetAddress(args)), memGetAddress(memGetAddress(args + Long.BYTES))));
}
int invoke(long display, long errorEvent);
}
// The few Xlib calls LWJGL has no binding for.
private static final class Xlib {
static long defaultVisualId(long display, int screen) {
long visual = JNI.callPP(display, screen, function("XDefaultVisual"));
return JNI.callPP(visual, function("XVisualIDFromVisual"));
}
static void sync(long display) {
JNI.callPI(display, 0, function("XSync"));
}
static long setErrorHandler(long handler) {
return JNI.callPP(handler, function("XSetErrorHandler"));
}
private static long function(String name) {
long address = X11.getLibrary().getFunctionAddress(name);
if (address == NULL) {
throw new IllegalStateException("Xlib has no " + name);
}
return address;
}
}
private static final class LibC {
private static final int KEEP_EXISTING = 0;
private static final long EVERYTHING_LOADED = NULL;
// Looked up among everything the process loaded, which LWJGL's own dlsym won't do.
static void setenvUnlessSet(String name, String value) {
try (MemoryStack stack = MemoryStack.stackPush()) {
long setenv = DynamicLinkLoader.ndlsym(EVERYTHING_LOADED, memAddress(stack.ASCII("setenv")));
if (setenv == NULL) {
return;
}
JNI.callPPI(memAddress(stack.ASCII(name)), memAddress(stack.ASCII(value)), KEEP_EXISTING, setenv);
}
}
}
}
@@ -0,0 +1,106 @@
/*
* 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 art.arcane.iris.studio.map;
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.lang.ref.WeakReference;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
/**
* What was put on the graphics card, by what it was made from. The sources are held weakly: once nothing else keeps
* one, its upload can never be asked for again and is deleted. Only for the GPU thread.
*/
final class GpuCache<U extends GpuCache.Upload> {
private final LinkedHashMap<Source, U> leastRecentlyUsedFirst = new LinkedHashMap<>(16, 0.75F, true);
private final ReferenceQueue<Object> collectedSources = new ReferenceQueue<>();
U get(Object source) {
return leastRecentlyUsedFirst.get(Source.lookingFor(source));
}
void put(Object source, U upload) {
leastRecentlyUsedFirst.put(new Source(source, collectedSources), upload);
}
void forgetCollected() {
for (Reference<?> collected = collectedSources.poll(); collected != null; collected = collectedSources.poll()) {
U upload = leastRecentlyUsedFirst.remove(collected);
if (upload != null) {
upload.delete();
}
}
}
/**
* Deletes the least recently used uploads until the cache fits, sparing those made from what is kept.
*/
void forgetBeyond(long mostBytes, Set<Object> kept) {
long bytes = 0L;
for (U upload : leastRecentlyUsedFirst.values()) {
bytes += upload.bytes();
}
Iterator<Map.Entry<Source, U>> oldestFirst = leastRecentlyUsedFirst.entrySet().iterator();
while (bytes > mostBytes && oldestFirst.hasNext()) {
Map.Entry<Source, U> entry = oldestFirst.next();
if (!kept.contains(entry.getKey().get())) {
bytes -= entry.getValue().bytes();
entry.getValue().delete();
oldestFirst.remove();
}
}
}
interface Upload {
long bytes();
void delete();
}
// Equal only to a source of the same object, so a collected one is equal to nothing but itself.
private static final class Source extends WeakReference<Object> {
private final int identityHash;
private Source(Object source, ReferenceQueue<Object> collected) {
super(source, collected);
identityHash = System.identityHashCode(source);
}
static Source lookingFor(Object source) {
return new Source(source, null);
}
@Override
public int hashCode() {
return identityHash;
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
Object source = get();
return source != null && other instanceof Source otherSource && source == otherSource.get();
}
}
}
@@ -0,0 +1,187 @@
/*
* 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 art.arcane.iris.studio.map;
import org.lwjgl.system.MemoryStack;
import org.lwjgl.system.jawt.JAWT;
import org.lwjgl.system.jawt.JAWTDrawingSurface;
import org.lwjgl.system.jawt.JAWTDrawingSurfaceInfo;
import org.lwjgl.system.jawt.JAWTX11DrawingSurfaceInfo;
import javax.swing.SwingUtilities;
import java.awt.Canvas;
import java.awt.Component;
import java.awt.Graphics;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseWheelEvent;
import static org.lwjgl.system.jawt.JAWTFunctions.JAWT_DrawingSurface_FreeDrawingSurfaceInfo;
import static org.lwjgl.system.jawt.JAWTFunctions.JAWT_DrawingSurface_GetDrawingSurfaceInfo;
import static org.lwjgl.system.jawt.JAWTFunctions.JAWT_DrawingSurface_Lock;
import static org.lwjgl.system.jawt.JAWTFunctions.JAWT_DrawingSurface_Unlock;
import static org.lwjgl.system.jawt.JAWTFunctions.JAWT_FreeDrawingSurface;
import static org.lwjgl.system.jawt.JAWTFunctions.JAWT_GetAWT;
import static org.lwjgl.system.jawt.JAWTFunctions.JAWT_GetDrawingSurface;
import static org.lwjgl.system.jawt.JAWTFunctions.JAWT_LOCK_ERROR;
import static org.lwjgl.system.jawt.JAWTFunctions.JAWT_VERSION_1_4;
/**
* A native window inside a 3D view that the graphics card draws frames straight into. Swing can't paint over it, so
* panels floating over the view are cut out of it, and the view paints what lies behind them itself. The mouse belongs
* to the view behind it, as if this window weren't there.
*/
final class GpuCanvas extends Canvas {
private static final long serialVersionUID = 2874650379461920137L;
private final transient Component view;
private final transient Runnable onExposed;
private volatile long window;
private volatile long visualId;
/**
* @param onExposed runs on the Swing thread when what the window showed was lost and has to be drawn again
*/
GpuCanvas(Component view, Runnable onExposed) {
this.view = view;
this.onExposed = onExposed;
setFocusable(false);
setBackground(StudioTheme.EDITOR_BACKGROUND);
handMouseToView();
}
/**
* @return the X window frames go into, or 0 while there is none
*/
long window() {
return window;
}
long visualId() {
return visualId;
}
@Override
public void addNotify() {
super.addNotify();
readNativeWindow();
}
// A frame on its way into the window would otherwise draw into one that no longer exists.
@Override
public void removeNotify() {
long goingWindow = window;
window = 0L;
if (goingWindow != 0L) {
GpuContext.forgetWindow(goingWindow);
}
super.removeNotify();
}
// Erasing to the background first would flash it between frames.
@Override
public void update(Graphics graphics) {
onExposed.run();
}
@Override
public void paint(Graphics graphics) {
onExposed.run();
}
private void readNativeWindow() {
try (MemoryStack stack = MemoryStack.stackPush()) {
JAWT awt = JAWT.calloc(stack).version(JAWT_VERSION_1_4);
if (!JAWT_GetAWT(awt)) {
return;
}
JAWTDrawingSurface surface = JAWT_GetDrawingSurface(this, awt.GetDrawingSurface());
if (surface == null) {
return;
}
try {
readLocked(surface);
} finally {
JAWT_FreeDrawingSurface(surface, awt.FreeDrawingSurface());
}
}
}
private void readLocked(JAWTDrawingSurface surface) {
if ((JAWT_DrawingSurface_Lock(surface, surface.Lock()) & JAWT_LOCK_ERROR) != 0) {
return;
}
try {
JAWTDrawingSurfaceInfo info = JAWT_DrawingSurface_GetDrawingSurfaceInfo(surface, surface.GetDrawingSurfaceInfo());
if (info == null) {
return;
}
try {
JAWTX11DrawingSurfaceInfo x11 = JAWTX11DrawingSurfaceInfo.create(info.platformInfo());
visualId = x11.visualID();
window = x11.drawable();
} finally {
JAWT_DrawingSurface_FreeDrawingSurfaceInfo(info, surface.FreeDrawingSurfaceInfo());
}
} finally {
JAWT_DrawingSurface_Unlock(surface, surface.Unlock());
}
}
private void handMouseToView() {
MouseAdapter handOver = new MouseAdapter() {
@Override
public void mousePressed(MouseEvent event) {
handOver(event);
}
@Override
public void mouseReleased(MouseEvent event) {
handOver(event);
}
@Override
public void mouseClicked(MouseEvent event) {
handOver(event);
}
@Override
public void mouseDragged(MouseEvent event) {
handOver(event);
}
@Override
public void mouseMoved(MouseEvent event) {
handOver(event);
}
@Override
public void mouseWheelMoved(MouseWheelEvent event) {
handOver(event);
}
};
addMouseListener(handOver);
addMouseMotionListener(handOver);
addMouseWheelListener(handOver);
}
private void handOver(MouseEvent event) {
view.dispatchEvent(SwingUtilities.convertMouseEvent(this, event, view));
}
}
@@ -0,0 +1,75 @@
/*
* 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 art.arcane.iris.studio.map;
import static org.lwjgl.opengl.GL12.GL_BGRA;
import static org.lwjgl.opengl.GL12.GL_NEAREST;
import static org.lwjgl.opengl.GL12.GL_RGBA8;
import static org.lwjgl.opengl.GL12.GL_TEXTURE_2D;
import static org.lwjgl.opengl.GL12.GL_TEXTURE_MAG_FILTER;
import static org.lwjgl.opengl.GL12.GL_TEXTURE_MIN_FILTER;
import static org.lwjgl.opengl.GL12.GL_UNSIGNED_INT_8_8_8_8_REV;
import static org.lwjgl.opengl.GL12.glBindTexture;
import static org.lwjgl.opengl.GL12.glDeleteTextures;
import static org.lwjgl.opengl.GL12.glGenTextures;
import static org.lwjgl.opengl.GL12.glTexImage2D;
import static org.lwjgl.opengl.GL12.glTexParameteri;
/**
* Terrain's column colours on the graphics card, one texel per column, so faces running over many columns still colour
* each one. Only for the GPU thread.
*/
final class GpuColors {
private static final int BYTES_PER_COLUMN = 4;
private final int texture = glGenTextures();
private final long bytes;
private GpuColors(NoiseTerrain terrain) {
this.bytes = (long) terrain.columns() * terrain.rows() * BYTES_PER_COLUMN;
}
/**
* @param largestSide the card's GL_MAX_TEXTURE_SIZE
* @return the colours, or null when the terrain has more columns or rows than the card's textures can hold
*/
static GpuColors upload(NoiseTerrain terrain, int largestSide) {
if (terrain.columns() > largestSide || terrain.rows() > largestSide) {
return null;
}
GpuColors uploaded = new GpuColors(terrain);
glBindTexture(GL_TEXTURE_2D, uploaded.texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, terrain.columns(), terrain.rows(), 0, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, terrain.colors());
return uploaded;
}
long bytes() {
return bytes;
}
void bind() {
glBindTexture(GL_TEXTURE_2D, texture);
}
void delete() {
glDeleteTextures(texture);
}
}
@@ -0,0 +1,301 @@
/*
* 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 art.arcane.iris.studio.map;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.studio.view.GuiHost;
import org.lwjgl.PointerBuffer;
import org.lwjgl.egl.EGL;
import org.lwjgl.opengl.GL;
import org.lwjgl.system.Configuration;
import java.util.Arrays;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.lwjgl.egl.EGL15.EGL_CONTEXT_MAJOR_VERSION;
import static org.lwjgl.egl.EGL15.EGL_CONTEXT_MINOR_VERSION;
import static org.lwjgl.egl.EGL15.EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT;
import static org.lwjgl.egl.EGL15.EGL_CONTEXT_OPENGL_PROFILE_MASK;
import static org.lwjgl.egl.EGL15.EGL_EXTENSIONS;
import static org.lwjgl.egl.EGL15.EGL_NONE;
import static org.lwjgl.egl.EGL15.EGL_NO_CONTEXT;
import static org.lwjgl.egl.EGL15.EGL_NO_DISPLAY;
import static org.lwjgl.egl.EGL15.EGL_NO_SURFACE;
import static org.lwjgl.egl.EGL15.EGL_OPENGL_API;
import static org.lwjgl.egl.EGL15.EGL_OPENGL_BIT;
import static org.lwjgl.egl.EGL15.EGL_PBUFFER_BIT;
import static org.lwjgl.egl.EGL15.EGL_RENDERABLE_TYPE;
import static org.lwjgl.egl.EGL15.EGL_SURFACE_TYPE;
import static org.lwjgl.egl.EGL15.eglBindAPI;
import static org.lwjgl.egl.EGL15.eglChooseConfig;
import static org.lwjgl.egl.EGL15.eglCreateContext;
import static org.lwjgl.egl.EGL15.eglDestroyContext;
import static org.lwjgl.egl.EGL15.eglInitialize;
import static org.lwjgl.egl.EGL15.eglMakeCurrent;
import static org.lwjgl.egl.EGL15.eglQueryString;
import static org.lwjgl.egl.EGL15.eglReleaseThread;
import static org.lwjgl.egl.EGL15.eglTerminate;
import static org.lwjgl.egl.EXTDeviceEnumeration.eglQueryDevicesEXT;
import static org.lwjgl.egl.EXTDeviceQuery.eglQueryDeviceStringEXT;
import static org.lwjgl.egl.EXTPlatformBase.eglGetPlatformDisplayEXT;
import static org.lwjgl.egl.EXTPlatformDevice.EGL_PLATFORM_DEVICE_EXT;
import static org.lwjgl.opengl.GL11.GL_RENDERER;
import static org.lwjgl.opengl.GL11.glGetString;
/**
* An OpenGL context on a graphics card, and the one thread allowed to use it. Where Swing draws on X, the context draws
* straight into Swing's windows through GLX. Anywhere else, or when GLX only draws in software, it is made through
* EGL without any window: EGL lists the cards and the first one that isn't drawn in software is taken.
*/
final class GpuContext {
private static final String THREAD_NAME = "Iris GPU";
private static final long RELEASE_WAIT_SECONDS = 5L;
private static final int OPENGL_MAJOR_VERSION = 3;
private static final int OPENGL_MINOR_VERSION = 3;
private static final String SOFTWARE_DEVICE_EXTENSION = "EGL_MESA_device_software";
private static final Set<String> REQUIRED_CLIENT_EXTENSIONS = Set.of("EGL_EXT_device_enumeration", "EGL_EXT_device_query",
"EGL_EXT_platform_base", "EGL_EXT_platform_device");
private static volatile GpuContext shared;
private static boolean unavailable;
private final ExecutorService thread = Executors.newSingleThreadExecutor(runnable -> {
Thread gpuThread = new Thread(runnable, THREAD_NAME);
gpuThread.setDaemon(true);
return gpuThread;
});
private String renderer;
private GlxDisplay windows;
private volatile boolean drawsIntoWindows;
private long eglDisplay = EGL_NO_DISPLAY;
private long eglContext = EGL_NO_CONTEXT;
private GpuContext() {
}
/**
* Whether frames can go straight into windows, as far as is known without opening the context.
*/
static boolean drawsIntoWindows() {
GpuContext context = shared;
return context != null && context.drawsIntoWindows;
}
/**
* Only for the context's thread.
*
* @return the connection drawing into windows, or nothing when frames are only drawn off screen
*/
Optional<GlxDisplay> windows() {
return drawsIntoWindows ? Optional.of(windows) : Optional.empty();
}
/**
* Stops drawing into a window about to be destroyed, waiting for a frame going into it to finish first.
*/
static void forgetWindow(long window) {
GpuContext context = shared;
if (context == null || context.windows == null) {
return;
}
try {
context.call(() -> {
context.windows.forget(window);
return null;
});
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
} catch (RuntimeException failure) {
IrisLogging.reportError(failure);
}
}
// Off-screen frames still work in the same context, read back like on a machine without GLX.
void stopDrawingIntoWindows(RuntimeException failure) {
if (!drawsIntoWindows) {
return;
}
drawsIntoWindows = false;
IrisLogging.reportError(failure);
IrisLogging.warn("3D views stop drawing straight into windows: %s", failure.getMessage());
}
/**
* @return the context every view shares, opened on first use, or nothing when this machine has no usable card
*/
static synchronized Optional<GpuContext> shared() {
if (shared == null && !unavailable) {
shared = open();
unavailable = shared == null;
}
return Optional.ofNullable(shared);
}
private static GpuContext open() {
GpuContext context = new GpuContext();
try {
context.thread.submit(context::openOnCard).get();
IrisLogging.info(context.drawsIntoWindows ? "3D views draw on %s, straight into their windows" : "3D views draw on %s",
context.renderer);
releaseOnShutdown();
return context;
} catch (ExecutionException failure) {
IrisLogging.warn("3D views draw without a graphics card: %s", failure.getCause());
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
}
context.thread.shutdownNow();
return null;
}
// NVIDIA's driver crashes the JVM on its way out when a context is still current on some thread.
private static void releaseOnShutdown() {
GuiHost.runAtShutdown(GpuContext::releaseShared);
}
/**
* Lets go of the card once frames already asked for are drawn. Frames asked for later are drawn without it.
*/
static synchronized void releaseShared() {
GpuContext context = shared;
if (context == null) {
return;
}
context.thread.execute(context::release);
context.thread.shutdown();
try {
if (!context.thread.awaitTermination(RELEASE_WAIT_SECONDS, TimeUnit.SECONDS)) {
IrisLogging.warn("3D views didn't let go of the graphics card in time");
}
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
}
}
private void release() {
drawsIntoWindows = false;
if (windows != null) {
windows.close();
return;
}
eglMakeCurrent(eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
eglDestroyContext(eglDisplay, eglContext);
eglTerminate(eglDisplay);
eglReleaseThread();
}
/**
* Runs {@code work} on the context's thread and waits for it.
*/
<T> T call(Callable<T> work) throws InterruptedException {
try {
return thread.submit(work).get();
} catch (ExecutionException failure) {
throw new IllegalStateException("Drawing on the graphics card failed", failure.getCause());
}
}
// GL would otherwise look for a GLX library as soon as it loads, which a context from EGL doesn't use.
private Void openOnCard() {
Configuration.OPENGL_EXPLICIT_INIT.set(true);
try {
windows = GlxDisplay.open();
renderer = windows.renderer();
drawsIntoWindows = true;
return null;
} catch (RuntimeException | LinkageError noWindows) {
IrisLogging.info("3D views can't draw straight into windows: %s", noWindows);
}
openWithoutWindows();
return null;
}
private void openWithoutWindows() {
if (!canListDevices()) {
throw new IllegalStateException("EGL can't list graphics cards");
}
GL.create(EGL.getFunctionProvider());
for (long device : devices()) {
if (!isDrawnInSoftware(device) && makeCurrentOn(device)) {
GL.createCapabilities();
renderer = glGetString(GL_RENDERER);
return;
}
}
throw new IllegalStateException("No graphics card supports OpenGL " + OPENGL_MAJOR_VERSION + "." + OPENGL_MINOR_VERSION);
}
// LWJGL's own capability flags read false for these even where the library has them, so the list is asked for.
private static boolean canListDevices() {
String extensions = eglQueryString(EGL_NO_DISPLAY, EGL_EXTENSIONS);
return extensions != null && Arrays.asList(extensions.split(" ")).containsAll(REQUIRED_CLIENT_EXTENSIONS);
}
private static long[] devices() {
int[] count = new int[1];
eglQueryDevicesEXT(null, count);
PointerBuffer found = PointerBuffer.allocateDirect(count[0]);
eglQueryDevicesEXT(found, count);
long[] devices = new long[count[0]];
for (int device = 0; device < devices.length; device++) {
devices[device] = found.get(device);
}
return devices;
}
private static boolean isDrawnInSoftware(long device) {
String extensions = eglQueryDeviceStringEXT(device, EGL_EXTENSIONS);
return extensions != null && extensions.contains(SOFTWARE_DEVICE_EXTENSION);
}
// Nothing is drawn to a surface, only to framebuffers of the context's own, so the context needs none. Configs
// default to window surfaces, which a card without a display has none of.
private boolean makeCurrentOn(long device) {
long display = eglGetPlatformDisplayEXT(EGL_PLATFORM_DEVICE_EXT, device, (int[]) null);
if (display == EGL_NO_DISPLAY || !eglInitialize(display, new int[1], new int[1])) {
return false;
}
PointerBuffer config = PointerBuffer.allocateDirect(1);
int[] configCount = new int[1];
int[] wanted = {EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_BIT, EGL_NONE};
if (!eglChooseConfig(display, wanted, config, configCount) || configCount[0] == 0
|| !eglBindAPI(EGL_OPENGL_API)) {
eglTerminate(display);
return false;
}
long context = eglCreateContext(display, config.get(0), EGL_NO_CONTEXT, new int[]{
EGL_CONTEXT_MAJOR_VERSION, OPENGL_MAJOR_VERSION,
EGL_CONTEXT_MINOR_VERSION, OPENGL_MINOR_VERSION,
EGL_CONTEXT_OPENGL_PROFILE_MASK, EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT,
EGL_NONE});
if (context == EGL_NO_CONTEXT || !eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, context)) {
eglTerminate(display);
return false;
}
eglDisplay = display;
eglContext = context;
return true;
}
}
@@ -0,0 +1,139 @@
/*
* 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 art.arcane.iris.studio.map;
import org.lwjgl.system.MemoryUtil;
import java.nio.IntBuffer;
import static org.lwjgl.opengl.GL33.GL_RGBA32I;
import static org.lwjgl.opengl.GL33.GL_STATIC_DRAW;
import static org.lwjgl.opengl.GL33.GL_TEXTURE_BUFFER;
import static org.lwjgl.opengl.GL33.glBindBuffer;
import static org.lwjgl.opengl.GL33.glBindTexture;
import static org.lwjgl.opengl.GL33.glBufferData;
import static org.lwjgl.opengl.GL33.glBufferSubData;
import static org.lwjgl.opengl.GL33.glDeleteBuffers;
import static org.lwjgl.opengl.GL33.glDeleteTextures;
import static org.lwjgl.opengl.GL33.glGenBuffers;
import static org.lwjgl.opengl.GL33.glGenTextures;
import static org.lwjgl.opengl.GL33.glTexBuffer;
/**
* A layer's block faces on the graphics card, four ints each: first column, first row, bottom, and run with height.
* The vertex shader reads them from a buffer texture and works out each face's two triangles itself, which cards draw
* far faster than millions of tiny instances. Only for the GPU thread.
*/
final class GpuFaces {
static final int CORNERS_PER_FACE = 6;
private static final int INTS_PER_FACE = 4;
private static final int BYTES_PER_FACE = INTS_PER_FACE * Integer.BYTES;
private static final int FACES_PER_UPLOAD = 1 << 20;
private static final int LARGEST_PACKED = 0xFFFF;
private static final BlockFaces.Facing[] FACINGS = BlockFaces.Facing.values();
private static final BlockFaces.Kind[] KINDS = BlockFaces.Kind.values();
// Copied rather than kept, so the faces left in memory can be dropped once nothing else uses them.
private final int[][] firstOf = new int[KINDS.length][FACINGS.length];
private final int[][] countOf = new int[KINDS.length][FACINGS.length];
private final long bytes;
private final int buffer = glGenBuffers();
private final int texture = glGenTextures();
private GpuFaces(BlockFaces faces) {
for (BlockFaces.Kind kind : KINDS) {
for (BlockFaces.Facing facing : FACINGS) {
firstOf[kind.ordinal()][facing.ordinal()] = faces.first(kind, facing);
countOf[kind.ordinal()][facing.ordinal()] = faces.count(kind, facing);
}
}
bytes = (long) faces.count() * BYTES_PER_FACE;
}
/**
* @throws IllegalArgumentException when there are more faces than the card's buffer textures hold, or a face runs
* or reaches further than four ints can hold
* @param mostFaces the card's GL_MAX_TEXTURE_BUFFER_SIZE
*/
static GpuFaces upload(BlockFaces faces, int mostFaces) {
if (faces.count() > mostFaces || (long) faces.count() * CORNERS_PER_FACE > Integer.MAX_VALUE) {
throw new IllegalArgumentException(faces.count() + " faces are more than the graphics card can hold");
}
GpuFaces uploaded = new GpuFaces(faces);
try {
uploaded.fill(faces);
} catch (RuntimeException failure) {
uploaded.delete();
throw failure;
}
return uploaded;
}
// Packed a slice at a time, so large terrain doesn't need all of it twice in memory.
private void fill(BlockFaces faces) {
glBindBuffer(GL_TEXTURE_BUFFER, buffer);
glBufferData(GL_TEXTURE_BUFFER, (long) Math.max(1, faces.count()) * BYTES_PER_FACE, GL_STATIC_DRAW);
IntBuffer packed = MemoryUtil.memAllocInt(Math.max(1, Math.min(faces.count(), FACES_PER_UPLOAD)) * INTS_PER_FACE);
try {
for (int from = 0; from < faces.count(); from += FACES_PER_UPLOAD) {
int to = Math.min(faces.count(), from + FACES_PER_UPLOAD);
pack(faces, packed, from, to);
glBufferSubData(GL_TEXTURE_BUFFER, (long) from * BYTES_PER_FACE, packed);
}
} finally {
MemoryUtil.memFree(packed);
}
glBindTexture(GL_TEXTURE_BUFFER, texture);
glTexBuffer(GL_TEXTURE_BUFFER, GL_RGBA32I, buffer);
}
private static void pack(BlockFaces faces, IntBuffer packed, int fromFace, int toFace) {
packed.clear();
for (int face = fromFace; face < toFace; face++) {
int run = faces.columns(face);
int reach = faces.height(face);
if (run > LARGEST_PACKED || reach > LARGEST_PACKED) {
throw new IllegalArgumentException("A face of " + run + " by " + reach + " is too large to draw on the graphics card");
}
packed.put(faces.firstColumn(face)).put(faces.firstRow(face)).put(faces.bottomY(face)).put(run | reach << 16);
}
packed.flip();
}
void bind() {
glBindTexture(GL_TEXTURE_BUFFER, texture);
}
int first(BlockFaces.Kind kind, BlockFaces.Facing facing) {
return firstOf[kind.ordinal()][facing.ordinal()];
}
int count(BlockFaces.Kind kind, BlockFaces.Facing facing) {
return countOf[kind.ordinal()][facing.ordinal()];
}
long bytes() {
return bytes;
}
void delete() {
glDeleteTextures(texture);
glDeleteBuffers(buffer);
}
}
@@ -0,0 +1,67 @@
/*
* 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 art.arcane.iris.studio.map;
import java.util.OptionalDouble;
import static org.lwjgl.opengl.GL11.GL_DEPTH_COMPONENT;
import static org.lwjgl.opengl.GL11.GL_FLOAT;
import static org.lwjgl.opengl.GL11.glReadPixels;
/**
* Where the graphics card draws a frame: a picture read back for Swing, or straight into a window. Only for the GPU
* thread.
*/
interface GpuFrameTarget {
double FARTHEST_DEPTH = 1D;
/**
* Makes the target the one drawn into, at the given size.
*/
void bindSized(int width, int height);
/**
* Ends the frame drawn since {@link #bindSized}.
*
* @param depthRange the range the clip matrix was made for
*/
OrbitPicture finish(OrbitProjection projection, double depthRange);
/**
* Reads the one depth a picture keeps, the middle's, from what is bound.
*/
static OptionalDouble depthInMiddle(OrbitProjection projection, double depthRange, int width, int height) {
int middleX = (int) projection.screenCenterX();
int middleY = (int) projection.screenCenterY();
if (middleX < 0 || middleY < 0 || middleX >= width || middleY >= height) {
return OptionalDouble.empty();
}
float[] middleDepth = new float[1];
glReadPixels(middleX, height - 1 - middleY, 1, 1, GL_DEPTH_COMPONENT, GL_FLOAT, middleDepth);
return OrbitPicture.finite(blockDepth(middleDepth[0], depthRange));
}
// The depth buffer holds 0 at the near plane and 1 at the far one, nearer being smaller.
private static double blockDepth(float bufferDepth, double depthRange) {
if (bufferDepth >= FARTHEST_DEPTH) {
return Double.NEGATIVE_INFINITY;
}
return -(2D * bufferDepth - 1D) * depthRange;
}
}
@@ -0,0 +1,90 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.studio.map;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
import static org.lwjgl.opengl.GL30.GL_BGRA;
import static org.lwjgl.opengl.GL30.GL_COLOR_ATTACHMENT0;
import static org.lwjgl.opengl.GL30.GL_DEPTH_ATTACHMENT;
import static org.lwjgl.opengl.GL30.GL_DEPTH_COMPONENT24;
import static org.lwjgl.opengl.GL30.GL_FRAMEBUFFER;
import static org.lwjgl.opengl.GL30.GL_FRAMEBUFFER_COMPLETE;
import static org.lwjgl.opengl.GL30.GL_RENDERBUFFER;
import static org.lwjgl.opengl.GL30.GL_RGBA8;
import static org.lwjgl.opengl.GL30.GL_UNSIGNED_INT_8_8_8_8_REV;
import static org.lwjgl.opengl.GL30.glBindFramebuffer;
import static org.lwjgl.opengl.GL30.glBindRenderbuffer;
import static org.lwjgl.opengl.GL30.glCheckFramebufferStatus;
import static org.lwjgl.opengl.GL30.glFramebufferRenderbuffer;
import static org.lwjgl.opengl.GL30.glGenFramebuffers;
import static org.lwjgl.opengl.GL30.glGenRenderbuffers;
import static org.lwjgl.opengl.GL30.glReadPixels;
import static org.lwjgl.opengl.GL30.glRenderbufferStorage;
import static org.lwjgl.opengl.GL30.glViewport;
/**
* A picture the graphics card draws into instead of a window, read back as an {@link OrbitPicture} so Swing shows it
* like any other. Only for the GPU thread.
*/
final class GpuFramebuffer implements GpuFrameTarget {
private final int framebuffer = glGenFramebuffers();
private final int colorBuffer = glGenRenderbuffers();
private final int depthBuffer = glGenRenderbuffers();
private int width;
private int height;
private int[] colorRows = new int[0];
@Override
public void bindSized(int nextWidth, int nextHeight) {
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
if (nextWidth != width || nextHeight != height) {
resizeTo(nextWidth, nextHeight);
}
glViewport(0, 0, width, height);
}
private void resizeTo(int nextWidth, int nextHeight) {
width = nextWidth;
height = nextHeight;
glBindRenderbuffer(GL_RENDERBUFFER, colorBuffer);
glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, width, height);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, colorBuffer);
glBindRenderbuffer(GL_RENDERBUFFER, depthBuffer);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, width, height);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, depthBuffer);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
throw new IllegalStateException("The graphics card can't draw a " + width + "x" + height + " picture");
}
colorRows = new int[width * height];
}
// Reads what was drawn. Only the middle's depth is read back, the one depth a picture keeps.
@Override
public OrbitPicture finish(OrbitProjection projection, double depthRange) {
glReadPixels(0, 0, width, height, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, colorRows);
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
for (int row = 0; row < height; row++) {
System.arraycopy(colorRows, (height - 1 - row) * width, pixels, row * width, width);
}
return OrbitPicture.whole(image, GpuFrameTarget.depthInMiddle(projection, depthRange, width, height));
}
}
@@ -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 art.arcane.iris.studio.map;
import art.arcane.iris.spi.IrisLogging;
import java.util.Optional;
import static org.lwjgl.opengl.GL33.GL_BLEND;
import static org.lwjgl.opengl.GL33.GL_COLOR_BUFFER_BIT;
import static org.lwjgl.opengl.GL33.GL_CULL_FACE;
import static org.lwjgl.opengl.GL33.GL_DEPTH_BUFFER_BIT;
import static org.lwjgl.opengl.GL33.GL_DEPTH_TEST;
import static org.lwjgl.opengl.GL33.GL_LESS;
import static org.lwjgl.opengl.GL33.GL_NO_ERROR;
import static org.lwjgl.opengl.GL33.GL_POLYGON_OFFSET_FILL;
import static org.lwjgl.opengl.GL33.glClear;
import static org.lwjgl.opengl.GL33.glClearColor;
import static org.lwjgl.opengl.GL33.glClearDepth;
import static org.lwjgl.opengl.GL33.glDepthFunc;
import static org.lwjgl.opengl.GL33.glDepthMask;
import static org.lwjgl.opengl.GL33.glDisable;
import static org.lwjgl.opengl.GL33.glEnable;
import static org.lwjgl.opengl.GL33.glGetError;
/**
* Runs a 3D view's frame on the graphics card, straight into the view's window where frames can go there, or else into
* a picture read back for Swing. A card that fails once isn't used again, as it would most likely fail every frame,
* each time only after costing the frame its time.
*/
final class GpuFrames {
private static final double COLOR_CHANNEL_MAX = 255D;
private static GpuFramebuffer offScreen;
private static volatile boolean failed;
private GpuFrames() {
}
/**
* A frame drawn on the card's thread.
*/
@FunctionalInterface
interface Drawing {
/**
* @return the frame, or nothing when the card can't draw this scene and it should be drawn without one
*/
Optional<OrbitPicture> drawInto(GpuFrameTarget target);
}
/**
* @return the picture, or nothing when the caller should draw it without a card
*/
static Optional<OrbitPicture> drawPicture(Drawing drawing) {
return onCard(gpu -> drawing.drawInto(offScreenFramebuffer()));
}
static boolean canDrawOnScreen() {
return !failed && GpuContext.drawsIntoWindows();
}
/**
* @return the frame, or nothing when it can't go straight into the window and the caller should draw a picture
*/
static Optional<OrbitPicture> drawOnScreen(ScreenTarget screen, Drawing drawing) {
return onCard(gpu -> {
Optional<GlxDisplay> windows = gpu.windows();
long window = screen.canvas().window();
if (windows.isEmpty() || window == 0L) {
return Optional.empty();
}
try {
if (!windows.get().canDrawInto(screen.canvas().visualId())) {
throw new IllegalStateException("The view's window has another visual than the one GLX draws with");
}
if (!windows.get().drawInto(window)) {
throw new IllegalStateException("GLX can't draw into the view's window");
}
return drawing.drawInto(new GpuWindowFrame(screen.seeThrough()));
} catch (RuntimeException failure) {
gpu.stopDrawingIntoWindows(failure);
return Optional.empty();
}
});
}
/**
* Puts the last frame drawn into the view's window on screen. Another view may have drawn into its own window
* since, so the view's window is made current again first.
*
* @return false when the window didn't take it, and the caller should draw a picture instead
*/
static boolean showOnScreen(ScreenTarget screen) {
return onCard(gpu -> {
Optional<GlxDisplay> windows = gpu.windows();
long window = screen.canvas().window();
if (windows.isEmpty() || window == 0L) {
return Optional.<Boolean>empty();
}
try {
if (!windows.get().drawInto(window) || !windows.get().show(window)) {
throw new IllegalStateException("The window didn't take the frame");
}
return Optional.of(true);
} catch (RuntimeException failure) {
gpu.stopDrawingIntoWindows(failure);
return Optional.<Boolean>empty();
}
}).isPresent();
}
static void failOnError() {
int error = glGetError();
if (error != GL_NO_ERROR) {
throw new IllegalStateException("OpenGL error 0x" + Integer.toHexString(error));
}
}
/**
* Clears to the views' background, and sets up for opaque drawing with depth.
*/
static void clearToBackground() {
int background = StudioTheme.EDITOR_BACKGROUND.getRGB();
glClearColor(channel(background, 16), channel(background, 8), channel(background, 0), 1F);
glClearDepth(1D);
glDepthMask(true);
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LESS);
glDisable(GL_BLEND);
glDisable(GL_CULL_FACE);
glDisable(GL_POLYGON_OFFSET_FILL);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
}
static float channel(int color, int shift) {
return (float) (((color >>> shift) & 0xFF) / COLOR_CHANNEL_MAX);
}
private static GpuFramebuffer offScreenFramebuffer() {
if (offScreen == null) {
offScreen = new GpuFramebuffer();
}
return offScreen;
}
private static <T> Optional<T> onCard(CardWork<T> work) {
if (failed) {
return Optional.empty();
}
Optional<GpuContext> gpu = GpuContext.shared();
if (gpu.isEmpty()) {
return Optional.empty();
}
try {
return gpu.get().call(() -> work.drawOn(gpu.get()));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return Optional.empty();
} catch (RuntimeException failure) {
stopUsingCard(failure);
return Optional.empty();
}
}
private static void stopUsingCard(RuntimeException failure) {
failed = true;
IrisLogging.reportError(failure);
IrisLogging.warn("3D views draw without a graphics card from now on: %s", failure.getCause() == null ? failure : failure.getCause());
}
@FunctionalInterface
private interface CardWork<T> {
Optional<T> drawOn(GpuContext gpu);
}
}
@@ -0,0 +1,63 @@
/*
* 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 art.arcane.iris.studio.map;
import art.arcane.iris.spi.IrisLogging;
import java.util.List;
/**
* Whether the graphics card library the 3D views draw with and its native bindings can load. Mod loaders get it from the game,
* which ships only part of it, so the views check here before touching any class that links against it.
*/
final class GpuLibrary {
private static final List<String> REQUIRED_CLASSES = List.of(
"org.lwjgl.system.MemoryStack",
"org.lwjgl.opengl.GL",
"org.lwjgl.egl.EGL",
"org.lwjgl.system.jawt.JAWT"
);
private static final boolean LOADED = findRequiredClasses();
private GpuLibrary() {
}
static boolean isLoaded() {
return LOADED;
}
private static boolean findRequiredClasses() {
ClassLoader loader = GpuLibrary.class.getClassLoader();
for (String className : REQUIRED_CLASSES) {
try {
Class.forName(className, false, loader);
} catch (ClassNotFoundException | LinkageError missing) {
IrisLogging.info("3D views draw without a graphics card: %s isn't on the classpath", className);
return false;
}
}
try {
Class.forName("org.lwjgl.system.jawt.JAWTFunctions", true, loader);
return true;
} catch (ClassNotFoundException | LinkageError | SecurityException unavailable) {
IrisLogging.info("3D views draw without a graphics card: native bindings are unavailable (%s)", unavailable);
return false;
}
}
}
@@ -0,0 +1,293 @@
/*
* 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 art.arcane.iris.studio.map;
import art.arcane.iris.studio.map.BlockModel.Face;
import art.arcane.iris.studio.map.BlockModel.TextureMapping;
import art.arcane.iris.studio.map.BlockTextures.BlockTexture;
import org.lwjgl.system.MemoryUtil;
import java.nio.FloatBuffer;
import java.nio.IntBuffer;
import java.util.Arrays;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import static org.lwjgl.opengl.GL33.GL_R32I;
import static org.lwjgl.opengl.GL33.GL_RGBA32F;
import static org.lwjgl.opengl.GL33.GL_RGBA8;
import static org.lwjgl.opengl.GL33.GL_STATIC_DRAW;
import static org.lwjgl.opengl.GL33.GL_STREAM_DRAW;
import static org.lwjgl.opengl.GL33.GL_TEXTURE_BUFFER;
import static org.lwjgl.opengl.GL33.glBindBuffer;
import static org.lwjgl.opengl.GL33.glBindTexture;
import static org.lwjgl.opengl.GL33.glBufferData;
import static org.lwjgl.opengl.GL33.glBufferSubData;
import static org.lwjgl.opengl.GL33.glDeleteBuffers;
import static org.lwjgl.opengl.GL33.glDeleteTextures;
import static org.lwjgl.opengl.GL33.glGenBuffers;
import static org.lwjgl.opengl.GL33.glGenTextures;
import static org.lwjgl.opengl.GL33.glTexBuffer;
/**
* An object mesh's quads and the texels of their textures on the graphics card, each in a buffer texture the shaders
* read from. Quads come solid first, then the cut-away ghost, then the see-through ones, whose drawing order is sent
* again every frame, farthest first. Only for the GPU thread.
*/
final class GpuObjectMesh implements GpuCache.Upload {
static final int TEXELS_PER_QUAD = 7;
private static final int FLOATS_PER_QUAD = TEXELS_PER_QUAD * 4;
private static final int OPAQUE_ALPHA = 0xFF;
private final Vec3 anchor;
private final int solidCount;
private final int ghostCount;
private final int seeThroughCount;
private final float[] seeThroughCenters;
private final long bytes;
private final int quadBuffer = glGenBuffers();
private final int quadTexture = glGenTextures();
private final int texelBuffer = glGenBuffers();
private final int texelTexture = glGenTextures();
private final int orderBuffer = glGenBuffers();
private final int orderTexture = glGenTextures();
private GpuObjectMesh(ObjectMesh mesh, int texelCount) {
ObjectMesh.Bounds bounds = mesh.bounds();
anchor = new Vec3(bounds.minX(), bounds.minY(), bounds.minZ());
solidCount = mesh.solidQuads().size();
ghostCount = mesh.cutAwayQuads().size();
seeThroughCount = mesh.translucentQuads().size();
seeThroughCenters = centersOf(mesh.translucentQuads());
bytes = (long) quadCount() * FLOATS_PER_QUAD * Float.BYTES + (long) texelCount * Integer.BYTES + (long) seeThroughCount * Integer.BYTES;
}
/**
* @param mostTexels the card's GL_MAX_TEXTURE_BUFFER_SIZE
* @return the uploaded mesh, or null when it is more than the card's buffer textures hold
*/
static GpuObjectMesh upload(ObjectMesh mesh, int mostTexels) {
Map<BlockTexture, Integer> textureOffsets = new IdentityHashMap<>();
int texelCount = placeTextures(mesh, textureOffsets);
long quadTexels = (long) (mesh.solidQuads().size() + mesh.cutAwayQuads().size() + mesh.translucentQuads().size()) * TEXELS_PER_QUAD;
if (quadTexels > mostTexels || texelCount > mostTexels || quadTexels * 6L > Integer.MAX_VALUE) {
return null;
}
GpuObjectMesh uploaded = new GpuObjectMesh(mesh, texelCount);
try {
uploaded.fillQuads(mesh, textureOffsets);
uploaded.fillTexels(textureOffsets, texelCount);
uploaded.prepareOrder();
} catch (RuntimeException failure) {
uploaded.delete();
throw failure;
}
return uploaded;
}
private static int placeTextures(ObjectMesh mesh, Map<BlockTexture, Integer> offsets) {
int texelCount = 0;
for (List<ObjectMesh.Quad> quads : List.of(mesh.solidQuads(), mesh.cutAwayQuads(), mesh.translucentQuads())) {
for (ObjectMesh.Quad quad : quads) {
BlockTexture texture = quad.face().texture();
if (!offsets.containsKey(texture)) {
offsets.put(texture, texelCount);
texelCount += texture.width() * texture.height();
}
}
}
return texelCount;
}
private float[] centersOf(List<ObjectMesh.Quad> quads) {
float[] centers = new float[quads.size() * 3];
for (int index = 0; index < quads.size(); index++) {
ObjectMesh.Quad quad = quads.get(index);
Face face = quad.face();
centers[index * 3] = (float) (quad.originX() - anchor.x() + (face.edgeU().x() + face.edgeV().x()) / 2D);
centers[index * 3 + 1] = (float) (quad.originY() - anchor.y() + (face.edgeU().y() + face.edgeV().y()) / 2D);
centers[index * 3 + 2] = (float) (quad.originZ() - anchor.z() + (face.edgeU().z() + face.edgeV().z()) / 2D);
}
return centers;
}
private void fillQuads(ObjectMesh mesh, Map<BlockTexture, Integer> textureOffsets) {
FloatBuffer packed = MemoryUtil.memAllocFloat(Math.max(1, quadCount()) * FLOATS_PER_QUAD);
try {
for (List<ObjectMesh.Quad> quads : List.of(mesh.solidQuads(), mesh.cutAwayQuads(), mesh.translucentQuads())) {
for (ObjectMesh.Quad quad : quads) {
pack(quad, textureOffsets.get(quad.face().texture()), packed);
}
}
packed.flip();
glBindBuffer(GL_TEXTURE_BUFFER, quadBuffer);
glBufferData(GL_TEXTURE_BUFFER, packed, GL_STATIC_DRAW);
} finally {
MemoryUtil.memFree(packed);
}
glBindTexture(GL_TEXTURE_BUFFER, quadTexture);
glTexBuffer(GL_TEXTURE_BUFFER, GL_RGBA32F, quadBuffer);
}
// Whole numbers ride along as float bits, read back exactly by the shader.
private void pack(ObjectMesh.Quad quad, int textureOffset, FloatBuffer packed) {
Face face = quad.face();
BlockTexture texture = face.texture();
TextureMapping mapping = face.mapping();
ObjectRenderer.TexelRange columns = ObjectRenderer.TexelRange.of(mapping.u(), mapping.uAlongU(), mapping.uAlongV(), texture.width());
ObjectRenderer.TexelRange rows = ObjectRenderer.TexelRange.of(mapping.v(), mapping.vAlongU(), mapping.vAlongV(), texture.height());
put(packed, quad.originX() - anchor.x(), quad.originY() - anchor.y(), quad.originZ() - anchor.z()).put((float) face.shade());
put(packed, face.edgeU()).put((float) quad.opacity());
put(packed, face.edgeV()).put(Float.intBitsToFloat(textureOffset));
put(packed, face.normal()).put(Float.intBitsToFloat(texture.width()));
put(packed, mapping.u(), mapping.uAlongU(), mapping.uAlongV()).put(Float.intBitsToFloat(texture.height()));
put(packed, mapping.v(), mapping.vAlongU(), mapping.vAlongV()).put(0F);
packed.put(Float.intBitsToFloat(columns.first())).put(Float.intBitsToFloat(columns.last()))
.put(Float.intBitsToFloat(rows.first())).put(Float.intBitsToFloat(rows.last()));
}
private static FloatBuffer put(FloatBuffer packed, Vec3 vector) {
return put(packed, vector.x(), vector.y(), vector.z());
}
private static FloatBuffer put(FloatBuffer packed, double x, double y, double z) {
return packed.put((float) x).put((float) y).put((float) z);
}
// Texels the game doesn't draw are fully transparent, so the shader skips them.
private void fillTexels(Map<BlockTexture, Integer> textureOffsets, int texelCount) {
IntBuffer packed = MemoryUtil.memAllocInt(Math.max(1, texelCount));
try {
textureOffsets.forEach((texture, offset) -> {
int[] alphas = texture.alphas();
for (int texel = 0; texel < texture.width() * texture.height(); texel++) {
int alpha = !texture.opaque()[texel] ? 0 : alphas == null ? OPAQUE_ALPHA : alphas[texel];
packed.put(offset + texel, rgba(texture.pixels()[texel], alpha));
}
});
glBindBuffer(GL_TEXTURE_BUFFER, texelBuffer);
glBufferData(GL_TEXTURE_BUFFER, packed, GL_STATIC_DRAW);
} finally {
MemoryUtil.memFree(packed);
}
glBindTexture(GL_TEXTURE_BUFFER, texelTexture);
glTexBuffer(GL_TEXTURE_BUFFER, GL_RGBA8, texelBuffer);
}
// GL_RGBA8 takes red from the lowest byte, which is the first in memory on the little-endian machines LWJGL runs on.
private static int rgba(int rgb, int alpha) {
return ((rgb >>> 16) & 0xFF) | (rgb & 0xFF00) | ((rgb & 0xFF) << 16) | (alpha << 24);
}
private void prepareOrder() {
glBindBuffer(GL_TEXTURE_BUFFER, orderBuffer);
glBufferData(GL_TEXTURE_BUFFER, (long) Math.max(1, seeThroughCount) * Integer.BYTES, GL_STREAM_DRAW);
glBindTexture(GL_TEXTURE_BUFFER, orderTexture);
glTexBuffer(GL_TEXTURE_BUFFER, GL_R32I, orderBuffer);
}
/**
* Sends the see-through quads' drawing order, farthest from the camera first, keeping the mesh's order between
* quads at the same depth.
*
* @param towardCamera how far each block along x, y and z brings a point toward the camera
*/
void sortSeeThroughFarthestFirst(Vec3 towardCamera) {
if (seeThroughCount == 0) {
return;
}
long[] depthThenIndex = new long[seeThroughCount];
for (int index = 0; index < seeThroughCount; index++) {
float depth = (float) (seeThroughCenters[index * 3] * towardCamera.x() + seeThroughCenters[index * 3 + 1] * towardCamera.y()
+ seeThroughCenters[index * 3 + 2] * towardCamera.z());
depthThenIndex[index] = (long) sortableBits(depth) << 32 | index;
}
Arrays.sort(depthThenIndex);
IntBuffer order = MemoryUtil.memAllocInt(seeThroughCount);
try {
int firstSeeThrough = firstSeeThrough();
for (long entry : depthThenIndex) {
order.put(firstSeeThrough + (int) entry);
}
order.flip();
glBindBuffer(GL_TEXTURE_BUFFER, orderBuffer);
glBufferSubData(GL_TEXTURE_BUFFER, 0L, order);
} finally {
MemoryUtil.memFree(order);
}
}
// Ordered as signed ints the way the floats are ordered.
private static int sortableBits(float value) {
int bits = Float.floatToIntBits(value);
return bits < 0 ? bits ^ Integer.MAX_VALUE : bits;
}
void bindQuads() {
glBindTexture(GL_TEXTURE_BUFFER, quadTexture);
}
void bindTexels() {
glBindTexture(GL_TEXTURE_BUFFER, texelTexture);
}
void bindOrder() {
glBindTexture(GL_TEXTURE_BUFFER, orderTexture);
}
Vec3 anchor() {
return anchor;
}
int solidCount() {
return solidCount;
}
int firstGhost() {
return solidCount;
}
int ghostCount() {
return ghostCount;
}
int firstSeeThrough() {
return solidCount + ghostCount;
}
int seeThroughCount() {
return seeThroughCount;
}
private int quadCount() {
return solidCount + ghostCount + seeThroughCount;
}
@Override
public long bytes() {
return bytes;
}
@Override
public void delete() {
glDeleteTextures(new int[]{quadTexture, texelTexture, orderTexture});
glDeleteBuffers(new int[]{quadBuffer, texelBuffer, orderBuffer});
}
}
@@ -0,0 +1,300 @@
/*
* 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 art.arcane.iris.studio.map;
import java.util.Optional;
import java.util.Set;
import static org.lwjgl.opengl.GL33.GL_BLEND;
import static org.lwjgl.opengl.GL33.GL_LEQUAL;
import static org.lwjgl.opengl.GL33.GL_LINES;
import static org.lwjgl.opengl.GL33.GL_MAX_TEXTURE_BUFFER_SIZE;
import static org.lwjgl.opengl.GL33.GL_ONE;
import static org.lwjgl.opengl.GL33.GL_ONE_MINUS_SRC_ALPHA;
import static org.lwjgl.opengl.GL33.GL_SRC_ALPHA;
import static org.lwjgl.opengl.GL33.GL_TEXTURE0;
import static org.lwjgl.opengl.GL33.GL_TEXTURE1;
import static org.lwjgl.opengl.GL33.GL_TEXTURE2;
import static org.lwjgl.opengl.GL33.GL_TRIANGLES;
import static org.lwjgl.opengl.GL33.GL_ZERO;
import static org.lwjgl.opengl.GL33.glActiveTexture;
import static org.lwjgl.opengl.GL33.glBindVertexArray;
import static org.lwjgl.opengl.GL33.glBlendFuncSeparate;
import static org.lwjgl.opengl.GL33.glDepthFunc;
import static org.lwjgl.opengl.GL33.glDepthMask;
import static org.lwjgl.opengl.GL33.glDisable;
import static org.lwjgl.opengl.GL33.glDrawArrays;
import static org.lwjgl.opengl.GL33.glEnable;
import static org.lwjgl.opengl.GL33.glGenVertexArrays;
import static org.lwjgl.opengl.GL33.glGetInteger;
/**
* Draws an object mesh on the graphics card, looking the way {@link ObjectRenderer} draws it. That one still draws on
* machines without a card, and the thumbnails. The mesh stays on the card while it is shown, so a frame only sends the
* camera and the see-through quads' order.
*/
final class GpuObjectRenderer {
private static final double DEPTH_MARGIN_BLOCKS = 4D;
// Several large objects, well within what any card able to draw them has.
private static final long MOST_UPLOADED_BYTES = 256L << 20;
private static final int CORNERS_PER_QUAD = 6;
private static final int ENDS_PER_LINE = 2;
private static final int GRID_DIRECTIONS = 2;
private static final String QUAD_VERTEX_SHADER = """
#version 330 core
const int[6] CORNERS = int[6](0, 1, 2, 2, 1, 3);
const int TEXELS_PER_QUAD = %d;
uniform samplerBuffer quads;
uniform isamplerBuffer order;
uniform bool inOrder;
uniform int firstQuad;
uniform mat4 toClip;
uniform vec3 anchorFromCenter;
uniform vec3 towardCamera;
out vec2 texturePosition;
flat out int firstTexel;
flat out ivec2 textureSize;
flat out ivec4 texelRange;
flat out float shade;
flat out float opacity;
// Faces looking away from the camera are hidden behind the ones looking toward it, so every corner of
// theirs is put in the same spot outside the view, which draws nothing.
void main() {
int index = gl_VertexID / 6;
int quad = inOrder ? texelFetch(order, index).r : firstQuad + index;
int base = quad * TEXELS_PER_QUAD;
vec4 origin = texelFetch(quads, base);
vec4 edgeU = texelFetch(quads, base + 1);
vec4 edgeV = texelFetch(quads, base + 2);
vec4 normal = texelFetch(quads, base + 3);
if (dot(normal.xyz, towardCamera) <= 0.0) {
gl_Position = vec4(2.0, 2.0, 2.0, 1.0);
return;
}
vec4 mappingU = texelFetch(quads, base + 4);
vec4 mappingV = texelFetch(quads, base + 5);
int corner = CORNERS[gl_VertexID %% 6];
float alongU = float(corner & 1);
float alongV = float(corner >> 1);
gl_Position = toClip * vec4(anchorFromCenter + origin.xyz + alongU * edgeU.xyz + alongV * edgeV.xyz, 1.0);
texturePosition = vec2(mappingU.x + alongU * mappingU.y + alongV * mappingU.z,
mappingV.x + alongU * mappingV.y + alongV * mappingV.z);
firstTexel = floatBitsToInt(edgeV.w);
textureSize = ivec2(floatBitsToInt(normal.w), floatBitsToInt(mappingU.w));
texelRange = floatBitsToInt(texelFetch(quads, base + 6));
shade = origin.w;
opacity = edgeU.w;
}
""".formatted(GpuObjectMesh.TEXELS_PER_QUAD);
private static final String QUAD_FRAGMENT_SHADER = """
#version 330 core
uniform samplerBuffer texels;
uniform bool blended;
in vec2 texturePosition;
flat in int firstTexel;
flat in ivec2 textureSize;
flat in ivec4 texelRange;
flat in float shade;
flat in float opacity;
out vec4 fragment;
void main() {
int column = clamp(int(floor(texturePosition.x * float(textureSize.x))), texelRange.x, texelRange.y);
int row = clamp(int(floor(texturePosition.y * float(textureSize.y))), texelRange.z, texelRange.w);
vec4 texel = texelFetch(texels, firstTexel + row * textureSize.x + column);
if (texel.a == 0.0) {
discard;
}
fragment = vec4(min(texel.rgb * shade, vec3(1.0)), blended ? texel.a * opacity : 1.0);
}
""";
private static final String GRID_VERTEX_SHADER = """
#version 330 core
uniform mat4 toClip;
uniform vec3 gridCenter;
uniform int reach;
uniform int step;
uniform int linesPerDirection;
void main() {
int line = gl_VertexID / 2;
float across = float(-reach + (line % linesPerDirection) * step);
float along = float((gl_VertexID % 2) * 2 - 1) * float(reach);
vec3 offset = line < linesPerDirection ? vec3(across, 0.0, along) : vec3(along, 0.0, across);
gl_Position = toClip * vec4(gridCenter + offset, 1.0);
}
""";
private static final String FLAT_FRAGMENT_SHADER = """
#version 330 core
uniform vec4 color;
out vec4 fragment;
void main() {
fragment = color;
}
""";
private static GpuObjectRenderer onCard;
private final GpuProgram quadProgram = GpuProgram.of(QUAD_VERTEX_SHADER, QUAD_FRAGMENT_SHADER);
private final GpuProgram gridProgram = GpuProgram.of(GRID_VERTEX_SHADER, FLAT_FRAGMENT_SHADER);
private final int attributelessVertexArray = glGenVertexArrays();
// Asked once: asking the card waits for everything sent to it so far.
private final int mostBufferTexels = glGetInteger(GL_MAX_TEXTURE_BUFFER_SIZE);
private final GpuCache<GpuObjectMesh> uploadedMeshes = new GpuCache<>();
/**
* @return the picture, or nothing when the card can't draw this mesh and the caller should draw it without one
*/
static Optional<OrbitPicture> render(ObjectMesh mesh, OrbitCamera camera, int width, int height, int coveredLeftWidth) {
return GpuFrames.drawPicture(target -> onCard().draw(mesh, camera, width, height, coveredLeftWidth, target));
}
/**
* @return the frame, or nothing when it can't go straight into the window and the caller should draw a picture
*/
static Optional<OrbitPicture> renderOnScreen(ObjectMesh mesh, ScreenTarget screen, OrbitCamera camera, int width, int height,
int coveredLeftWidth) {
return GpuFrames.drawOnScreen(screen, target -> onCard().draw(mesh, camera, width, height, coveredLeftWidth, target));
}
private static GpuObjectRenderer onCard() {
if (onCard == null) {
onCard = new GpuObjectRenderer();
}
return onCard;
}
private Optional<OrbitPicture> draw(ObjectMesh mesh, OrbitCamera camera, int width, int height, int coveredLeftWidth,
GpuFrameTarget target) {
GpuObjectMesh uploaded = uploaded(mesh);
if (uploaded == null) {
return Optional.empty();
}
OrbitProjection projection = ObjectRenderer.projection(mesh, camera, width, height, coveredLeftWidth);
ObjectRenderer.FloorGrid grid = ObjectRenderer.FloorGrid.under(mesh.bounds());
double depthRange = farthestFrom(projection.center(), mesh.bounds(), grid) + DEPTH_MARGIN_BLOCKS;
float[] toClip = projection.clipMatrix(width, height, depthRange);
target.bindSized(width, height);
GpuFrames.clearToBackground();
glDepthFunc(GL_LEQUAL);
glBindVertexArray(attributelessVertexArray);
drawGrid(grid, projection, toClip);
drawMesh(uploaded, projection, toClip);
GpuFrames.failOnError();
return Optional.of(target.finish(projection, depthRange));
}
private GpuObjectMesh uploaded(ObjectMesh mesh) {
uploadedMeshes.forgetCollected();
GpuObjectMesh uploaded = uploadedMeshes.get(mesh);
if (uploaded == null) {
uploaded = GpuObjectMesh.upload(mesh, mostBufferTexels);
if (uploaded == null) {
return null;
}
uploadedMeshes.put(mesh, uploaded);
}
uploadedMeshes.forgetBeyond(MOST_UPLOADED_BYTES, Set.of(mesh));
return uploaded;
}
// No point of the scene is further from the projection's centre than the farthest corner of a box around it all.
private static double farthestFrom(Vec3 center, ObjectMesh.Bounds bounds, ObjectRenderer.FloorGrid grid) {
double reachX = Math.max(Math.abs(bounds.minX() - center.x()), Math.abs(bounds.maxX() - center.x()));
double reachZ = Math.max(Math.abs(bounds.minZ() - center.z()), Math.abs(bounds.maxZ() - center.z()));
double gridReachX = Math.abs(grid.centerX() - center.x()) + grid.reach();
double gridReachZ = Math.abs(grid.centerZ() - center.z()) + grid.reach();
double reachY = Math.max(Math.abs(Math.min(bounds.minY(), grid.floorY()) - center.y()), Math.abs(bounds.maxY() - center.y()));
return Math.sqrt(square(Math.max(reachX, gridReachX)) + square(reachY) + square(Math.max(reachZ, gridReachZ)));
}
private static double square(double value) {
return value * value;
}
private void drawGrid(ObjectRenderer.FloorGrid grid, OrbitProjection projection, float[] toClip) {
Vec3 center = projection.center();
int color = grid.color();
gridProgram.use();
gridProgram.setMatrix("toClip", toClip);
gridProgram.set("gridCenter", new Vec3(grid.centerX() - center.x(), grid.floorY() - center.y(), grid.centerZ() - center.z()));
gridProgram.set("reach", grid.reach());
gridProgram.set("step", grid.step());
gridProgram.set("linesPerDirection", grid.linesPerDirection());
gridProgram.set("color", GpuFrames.channel(color, 16), GpuFrames.channel(color, 8), GpuFrames.channel(color, 0), 1D);
glDrawArrays(GL_LINES, 0, grid.linesPerDirection() * GRID_DIRECTIONS * ENDS_PER_LINE);
}
/**
* The cut-away ghost is drawn first, hiding nothing, so the solid quads drawn over it replace it wherever the object
* shows. Later quads win ties, so an overlay on the same box, like grass on a grass block's side, shows on top.
*/
private void drawMesh(GpuObjectMesh mesh, OrbitProjection projection, float[] toClip) {
Vec3 towardCamera = new Vec3(projection.depthStep(1D, 0D, 0D), projection.depthStep(0D, 1D, 0D), projection.depthStep(0D, 0D, 1D));
Vec3 anchor = mesh.anchor();
Vec3 center = projection.center();
quadProgram.use();
quadProgram.setMatrix("toClip", toClip);
quadProgram.set("anchorFromCenter", new Vec3(anchor.x() - center.x(), anchor.y() - center.y(), anchor.z() - center.z()));
quadProgram.set("towardCamera", towardCamera);
quadProgram.set("quads", 0);
quadProgram.set("texels", 1);
quadProgram.set("order", 2);
glActiveTexture(GL_TEXTURE0);
mesh.bindQuads();
glActiveTexture(GL_TEXTURE1);
mesh.bindTexels();
glActiveTexture(GL_TEXTURE2);
mesh.bindOrder();
glActiveTexture(GL_TEXTURE0);
startBlending();
drawQuads(mesh.firstGhost(), mesh.ghostCount(), true);
glDisable(GL_BLEND);
glDepthMask(true);
drawQuads(0, mesh.solidCount(), false);
startBlending();
mesh.sortSeeThroughFarthestFirst(towardCamera);
quadProgram.set("inOrder", 1);
drawQuads(0, mesh.seeThroughCount(), true);
quadProgram.set("inOrder", 0);
}
// Blended quads hide nothing behind them. The window's own alpha is left whole, so nothing behind it shows through.
private static void startBlending() {
glEnable(GL_BLEND);
glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ZERO, GL_ONE);
glDepthMask(false);
}
private void drawQuads(int firstQuad, int count, boolean blended) {
if (count == 0) {
return;
}
quadProgram.set("firstQuad", firstQuad);
quadProgram.set("blended", blended ? 1 : 0);
glDrawArrays(GL_TRIANGLES, 0, count * CORNERS_PER_QUAD);
}
}
@@ -0,0 +1,131 @@
/*
* 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 art.arcane.iris.studio.map;
import java.util.HashMap;
import java.util.Map;
import static org.lwjgl.opengl.GL20.GL_COMPILE_STATUS;
import static org.lwjgl.opengl.GL20.GL_FALSE;
import static org.lwjgl.opengl.GL20.GL_FRAGMENT_SHADER;
import static org.lwjgl.opengl.GL20.GL_LINK_STATUS;
import static org.lwjgl.opengl.GL20.GL_VERTEX_SHADER;
import static org.lwjgl.opengl.GL20.glAttachShader;
import static org.lwjgl.opengl.GL20.glCompileShader;
import static org.lwjgl.opengl.GL20.glCreateProgram;
import static org.lwjgl.opengl.GL20.glCreateShader;
import static org.lwjgl.opengl.GL20.glDeleteProgram;
import static org.lwjgl.opengl.GL20.glDeleteShader;
import static org.lwjgl.opengl.GL20.glGetProgramInfoLog;
import static org.lwjgl.opengl.GL20.glGetProgrami;
import static org.lwjgl.opengl.GL20.glGetShaderInfoLog;
import static org.lwjgl.opengl.GL20.glGetShaderi;
import static org.lwjgl.opengl.GL20.glGetUniformLocation;
import static org.lwjgl.opengl.GL20.glLinkProgram;
import static org.lwjgl.opengl.GL20.glShaderSource;
import static org.lwjgl.opengl.GL20.glUniform1f;
import static org.lwjgl.opengl.GL20.glUniform1i;
import static org.lwjgl.opengl.GL20.glUniform1iv;
import static org.lwjgl.opengl.GL20.glUniform2i;
import static org.lwjgl.opengl.GL20.glUniform3f;
import static org.lwjgl.opengl.GL20.glUniform3i;
import static org.lwjgl.opengl.GL20.glUniform4f;
import static org.lwjgl.opengl.GL20.glUniformMatrix4fv;
import static org.lwjgl.opengl.GL20.glUseProgram;
/**
* A vertex and a fragment shader linked into one program, with its uniforms set by name. Only for the GPU thread.
*/
final class GpuProgram {
private final int id;
private final Map<String, Integer> uniformLocations = new HashMap<>();
private GpuProgram(int id) {
this.id = id;
}
static GpuProgram of(String vertexSource, String fragmentSource) {
int vertexShader = compile(GL_VERTEX_SHADER, vertexSource);
int fragmentShader = compile(GL_FRAGMENT_SHADER, fragmentSource);
int program = glCreateProgram();
glAttachShader(program, vertexShader);
glAttachShader(program, fragmentShader);
glLinkProgram(program);
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
if (glGetProgrami(program, GL_LINK_STATUS) == GL_FALSE) {
String log = glGetProgramInfoLog(program);
glDeleteProgram(program);
throw new IllegalStateException("Linking a shader program failed: " + log);
}
return new GpuProgram(program);
}
private static int compile(int type, String source) {
int shader = glCreateShader(type);
glShaderSource(shader, source);
glCompileShader(shader);
if (glGetShaderi(shader, GL_COMPILE_STATUS) == GL_FALSE) {
String log = glGetShaderInfoLog(shader);
glDeleteShader(shader);
throw new IllegalStateException("Compiling a shader failed: " + log);
}
return shader;
}
void use() {
glUseProgram(id);
}
void set(String name, int value) {
glUniform1i(location(name), value);
}
void set(String name, int x, int y) {
glUniform2i(location(name), x, y);
}
void set(String name, int x, int y, int z) {
glUniform3i(location(name), x, y, z);
}
void set(String name, int[] values) {
glUniform1iv(location(name), values);
}
void set(String name, double value) {
glUniform1f(location(name), (float) value);
}
void set(String name, Vec3 value) {
glUniform3f(location(name), (float) value.x(), (float) value.y(), (float) value.z());
}
void set(String name, double red, double green, double blue, double alpha) {
glUniform4f(location(name), (float) red, (float) green, (float) blue, (float) alpha);
}
void setMatrix(String name, float[] columnMajor) {
glUniformMatrix4fv(location(name), false, columnMajor);
}
private int location(String name) {
return uniformLocations.computeIfAbsent(name, uniform -> glGetUniformLocation(id, uniform));
}
}
@@ -0,0 +1,400 @@
/*
* 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 art.arcane.iris.studio.map;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static org.lwjgl.opengl.GL33.GL_BLEND;
import static org.lwjgl.opengl.GL33.GL_MAX_TEXTURE_BUFFER_SIZE;
import static org.lwjgl.opengl.GL33.GL_MAX_TEXTURE_SIZE;
import static org.lwjgl.opengl.GL33.GL_ONE_MINUS_SRC_ALPHA;
import static org.lwjgl.opengl.GL33.GL_POLYGON_OFFSET_FILL;
import static org.lwjgl.opengl.GL33.GL_SRC_ALPHA;
import static org.lwjgl.opengl.GL33.GL_TEXTURE0;
import static org.lwjgl.opengl.GL33.GL_TEXTURE1;
import static org.lwjgl.opengl.GL33.GL_TRIANGLES;
import static org.lwjgl.opengl.GL33.GL_TRIANGLE_STRIP;
import static org.lwjgl.opengl.GL33.glActiveTexture;
import static org.lwjgl.opengl.GL33.glBindVertexArray;
import static org.lwjgl.opengl.GL33.glBlendFunc;
import static org.lwjgl.opengl.GL33.glDepthMask;
import static org.lwjgl.opengl.GL33.glDrawArrays;
import static org.lwjgl.opengl.GL33.glEnable;
import static org.lwjgl.opengl.GL33.glGenVertexArrays;
import static org.lwjgl.opengl.GL33.glGetInteger;
import static org.lwjgl.opengl.GL33.glPolygonOffset;
/**
* Draws noise terrain as block columns on the graphics card, looking the way {@link NoiseTerrainRenderer} draws them.
* That one still draws on machines without a card. Faces and colours stay on the card while the scene shows them, so
* a frame only sends the camera.
*/
final class GpuTerrainRenderer {
private static final double DEPTH_MARGIN_BLOCKS = 16D;
// A few full views' worth, well within what any card able to draw them has.
private static final long MOST_UPLOADED_BYTES = 768L << 20;
// Half a frame at 60 frames a second.
private static final long UPLOAD_NANOS_PER_FRAME = 8_000_000L;
private static final double FACE_OVERLAP_PIXELS = 0.05D;
private static final String FACE_VERTEX_SHADER = """
#version 330 core
const int[6] CORNERS = int[6](0, 1, 2, 2, 1, 3);
uniform isamplerBuffer faces;
uniform int firstFace;
uniform mat4 toClip;
uniform ivec3 layerOrigin;
uniform vec3 centerFraction;
uniform int step;
uniform ivec3 normal;
uniform float overlap;
uniform int hiddenCells[%1$d];
uniform bool holeWall;
out vec2 units;
flat out ivec2 firstCell;
flat out int run;
bool hides(ivec2 column) {
if (any(lessThan(column, ivec2(0))) || any(greaterThanEqual(column, ivec2(%2$d)))) {
return false;
}
int cell = column.y / %3$d * %4$d + column.x / %3$d;
return (hiddenCells[cell / 32] & (1 << (cell %% 32))) != 0;
}
// The same as TerrainHole.shows: no face of a hidden column, and beside the hole its wall instead of the
// usual side. No face crosses a cell's edge, so its first column decides for all of it.
bool showsWithHole(ivec2 column) {
if (hides(column)) {
return false;
}
return normal.y == 1 || holeWall == hides(column + normal.xz);
}
// Corners are worked out in whole blocks relative to the view centre, so far from the world's origin
// nothing is lost to float precision. Faces meeting the middle of a longer face's edge leave hairline
// cracks along it, so every face reaches a little past its edges. Faces the hole leaves out land outside
// the view, where they're clipped away before any pixel is drawn.
void main() {
ivec4 face = texelFetch(faces, firstFace + gl_VertexID / 6);
if (!showsWithHole(face.xy)) {
gl_Position = vec4(2.0, 2.0, 2.0, 1.0);
units = vec2(0.0);
firstCell = ivec2(0);
run = 1;
return;
}
int corner = CORNERS[gl_VertexID %% 6];
int alongRun = corner & 1;
int alongReach = corner >> 1;
run = face.w & 0xFFFF;
int reach = (face.w >> 16) & 0xFFFF;
bool runsAlongX = normal.x == 0;
bool isTop = normal.y == 1;
ivec3 origin = ivec3((face.x + max(normal.x, 0)) * step, face.z, (face.y + max(normal.z, 0)) * step);
ivec3 runEdge = runsAlongX ? ivec3(run * step, 0, 0) : ivec3(0, 0, run * step);
ivec3 reachEdge = isTop ? ivec3(0, 0, reach * step) : ivec3(0, reach, 0);
ivec3 position = layerOrigin + origin + alongRun * runEdge + alongReach * reachEdge;
vec3 runDirection = runsAlongX ? vec3(1.0, 0.0, 0.0) : vec3(0.0, 0.0, 1.0);
vec3 reachDirection = isTop ? vec3(0.0, 0.0, 1.0) : vec3(0.0, 1.0, 0.0);
vec3 outward = (float(alongRun) * 2.0 - 1.0) * runDirection + (float(alongReach) * 2.0 - 1.0) * reachDirection;
gl_Position = toClip * vec4(vec3(position) - centerFraction + outward * overlap, 1.0);
units = vec2(alongRun * run, alongReach * reach);
firstCell = face.xy;
}
""".formatted(TerrainHole.WORDS, TerrainTile.COLUMNS, TerrainHole.CELL_COLUMNS, TerrainHole.CELLS_ACROSS);
private static final String FACE_FRAGMENT_SHADER = """
#version 330 core
uniform sampler2D colors;
uniform ivec2 runDirection;
uniform float shade;
in vec2 units;
flat in ivec2 firstCell;
flat in int run;
out vec4 fragment;
float outlineStrength(float pixelsPerUnit) {
return clamp((pixelsPerUnit - %1$s) / (%2$s - %1$s), 0.0, 1.0);
}
float lineStrength(float along, float unitsPerPixel) {
float pixelsPerUnit = 1.0 / max(unitsPerPixel, 1e-6);
if (pixelsPerUnit < %1$s || abs(along - round(along)) * pixelsPerUnit >= %3$s) {
return 0.0;
}
return outlineStrength(pixelsPerUnit);
}
void main() {
int along = clamp(int(floor(units.x)), 0, run - 1);
vec3 color = min(texelFetch(colors, firstCell + runDirection * along, 0).rgb * shade, vec3(1.0));
vec2 unitsPerPixel = fwidth(units);
float strength = lineStrength(units.x, unitsPerPixel.x);
if (strength == 0.0) {
strength = lineStrength(units.y, unitsPerPixel.y);
}
float luminance = dot(color, vec3(0.3, 0.59, 0.11)) * 255.0;
vec3 outline = luminance < %4$s ? mix(color, vec3(1.0), %5$s) : color * %6$s;
fragment = vec4(mix(color, outline, strength), 1.0);
}
""".formatted(NoiseTerrainRenderer.LEAST_OUTLINE_SPACING_PIXELS, NoiseTerrainRenderer.FULL_OUTLINE_SPACING_PIXELS,
NoiseTerrainRenderer.OUTLINE_HALF_WIDTH_PIXELS, NoiseTerrainRenderer.DARK_LUMINANCE,
NoiseTerrainRenderer.OUTLINE_LIGHTENING, NoiseTerrainRenderer.OUTLINE_SHADE);
private static final String FLAT_VERTEX_SHADER = """
#version 330 core
uniform mat4 toClip;
uniform vec3 corner;
uniform vec3 edgeU;
uniform vec3 edgeV;
void main() {
float alongU = float(gl_VertexID & 1);
float alongV = float(gl_VertexID >> 1);
gl_Position = toClip * vec4(corner + alongU * edgeU + alongV * edgeV, 1.0);
}
""";
private static final String FLAT_FRAGMENT_SHADER = """
#version 330 core
uniform vec4 color;
out vec4 fragment;
void main() {
fragment = color;
}
""";
private static GpuTerrainRenderer onCard;
private final GpuProgram facesProgram = GpuProgram.of(FACE_VERTEX_SHADER, FACE_FRAGMENT_SHADER);
private final GpuProgram flatProgram = GpuProgram.of(FLAT_VERTEX_SHADER, FLAT_FRAGMENT_SHADER);
private final int attributelessVertexArray = glGenVertexArrays();
// Asked once: asking the card waits for everything sent to it so far.
private final int mostFacesPerLayer = glGetInteger(GL_MAX_TEXTURE_BUFFER_SIZE);
private final int largestColorsSide = glGetInteger(GL_MAX_TEXTURE_SIZE);
private final GpuTileCache uploadedTiles = new GpuTileCache();
/**
* @return the picture, or nothing when the card can't draw this scene and the caller should draw it without one
*/
static Optional<OrbitPicture> render(NoiseTerrainScene scene, OrbitCamera camera, int width, int height, int coveredLeftWidth) {
return GpuFrames.drawPicture(target -> onCard().draw(scene, camera, width, height, coveredLeftWidth, target));
}
/**
* @return the frame, or nothing when it can't go straight into the window and the caller should draw a picture
*/
static Optional<OrbitPicture> renderOnScreen(NoiseTerrainScene scene, ScreenTarget screen, OrbitCamera camera, int width, int height,
int coveredLeftWidth) {
return GpuFrames.drawOnScreen(screen, target -> onCard().draw(scene, camera, width, height, coveredLeftWidth, target));
}
private static GpuTerrainRenderer onCard() {
if (onCard == null) {
onCard = new GpuTerrainRenderer();
}
return onCard;
}
/**
* Draws what of the scene is on the card. What isn't yet is put there over the next frames, as the picture asks to
* be drawn again, so turning the camera never waits on new ground being put on the card.
*/
private Optional<OrbitPicture> draw(NoiseTerrainScene scene, OrbitCamera camera, int width, int height, int coveredLeftWidth,
GpuFrameTarget target) {
Uploads uploads = uploadWithinBudget(scene);
if (uploads == null) {
return Optional.empty();
}
OrbitPicture picture = drawLayers(scene, uploads.layers(), camera, width, height, coveredLeftWidth, target);
return Optional.of(uploads.complete() ? picture : picture.stillCatchingUp());
}
private OrbitPicture drawLayers(NoiseTerrainScene scene, List<UploadedLayer> layers, OrbitCamera camera, int width, int height,
int coveredLeftWidth, GpuFrameTarget target) {
OrbitProjection projection = scene.projection(camera, width, height, coveredLeftWidth);
NoiseTerrainScene.Extent extent = scene.extent();
double depthRange = extent.farthestFrom(projection.center()) + DEPTH_MARGIN_BLOCKS;
float[] toClip = projection.clipMatrix(width, height, depthRange);
target.bindSized(width, height);
GpuFrames.clearToBackground();
for (UploadedLayer layer : layers) {
drawFaces(layer, projection, toClip);
}
if (scene.hasWater()) {
drawWater(extent, BlockFaces.blockTop(scene.waterLevel()), projection, toClip);
}
GpuFrames.failOnError();
return target.finish(projection, depthRange);
}
/**
* Puts as many of the scene's layers on the card as fit in one frame's time, reusing what is already there, and
* says how to draw what is there. A place whose newest ground isn't on the card yet is drawn with what is, such as
* the ground from before the heights changed. Tiles the scene doesn't show stay on the card while they fit and are
* still in memory, as turning back to them is likely.
*
* @return the layers on the card, or null when one is too large for it
*/
private Uploads uploadWithinBudget(NoiseTerrainScene scene) {
UploadProgress progress = uploadCoarsestFirst(scene.layers());
if (progress == UploadProgress.TOO_LARGE) {
return null;
}
List<UploadedLayer> layers = layersOnCard(scene.layers());
Set<GpuTileCache.Place> shown = new HashSet<>();
for (HoledLayer layer : scene.layers()) {
shown.add(GpuTileCache.Place.of(layer.terrain()));
}
uploadedTiles.forgetBeyond(MOST_UPLOADED_BYTES, shown);
return new Uploads(layers, progress == UploadProgress.COMPLETE);
}
// Coarse tiles cover the most ground for their upload, so they go first; finer ones then only cut their holes.
private UploadProgress uploadCoarsestFirst(List<HoledLayer> layers) {
long deadline = System.nanoTime() + UPLOAD_NANOS_PER_FRAME;
List<HoledLayer> coarsestFirst = new ArrayList<>(layers);
coarsestFirst.sort(Comparator.comparingInt((HoledLayer layer) -> layer.terrain().step()).reversed());
UploadProgress progress = UploadProgress.COMPLETE;
for (HoledLayer layer : coarsestFirst) {
GpuTileCache.Place place = GpuTileCache.Place.of(layer.terrain());
GpuTile tile = uploadedTiles.get(place);
if (tile != null && tile.shows(layer.layer())) {
continue;
}
if (System.nanoTime() > deadline) {
progress = UploadProgress.CATCHING_UP;
continue;
}
if (upload(layer.layer(), place, tile) == null) {
return UploadProgress.TOO_LARGE;
}
}
return progress;
}
// A hole is only cut where what fills it is on the card, so no ground is ever left out with nothing in its place.
// Tiles whose fillers are all on the card show nothing, so they aren't drawn at all.
private List<UploadedLayer> layersOnCard(List<HoledLayer> layers) {
List<UploadedLayer> onCard = new ArrayList<>(layers.size());
for (HoledLayer layer : layers) {
GpuTile tile = uploadedTiles.get(GpuTileCache.Place.of(layer.terrain()));
if (tile == null) {
continue;
}
TerrainHole hole = layer.holeAmong(filler -> uploadedTiles.get(GpuTileCache.Place.of(filler)) != null);
if (!hole.hidesAll()) {
onCard.add(new UploadedLayer(layer.terrain(), tile.faces(), tile.colors(), hole));
}
}
return onCard;
}
private GpuTile upload(TerrainLayer layer, GpuTileCache.Place place, GpuTile previous) {
if (previous != null) {
return previous.showInstead(layer, largestColorsSide, mostFacesPerLayer) ? previous : null;
}
GpuTile tile = GpuTile.upload(layer, largestColorsSide, mostFacesPerLayer);
if (tile != null) {
uploadedTiles.put(place, tile);
}
return tile;
}
// Faces looking away from the camera are hidden behind the ones looking toward it, so they're never drawn.
private void drawFaces(UploadedLayer layer, OrbitProjection projection, float[] toClip) {
Vec3 center = projection.center();
int wholeX = (int) Math.floor(center.x());
int wholeY = (int) Math.floor(center.y());
int wholeZ = (int) Math.floor(center.z());
facesProgram.use();
facesProgram.setMatrix("toClip", toClip);
facesProgram.set("colors", 0);
facesProgram.set("faces", 1);
facesProgram.set("layerOrigin", layer.terrain().originX() - wholeX, -wholeY, layer.terrain().originZ() - wholeZ);
facesProgram.set("centerFraction", new Vec3(center.x() - wholeX, center.y() - wholeY, center.z() - wholeZ));
facesProgram.set("step", layer.terrain().step());
facesProgram.set("overlap", FACE_OVERLAP_PIXELS / projection.pixelsPerBlock());
facesProgram.set("hiddenCells", layer.hole().hiddenCells());
glActiveTexture(GL_TEXTURE0);
layer.colors().bind();
glActiveTexture(GL_TEXTURE1);
layer.faces().bind();
glBindVertexArray(attributelessVertexArray);
for (BlockFaces.Kind kind : BlockFaces.Kind.values()) {
facesProgram.set("holeWall", kind == BlockFaces.Kind.HOLE_WALL ? 1 : 0);
for (BlockFaces.Facing facing : BlockFaces.Facing.values()) {
drawFacing(layer.faces(), kind, facing, projection);
}
}
glActiveTexture(GL_TEXTURE0);
}
private void drawFacing(GpuFaces faces, BlockFaces.Kind kind, BlockFaces.Facing facing, OrbitProjection projection) {
int count = faces.count(kind, facing);
if (count == 0 || !projection.facesCamera(facing.normalX, facing.normalY, facing.normalZ)) {
return;
}
facesProgram.set("firstFace", faces.first(kind, facing));
facesProgram.set("normal", facing.normalX, facing.normalY, facing.normalZ);
facesProgram.set("runDirection", facing.runsAlongX() ? 1 : 0, facing.runsAlongX() ? 0 : 1);
facesProgram.set("shade", NoiseTerrainRenderer.faceShadeAt(facing, projection.pixelsPerBlock()));
glDrawArrays(GL_TRIANGLES, 0, count * GpuFaces.CORNERS_PER_FACE);
}
// Water is blended over what lies below its surface, and hides nothing behind it. Ground level with the water's
// surface stays dry rather than flickering between the two.
private void drawWater(NoiseTerrainScene.Extent extent, int level, OrbitProjection projection, float[] toClip) {
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glDepthMask(false);
glEnable(GL_POLYGON_OFFSET_FILL);
glPolygonOffset(1F, 1F);
Vec3 center = projection.center();
flatProgram.use();
flatProgram.setMatrix("toClip", toClip);
flatProgram.set("corner", new Vec3(extent.minX() - center.x(), level - center.y(), extent.minZ() - center.z()));
flatProgram.set("edgeU", new Vec3(extent.sizeX(), 0D, 0D));
flatProgram.set("edgeV", new Vec3(0D, 0D, extent.sizeZ()));
int color = NoiseTerrainRenderer.WATER_COLOR;
flatProgram.set("color", GpuFrames.channel(color, 16), GpuFrames.channel(color, 8), GpuFrames.channel(color, 0),
NoiseTerrainRenderer.WATER_OPACITY);
glBindVertexArray(attributelessVertexArray);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
}
private record Uploads(List<UploadedLayer> layers, boolean complete) {
}
private enum UploadProgress {
COMPLETE,
CATCHING_UP,
TOO_LARGE
}
private record UploadedLayer(NoiseTerrain terrain, GpuFaces faces, GpuColors colors, TerrainHole hole) {
}
}
@@ -0,0 +1,101 @@
/*
* 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 art.arcane.iris.studio.map;
import java.lang.ref.WeakReference;
/**
* A place on the ground on the graphics card: the colours and faces of the terrain last sampled there. They're put on
* the card once, whole, and stay as finer tiles inside come and go, since the face shader leaves out whatever hole they
* cut. When the noise or its heights change, the place is sampled anew; until the newest terrain is on the card, the
* older one still covers the place. Only for the GPU thread.
*/
final class GpuTile implements GpuCache.Upload {
private GpuColors colors;
private GpuFaces faces;
// Held weakly, so ground no longer drawn can be dropped from memory while its upload still stands in for it.
private WeakReference<TerrainLayer> source;
private GpuTile(GpuColors colors, GpuFaces faces, TerrainLayer source) {
this.colors = colors;
this.faces = faces;
this.source = new WeakReference<>(source);
}
/**
* @param largestSide the card's GL_MAX_TEXTURE_SIZE
* @param mostFaces the card's GL_MAX_TEXTURE_BUFFER_SIZE
* @return the tile, or null when its terrain has more columns or rows than the card's textures can hold
*/
static GpuTile upload(TerrainLayer layer, int largestSide, int mostFaces) {
GpuColors colors = GpuColors.upload(layer.terrain(), largestSide);
if (colors == null) {
return null;
}
try {
return new GpuTile(colors, GpuFaces.upload(layer.faces(), mostFaces), layer);
} catch (RuntimeException failure) {
colors.delete();
throw failure;
}
}
boolean shows(TerrainLayer wanted) {
return source.get() == wanted;
}
// Ground whose terrain is gone from memory is drawn only while nothing newer for its place is on the card.
boolean showsForgottenTerrain() {
return source.get() == null;
}
/**
* @return false when the layer's terrain has more columns or rows than the card's textures can hold
*/
boolean showInstead(TerrainLayer wanted, int largestSide, int mostFaces) {
GpuTile next = upload(wanted, largestSide, mostFaces);
if (next == null) {
return false;
}
delete();
colors = next.colors;
faces = next.faces;
source = next.source;
return true;
}
GpuColors colors() {
return colors;
}
GpuFaces faces() {
return faces;
}
@Override
public long bytes() {
return colors.bytes() + faces.bytes();
}
@Override
public void delete() {
colors.delete();
faces.delete();
}
}
@@ -0,0 +1,82 @@
/*
* 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 art.arcane.iris.studio.map;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
/**
* The ground on the graphics card by where it lies, so ground sampled anew for a place replaces what was there, which
* covers the place until then. Only for the GPU thread.
*/
final class GpuTileCache {
private final LinkedHashMap<Place, GpuTile> leastRecentlyUsedFirst = new LinkedHashMap<>(16, 0.75F, true);
GpuTile get(Place place) {
return leastRecentlyUsedFirst.get(place);
}
void put(Place place, GpuTile tile) {
leastRecentlyUsedFirst.put(place, tile);
}
/**
* Deletes ground not shown whose terrain is gone from memory, as it can never be drawn again, then the least
* recently used until the cache fits, sparing what is shown.
*/
void forgetBeyond(long mostBytes, Set<Place> shown) {
long bytes = 0L;
Iterator<Map.Entry<Place, GpuTile>> oldestFirst = leastRecentlyUsedFirst.entrySet().iterator();
while (oldestFirst.hasNext()) {
Map.Entry<Place, GpuTile> entry = oldestFirst.next();
if (!shown.contains(entry.getKey()) && entry.getValue().showsForgottenTerrain()) {
entry.getValue().delete();
oldestFirst.remove();
} else {
bytes += entry.getValue().bytes();
}
}
oldestFirst = leastRecentlyUsedFirst.entrySet().iterator();
while (bytes > mostBytes && oldestFirst.hasNext()) {
Map.Entry<Place, GpuTile> entry = oldestFirst.next();
if (!shown.contains(entry.getKey())) {
bytes -= entry.getValue().bytes();
entry.getValue().delete();
oldestFirst.remove();
}
}
}
/**
* Where a tile's ground lies, and how coarse it is.
*/
record Place(int originX, int originZ, int step) {
static Place of(NoiseTerrain terrain) {
return new Place(terrain.originX(), terrain.originZ(), terrain.step());
}
// The same place as the tile's terrain once sampled.
static Place of(TerrainTile tile) {
TerrainGrid grid = tile.grid();
return new Place(grid.originX(), grid.originZ(), grid.step());
}
}
}
@@ -0,0 +1,96 @@
/*
* 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 art.arcane.iris.studio.map;
import java.awt.Rectangle;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
import java.util.ArrayList;
import java.util.List;
import static org.lwjgl.opengl.GL30.GL_BACK;
import static org.lwjgl.opengl.GL30.GL_BGRA;
import static org.lwjgl.opengl.GL30.GL_FRAMEBUFFER;
import static org.lwjgl.opengl.GL30.GL_UNSIGNED_INT_8_8_8_8_REV;
import static org.lwjgl.opengl.GL30.glBindFramebuffer;
import static org.lwjgl.opengl.GL30.glDrawBuffer;
import static org.lwjgl.opengl.GL30.glReadBuffer;
import static org.lwjgl.opengl.GL30.glReadPixels;
import static org.lwjgl.opengl.GL30.glViewport;
/**
* A frame drawn straight into a view's window. Of what was drawn, only the patches Swing paints where panels float
* over the window are read back. The frame waits in the window's back buffer until {@link GpuFrames#showOnScreen},
* so it can go on screen together with its patches. Only for the GPU thread.
*/
final class GpuWindowFrame implements GpuFrameTarget {
private static final int WINDOW_FRAMEBUFFER = 0;
private final List<Rectangle> seeThrough;
private int width;
private int height;
/**
* @param seeThrough the parts of the view panels float over, which Swing paints instead of the window
*/
GpuWindowFrame(List<Rectangle> seeThrough) {
this.seeThrough = seeThrough;
}
// The context was first made current without any window, which left it drawing into no buffer; NVIDIA's driver
// keeps that once a window comes along.
@Override
public void bindSized(int nextWidth, int nextHeight) {
width = nextWidth;
height = nextHeight;
glBindFramebuffer(GL_FRAMEBUFFER, WINDOW_FRAMEBUFFER);
glDrawBuffer(GL_BACK);
glReadBuffer(GL_BACK);
glViewport(0, 0, width, height);
}
@Override
public OrbitPicture finish(OrbitProjection projection, double depthRange) {
return OrbitPicture.onScreen(width, height, readPatches(), GpuFrameTarget.depthInMiddle(projection, depthRange, width, height));
}
private List<PicturePatch> readPatches() {
Rectangle frame = new Rectangle(0, 0, width, height);
List<PicturePatch> patches = new ArrayList<>(seeThrough.size());
for (Rectangle part : seeThrough) {
Rectangle inFrame = part.intersection(frame);
if (!inFrame.isEmpty()) {
patches.add(read(inFrame));
}
}
return patches;
}
// Rows come bottom up.
private PicturePatch read(Rectangle area) {
int[] rows = new int[area.width * area.height];
glReadPixels(area.x, height - area.y - area.height, area.width, area.height, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, rows);
BufferedImage image = new BufferedImage(area.width, area.height, BufferedImage.TYPE_INT_RGB);
int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
for (int row = 0; row < area.height; row++) {
System.arraycopy(rows, (area.height - 1 - row) * area.width, pixels, row * area.width, area.width);
}
return new PicturePatch(area.x, area.y, image);
}
}
@@ -0,0 +1,130 @@
/*
* 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 art.arcane.iris.studio.map;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.DoubleBinaryOperator;
/**
* How the 3D terrain view looks at the ground for one camera: which ground lies under a pixel, and which ground is on
* screen. The ground's height is asked for anew, so it holds wherever tiles are still coarse or missing.
*
* @param left where the part of the view no panel floats over starts
* @param floorY the lowest the ground goes, as far as the view knows
* @param topY the highest the ground goes, as far as the view knows
*/
record GroundSight(OrbitProjection projection, int left, int width, int height, int floorY, int topY) {
// Across the ground per step along a line of sight, so no column is stepped over.
private static final double MARCH_STEP_BLOCKS = 1D;
private static final int REFINE_STEPS = 10;
/**
* The ground drawn at a pixel: the first point down the line of sight that lies below the surface.
*
* @param surfaceY the ground's height at a spot, or the water's where that is higher
*/
Optional<Vec3> groundAt(double screenX, double screenY, DoubleBinaryOperator surfaceY) {
double sinPitch = projection.towardCamera().y();
double stepY = MARCH_STEP_BLOCKS * sinPitch / Math.sqrt(Math.max(1E-6, 1D - sinPitch * sinPitch));
double aboveY = topY;
for (double y = topY; y >= floorY; y -= stepY) {
Vec3 point = projection.pointAt(screenX, screenY, y);
if (surfaceY.applyAsDouble(point.x(), point.z()) >= y) {
return Optional.of(refine(screenX, screenY, aboveY, y, surfaceY));
}
aboveY = y;
}
return Optional.empty();
}
// Halves the stretch of the line of sight where it passes into the ground until the point sits on the surface.
private Vec3 refine(double screenX, double screenY, double aboveY, double belowY, DoubleBinaryOperator surfaceY) {
double above = aboveY;
double below = belowY;
for (int step = 0; step < REFINE_STEPS; step++) {
double middle = (above + below) / 2D;
Vec3 point = projection.pointAt(screenX, screenY, middle);
if (surfaceY.applyAsDouble(point.x(), point.z()) >= middle) {
below = middle;
} else {
above = middle;
}
}
Vec3 point = projection.pointAt(screenX, screenY, below);
return new Vec3(point.x(), surfaceY.applyAsDouble(point.x(), point.z()), point.z());
}
/**
* Spots spread evenly over the ground on screen, about {@code most} of them over the stretch the view can reach. The
* view looks along parallel lines, so even on the ground is even on screen, but for slopes and hidden valleys. The
* spots keep to a grid fixed in the world, so turning the view a little doesn't change which spots are counted.
*/
List<Vec3> groundInView(int most, DoubleBinaryOperator surfaceY) {
Reach reach = reach();
double spacing = gridSpacing(reach, most);
List<Vec3> spots = new ArrayList<>();
for (double z = Math.floor(reach.minZ() / spacing) * spacing; z <= reach.maxZ(); z += spacing) {
for (double x = Math.floor(reach.minX() / spacing) * spacing; x <= reach.maxX(); x += spacing) {
double y = surfaceY.applyAsDouble(x, z);
if (isOnScreen(x, y, z)) {
spots.add(new Vec3(x, y, z));
}
}
}
return spots;
}
private boolean isOnScreen(double x, double y, double z) {
double screenX = projection.screenX(x, y, z);
double screenY = projection.screenY(x, y, z);
return screenX >= left && screenX < width && screenY >= 0D && screenY < height;
}
// The ground the view's corners look at, from the floor to the top.
private Reach reach() {
double minX = Double.POSITIVE_INFINITY;
double minZ = Double.POSITIVE_INFINITY;
double maxX = Double.NEGATIVE_INFINITY;
double maxZ = Double.NEGATIVE_INFINITY;
for (double screenX : new double[]{left, width}) {
for (double screenY : new double[]{0D, height}) {
for (double groundY : new double[]{floorY, topY}) {
Vec3 point = projection.pointAt(screenX, screenY, groundY);
minX = Math.min(minX, point.x());
minZ = Math.min(minZ, point.z());
maxX = Math.max(maxX, point.x());
maxZ = Math.max(maxZ, point.z());
}
}
}
return new Reach(minX, minZ, maxX, maxZ);
}
// A whole power of two, so zooming only changes the grid when the spacing has doubled or halved.
private static double gridSpacing(Reach reach, int most) {
double area = Math.max(1D, (reach.maxX() - reach.minX()) * (reach.maxZ() - reach.minZ()));
double spacing = Math.sqrt(area / Math.max(1, most));
return Math.pow(2D, Math.ceil(Math.log(Math.max(1D, spacing)) / Math.log(2D)));
}
private record Reach(double minX, double minZ, double maxX, double maxZ) {
}
}
@@ -0,0 +1,41 @@
/*
* 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 art.arcane.iris.studio.map;
import java.util.List;
import java.util.function.Predicate;
/**
* A tile's ground as the 3D noise view shows it, less the finer tiles inside it that are ready to fill their part. The
* hole is cut when drawing, from the fillers that are drawable by then, so ground is never left out before what fills
* it can be drawn.
*/
record HoledLayer(TerrainTile tile, TerrainLayer layer, List<TerrainTile> fillers) {
TerrainHole holeAmong(Predicate<TerrainTile> drawable) {
return TerrainHole.of(tile, fillers.stream().filter(drawable).toList());
}
TerrainHole holeOfAllFillers() {
return TerrainHole.of(tile, fillers);
}
NoiseTerrain terrain() {
return layer.terrain();
}
}
@@ -1,6 +1,5 @@
package art.arcane.iris.studio.view;
package art.arcane.iris.studio.map;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.localization.DesktopUiMessages;
import art.arcane.iris.localization.IrisLanguage;
@@ -32,8 +31,8 @@ import javax.swing.DefaultCellEditor;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JFileChooser;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
@@ -47,17 +46,13 @@ import javax.swing.filechooser.FileNameExtensionFilter;
import javax.swing.table.DefaultTableModel;
import java.awt.BorderLayout;
import java.awt.CardLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.FlowLayout;
import java.awt.Font;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.GridLayout;
import java.awt.Insets;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.File;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -66,25 +61,23 @@ import java.util.List;
import java.util.Objects;
import java.util.function.DoubleBinaryOperator;
public final class ImageMapStudioGUI {
private static final Color BACKGROUND = new Color(12, 15, 22);
private static final Color SIDEBAR_BACKGROUND = new Color(17, 20, 29);
private static final Color PANEL_BACKGROUND = new Color(23, 27, 38);
private static final Color FIELD_BACKGROUND = new Color(29, 34, 47);
private static final Color PRIMARY_TEXT = new Color(230, 234, 242);
private static final Color SECONDARY_TEXT = new Color(151, 160, 178);
private static final Color ACCENT = new Color(99, 161, 255);
private static final Color BORDER = new Color(48, 55, 72);
private static final Color ERROR = new Color(255, 116, 116);
private static final Color SUCCESS = new Color(91, 204, 139);
private static final Font BODY_FONT = new Font(Font.SANS_SERIF, Font.PLAIN, 12);
private static final Font LABEL_FONT = new Font(Font.SANS_SERIF, Font.BOLD, 11);
/**
* The map window's Image maps view: turns a PNG into terrain height, biomes or a mask, previews it on the world and
* exports it into the pack.
*/
final class ImageMapExplorer {
private static final int EDITOR_WIDTH = 470;
private static final int OVERLAY_COLUMNS = 2;
private static final int TABLE_ROW_HEIGHT = 23;
private static final String STATUS_SEPARATOR = " · ";
private final Engine engine;
private final File packFolder;
private final String dimensionKey;
private final JFrame frame;
private final ImageMapStudioPreviewPanel previewPanel = new ImageMapStudioPreviewPanel();
private final StudioStatusBar statusBar = new StudioStatusBar();
private final List<JComponent> controls = new ArrayList<>();
private final JSplitPane view;
private final JComboBox<String> presetPicker = new JComboBox<>();
private final JTextField bindingKeyField = field("image-map");
private final JTextField mapKeyField = field("image-map");
@@ -132,7 +125,6 @@ public final class ImageMapStudioGUI {
private final JPanel typeCards = new JPanel(new CardLayout());
private final JTextArea metadataArea = area(3);
private final JTextArea diagnosticsArea = area(5);
private final JLabel statusLabel = new JLabel();
private final JCheckBox chunksOverlay = selectedCheck();
private final JCheckBox regionsOverlay = selectedCheck();
private final JCheckBox boundaryOverlay = selectedCheck();
@@ -141,59 +133,60 @@ public final class ImageMapStudioGUI {
private IrisWorldBoundary boundary;
private boolean controlsUpdating;
private ImageMapStudioGUI(Engine engine) {
ImageMapExplorer(Engine engine) {
this.engine = Objects.requireNonNull(engine, "Image Map Studio engine");
this.packFolder = engine.getPackSource().toFile();
this.dimensionKey = engine.getDimension().getLoadKey();
this.boundary = engine.getDimension().getWorldBoundary() == null
? null
: IrisWorldBoundary.snapshot(engine.getDimension().getWorldBoundary());
this.frame = new JFrame(text(DesktopUiMessages.IMAGEMAP_TITLE));
configureFrame();
buildControls();
this.view = buildWorkspace();
setStatus(DesktopUiMessages.IMAGEMAP_READY);
configureControls();
refreshPresets();
}
public static boolean isAvailable() {
return GuiHost.isAvailable() && IrisSettings.get().getGui().isUseServerLaunchedGuis();
}
public static void launch(Engine engine) {
if (!GuiHost.isAvailable()) {
return;
}
EventQueue.invokeLater(() -> {
if (GuiHost.isAvailable()) {
new ImageMapStudioGUI(engine).show();
}
});
}
static void reloadActiveEngine(Engine engine) {
engine.hotloadSilently();
}
private void configureFrame() {
GuiHost.prepareFrame(frame);
frame.getContentPane().setBackground(BACKGROUND);
frame.setLayout(new BorderLayout());
frame.add(buildToolbar(), BorderLayout.NORTH);
frame.add(buildWorkspace(), BorderLayout.CENTER);
frame.add(buildStatusBar(), BorderLayout.SOUTH);
frame.setSize(1540, 920);
frame.setMinimumSize(new Dimension(1040, 680));
frame.setLocationRelativeTo(null);
frame.addWindowListener(new WindowAdapter() {
@Override
public void windowClosed(WindowEvent event) {
previewPanel.close();
}
JComponent view() {
return view;
}
@Override
public void windowClosing(WindowEvent event) {
previewPanel.close();
}
});
JComponent statusBar() {
return statusBar;
}
/**
* Starts the editor right of the floating view list instead of under it.
*/
void setCoveredLeftWidth(int width) {
view.setBorder(BorderFactory.createEmptyBorder(0, width, 0, 0));
view.setDividerLocation(width + StudioTheme.scale(EDITOR_WIDTH));
view.revalidate();
}
/**
* The toolbar buttons that belong to this view.
*/
List<JComponent> controls() {
return controls;
}
/**
* Loads the first preset, once the view is on screen for the first time.
*/
void open() {
String selected = (String) presetPicker.getSelectedItem();
if (selected != null && !selected.isBlank()) {
loadPreset(selected);
}
}
void close() {
previewPanel.close();
}
private void configureControls() {
@@ -214,90 +207,64 @@ public final class ImageMapStudioGUI {
updateOverlays();
}
private void show() {
frame.setVisible(true);
EventQueue.invokeLater(() -> {
String selected = (String) presetPicker.getSelectedItem();
if (selected != null && !selected.isBlank()) {
loadPreset(selected);
}
});
}
private JPanel buildToolbar() {
JPanel toolbar = new JPanel(new BorderLayout());
toolbar.setBackground(SIDEBAR_BACKGROUND);
toolbar.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, BORDER));
JPanel leading = new JPanel(new FlowLayout(FlowLayout.LEFT, 7, 6));
leading.setOpaque(false);
JLabel presetLabel = label(DesktopUiMessages.IMAGEMAP_PRESET);
leading.add(presetLabel);
private void buildControls() {
presetPicker.setPrototypeDisplayValue("terrain/example-long-preset");
styleCombo(presetPicker);
leading.add(presetPicker);
JButton load = button(DesktopUiMessages.IMAGEMAP_LOAD);
load.addActionListener(event -> {
presetPicker.setToolTipText("Image maps already in the pack");
presetPicker.setFocusable(false);
presetPicker.setMaximumSize(presetPicker.getPreferredSize());
controls.add(StudioToolbar.label(text(DesktopUiMessages.IMAGEMAP_PRESET)));
controls.add(presetPicker);
controls.add(StudioToolbar.button(text(DesktopUiMessages.IMAGEMAP_LOAD), "Load the chosen image map", () -> {
String selected = (String) presetPicker.getSelectedItem();
if (selected != null) {
loadPreset(selected);
}
});
leading.add(load);
JButton importButton = button(DesktopUiMessages.IMAGEMAP_IMPORT_PNG);
importButton.addActionListener(event -> chooseSource(false));
leading.add(importButton);
JButton replaceButton = button(DesktopUiMessages.IMAGEMAP_REPLACE_PNG);
replaceButton.addActionListener(event -> chooseSource(true));
leading.add(replaceButton);
toolbar.add(leading, BorderLayout.WEST);
JPanel trailing = new JPanel(new FlowLayout(FlowLayout.RIGHT, 7, 6));
trailing.setOpaque(false);
JButton preview = button(DesktopUiMessages.IMAGEMAP_PREVIEW);
preview.addActionListener(event -> preview());
trailing.add(preview);
JButton export = button(DesktopUiMessages.IMAGEMAP_EXPORT);
export.setForeground(SUCCESS);
export.addActionListener(event -> export());
trailing.add(export);
toolbar.add(trailing, BorderLayout.EAST);
return toolbar;
}));
controls.add(StudioToolbar.button(text(DesktopUiMessages.IMAGEMAP_IMPORT_PNG), "Start a new image map from a PNG",
() -> chooseSource(false)));
controls.add(StudioToolbar.button(text(DesktopUiMessages.IMAGEMAP_REPLACE_PNG), "Swap the PNG behind this image map",
() -> chooseSource(true)));
controls.add(StudioToolbar.button(text(DesktopUiMessages.IMAGEMAP_PREVIEW), "Preview the image map on the world",
this::preview));
controls.add(StudioToolbar.button(text(DesktopUiMessages.IMAGEMAP_EXPORT), "Write the image map into the pack and hotload",
this::export));
}
private Component buildWorkspace() {
private JSplitPane buildWorkspace() {
JScrollPane editorScroll = new JScrollPane(buildEditor());
editorScroll.setBorder(BorderFactory.createEmptyBorder());
editorScroll.getVerticalScrollBar().setUnitIncrement(18);
editorScroll.setPreferredSize(new Dimension(470, 760));
editorScroll.getVerticalScrollBar().setUnitIncrement(StudioTheme.scale(18));
editorScroll.setPreferredSize(StudioTheme.scaledSize(EDITOR_WIDTH, 760));
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, editorScroll, previewPanel);
split.setBorder(BorderFactory.createEmptyBorder());
split.setDividerSize(5);
split.setResizeWeight(0D);
split.setDividerLocation(470);
split.setDividerLocation(StudioTheme.scale(EDITOR_WIDTH));
split.putClientProperty("FlatLaf.style", "style: grip");
return split;
}
private JPanel buildEditor() {
JPanel editor = new JPanel();
JPanel editor = new ViewportWidthPanel();
editor.setLayout(new BoxLayout(editor, BoxLayout.Y_AXIS));
editor.setBackground(SIDEBAR_BACKGROUND);
editor.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
editor.setBackground(StudioTheme.PANEL_BACKGROUND);
editor.setBorder(StudioTheme.emptyBorder(10, 10, 10, 10));
editor.add(section(DesktopUiMessages.IMAGEMAP_METADATA, metadataPanel()));
editor.add(Box.createVerticalStrut(9));
editor.add(Box.createVerticalStrut(StudioTheme.scale(9)));
editor.add(section(DesktopUiMessages.IMAGEMAP_RESOURCE, resourcePanel()));
editor.add(Box.createVerticalStrut(9));
editor.add(Box.createVerticalStrut(StudioTheme.scale(9)));
editor.add(section(DesktopUiMessages.IMAGEMAP_COORDINATES, coordinatePanel()));
editor.add(Box.createVerticalStrut(9));
editor.add(Box.createVerticalStrut(StudioTheme.scale(9)));
typeCards.add(heightPanel(), "HEIGHT");
typeCards.add(colorPanel(), "COLOR");
typeCards.add(maskPanel(), "MASK");
typeCards.setBackground(SIDEBAR_BACKGROUND);
typeCards.setOpaque(false);
typeCards.setAlignmentX(Component.LEFT_ALIGNMENT);
editor.add(typeCards);
editor.add(Box.createVerticalStrut(9));
editor.add(Box.createVerticalStrut(StudioTheme.scale(9)));
editor.add(section(DesktopUiMessages.IMAGEMAP_COMPOSED_MASKS, composedMaskPanel()));
editor.add(Box.createVerticalStrut(9));
editor.add(Box.createVerticalStrut(StudioTheme.scale(9)));
editor.add(section(DesktopUiMessages.IMAGEMAP_OVERLAYS, overlayPanel()));
editor.add(Box.createVerticalStrut(9));
editor.add(Box.createVerticalStrut(StudioTheme.scale(9)));
editor.add(section(DesktopUiMessages.IMAGEMAP_DIAGNOSTICS, diagnosticsPanel()));
return editor;
}
@@ -358,18 +325,15 @@ public final class ImageMapStudioGUI {
}
private JPanel colorPanel() {
JPanel panel = new JPanel(new BorderLayout(0, 7));
JPanel panel = new JPanel(new BorderLayout(0, StudioTheme.scale(7)));
panel.setOpaque(false);
JPanel settings = form();
addRow(settings, 0, DesktopUiMessages.IMAGEMAP_COLOR_TOLERANCE, toleranceField);
addRow(settings, 1, DesktopUiMessages.IMAGEMAP_UNKNOWN_COLOR, unknownColorPicker);
panel.add(settings, BorderLayout.NORTH);
configureLegendTable();
JScrollPane scroll = new JScrollPane(legendTable);
scroll.setPreferredSize(new Dimension(390, 160));
scroll.setBorder(BorderFactory.createLineBorder(BORDER));
panel.add(scroll, BorderLayout.CENTER);
JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0));
configureTable(legendTable);
panel.add(tableScroll(legendTable, 160), BorderLayout.CENTER);
JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT, StudioTheme.scale(6), 0));
actions.setOpaque(false);
JButton add = button(DesktopUiMessages.IMAGEMAP_ADD_COLOR);
add.addActionListener(event -> legendModel.addRow(new Object[]{"#000000", "iris:"}));
@@ -382,7 +346,7 @@ public final class ImageMapStudioGUI {
}
private JPanel overlayPanel() {
JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEFT, 9, 2));
JPanel panel = new JPanel(new GridLayout(0, OVERLAY_COLUMNS, StudioTheme.scale(9), StudioTheme.scale(2)));
panel.setOpaque(false);
chunksOverlay.setText(text(DesktopUiMessages.IMAGEMAP_CHUNKS));
regionsOverlay.setText(text(DesktopUiMessages.IMAGEMAP_REGIONS));
@@ -396,13 +360,10 @@ public final class ImageMapStudioGUI {
}
private JPanel composedMaskPanel() {
JPanel panel = new JPanel(new BorderLayout(0, 7));
JPanel panel = new JPanel(new BorderLayout(0, StudioTheme.scale(7)));
panel.setOpaque(false);
JScrollPane scroll = new JScrollPane(composedMaskTable);
scroll.setPreferredSize(new Dimension(390, 120));
scroll.setBorder(BorderFactory.createLineBorder(BORDER));
panel.add(scroll, BorderLayout.CENTER);
JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0));
panel.add(tableScroll(composedMaskTable, 120), BorderLayout.CENTER);
JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT, StudioTheme.scale(6), 0));
actions.setOpaque(false);
JButton add = button(DesktopUiMessages.IMAGEMAP_ADD_MASK);
add.addActionListener(event -> composedMaskModel.addRow(new Object[]{
@@ -421,21 +382,6 @@ public final class ImageMapStudioGUI {
return single(diagnosticsArea);
}
private JPanel buildStatusBar() {
JPanel panel = new JPanel(new BorderLayout());
panel.setBackground(PANEL_BACKGROUND);
panel.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createMatteBorder(1, 0, 0, 0, BORDER),
BorderFactory.createEmptyBorder(5, 9, 5, 9)
));
statusLabel.setForeground(SECONDARY_TEXT);
statusLabel.setFont(BODY_FONT);
statusLabel.setText(packFolder.getName() + " | " + dimensionKey + " | "
+ text(DesktopUiMessages.IMAGEMAP_READY));
panel.add(statusLabel, BorderLayout.WEST);
return panel;
}
private void refreshPresets() {
IrisData data = IrisData.openRuntime(packFolder);
try {
@@ -518,7 +464,7 @@ public final class ImageMapStudioGUI {
? DesktopUiMessages.IMAGEMAP_REPLACE_PNG
: DesktopUiMessages.IMAGEMAP_IMPORT_PNG));
chooser.setFileFilter(new FileNameExtensionFilter("PNG", "png"));
if (chooser.showOpenDialog(frame) != JFileChooser.APPROVE_OPTION) {
if (chooser.showOpenDialog(view) != JFileChooser.APPROVE_OPTION) {
return;
}
sourcePath = chooser.getSelectedFile().toPath().toAbsolutePath().normalize();
@@ -626,7 +572,7 @@ public final class ImageMapStudioGUI {
diagnosticsArea.setText(warnings.isEmpty()
? text(DesktopUiMessages.IMAGEMAP_PREVIEW_VALID)
: String.join("\n", warnings));
diagnosticsArea.setForeground(warnings.isEmpty() ? SUCCESS : PRIMARY_TEXT);
diagnosticsArea.setForeground(warnings.isEmpty() ? StudioTheme.SUCCESS : StudioTheme.TEXT);
setStatus(DesktopUiMessages.IMAGEMAP_READY);
} catch (Exception exception) {
showFailure(text(DesktopUiMessages.IMAGEMAP_PREVIEW_FAILED), exception);
@@ -682,7 +628,7 @@ public final class ImageMapStudioGUI {
for (String warning : result.warnings()) {
message.append('\n').append(warning);
}
diagnosticsArea.setForeground(SUCCESS);
diagnosticsArea.setForeground(StudioTheme.SUCCESS);
diagnosticsArea.setText(message.toString());
setStatus(DesktopUiMessages.IMAGEMAP_EXPORTED);
refreshPresets();
@@ -853,35 +799,25 @@ public final class ImageMapStudioGUI {
);
}
private void configureLegendTable() {
legendTable.setBackground(FIELD_BACKGROUND);
legendTable.setForeground(PRIMARY_TEXT);
legendTable.setGridColor(BORDER);
legendTable.setSelectionBackground(new Color(42, 55, 78));
legendTable.setSelectionForeground(PRIMARY_TEXT);
legendTable.setFont(BODY_FONT);
legendTable.setRowHeight(23);
legendTable.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
legendTable.getTableHeader().setBackground(PANEL_BACKGROUND);
legendTable.getTableHeader().setForeground(PRIMARY_TEXT);
private static void configureTable(JTable table) {
table.setRowHeight(StudioTheme.scale(TABLE_ROW_HEIGHT));
table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
}
private void configureComposedMaskTable() {
composedMaskTable.setBackground(FIELD_BACKGROUND);
composedMaskTable.setForeground(PRIMARY_TEXT);
composedMaskTable.setGridColor(BORDER);
composedMaskTable.setSelectionBackground(new Color(42, 55, 78));
composedMaskTable.setSelectionForeground(PRIMARY_TEXT);
composedMaskTable.setFont(BODY_FONT);
composedMaskTable.setRowHeight(23);
composedMaskTable.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
composedMaskTable.getTableHeader().setBackground(PANEL_BACKGROUND);
composedMaskTable.getTableHeader().setForeground(PRIMARY_TEXT);
configureTable(composedMaskTable);
composedMaskTable.getColumnModel().getColumn(1).setCellEditor(
new DefaultCellEditor(new JComboBox<>(IrisImageMapMaskOperation.values()))
);
}
private static JScrollPane tableScroll(JTable table, int height) {
JScrollPane scroll = new JScrollPane(table);
scroll.setPreferredSize(StudioTheme.scaledSize(390, height));
scroll.setBorder(BorderFactory.createLineBorder(StudioTheme.CONTROL_BORDER));
return scroll;
}
private void removeLegendRows() {
removeRows(legendTable, legendModel);
}
@@ -927,7 +863,7 @@ public final class ImageMapStudioGUI {
return;
}
String current = diagnosticsArea.getText();
diagnosticsArea.setForeground(ERROR);
diagnosticsArea.setForeground(StudioTheme.ERROR);
diagnosticsArea.setText(current.isBlank() ? diagnostic : current + "\n" + diagnostic);
}
@@ -939,13 +875,13 @@ public final class ImageMapStudioGUI {
if (!(cause instanceof IllegalArgumentException)) {
IrisLogging.reportError(cause);
}
diagnosticsArea.setForeground(ERROR);
diagnosticsArea.setForeground(StudioTheme.ERROR);
diagnosticsArea.setText(prefix + ": " + String.valueOf(cause.getMessage()));
statusLabel.setText(prefix);
statusBar.showError(prefix);
}
private void setStatus(TextKey key) {
statusLabel.setText(packFolder.getName() + " | " + dimensionKey + " | " + text(key));
statusBar.showSummary(packFolder.getName() + STATUS_SEPARATOR + dimensionKey + STATUS_SEPARATOR + text(key));
}
private void requireSource() {
@@ -961,42 +897,41 @@ public final class ImageMapStudioGUI {
}
private static void addRow(JPanel panel, int row, TextKey key, Component component) {
int rowGap = StudioTheme.scale(3);
GridBagConstraints labelConstraints = new GridBagConstraints();
labelConstraints.gridx = 0;
labelConstraints.gridy = row;
labelConstraints.anchor = GridBagConstraints.WEST;
labelConstraints.insets = new Insets(3, 0, 3, 8);
JLabel label = label(key);
panel.add(label, labelConstraints);
labelConstraints.insets = new Insets(rowGap, 0, rowGap, StudioTheme.scale(8));
panel.add(label(key), labelConstraints);
GridBagConstraints componentConstraints = new GridBagConstraints();
componentConstraints.gridx = 1;
componentConstraints.gridy = row;
componentConstraints.weightx = 1D;
componentConstraints.fill = GridBagConstraints.HORIZONTAL;
componentConstraints.insets = new Insets(3, 0, 3, 0);
componentConstraints.insets = new Insets(rowGap, 0, rowGap, 0);
panel.add(component, componentConstraints);
}
private static JPanel section(TextKey title, Component content) {
JPanel panel = new JPanel(new BorderLayout());
panel.setAlignmentX(Component.LEFT_ALIGNMENT);
panel.setBackground(PANEL_BACKGROUND);
panel.setBorder(BorderFactory.createLineBorder(BORDER));
panel.setOpaque(false);
panel.setBorder(BorderFactory.createLineBorder(StudioTheme.CONTROL_BORDER));
JLabel heading = new JLabel(text(title));
heading.setForeground(PRIMARY_TEXT);
heading.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 12));
heading.setFont(StudioTheme.sectionFont());
heading.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createMatteBorder(0, 0, 1, 0, BORDER),
BorderFactory.createEmptyBorder(7, 9, 7, 9)
BorderFactory.createMatteBorder(0, 0, 1, 0, StudioTheme.CONTROL_BORDER),
StudioTheme.emptyBorder(7, 9, 7, 9)
));
panel.add(heading, BorderLayout.NORTH);
JPanel body = new JPanel(new BorderLayout());
body.setOpaque(false);
body.setBorder(BorderFactory.createEmptyBorder(8, 9, 9, 9));
body.setBorder(StudioTheme.emptyBorder(8, 9, 9, 9));
body.add(content, BorderLayout.CENTER);
panel.add(body, BorderLayout.CENTER);
panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, panel.getPreferredSize().height + 300));
panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, panel.getPreferredSize().height + StudioTheme.scale(300)));
return panel;
}
@@ -1009,34 +944,19 @@ public final class ImageMapStudioGUI {
private static JLabel label(TextKey key) {
JLabel label = new JLabel(text(key));
label.setForeground(SECONDARY_TEXT);
label.setFont(LABEL_FONT);
label.setForeground(StudioTheme.SECONDARY_TEXT);
label.setFont(StudioTheme.smallFont());
return label;
}
private static JButton button(TextKey key) {
JButton button = new JButton(text(key));
button.setBackground(FIELD_BACKGROUND);
button.setForeground(PRIMARY_TEXT);
button.setFocusPainted(false);
button.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createLineBorder(BORDER),
BorderFactory.createEmptyBorder(5, 9, 5, 9)
));
button.setFocusable(false);
return button;
}
private static JTextField field(String value) {
JTextField field = new JTextField(value);
field.setBackground(FIELD_BACKGROUND);
field.setForeground(PRIMARY_TEXT);
field.setCaretColor(PRIMARY_TEXT);
field.setFont(BODY_FONT);
field.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createLineBorder(BORDER),
BorderFactory.createEmptyBorder(5, 7, 5, 7)
));
return field;
return new JTextField(value);
}
private static JTextArea area(int rows) {
@@ -1044,18 +964,14 @@ public final class ImageMapStudioGUI {
area.setEditable(false);
area.setLineWrap(true);
area.setWrapStyleWord(true);
area.setBackground(FIELD_BACKGROUND);
area.setForeground(PRIMARY_TEXT);
area.setFont(BODY_FONT);
area.setBorder(BorderFactory.createEmptyBorder(6, 7, 6, 7));
area.setBorder(StudioTheme.emptyBorder(6, 7, 6, 7));
return area;
}
private static JCheckBox check() {
JCheckBox check = new JCheckBox();
check.setOpaque(false);
check.setForeground(PRIMARY_TEXT);
check.setFocusPainted(false);
check.setFocusable(false);
return check;
}
@@ -1067,15 +983,8 @@ public final class ImageMapStudioGUI {
private static <T> JComboBox<T> combo(T[] values) {
JComboBox<T> combo = new JComboBox<>(values);
styleCombo(combo);
return combo;
}
private static void styleCombo(JComboBox<?> combo) {
combo.setBackground(FIELD_BACKGROUND);
combo.setForeground(PRIMARY_TEXT);
combo.setFont(BODY_FONT);
combo.setFocusable(false);
return combo;
}
private static double number(JTextField field, String name) {
@@ -1,4 +1,4 @@
package art.arcane.iris.studio.view;
package art.arcane.iris.studio.map;
import art.arcane.iris.generation.image.IrisImage;
import art.arcane.iris.generation.image.IrisImageMap;
@@ -1,4 +1,4 @@
package art.arcane.iris.studio.view;
package art.arcane.iris.studio.map;
import art.arcane.iris.localization.DesktopUiMessages;
import art.arcane.iris.localization.IrisLanguage;
@@ -40,11 +40,9 @@ import java.util.function.Consumer;
import java.util.function.DoubleBinaryOperator;
final class ImageMapStudioPreviewPanel extends JPanel implements AutoCloseable {
private static final Color BACKGROUND = new Color(12, 15, 22);
private static final Color PANEL_BACKGROUND = new Color(20, 24, 33);
private static final Color BORDER = new Color(48, 55, 72);
private static final Color TEXT = new Color(230, 234, 242);
private static final Color SECONDARY = new Color(151, 160, 178);
// The images and their overlay lines are drawn for a dark backdrop, in either theme.
private static final Color CANVAS_BACKGROUND = new Color(12, 15, 22);
private static final Color CANVAS_TEXT = new Color(151, 160, 178);
private final SourceCanvas sourceCanvas = new SourceCanvas();
private final WorldCanvas worldCanvas = new WorldCanvas();
@@ -52,21 +50,19 @@ final class ImageMapStudioPreviewPanel extends JPanel implements AutoCloseable {
ImageMapStudioPreviewPanel() {
super(new BorderLayout());
setBackground(BACKGROUND);
setBackground(StudioTheme.PANEL_BACKGROUND);
JPanel source = wrap(IrisLanguage.plain(DesktopUiMessages.IMAGEMAP_SOURCE), sourceCanvas);
JPanel interpreted = wrap(IrisLanguage.plain(DesktopUiMessages.IMAGEMAP_INTERPRETED), worldCanvas);
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, source, interpreted);
split.setBorder(BorderFactory.createEmptyBorder());
split.setDividerSize(5);
split.setResizeWeight(0.42D);
split.setBackground(BACKGROUND);
split.putClientProperty("FlatLaf.style", "style: grip");
add(split, BorderLayout.CENTER);
statusLabel.setForeground(SECONDARY);
statusLabel.setBackground(PANEL_BACKGROUND);
statusLabel.setOpaque(true);
statusLabel.setForeground(StudioTheme.SECONDARY_TEXT);
statusLabel.setFont(StudioTheme.smallFont());
statusLabel.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createMatteBorder(1, 0, 0, 0, BORDER),
BorderFactory.createEmptyBorder(5, 9, 5, 9)
BorderFactory.createMatteBorder(1, 0, 0, 0, StudioTheme.SEPARATOR),
StudioTheme.emptyBorder(5, 9, 5, 9)
));
add(statusLabel, BorderLayout.SOUTH);
worldCanvas.setHoverConsumer(statusLabel::setText);
@@ -123,15 +119,13 @@ final class ImageMapStudioPreviewPanel extends JPanel implements AutoCloseable {
private static JPanel wrap(String title, JPanel canvas) {
JPanel panel = new JPanel(new BorderLayout());
panel.setBackground(PANEL_BACKGROUND);
panel.setBorder(BorderFactory.createLineBorder(BORDER));
panel.setOpaque(false);
panel.setBorder(BorderFactory.createLineBorder(StudioTheme.SEPARATOR));
JLabel label = new JLabel(title);
label.setForeground(TEXT);
label.setBackground(PANEL_BACKGROUND);
label.setOpaque(true);
label.setFont(StudioTheme.sectionFont());
label.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createMatteBorder(0, 0, 1, 0, BORDER),
BorderFactory.createEmptyBorder(6, 9, 6, 9)
BorderFactory.createMatteBorder(0, 0, 1, 0, StudioTheme.SEPARATOR),
StudioTheme.emptyBorder(6, 9, 6, 9)
));
panel.add(label, BorderLayout.NORTH);
panel.add(canvas, BorderLayout.CENTER);
@@ -142,7 +136,7 @@ final class ImageMapStudioPreviewPanel extends JPanel implements AutoCloseable {
private BufferedImage source;
private SourceCanvas() {
setBackground(BACKGROUND);
setBackground(CANVAS_BACKGROUND);
setPreferredSize(new Dimension(420, 520));
}
@@ -217,7 +211,7 @@ final class ImageMapStudioPreviewPanel extends JPanel implements AutoCloseable {
private double dragCenterZ;
private WorldCanvas() {
setBackground(BACKGROUND);
setBackground(CANVAS_BACKGROUND);
setPreferredSize(new Dimension(620, 520));
MouseAdapter mouse = new MouseAdapter() {
@Override
@@ -414,7 +408,7 @@ final class ImageMapStudioPreviewPanel extends JPanel implements AutoCloseable {
);
case COLOR_MAP -> categoricalApplication()
&& !IrisImageMapRuntime.selectCategorical(maskWeight)
? BACKGROUND.getRGB()
? CANVAS_BACKGROUND.getRGB()
: ImageMapStudioModel.targetColor(compiled.sampleTarget(worldX, worldZ));
case BINARY_MASK, GRAYSCALE_MASK, ALPHA_MASK -> grayscale(
compiled.sampleNormalized(worldX, worldZ)
@@ -603,7 +597,7 @@ final class ImageMapStudioPreviewPanel extends JPanel implements AutoCloseable {
private static void drawEmpty(Graphics graphics, String text) {
Graphics2D canvas = (Graphics2D) graphics.create();
try {
canvas.setColor(SECONDARY);
canvas.setColor(CANVAS_TEXT);
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
int width = canvas.getFontMetrics().stringWidth(text);
int x = Math.max(8, (graphics.getClipBounds() == null ? 0 : graphics.getClipBounds().width - width) / 2);
@@ -0,0 +1,105 @@
/*
* 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 art.arcane.iris.studio.map;
import javax.swing.JComponent;
import javax.swing.JLayeredPane;
import java.awt.Cursor;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
/**
* Lays an island over content that fills the whole area behind it. Dragging the island's right edge resizes it,
* always leaving the content some room beside it.
*/
final class IslandOverlay extends JLayeredPane {
private static final long serialVersionUID = -4182209135360718342L;
private final IslandPanel island;
private final JComponent background;
private final int minimumIslandWidth;
private final int minimumUncoveredWidth;
private int islandWidth;
IslandOverlay(IslandPanel island, JComponent background, int islandWidth, int minimumIslandWidth, int minimumUncoveredWidth) {
this.island = island;
this.background = background;
this.islandWidth = islandWidth;
this.minimumIslandWidth = minimumIslandWidth;
this.minimumUncoveredWidth = minimumUncoveredWidth;
add(background, JLayeredPane.DEFAULT_LAYER);
add(island, JLayeredPane.PALETTE_LAYER);
installResizing();
}
@Override
public void doLayout() {
background.setBounds(0, 0, getWidth(), getHeight());
island.setBounds(0, 0, clampedIslandWidth(), getHeight());
}
private int clampedIslandWidth() {
int widest = Math.max(minimumIslandWidth, getWidth() - minimumUncoveredWidth);
return Math.clamp(islandWidth, minimumIslandWidth, widest);
}
private void installResizing() {
MouseAdapter resizer = new MouseAdapter() {
private boolean dragging;
@Override
public void mouseMoved(MouseEvent event) {
island.setCursor(island.isOnResizeEdge(event.getX(), event.getY())
? Cursor.getPredefinedCursor(Cursor.E_RESIZE_CURSOR)
: Cursor.getDefaultCursor());
}
@Override
public void mouseExited(MouseEvent event) {
if (!dragging) {
island.setCursor(Cursor.getDefaultCursor());
}
}
@Override
public void mousePressed(MouseEvent event) {
dragging = island.isOnResizeEdge(event.getX(), event.getY());
}
@Override
public void mouseDragged(MouseEvent event) {
if (!dragging) {
return;
}
islandWidth = event.getX();
islandWidth = clampedIslandWidth();
revalidate();
repaint();
}
@Override
public void mouseReleased(MouseEvent event) {
dragging = false;
}
};
island.addMouseListener(resizer);
island.addMouseMotionListener(resizer);
}
}
@@ -0,0 +1,93 @@
/*
* 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 art.arcane.iris.studio.map;
import javax.swing.JComponent;
import javax.swing.JPanel;
import java.awt.BorderLayout;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.geom.RoundRectangle2D;
import java.util.List;
/**
* A rounded panel floating over the window content with a gap all around it, like the tool windows of IntelliJ's
* islands theme. The gap belongs to the content behind it, except for the strip along the right edge, which grabs the
* island to resize it.
*/
final class IslandPanel extends JPanel implements FloatingPanels.PartlySeeThrough {
private static final long serialVersionUID = 7313084027614358519L;
private static final int GAP = 6;
private static final int CORNER_ARC = 12;
private static final int INNER_PADDING = 4;
IslandPanel(JComponent content) {
super(new BorderLayout());
setOpaque(false);
int inset = GAP + INNER_PADDING;
setBorder(StudioTheme.emptyBorder(inset, inset, inset, inset));
add(content, BorderLayout.CENTER);
}
boolean isOnResizeEdge(int x, int y) {
int edgeWidth = StudioTheme.scale(GAP + INNER_PADDING);
return x >= getWidth() - edgeWidth && x < getWidth() && y >= 0 && y < getHeight();
}
// Clicks in the gap reach the content behind the island instead of the island itself.
@Override
public boolean contains(int x, int y) {
return island().contains(x, y) || isOnResizeEdge(x, y);
}
// The gap all around, reaching in as far as the rounded corners do.
@Override
public List<Rectangle> seeThroughParts() {
int width = getWidth();
int height = getHeight();
int gap = StudioTheme.scale(GAP);
int edge = gap + (int) Math.ceil(StudioTheme.scale((double) CORNER_ARC) / 2D);
return List.of(
new Rectangle(0, 0, width, edge),
new Rectangle(0, height - edge, width, edge),
new Rectangle(0, edge, gap, height - edge * 2),
new Rectangle(width - gap, edge, gap, height - edge * 2));
}
@Override
protected void paintComponent(Graphics graphics) {
Graphics2D canvas = (Graphics2D) graphics.create();
try {
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
canvas.setColor(StudioTheme.PANEL_BACKGROUND);
canvas.fill(island());
} finally {
canvas.dispose();
}
}
private RoundRectangle2D island() {
double gap = StudioTheme.scale(GAP);
double arc = StudioTheme.scale(CORNER_ARC);
return new RoundRectangle2D.Double(gap, gap, getWidth() - gap * 2D, getHeight() - gap * 2D, arc, arc);
}
}
@@ -0,0 +1,92 @@
/*
* 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 art.arcane.iris.studio.map;
import art.arcane.iris.spi.IrisLogging;
import java.awt.EventQueue;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* Measures off the Swing thread, one measurement at a time, and only hands back the newest one asked for.
*/
final class LatestMeasurement<T> {
private final ThreadPoolExecutor worker;
private final AtomicLong generation = new AtomicLong();
LatestMeasurement(String threadName) {
this.worker = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(1), task -> {
Thread thread = new Thread(task, threadName);
thread.setDaemon(true);
return thread;
}, new ThreadPoolExecutor.DiscardOldestPolicy());
}
/**
* Runs the measurement and shows its result on the Swing thread, unless another was asked for meanwhile or it
* measured nothing.
*/
void measure(Supplier<T> measurement, Consumer<T> show) {
measureUntilSuperseded(stillWanted -> measurement.get(), show);
}
/**
* Like {@link #measure}, for long measurements that ask {@code stillWanted} between steps and give up once another
* measurement was asked for.
*/
void measureUntilSuperseded(Function<BooleanSupplier, T> measurement, Consumer<T> show) {
long requested = generation.incrementAndGet();
BooleanSupplier stillWanted = () -> requested == generation.get();
worker.execute(() -> {
T result = measureSafely(() -> measurement.apply(stillWanted));
EventQueue.invokeLater(() -> {
if (requested == generation.get() && result != null) {
show.accept(result);
}
});
});
}
/**
* Drops the measurement under way, whose result no longer fits.
*/
void discardPending() {
generation.incrementAndGet();
}
void close() {
worker.shutdownNow();
}
private static <T> T measureSafely(Supplier<T> measurement) {
try {
return measurement.get();
} catch (Throwable error) {
IrisLogging.reportError(error);
return null;
}
}
}
@@ -0,0 +1,159 @@
/*
* 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 art.arcane.iris.studio.map;
import java.awt.AlphaComposite;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.Path2D;
import java.awt.image.BufferedImage;
/**
* A little cube of blocks building itself while something loads: the blocks drop in one by one, the cube holds a
* moment, fades, and starts over. Loads shorter than {@link #SHOW_AFTER_MILLIS} never show it, and once it shows it
* stays for {@link #SHOW_AT_LEAST_MILLIS}, so it never flashes.
*/
final class LoadingAnimation {
static final int SHOW_AFTER_MILLIS = 300;
static final int SHOW_AT_LEAST_MILLIS = 700;
static final int FRAME_MILLIS = 33;
// Back to front, so each block lands on or beside ones already there.
private static final int[][] BUILD_ORDER = {
{0, 0, 0}, {1, 0, 0}, {0, 0, 1}, {1, 0, 1},
{0, 1, 0}, {1, 1, 0}, {0, 1, 1}, {1, 1, 1}};
private static final int STEP_MILLIS = 140;
private static final int DROP_MILLIS = 260;
private static final int HOLD_MILLIS = 450;
private static final int FADE_MILLIS = 320;
private static final int CYCLE_MILLIS = STEP_MILLIS * (BUILD_ORDER.length - 1) + DROP_MILLIS + HOLD_MILLIS + FADE_MILLIS;
private static final double DROP_HEIGHT_BLOCKS = 1.6D;
private static final double ISOMETRIC_WIDTH = Math.cos(Math.PI / 6D);
private static final double ISOMETRIC_DEPTH = 0.5D;
private static final float SIDE_SHADE = 0.78F;
private static final float FAR_SIDE_SHADE = 0.6F;
private LoadingAnimation() {
}
/**
* Whether a load that began at {@code loadingSinceMillis} has taken long enough to show the animation.
*/
static boolean isDue(long loadingSinceMillis) {
return System.currentTimeMillis() - loadingSinceMillis >= SHOW_AFTER_MILLIS;
}
/**
* How much longer the animation of a load that began at {@code loadingSinceMillis} has to stay up once the load is
* done; zero when it never showed or has shown long enough.
*/
static long remainingShowMillis(long loadingSinceMillis) {
if (!isDue(loadingSinceMillis)) {
return 0L;
}
long shownMillis = System.currentTimeMillis() - loadingSinceMillis - SHOW_AFTER_MILLIS;
return Math.max(0L, SHOW_AT_LEAST_MILLIS - shownMillis);
}
/**
* Draws the cube centred on a point, {@code blockSize} pixels to a block edge, as it looks
* {@code elapsedMillis} after it first showed.
*/
static void paint(Graphics2D graphics, double centerX, double centerY, double blockSize, long elapsedMillis) {
long time = Math.floorMod(elapsedMillis, CYCLE_MILLIS);
long fadeStart = CYCLE_MILLIS - FADE_MILLIS;
if (time < fadeStart) {
drawCube(graphics, centerX, centerY, blockSize, time);
return;
}
fadeCube(graphics, centerX, centerY, blockSize, time, 1F - (time - fadeStart) / (float) FADE_MILLIS);
}
// Faded block by block, the blocks behind would show through; the whole cube is drawn first and faded as one.
private static void fadeCube(Graphics2D graphics, double centerX, double centerY, double blockSize, long time, float opacity) {
int extent = (int) Math.ceil(blockSize * (2D + DROP_HEIGHT_BLOCKS) * 2D);
BufferedImage cube = new BufferedImage(extent, extent, BufferedImage.TYPE_INT_ARGB);
Graphics2D cubeCanvas = cube.createGraphics();
drawCube(cubeCanvas, extent / 2D, extent / 2D, blockSize, time);
cubeCanvas.dispose();
Graphics2D canvas = (Graphics2D) graphics.create();
try {
canvas.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, Math.max(0F, opacity)));
canvas.drawImage(cube, (int) Math.round(centerX - extent / 2D), (int) Math.round(centerY - extent / 2D), null);
} finally {
canvas.dispose();
}
}
private static void drawCube(Graphics2D graphics, double centerX, double centerY, double blockSize, long time) {
Graphics2D canvas = (Graphics2D) graphics.create();
try {
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
for (int index = 0; index < BUILD_ORDER.length; index++) {
long sinceDropStart = time - (long) index * STEP_MILLIS;
if (sinceDropStart < 0) {
break;
}
double fallen = easeOut(Math.min(1D, sinceDropStart / (double) DROP_MILLIS));
int[] block = BUILD_ORDER[index];
drawBlock(canvas, centerX, centerY, blockSize, block[0], block[1] + DROP_HEIGHT_BLOCKS * (1D - fallen), block[2]);
}
} finally {
canvas.dispose();
}
}
private static double easeOut(double progress) {
double remaining = 1D - progress;
return 1D - remaining * remaining * remaining;
}
// The cube's middle, (1, 1, 1), lands on the centre point; the top and the two sides facing the viewer show.
private static void drawBlock(Graphics2D canvas, double centerX, double centerY, double size, double x, double y, double z) {
Color top = StudioTheme.ACCENT;
canvas.setColor(top);
canvas.fill(face(centerX, centerY, size, x, y, z, new double[][]{{0, 1, 0}, {1, 1, 0}, {1, 1, 1}, {0, 1, 1}}));
canvas.setColor(shaded(top, SIDE_SHADE));
canvas.fill(face(centerX, centerY, size, x, y, z, new double[][]{{1, 0, 0}, {1, 1, 0}, {1, 1, 1}, {1, 0, 1}}));
canvas.setColor(shaded(top, FAR_SIDE_SHADE));
canvas.fill(face(centerX, centerY, size, x, y, z, new double[][]{{0, 0, 1}, {1, 0, 1}, {1, 1, 1}, {0, 1, 1}}));
}
private static Path2D face(double centerX, double centerY, double size, double x, double y, double z, double[][] corners) {
Path2D path = new Path2D.Double();
for (int corner = 0; corner < corners.length; corner++) {
double cornerX = x + corners[corner][0] - 1D;
double cornerY = y + corners[corner][1] - 1D;
double cornerZ = z + corners[corner][2] - 1D;
double screenX = centerX + (cornerX - cornerZ) * ISOMETRIC_WIDTH * size;
double screenY = centerY + (cornerX + cornerZ) * ISOMETRIC_DEPTH * size - cornerY * size;
if (corner == 0) {
path.moveTo(screenX, screenY);
} else {
path.lineTo(screenX, screenY);
}
}
path.closePath();
return path;
}
private static Color shaded(Color color, float shade) {
return new Color(Math.round(color.getRed() * shade), Math.round(color.getGreen() * shade), Math.round(color.getBlue() * shade));
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,183 @@
/*
* 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 art.arcane.iris.studio.map;
import art.arcane.iris.studio.map.components.MapLabels;
import art.arcane.iris.studio.map.components.MapStyle;
import art.arcane.iris.studio.map.components.MapTile;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.ToIntFunction;
/**
* Measures the visible part of the map from the tiles already drawn: how much each biome or region covers, and which
* heights it spans.
*/
final class MapCensus {
private static final long MAXIMUM_COUNTED_PIXELS = 250_000L;
private static final int MINIMUM_HEIGHT_SAMPLES = 64;
private static final double LOW_HEIGHT_PERCENTILE = 0.01D;
private static final double HIGH_HEIGHT_PERCENTILE = 0.99D;
private static final double HEIGHT_RANGE_STEP_BLOCKS = 8D;
private static final double MINIMUM_WATER_DEPTH_BLOCKS = 8D;
private static final double MINIMUM_LAND_HEIGHT_BLOCKS = 16D;
private MapCensus() {
}
static List<Share> count(VisionRenderController.Frame frame, ViewArea area, ToIntFunction<MapLabels.Label> colorOf) {
Map<Short, long[]> counts = new HashMap<>();
MapLabels[] labels = new MapLabels[1];
long counted = visitVisiblePixels(frame, area, (tile, pixelX, pixelZ) -> {
labels[0] = tile.labels();
counts.computeIfAbsent(tile.subjectAt(pixelX, pixelZ), id -> new long[1])[0]++;
});
return shares(counts, labels[0], counted, colorOf);
}
/**
* The heights on screen, ignoring the highest and lowest few percent so one spike doesn't wash out every colour.
* Rounded to whole steps, so panning around doesn't recolour the map for every small change.
*
* @return the range, or null until enough of the map is drawn to tell
*/
static MapStyle.HeightRange heightRange(VisionRenderController.Frame frame, ViewArea area, double seaLevel) {
DoubleList heights = new DoubleList();
visitVisiblePixels(frame, area, (tile, pixelX, pixelZ) -> {
if (tile.hasHeights()) {
heights.add(tile.heightAt(pixelX, pixelZ));
}
});
if (heights.size() < MINIMUM_HEIGHT_SAMPLES) {
return null;
}
double[] sorted = heights.sorted();
double low = sorted[(int) ((sorted.length - 1) * LOW_HEIGHT_PERCENTILE)];
double high = sorted[(int) ((sorted.length - 1) * HIGH_HEIGHT_PERCENTILE)];
double lowest = Math.floor(Math.min(low, seaLevel - MINIMUM_WATER_DEPTH_BLOCKS) / HEIGHT_RANGE_STEP_BLOCKS)
* HEIGHT_RANGE_STEP_BLOCKS;
double highest = Math.ceil(Math.max(high, seaLevel + MINIMUM_LAND_HEIGHT_BLOCKS) / HEIGHT_RANGE_STEP_BLOCKS)
* HEIGHT_RANGE_STEP_BLOCKS;
return new MapStyle.HeightRange(lowest, seaLevel, highest);
}
private static long visitVisiblePixels(VisionRenderController.Frame frame, ViewArea area, PixelVisitor visitor) {
if (frame == null) {
return 0L;
}
double blocksPerPixel = frame.renderBlocksPerPixel();
int stride = countingStride(area.maximumX() - area.minimumX(), area.maximumZ() - area.minimumZ(), blocksPerPixel);
double tileSpan = VisionRenderController.TILE_PIXELS * blocksPerPixel;
long visited = 0L;
for (VisionRenderController.VisibleTile visible : frame.tiles()) {
MapTile tile = visible.tile();
if (tile == null) {
continue;
}
double originX = visible.tileX() * tileSpan;
double originZ = visible.tileZ() * tileSpan;
int firstX = firstPixel(area.minimumX() - originX, blocksPerPixel, stride, visible.tileX());
int firstZ = firstPixel(area.minimumZ() - originZ, blocksPerPixel, stride, visible.tileZ());
int endX = endPixel(area.maximumX() - originX, blocksPerPixel, tile.size());
int endZ = endPixel(area.maximumZ() - originZ, blocksPerPixel, tile.size());
for (int pixelZ = firstZ; pixelZ < endZ; pixelZ += stride) {
for (int pixelX = firstX; pixelX < endX; pixelX += stride) {
visitor.visit(tile, pixelX, pixelZ);
visited++;
}
}
}
return visited;
}
private static List<Share> shares(Map<Short, long[]> counts, MapLabels labels, long counted,
ToIntFunction<MapLabels.Label> colorOf) {
if (labels == null || counted == 0L) {
return List.of();
}
List<Share> shares = new ArrayList<>(counts.size());
for (Map.Entry<Short, long[]> entry : counts.entrySet()) {
MapLabels.Label label = labels.label(entry.getKey());
if (label != null) {
shares.add(new Share(label, colorOf.applyAsInt(label), entry.getValue()[0] / (double) counted));
}
}
shares.sort(Comparator.comparingDouble(Share::fraction).reversed().thenComparing(share -> share.label().name()));
return List.copyOf(shares);
}
// Counting every pixel of a big screen is wasted work; an evenly spread sample gives the same percentages.
private static int countingStride(double widthBlocks, double heightBlocks, double blocksPerPixel) {
double pixels = (widthBlocks / blocksPerPixel) * (heightBlocks / blocksPerPixel);
return Math.max(1, (int) Math.ceil(Math.sqrt(pixels / MAXIMUM_COUNTED_PIXELS)));
}
// Starting on the global stride grid keeps neighbouring tiles from sampling the same columns twice as densely.
private static int firstPixel(double offsetBlocks, double blocksPerPixel, int stride, long tileIndex) {
int first = Math.max(0, (int) Math.ceil(offsetBlocks / blocksPerPixel));
long globalPixel = tileIndex * VisionRenderController.TILE_PIXELS + first;
int misalignment = (int) Math.floorMod(globalPixel, (long) stride);
return misalignment == 0 ? first : first + stride - misalignment;
}
private static int endPixel(double offsetBlocks, double blocksPerPixel, int size) {
return Math.min(size, (int) Math.ceil(offsetBlocks / blocksPerPixel));
}
record Share(MapLabels.Label label, int color, double fraction) {
}
/**
* The world area the screen shows, in blocks.
*/
record ViewArea(double minimumX, double minimumZ, double maximumX, double maximumZ) {
}
@FunctionalInterface
private interface PixelVisitor {
void visit(MapTile tile, int pixelX, int pixelZ);
}
private static final class DoubleList {
private double[] values = new double[1024];
private int size;
void add(double value) {
if (size == values.length) {
values = Arrays.copyOf(values, size * 2);
}
values[size++] = value;
}
int size() {
return size;
}
double[] sorted() {
double[] sorted = Arrays.copyOf(values, size);
Arrays.sort(sorted);
return sorted;
}
}
}
@@ -0,0 +1,122 @@
/*
* 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 art.arcane.iris.studio.map;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.PreservationRegistry;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.studio.view.GuiHost;
import art.arcane.iris.studio.view.GuiOverlay;
import javax.swing.JFrame;
import java.awt.Dimension;
import java.awt.GraphicsEnvironment;
import java.awt.Rectangle;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.awt.image.BufferedImage;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
/**
* What the studio windows need from the server they run in: frames it closes on shutdown, engines and hotload
* notices from the platform's {@link GuiHost.Provider}, and threads it stops with the plugin.
*/
public final class MapHost {
private MapHost() {
}
static boolean isAvailable() {
return GuiHost.isAvailable();
}
static JFrame createFrame(String title) {
XwaylandWindowManagerHint.apply();
StudioTheme.install();
JFrame frame = new JFrame(title);
GuiHost.prepareFrame(frame);
frame.addWindowListener(new WindowAdapter() {
@Override
public void windowActivated(WindowEvent event) {
StudioTheme.followSystem();
}
});
return frame;
}
/**
* Wraps rendered pixels in an image Java2D draws straight from memory. Left to itself Java2D copies every image it
* draws into a texture inside the display server; with a map's hundreds of tiles that crashed Xwayland's Intel driver
* and took every X window on the desktop down with it.
*/
public static BufferedImage uncachedImage(int[] pixels, int width, int height) {
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
image.getRaster().setDataElements(0, 0, width, height, pixels);
image.setAccelerationPriority(0F);
return image;
}
// A scaled-up default size can be bigger than a small or low resolution screen.
static Dimension fitToScreen(Dimension preferred) {
Rectangle usable = GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds();
return new Dimension(Math.min(preferred.width, usable.width), Math.min(preferred.height, usable.height));
}
static Engine findActiveEngine() {
return usable(GuiHost.get().findActiveEngine());
}
/**
* The engine of the world the window was opened for, or the active one on platforms that can't look worlds up.
*/
static Engine findEngine(UUID worldId) {
Engine engine = worldId == null ? null : usable(GuiHost.get().findEngine(worldId));
return engine != null ? engine : findActiveEngine();
}
static GuiOverlay overlayFor(Engine engine, UUID openerId) {
return engine == null ? null : GuiHost.get().overlayFor(engine, openerId);
}
static void registerHotloadHook(Runnable onHotload) {
GuiHost.get().registerHotloadHook(onHotload);
}
static void unregisterHotloadHook(Runnable onHotload) {
GuiHost.get().unregisterHotloadHook(onHotload);
}
static void interruptOnPluginShutdown(ExecutorService executor) {
PreservationRegistry preservation = IrisServices.getOrNull(PreservationRegistry.class);
if (preservation != null) {
preservation.register(executor);
}
}
static void interruptOnPluginShutdown(Thread thread) {
PreservationRegistry preservation = IrisServices.getOrNull(PreservationRegistry.class);
if (preservation != null) {
preservation.register(thread);
}
}
private static Engine usable(Engine engine) {
return engine == null || engine.isClosed() || engine.isClosing() ? null : engine;
}
}
@@ -0,0 +1,538 @@
/*
* 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 art.arcane.iris.studio.map;
import art.arcane.iris.studio.map.MapHoverProbe.PickStep;
import art.arcane.iris.studio.map.MapHoverProbe.StepKind;
import art.arcane.iris.studio.map.NoisePickSpread.Band;
import art.arcane.iris.studio.map.components.MapLabels;
import art.arcane.iris.studio.map.components.TileType;
import art.arcane.iris.pack.loading.IrisRegistrant;
import art.arcane.iris.generation.biome.IrisBiome;
import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
/**
* The card that follows the cursor: the biome and its region, the ground's height, and a row per noise that picked
* them, each split into one segment per option with the picked one lit and a line at the spot's value. Shift adds the
* terrain, location and pack files; a footer bar lists the shortcuts.
*/
record MapHoverCard(Header header, String subtitle, List<NoiseRow> noiseRows, List<Group> groups, List<Hint> hints) {
private static final int MINIMUM_CONTENT_WIDTH = 240;
private static final int MINIMUM_SEGMENTS_WIDTH = 120;
private static final int PADDING_X = 12;
private static final int PADDING_TOP = 9;
private static final int PADDING_BOTTOM = 11;
private static final int ICON_SIZE = 12;
private static final int ICON_GAP = 8;
private static final int REGION_GAP = 16;
private static final int ROWS_GAP = 9;
private static final int ROW_HEIGHT = 18;
private static final int COLUMN_GAP = 8;
private static final int SEGMENT_HEIGHT = 6;
private static final int WIDE_SEGMENT_GAP = 2;
private static final int NARROW_SEGMENT_GAP = 1;
private static final int WIDE_SEGMENT_WIDTH = 8;
private static final int NARROW_SEGMENT_WIDTH = 3;
private static final int MARKER_WIDTH = 2;
private static final int MARKER_HEIGHT = 12;
private static final int GROUP_GAP = 8;
private static final int GROUP_TITLE_GAP = 4;
private static final int DETAIL_COLUMN_GAP = 14;
private static final int DETAIL_ROW_GAP = 3;
private static final int FOOTER_PADDING_Y = 7;
private static final int KEY_PADDING_X = 4;
private static final int KEY_GAP = 4;
private static final int HINT_GAP = 14;
private static final Color ICON_EDGE = new ThemeColor(new Color(0, 0, 0, 38), new Color(255, 255, 255, 38));
/**
* @param subject the biome, or region in the region view, the drawn tile shows; null while that tile is drawing
* @param region the region the drawn tile shows
* @param details the probed facts about the spot, or null until they arrive
* @param detailed whether Shift is held, which adds the terrain, location and pack files
*/
static MapHoverCard of(MapLabels.Label subject, MapLabels.Label region, MapHoverProbe.Details details,
TileType type, boolean detailed, double worldX, double worldZ) {
List<PickStep> path = details == null ? List.of() : details.pickPath();
IrisBiome biome = cardBiome(subject, details, type);
List<Group> groups = new ArrayList<>();
if (detailed) {
addTerrainGroup(groups, details, type);
groups.add(locationGroup(worldX, worldZ));
addFilesGroup(groups, biome, details != null ? details.region() : region == null ? null : region.subject());
}
return new MapHoverCard(header(subject, region, biome, path), subtitle(details, path), noiseRows(path), groups,
hints(detailed));
}
/**
* Draws the card with its top left corner as close to the given point as fits inside the bounds.
*/
void paint(Graphics2D canvas, int anchorX, int anchorY, int boundsWidth, int boundsHeight) {
Graphics2D card = (Graphics2D) canvas.create();
card.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
card.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
int paddingX = StudioTheme.scale(PADDING_X);
int contentWidth = contentWidth(card);
int width = contentWidth + paddingX * 2;
int footerHeight = footerHeight(card);
int height = StudioTheme.scale(PADDING_TOP) + headerHeight(card) + rowsHeight() + groupsHeight(card)
+ StudioTheme.scale(PADDING_BOTTOM) + footerHeight;
int x = Math.max(paddingX, Math.min(boundsWidth - width - paddingX, anchorX));
int y = Math.max(paddingX, Math.min(boundsHeight - height - paddingX, anchorY));
StudioOverlay.drawCard(card, x, y, width, height, footerHeight);
int left = x + paddingX;
int top = y + StudioTheme.scale(PADDING_TOP);
top = drawHeader(card, left, top, contentWidth);
top = drawNoiseRows(card, left, top, contentWidth);
drawGroups(card, left, top);
drawFooter(card, left, y + height - footerHeight);
card.dispose();
}
private int contentWidth(Graphics2D canvas) {
int width = Math.max(headerWidth(canvas), rowsWidth(canvas));
width = Math.max(width, groupsWidth(canvas));
width = Math.max(width, footerWidth(canvas));
return Math.max(StudioTheme.scale(MINIMUM_CONTENT_WIDTH), width);
}
// Header: the biome's colour and name with its region on the right, and the ground below.
private int headerTextOffset() {
return header.color() == null ? 0 : StudioTheme.scale(ICON_SIZE + ICON_GAP);
}
private int headerWidth(Graphics2D canvas) {
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
int width = headerTextOffset() + canvas.getFontMetrics(StudioTheme.boldFont()).stringWidth(header.name());
if (header.region() != null) {
width += StudioTheme.scale(REGION_GAP) + small.stringWidth(header.region());
}
if (subtitle != null) {
width = Math.max(width, headerTextOffset() + small.stringWidth(subtitle));
}
return width;
}
private int headerHeight(Graphics2D canvas) {
int height = canvas.getFontMetrics(StudioTheme.boldFont()).getHeight();
if (subtitle != null) {
height += canvas.getFontMetrics(StudioTheme.smallFont()).getHeight();
}
return height;
}
private int drawHeader(Graphics2D canvas, int left, int top, int contentWidth) {
FontMetrics bold = canvas.getFontMetrics(StudioTheme.boldFont());
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
if (header.color() != null) {
int iconSize = StudioTheme.scale(ICON_SIZE);
drawIcon(canvas, header.color(), left, top + (bold.getHeight() - iconSize) / 2, iconSize);
}
int textLeft = left + headerTextOffset();
int baseline = top + bold.getAscent();
drawText(canvas, header.name(), StudioTheme.boldFont(), StudioTheme.TEXT, textLeft, baseline);
if (header.region() != null) {
drawText(canvas, header.region(), StudioTheme.smallFont(), StudioTheme.MUTED_TEXT,
left + contentWidth - small.stringWidth(header.region()), baseline);
}
top += bold.getHeight();
if (subtitle != null) {
drawText(canvas, subtitle, StudioTheme.smallFont(), StudioTheme.SECONDARY_TEXT, textLeft, top + small.getAscent());
top += small.getHeight();
}
return top;
}
private static void drawIcon(Graphics2D canvas, int rgb, int left, int top, int size) {
int arc = Math.max(2, size / 2);
canvas.setColor(new Color(rgb));
canvas.fillRoundRect(left, top, size, size, arc, arc);
canvas.setColor(ICON_EDGE);
canvas.drawRoundRect(left, top, size - 1, size - 1, arc, arc);
}
// Noise rows: a muted label, the segments (or in hybrid terrain how the biome won), and the value on the right.
private int labelColumnWidth(Graphics2D canvas) {
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
return noiseRows.stream().mapToInt(row -> small.stringWidth(row.label())).max().orElse(0);
}
private int valueColumnWidth(Graphics2D canvas) {
FontMetrics value = canvas.getFontMetrics(valueFont());
return noiseRows.stream().mapToInt(row -> value.stringWidth(row.value())).max().orElse(0);
}
private int rowsWidth(Graphics2D canvas) {
if (noiseRows.isEmpty()) {
return 0;
}
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
int middle = noiseRows.stream()
.mapToInt(row -> row.showsSegments() ? StudioTheme.scale(MINIMUM_SEGMENTS_WIDTH) : small.stringWidth(row.note()))
.max().orElse(0);
return labelColumnWidth(canvas) + valueColumnWidth(canvas) + middle + StudioTheme.scale(COLUMN_GAP) * 2;
}
private int rowsHeight() {
return noiseRows.isEmpty() ? 0 : StudioTheme.scale(ROWS_GAP) + noiseRows.size() * StudioTheme.scale(ROW_HEIGHT);
}
private int drawNoiseRows(Graphics2D canvas, int left, int top, int contentWidth) {
if (noiseRows.isEmpty()) {
return top;
}
top += StudioTheme.scale(ROWS_GAP);
int rowHeight = StudioTheme.scale(ROW_HEIGHT);
int columnGap = StudioTheme.scale(COLUMN_GAP);
int middleLeft = left + labelColumnWidth(canvas) + columnGap;
int valueRight = left + contentWidth;
int middleWidth = valueRight - valueColumnWidth(canvas) - columnGap - middleLeft;
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
FontMetrics value = canvas.getFontMetrics(valueFont());
for (NoiseRow row : noiseRows) {
int baseline = top + (rowHeight - small.getHeight()) / 2 + small.getAscent();
drawText(canvas, row.label(), StudioTheme.smallFont(), StudioTheme.MUTED_TEXT, left, baseline);
if (row.showsSegments()) {
drawSegments(canvas, row, middleLeft, top + (rowHeight - StudioTheme.scale(SEGMENT_HEIGHT)) / 2, middleWidth);
} else {
drawText(canvas, row.note(), StudioTheme.smallFont(), StudioTheme.SECONDARY_TEXT, middleLeft, baseline);
}
drawText(canvas, row.value(), valueFont(), StudioTheme.SECONDARY_TEXT, valueRight - value.stringWidth(row.value()),
baseline);
top += rowHeight;
}
return top;
}
// One segment per band, as wide as the band's share of the noise; the picked one in its option's colour.
private static void drawSegments(Graphics2D canvas, NoiseRow row, int left, int top, int width) {
List<Band> bands = row.bands();
int height = StudioTheme.scale(SEGMENT_HEIGHT);
int gap = segmentGap(width, bands.size());
int picked = pickedBandIndex(bands, row.noise());
int arc = StudioTheme.scale(2);
for (int index = 0; index < bands.size(); index++) {
Band band = bands.get(index);
int from = left + (int) Math.round(band.fromNoise() * width);
int to = left + (int) Math.round(band.toNoise() * width);
int segmentWidth = Math.max(1, to - from - (index == bands.size() - 1 ? 0 : gap));
canvas.setColor(index == picked ? new Color(band.option().color()) : StudioTheme.OVERLAY_BORDER);
canvas.fillRoundRect(from, top, segmentWidth, height, arc, arc);
}
drawMarker(canvas, left + (int) Math.round(Math.clamp(row.noise(), 0D, 1D) * width), top + height / 2);
}
private static int segmentGap(int width, int segmentCount) {
int averageWidth = width / Math.max(1, segmentCount);
if (averageWidth >= StudioTheme.scale(WIDE_SEGMENT_WIDTH)) {
return StudioTheme.scale(WIDE_SEGMENT_GAP);
}
return averageWidth >= StudioTheme.scale(NARROW_SEGMENT_WIDTH) ? StudioTheme.scale(NARROW_SEGMENT_GAP) : 0;
}
// The engine gives values below 0 the first option and values above 1 the last, the same as this.
private static int pickedBandIndex(List<Band> bands, double noise) {
for (int index = 0; index < bands.size(); index++) {
if (noise <= bands.get(index).toNoise()) {
return index;
}
}
return bands.size() - 1;
}
private static void drawMarker(Graphics2D canvas, int centerX, int centerY) {
int width = StudioTheme.scale(MARKER_WIDTH);
int height = StudioTheme.scale(MARKER_HEIGHT);
int left = centerX - width / 2;
int top = centerY - height / 2;
canvas.setColor(StudioTheme.PANEL_BACKGROUND);
canvas.fillRect(left - 1, top - 1, width + 2, height + 2);
canvas.setColor(StudioTheme.TEXT);
canvas.fillRect(left, top, width, height);
}
private static Font valueFont() {
return StudioTheme.monospacedFont().deriveFont(StudioTheme.smallFont().getSize2D());
}
// Groups: sentence-case titles over rows of muted labels and values, all labels in one column.
private int detailLabelWidth(Graphics2D canvas) {
FontMetrics metrics = canvas.getFontMetrics(StudioTheme.font());
return groups.stream().flatMap(group -> group.rows().stream())
.mapToInt(row -> metrics.stringWidth(row.label())).max().orElse(0);
}
private int groupsWidth(Graphics2D canvas) {
if (groups.isEmpty()) {
return 0;
}
FontMetrics metrics = canvas.getFontMetrics(StudioTheme.font());
int valueWidth = groups.stream().flatMap(group -> group.rows().stream())
.mapToInt(row -> metrics.stringWidth(row.value())).max().orElse(0);
return detailLabelWidth(canvas) + StudioTheme.scale(DETAIL_COLUMN_GAP) + valueWidth;
}
private int groupsHeight(Graphics2D canvas) {
int rowHeight = canvas.getFontMetrics(StudioTheme.font()).getHeight() + StudioTheme.scale(DETAIL_ROW_GAP);
return groups.stream().mapToInt(group -> groupTitleHeight(canvas) + group.rows().size() * rowHeight).sum();
}
private void drawGroups(Graphics2D canvas, int left, int top) {
FontMetrics regular = canvas.getFontMetrics(StudioTheme.font());
int valueLeft = left + detailLabelWidth(canvas) + StudioTheme.scale(DETAIL_COLUMN_GAP);
for (Group group : groups) {
top = drawGroupTitle(canvas, group.title(), left, top);
for (Row row : group.rows()) {
int baseline = top + regular.getAscent();
drawText(canvas, row.label(), StudioTheme.font(), StudioTheme.SECONDARY_TEXT, left, baseline);
drawText(canvas, row.value(), StudioTheme.font(), row.link() ? StudioTheme.LINK : StudioTheme.TEXT, valueLeft,
baseline);
top += regular.getHeight() + StudioTheme.scale(DETAIL_ROW_GAP);
}
}
}
private static int groupTitleHeight(Graphics2D canvas) {
return StudioTheme.scale(GROUP_GAP) + canvas.getFontMetrics(StudioTheme.smallFont()).getHeight()
+ StudioTheme.scale(GROUP_TITLE_GAP);
}
private static int drawGroupTitle(Graphics2D canvas, String title, int left, int top) {
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
top += StudioTheme.scale(GROUP_GAP);
drawText(canvas, title, StudioTheme.smallFont(), StudioTheme.MUTED_TEXT, left, top + small.getAscent());
return top + small.getHeight() + StudioTheme.scale(GROUP_TITLE_GAP);
}
// Footer: the shortcuts as key caps, each followed by what it does.
private int footerWidth(Graphics2D canvas) {
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
int width = 0;
for (Hint hint : hints) {
width += keyWidth(small, hint.key()) + StudioTheme.scale(KEY_GAP) + small.stringWidth(hint.action());
}
return width + StudioTheme.scale(HINT_GAP) * Math.max(0, hints.size() - 1);
}
private static int footerHeight(Graphics2D canvas) {
return canvas.getFontMetrics(StudioTheme.smallFont()).getHeight() + StudioTheme.scale(FOOTER_PADDING_Y) * 2;
}
private static int keyWidth(FontMetrics small, String key) {
return small.stringWidth(key) + StudioTheme.scale(KEY_PADDING_X) * 2;
}
private void drawFooter(Graphics2D canvas, int left, int top) {
FontMetrics small = canvas.getFontMetrics(StudioTheme.smallFont());
int textTop = top + StudioTheme.scale(FOOTER_PADDING_Y);
int baseline = textTop + small.getAscent();
int arc = StudioTheme.scale(6);
for (Hint hint : hints) {
int keyWidth = keyWidth(small, hint.key());
canvas.setColor(StudioTheme.HOVER_BACKGROUND);
canvas.fillRoundRect(left, textTop, keyWidth, small.getHeight(), arc, arc);
drawText(canvas, hint.key(), StudioTheme.smallFont(), StudioTheme.SECONDARY_TEXT,
left + StudioTheme.scale(KEY_PADDING_X), baseline);
left += keyWidth + StudioTheme.scale(KEY_GAP);
drawText(canvas, hint.action(), StudioTheme.smallFont(), StudioTheme.MUTED_TEXT, left, baseline);
left += small.stringWidth(hint.action()) + StudioTheme.scale(HINT_GAP);
}
}
private static void drawText(Graphics2D canvas, String text, Font font, Color color, int left, int baseline) {
canvas.setFont(font);
canvas.setColor(color);
canvas.drawString(text, left, baseline);
}
// The card is about a biome; the region view's tiles only know the region until the probe finds the biome.
private static IrisBiome cardBiome(MapLabels.Label subject, MapHoverProbe.Details details, TileType type) {
if (subject != null && subject.subject() instanceof IrisBiome biome) {
return biome;
}
if (details == null) {
return null;
}
return type == TileType.CAVE_LAND ? details.caveBiome() : details.surfaceBiome();
}
private static Header header(MapLabels.Label subject, MapLabels.Label region, IrisBiome biome, List<PickStep> path) {
String regionName = path.stream()
.filter(step -> step.kind() == StepKind.REGION_NOISE || step.kind() == StepKind.PREVIEWED_REGION)
.map(step -> step.picked().name())
.findFirst()
.orElse(region == null ? null : region.name());
if (biome != null) {
return new Header(biome.getName(), colorOf(biome, path), regionName);
}
if (subject != null) {
return new Header(subject.name(), null, subject.kind() == MapLabels.Kind.REGION ? null : regionName);
}
return new Header("Drawing…", null, null);
}
private static Integer colorOf(IrisBiome biome, List<PickStep> path) {
return path.stream()
.filter(step -> step.picked().key().equals(biome.getLoadKey()))
.reduce((first, last) -> last)
.map(step -> step.picked().color())
.orElse(null);
}
private static String subtitle(MapHoverProbe.Details details, List<PickStep> path) {
if (details == null) {
return null;
}
String ground = Math.round(details.height()) + " · " + signedHeight(details.height() - details.fluidHeight())
+ " · " + Math.round(details.slopeDegrees()) + "°";
return parentOfChild(path).map(parent -> "child of " + parent + " · " + ground).orElse(ground);
}
// A child biome took the spot from the biome the noise picked, which is the step before it.
private static Optional<String> parentOfChild(List<PickStep> path) {
for (int index = 1; index < path.size(); index++) {
if (path.get(index).kind() == StepKind.CHILD) {
return Optional.of(path.get(index - 1).picked().name());
}
}
return Optional.empty();
}
private static List<NoiseRow> noiseRows(List<PickStep> path) {
List<NoiseRow> rows = new ArrayList<>();
for (PickStep step : path) {
switch (step.kind()) {
case REGION_NOISE -> rows.add(new NoiseRow("Region", step.bands(), step.noise(), null, step.value()));
case BIOME_NOISE -> rows.add(new NoiseRow(capitalize(step.detail()), step.bands(), step.noise(), null,
step.value()));
case PREVIEWED_REGION, CHILD -> {
}
}
}
return rows;
}
private static void addTerrainGroup(List<Group> groups, MapHoverProbe.Details details, TileType type) {
if (details == null) {
return;
}
List<Row> rows = new ArrayList<>();
IrisBiome surface = details.surfaceBiome();
IrisBiome cave = details.caveBiome();
if (type == TileType.CAVE_LAND && surface != null) {
rows.add(new Row("Surface biome", surface.getName(), false));
} else if (type != TileType.CAVE_LAND && cave != null && cave != surface) {
rows.add(new Row("Cave biome", cave.getName(), false));
}
if (details.vanillaBiome() != null) {
rows.add(new Row("Minecraft", details.vanillaBiome().replace('_', ' '), false));
}
if (!rows.isEmpty()) {
groups.add(new Group("Terrain", rows));
}
}
private static Group locationGroup(double worldX, double worldZ) {
int blockX = MapCanvas.floorWorldCoordinate(worldX);
int blockZ = MapCanvas.floorWorldCoordinate(worldZ);
return new Group("Location", List.of(
new Row("Block", blockX + ", " + blockZ, false),
new Row("Chunk", (blockX >> 4) + ", " + (blockZ >> 4), false),
new Row("Region file", "r." + (blockX >> 9) + "." + (blockZ >> 9) + ".mca", false)));
}
private static void addFilesGroup(List<Group> groups, IrisBiome biome, Object region) {
List<Row> rows = new ArrayList<>();
if (biome != null) {
rows.add(new Row("Biome", packPath(biome), true));
}
if (region instanceof IrisRegistrant registrant) {
rows.add(new Row("Region", packPath(registrant), true));
}
if (!rows.isEmpty()) {
groups.add(new Group("Files", rows));
}
}
// The key is the path under the type's folder, so folder, key and extension give the file within the pack.
private static String packPath(IrisRegistrant registrant) {
File file = registrant.getLoadFile();
String fileName = file == null ? "" : file.getName();
int extensionStart = fileName.lastIndexOf('.');
String extension = extensionStart < 0 ? "" : fileName.substring(extensionStart);
return registrant.getFolderName() + "/" + registrant.getLoadKey() + extension;
}
private static List<Hint> hints(boolean detailed) {
List<Hint> hints = new ArrayList<>();
if (!detailed) {
hints.add(new Hint("Shift", "More"));
}
hints.add(new Hint("H", "Highlight"));
hints.add(new Hint("Alt+Click", "Open file"));
return hints;
}
private static String signedHeight(double height) {
long rounded = Math.round(height);
return rounded < 0 ? "−" + -rounded : "+" + rounded;
}
private static String capitalize(String text) {
return text.substring(0, 1).toUpperCase(Locale.ROOT) + text.substring(1);
}
/**
* @param color the biome's map colour, or null when no step of the path tells it
* @param region the region's name, or null when the title already is the region
*/
record Header(String name, Integer color, String region) {
}
/**
* @param bands the bands the noise splits into, empty when no noise picked the step
* @param note how the step was picked when no noise did, or null
*/
record NoiseRow(String label, List<Band> bands, double noise, String note, String value) {
boolean showsSegments() {
return !bands.isEmpty();
}
}
record Group(String title, List<Row> rows) {
}
record Row(String label, String value, boolean link) {
}
record Hint(String key, String action) {
}
}
@@ -0,0 +1,158 @@
/*
* 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 art.arcane.iris.studio.map;
import art.arcane.iris.studio.map.NoisePickSpread.Band;
import art.arcane.iris.studio.map.NoisePickSpread.Option;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.terrain.InferredType;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisRegion;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* Looks up everything the hover card can tell about one spot of the world; runs off the Swing thread.
*/
final class MapHoverProbe {
// IrisComplex measures slope as the height change over this many blocks.
private static final double SLOPE_STREAM_RANGE_BLOCKS = 3D;
private MapHoverProbe() {
}
/**
* @param previewRegionKey the region the region preview treats every spot as part of, or null for the real world
* @param explainBiomePick whether the card shows the surface biome, so the whole way to it is worth looking up;
* otherwise only how the region was picked
*/
static Details sample(Engine engine, double worldX, double worldZ, String previewRegionKey, boolean explainBiomePick) {
IrisComplex complex = engine.getComplex();
IrisRegion previewRegion = previewRegionKey == null ? null : engine.getData().getRegionLoader().load(previewRegionKey);
IrisRegion region = previewRegion != null ? previewRegion : complex.getRegionStream().get(worldX, worldZ);
IrisComplex.BiomePick surfacePick = previewRegion != null
? complex.surfacePickAsIfIn(region, worldX, worldZ)
: complex.basePickIn(region, worldX, worldZ);
String vanillaBiomeKey = complex.getTrueBiomeStream().get(worldX, worldZ).getDerivativeKey();
double slope = complex.getSlopeStream().getDouble(worldX, worldZ);
IrisDimension dimension = engine.getDimension();
return new Details(
worldX,
worldZ,
surfacePick.biome(),
complex.getCaveBiomeStream().get(worldX, worldZ),
region,
vanillaBiomeKey == null ? null : vanillaBiomeKey.substring(vanillaBiomeKey.indexOf(':') + 1),
complex.getHeightStream().getDouble(worldX, worldZ),
dimension == null ? 0D : dimension.getFluidHeight(),
Math.toDegrees(Math.atan(slope / SLOPE_STREAM_RANGE_BLOCKS)),
pickPath(engine, region, previewRegion != null, explainBiomePick ? surfacePick : null, worldX, worldZ)
);
}
// The region preview chose the region itself; the real map picks it by the region noise. Each explains the biome it
// draws: the preview its surface biome, the real map its base biome, before the height corrects it near coasts.
private static List<PickStep> pickPath(Engine engine, IrisRegion region, boolean previewed,
IrisComplex.BiomePick surfacePick, double x, double z) {
List<PickStep> path = new ArrayList<>();
path.add(previewed ? previewedRegionStep(engine, region) : regionStep(engine, region, x, z));
if (surfacePick == null || surfacePick.type() == null || engine.getComplex().getFocusBiome() != null) {
return path;
}
PickStep biomeStep = noiseStep(engine, region, surfacePick.type(), x, z);
path.add(biomeStep);
if (!biomeStep.picked().key().equals(surfacePick.biome().getLoadKey())) {
path.add(new PickStep(StepKind.CHILD, PickMeasuring.biomeOption(engine, surfacePick.biome()), null, null, List.of(),
Double.NaN));
}
return path;
}
private static PickStep regionStep(Engine engine, IrisRegion region, double x, double z) {
IrisComplex complex = engine.getComplex();
double noise = complex.getRegionStyleStream().get(x, z);
List<Option> regions = complex.noisePickedRegions().stream()
.map(candidate -> PickMeasuring.regionOption(engine, candidate))
.toList();
return new PickStep(StepKind.REGION_NOISE, PickMeasuring.regionOption(engine, region), null, noiseText(noise),
NoisePickSpread.splitNoise(regions), noise);
}
private static PickStep previewedRegionStep(Engine engine, IrisRegion region) {
return new PickStep(StepKind.PREVIEWED_REGION, PickMeasuring.regionOption(engine, region), null, null, List.of(),
Double.NaN);
}
private static PickStep noiseStep(Engine engine, IrisRegion region, InferredType type, double x, double z) {
IrisComplex complex = engine.getComplex();
double noise = complex.biomePickNoise(region, type, x, z);
List<Option> biomes = complex.noisePickedBiomes(region, type).stream()
.map(biome -> PickMeasuring.biomeOption(engine, biome))
.toList();
IrisBiome picked = complex.biomePickedByNoise(region, type, x, z);
return new PickStep(StepKind.BIOME_NOISE, PickMeasuring.biomeOption(engine, picked), typeName(type), noiseText(noise),
NoisePickSpread.splitNoise(biomes), noise);
}
private static String typeName(InferredType type) {
return type.name().toLowerCase(Locale.ROOT);
}
private static String noiseText(double noise) {
return String.format(Locale.ROOT, "%.3f", noise);
}
record Details(
double worldX,
double worldZ,
IrisBiome surfaceBiome,
IrisBiome caveBiome,
IrisRegion region,
String vanillaBiome,
double height,
double fluidHeight,
double slopeDegrees,
List<PickStep> pickPath
) {
}
/**
* How a step on the way to the spot's biome was picked.
*/
enum StepKind {
REGION_NOISE,
PREVIEWED_REGION,
BIOME_NOISE,
CHILD
}
/**
* One step on the way to the spot's biome, starting with its region.
*
* @param detail the biome list the step picked from; null for the rest
* @param value the noise value that picked it, or null
* @param bands the noise bands of all the options the noise picked from, empty when no noise picked it
*/
record PickStep(StepKind kind, Option picked, String detail, String value, List<Band> bands, double noise) {
}
}
@@ -0,0 +1,244 @@
/*
* 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 art.arcane.iris.studio.map;
import art.arcane.iris.studio.map.components.MapStyle;
import javax.swing.BorderFactory;
import javax.swing.DefaultListCellRenderer;
import javax.swing.DefaultListModel;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JPanel;
import javax.swing.ListSelectionModel;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.function.Consumer;
/**
* The biomes or regions on screen with how much of it each covers; clicking one highlights it on the map.
*/
final class MapLegendPanel extends JPanel {
private static final long serialVersionUID = 4471297416386034128L;
private static final int PANEL_WIDTH = 250;
private static final int ROW_HEIGHT = 26;
private static final int SWATCH_SIZE = 12;
private static final int SWATCH_GAP = 8;
private static final int ROW_PADDING = 10;
private static final int PERCENT_WIDTH = 42;
private static final String DRAWING_MESSAGE = "Drawing the map…";
private final JLabel title = new JLabel();
private final DefaultListModel<MapCensus.Share> rows = new DefaultListModel<>();
private final JList<MapCensus.Share> list = new JList<>(rows) {
// Long names shorten with an ellipsis instead of pushing the percentages out of sight.
@Override
public boolean getScrollableTracksViewportWidth() {
return true;
}
};
private final Set<String> sharedNames = new HashSet<>();
private MapStyle.Highlight highlight;
MapLegendPanel(Consumer<MapCensus.Share> onRowClicked) {
this();
highlightOnClick(onRowClicked);
}
/**
* A legend whose rows only tell what is on screen; clicking them does nothing.
*/
static MapLegendPanel listingOnly() {
return new MapLegendPanel();
}
private MapLegendPanel() {
super(new BorderLayout());
setPreferredSize(StudioTheme.scaledSize(PANEL_WIDTH, 0));
setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, StudioTheme.SEPARATOR));
setBackground(StudioTheme.PANEL_BACKGROUND);
title.setFont(StudioTheme.sectionFont());
title.setForeground(StudioTheme.MUTED_TEXT);
title.setBorder(StudioTheme.emptyBorder(10, 16, 6, 16));
add(title, BorderLayout.NORTH);
list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
list.setFocusable(false);
list.setFixedCellHeight(StudioTheme.scale(ROW_HEIGHT));
list.setCellRenderer(new ShareRenderer());
list.setBackground(StudioTheme.PANEL_BACKGROUND);
list.setBorder(StudioTheme.emptyBorder(0, 6, 4, 6));
add(StudioToolbar.scroll(list), BorderLayout.CENTER);
}
private void highlightOnClick(Consumer<MapCensus.Share> onRowClicked) {
list.addMouseListener(new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent event) {
MapCensus.Share share = shareAt(event.getPoint());
if (share != null) {
onRowClicked.accept(share);
}
}
});
JLabel hint = new JLabel("Click to highlight · Esc clears");
hint.setFont(StudioTheme.smallFont());
hint.setForeground(StudioTheme.MUTED_TEXT);
hint.setBorder(StudioTheme.emptyBorder(6, 16, 8, 16));
add(hint, BorderLayout.SOUTH);
}
void show(String subjectTitle, List<MapCensus.Share> shares, MapStyle.Highlight nextHighlight) {
title.setText(subjectTitle.toUpperCase(Locale.ROOT));
highlight = nextHighlight;
rememberSharedNames(shares);
if (!sameRows(shares)) {
rows.clear();
rows.addAll(shares);
}
list.repaint();
}
// Two biomes can share a display name; those rows show their key so they can be told apart.
private void rememberSharedNames(List<MapCensus.Share> shares) {
Set<String> seen = new HashSet<>();
sharedNames.clear();
for (MapCensus.Share share : shares) {
if (!seen.add(share.label().name())) {
sharedNames.add(share.label().name());
}
}
}
private String rowText(MapCensus.Share share) {
String name = share.label().name();
return sharedNames.contains(name) ? name + " (" + share.label().key() + ")" : name;
}
private boolean sameRows(List<MapCensus.Share> shares) {
if (rows.size() != shares.size()) {
return false;
}
for (int index = 0; index < shares.size(); index++) {
if (!rows.get(index).equals(shares.get(index))) {
return false;
}
}
return true;
}
private MapCensus.Share shareAt(Point point) {
int index = list.locationToIndex(point);
if (index < 0) {
return null;
}
Rectangle bounds = list.getCellBounds(index, index);
return bounds != null && bounds.contains(point) ? rows.get(index) : null;
}
private boolean isHighlighted(MapCensus.Share share) {
return highlight != null
&& highlight.kind() == share.label().kind()
&& highlight.key().equals(share.label().key());
}
@Override
protected void paintChildren(Graphics graphics) {
super.paintChildren(graphics);
if (!rows.isEmpty()) {
return;
}
Graphics2D canvas = (Graphics2D) graphics.create();
try {
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
canvas.setFont(StudioTheme.font());
canvas.setColor(StudioTheme.MUTED_TEXT);
canvas.drawString(DRAWING_MESSAGE, StudioTheme.scale(16),
title.getHeight() + StudioTheme.scale(4) + canvas.getFontMetrics().getAscent());
} finally {
canvas.dispose();
}
}
private final class ShareRenderer extends DefaultListCellRenderer {
private static final long serialVersionUID = -2350212934617052618L;
private Color swatch = Color.BLACK;
private String percent = "";
@Override
public Component getListCellRendererComponent(JList<?> owner, Object value, int index, boolean selected, boolean focused) {
MapCensus.Share share = (MapCensus.Share) value;
super.getListCellRendererComponent(owner, rowText(share), index, isHighlighted(share), false);
setFont(StudioTheme.font());
setBorder(StudioTheme.emptyBorder(0, ROW_PADDING + SWATCH_SIZE + SWATCH_GAP, 0, ROW_PADDING + PERCENT_WIDTH));
setToolTipText(share.label().key());
swatch = new Color(share.color());
percent = formatPercent(share.fraction());
return this;
}
@Override
protected void paintComponent(Graphics graphics) {
super.paintComponent(graphics);
Graphics2D canvas = (Graphics2D) graphics.create();
try {
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
int size = StudioTheme.scale(SWATCH_SIZE);
int swatchX = StudioTheme.scale(ROW_PADDING);
int swatchY = (getHeight() - size) / 2;
int arc = StudioTheme.scale(4);
canvas.setColor(swatch);
canvas.fillRoundRect(swatchX, swatchY, size, size, arc, arc);
canvas.setColor(StudioTheme.CONTROL_BORDER);
canvas.drawRoundRect(swatchX, swatchY, size - 1, size - 1, arc, arc);
canvas.setFont(StudioTheme.smallFont());
canvas.setColor(StudioTheme.SECONDARY_TEXT);
int textWidth = canvas.getFontMetrics().stringWidth(percent);
int baseline = (getHeight() + canvas.getFontMetrics().getAscent() - canvas.getFontMetrics().getDescent()) / 2;
canvas.drawString(percent, getWidth() - StudioTheme.scale(ROW_PADDING) - textWidth, baseline);
} finally {
canvas.dispose();
}
}
}
private static String formatPercent(double fraction) {
double percent = fraction * 100D;
if (percent < 0.1D) {
return "<0.1%";
}
return percent < 10D
? String.format(Locale.ROOT, "%.1f%%", percent)
: String.format(Locale.ROOT, "%.0f%%", percent);
}
}
@@ -0,0 +1,130 @@
/*
* 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 art.arcane.iris.studio.map;
import art.arcane.iris.studio.map.components.TileType;
import java.awt.event.KeyEvent;
/**
* Everything the map window can show, in the order and groups of its view list.
*/
enum MapView {
BIOMES("Biomes", "The biome at each spot, in the biome's colour", Group.WORLD, TileType.BIOME, KeyEvent.VK_1),
REGIONS("Regions", "The region each spot belongs to; pick one to see the biomes it places",
Group.WORLD, TileType.REGION, KeyEvent.VK_2),
HEIGHT("Height", "Terrain height, from deep water to snowy peaks", Group.WORLD, TileType.HEIGHT, KeyEvent.VK_3),
RIVERS("Rivers", "Rivers, falls, grottos and the rest of the hydrology the engine plans",
Group.WORLD, TileType.RIVER, KeyEvent.VK_4),
WORLD_3D("3D world", "The terrain in 3D, coloured by biome or region", Group.WORLD, null, KeyEvent.VK_5),
LAND_BIOMES("Land biomes", "The land biome each spot would get, also where the sea is",
Group.BIOME_STREAMS, TileType.BIOME_LAND, KeyEvent.VK_6),
SEA_BIOMES("Sea biomes", "The sea biome each spot would get, also where the land is",
Group.BIOME_STREAMS, TileType.BIOME_SEA, KeyEvent.VK_7),
CAVE_BIOMES("Cave biomes", "The cave biome under each spot", Group.BIOME_STREAMS, TileType.CAVE_LAND, KeyEvent.VK_8),
PALETTES("Palettes", "The blocks each biome's palette layers place, with their textures",
Group.BLOCKS, null, KeyEvent.VK_P),
OBJECTS("Objects", "Every object in the pack in 3D, with the blocks it is made of",
Group.BLOCKS, null, KeyEvent.VK_O),
PROCEDURAL("Procedural", "Trees, rock formations and more that Iris generates from settings, every variant in 3D",
Group.BLOCKS, null, KeyEvent.VK_T),
NOISE("Noise", "The pack's noise generators and Iris' built-in noise styles",
Group.GENERATORS, null, KeyEvent.VK_N),
IMAGE_MAPS("Image maps", "Terrain height, biomes or masks painted from a PNG, previewed and exported into the pack",
Group.GENERATORS, null, KeyEvent.VK_I),
OBJECT_LOAD("Object Load", "Configuration estimate of object density and chance, not measured performance",
Group.DIAGNOSTICS, TileType.OBJECT_LOAD, KeyEvent.VK_F6),
DECORATOR_LOAD("Decorator Load", "Configuration estimate of decorator chance and stacks, not measured performance",
Group.DIAGNOSTICS, TileType.DECORATOR_LOAD, KeyEvent.VK_F7),
LAYER_LOAD("Layer Load", "Configuration estimate of biome layer count, not measured performance",
Group.DIAGNOSTICS, TileType.LAYER_LOAD, KeyEvent.VK_F8);
private final String label;
private final String description;
private final Group group;
private final TileType renderType;
private final int shortcutKey;
MapView(String label, String description, Group group, TileType renderType, int shortcutKey) {
this.label = label;
this.description = description;
this.group = group;
this.renderType = renderType;
this.shortcutKey = shortcutKey;
}
String label() {
return label;
}
String description() {
return description;
}
Group group() {
return group;
}
/**
* The tile renderer's mode for this view, or null for the 3D world, palette, object, procedural, noise and image map
* views, which draw themselves.
*/
TileType renderType() {
return renderType;
}
int shortcutKey() {
return shortcutKey;
}
String shortcutText() {
return KeyEvent.getKeyText(shortcutKey);
}
boolean isTileMap() {
return renderType != null;
}
static MapView forShortcut(int keyCode) {
for (MapView view : values()) {
if (view.shortcutKey == keyCode) {
return view;
}
}
return null;
}
enum Group {
WORLD("World"),
BIOME_STREAMS("Biome layers"),
BLOCKS("Blocks"),
GENERATORS("Generators"),
DIAGNOSTICS("Diagnostics");
private final String label;
Group(String label) {
this.label = label;
}
String label() {
return label;
}
}
}
@@ -0,0 +1,57 @@
/*
* 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 art.arcane.iris.studio.map;
import com.formdev.flatlaf.extras.FlatSVGIcon;
import javax.swing.Icon;
import java.util.EnumMap;
import java.util.Locale;
import java.util.Map;
/**
* The icon of each map view, taken from IntelliJ's new UI icon set (see the NOTICE in iris/icons).
*/
final class MapViewIcon {
private static final String ICON_FOLDER = "iris/icons/views/";
private static final int SIZE = 16;
private static final Map<MapView, Icon> LOADED = new EnumMap<>(MapView.class);
private MapViewIcon() {
}
static Icon of(MapView view) {
return LOADED.computeIfAbsent(view, shown -> load(shown, SIZE));
}
/**
* The view's icon drawn larger, for an empty view.
*/
static Icon large(MapView view, int size) {
return load(view, size);
}
private static Icon load(MapView view, int size) {
if (view.group() == MapView.Group.DIAGNOSTICS) {
return new FlatSVGIcon("iris/icons/profiler.svg", size, size, MapViewIcon.class.getClassLoader());
}
String fileName = view.name().toLowerCase(Locale.ROOT).replace('_', '-') + ".svg";
return new FlatSVGIcon(ICON_FOLDER + fileName, size, size, MapViewIcon.class.getClassLoader());
}
}
@@ -0,0 +1,246 @@
/*
* 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 art.arcane.iris.studio.map;
import javax.swing.DefaultListCellRenderer;
import javax.swing.DefaultListModel;
import javax.swing.JList;
import javax.swing.ListSelectionModel;
import javax.swing.UIManager;
import java.awt.Component;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Insets;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.Locale;
import java.util.function.Consumer;
/**
* The map window's list of views, grouped under headers, each row with a picture of the view and the key that opens
* it.
*/
final class MapViewList extends JList<MapViewList.Row> {
private static final long serialVersionUID = -4130872409914437415L;
private static final int ROW_HEIGHT = 28;
private static final int NO_ROW = -1;
private final DefaultListModel<Row> rows = new DefaultListModel<>();
private int lastViewRow;
private int hoveredRow = NO_ROW;
MapViewList(Consumer<MapView> onViewChosen) {
fillRows();
setModel(rows);
setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
setFixedCellHeight(StudioTheme.scale(ROW_HEIGHT));
setCellRenderer(new RowRenderer());
setBorder(StudioTheme.emptyBorder(4, 6, 4, 6));
addListSelectionListener(event -> {
if (event.getValueIsAdjusting() || skipHeaderRow()) {
return;
}
Row selected = getSelectedValue();
if (selected == null) {
return;
}
lastViewRow = getSelectedIndex();
onViewChosen.accept(selected.view());
});
MouseAdapter hover = new MouseAdapter() {
@Override
public void mouseMoved(MouseEvent event) {
hover(viewRowAt(event));
}
@Override
public void mouseExited(MouseEvent event) {
hover(NO_ROW);
}
};
addMouseListener(hover);
addMouseMotionListener(hover);
}
private int viewRowAt(MouseEvent event) {
int row = locationToIndex(event.getPoint());
if (row < 0 || rows.get(row).isHeader() || !getCellBounds(row, row).contains(event.getPoint())) {
return NO_ROW;
}
return row;
}
private void hover(int row) {
if (row == hoveredRow) {
return;
}
repaintRow(hoveredRow);
hoveredRow = row;
repaintRow(hoveredRow);
}
private void repaintRow(int row) {
Rectangle bounds = row == NO_ROW ? null : getCellBounds(row, row);
if (bounds != null) {
repaint(bounds);
}
}
void select(MapView view) {
for (int row = 0; row < rows.size(); row++) {
if (rows.get(row).view() == view) {
lastViewRow = row;
setSelectedIndex(row);
ensureIndexIsVisible(row);
return;
}
}
}
private void fillRows() {
MapView.Group currentGroup = null;
for (MapView view : MapView.values()) {
if (view.group() != currentGroup) {
currentGroup = view.group();
rows.addElement(Row.header(currentGroup));
}
rows.addElement(Row.of(view));
}
}
// Headers can't be chosen: clicking one keeps the current view, and the arrow keys carry on past it.
private boolean skipHeaderRow() {
int row = getSelectedIndex();
if (row < 0 || !rows.get(row).isHeader()) {
return false;
}
int step = row >= lastViewRow ? 1 : -1;
int next = nextViewRow(row, step);
if (next < 0) {
next = nextViewRow(row, -step);
}
setSelectedIndex(next < 0 ? lastViewRow : next);
ensureIndexIsVisible(getSelectedIndex());
return true;
}
private int nextViewRow(int from, int step) {
for (int row = from + step; row >= 0 && row < rows.size(); row += step) {
if (!rows.get(row).isHeader()) {
return row;
}
}
return -1;
}
record Row(MapView.Group group, MapView view) {
static Row header(MapView.Group group) {
return new Row(group, null);
}
static Row of(MapView view) {
return new Row(view.group(), view);
}
boolean isHeader() {
return view == null;
}
}
private static final class RowRenderer extends DefaultListCellRenderer {
private static final long serialVersionUID = 6302156810211578474L;
private static final int ICON_TEXT_GAP = 8;
private static final int KEYCAP_SIZE = 17;
private static final int KEYCAP_ARC = 5;
private String shortcut;
private boolean hovered;
@Override
public Component getListCellRendererComponent(JList<?> list, Object value, int index, boolean selected, boolean focused) {
Row row = (Row) value;
super.getListCellRendererComponent(list, row.isHeader() ? "" : row.view().label(), index, selected && !row.isHeader(), false);
if (row.isHeader()) {
setText(row.group().label().toUpperCase(Locale.ROOT));
setFont(StudioTheme.sectionFont());
setForeground(StudioTheme.MUTED_TEXT);
setBorder(StudioTheme.emptyBorder(index == 0 ? 2 : 10, 8, 0, 6));
setIcon(null);
setToolTipText(null);
shortcut = null;
hovered = false;
setOpaque(true);
return this;
}
setFont(StudioTheme.font());
setBorder(StudioTheme.emptyBorder(0, 8, 0, 8));
setIcon(MapViewIcon.of(row.view()));
setIconTextGap(StudioTheme.scale(ICON_TEXT_GAP));
setToolTipText(row.view().description());
shortcut = row.view().shortcutText();
hovered = !selected && list instanceof MapViewList viewList && viewList.hoveredRow == index;
setOpaque(!hovered);
return this;
}
@Override
protected void paintComponent(Graphics graphics) {
Graphics2D canvas = (Graphics2D) graphics.create();
try {
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
if (hovered) {
paintHoverBackground(canvas);
}
super.paintComponent(graphics);
if (shortcut != null) {
paintKeycap(canvas);
}
} finally {
canvas.dispose();
}
}
// Shaped like the selection the look and feel paints, so moving between them doesn't jump.
private void paintHoverBackground(Graphics2D canvas) {
Insets inset = UIManager.getInsets("List.selectionInsets");
int left = inset == null ? 0 : StudioTheme.scale(inset.left);
int right = inset == null ? 0 : StudioTheme.scale(inset.right);
int arc = StudioTheme.scale(UIManager.getInt("List.selectionArc"));
canvas.setColor(StudioTheme.HOVER_BACKGROUND);
canvas.fillRoundRect(left, 0, getWidth() - left - right, getHeight(), arc, arc);
}
private void paintKeycap(Graphics2D canvas) {
canvas.setFont(StudioTheme.smallFont());
int textWidth = canvas.getFontMetrics().stringWidth(shortcut);
int height = StudioTheme.scale(KEYCAP_SIZE);
int width = Math.max(height, textWidth + StudioTheme.scale(8));
int x = getWidth() - getInsets().right - width;
int y = (getHeight() - height) / 2;
int arc = StudioTheme.scale(KEYCAP_ARC);
canvas.setColor(StudioTheme.CONTROL_BORDER);
canvas.drawRoundRect(x, y, width - 1, height - 1, arc, arc);
canvas.setColor(StudioTheme.SECONDARY_TEXT);
int baseline = y + (height + canvas.getFontMetrics().getAscent() - canvas.getFontMetrics().getDescent()) / 2;
canvas.drawString(shortcut, x + (width - textWidth) / 2, baseline);
}
}
}
@@ -0,0 +1,673 @@
/*
* 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 art.arcane.iris.studio.map;
import art.arcane.iris.generation.noise.IrisGenerator;
import art.arcane.iris.studio.map.components.MapStyle;
import javax.swing.AbstractAction;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.JComponent;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JRootPane;
import javax.swing.JSplitPane;
import javax.swing.JToggleButton;
import javax.swing.JToolBar;
import javax.swing.KeyStroke;
import javax.swing.SwingConstants;
import javax.swing.text.JTextComponent;
import java.awt.BorderLayout;
import java.awt.CardLayout;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.KeyboardFocusManager;
import java.awt.event.ActionEvent;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.KeyEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.function.IntConsumer;
/**
* The studio map: one window showing the world as tiles in several views, with the palette, object, noise and image map
* explorers as its Palettes, Objects, Noise and Image maps views.
*/
public final class MapWindow implements MapCanvas.Listener {
private static final String MAP_CARD = "map";
private static final String PALETTES_CARD = "palettes";
private static final String NOISE_CARD = "noise";
private static final String WORLD_CARD = "world";
private static final String IMAGE_MAPS_CARD = "image maps";
private static final int SIDEBAR_WIDTH = 270;
private static final int SIDEBAR_MINIMUM_WIDTH = 200;
private static final int CONTENT_MINIMUM_WIDTH = 320;
private static final double PALETTE_RECENTER_DISTANCE_BLOCKS = 1D;
private static final double WORLD_RECENTER_DISTANCE_BLOCKS = 1D;
private static final int VIEW_LIST_SCROLL_INSETS = 4;
private static final int EXPLORER_DIVIDER_HEIGHT = 7;
private final JFrame frame;
private final PaletteExplorerStart start;
private final NoiseSelection noiseSelection;
private final MapCanvas canvas;
private final MapViewList viewList;
private final JLabel viewTitle = new JLabel();
private final JPanel content = new JPanel(new CardLayout());
private final JPanel statusBars = new JPanel(new CardLayout());
private final JPanel sidebar = new JPanel(new BorderLayout());
private final IslandPanel sidebarIsland = new IslandPanel(sidebar);
private final JPanel inspectorSlot = new JPanel(new BorderLayout());
private final JComponent viewListScroll;
private final JSplitPane explorerSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT);
private final JSplitPane pickInspectorSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT);
private final List<JComponent> mapControls = new ArrayList<>();
private final List<JComponent> explorerControls = new ArrayList<>();
private final List<JComponent> noiseControls = new ArrayList<>();
private final List<JComponent> worldControls = new ArrayList<>();
private final List<JComponent> imageMapControls = new ArrayList<>();
private final JToolBar toolbar = StudioToolbar.create();
private final JComponent resetViewButton = StudioToolbar.button("Reset view", "Go back to the starting position and zoom (R)",
this::resetExplorerView);
private final JToggleButton gridToggle = StudioToolbar.toggle("Grid", "Show chunk and region borders (G)");
private final JToggleButton entitiesToggle = StudioToolbar.toggle("Entities", "Show mobs and other entities (E)");
private final JToggleButton followToggle = StudioToolbar.toggle("Follow", "Keep your player in the middle (F)");
private final JToggleButton shadingToggle = StudioToolbar.toggle("Shading", "Shade hills and valleys on every view (S)");
private final JToggleButton bordersToggle = StudioToolbar.toggle("Borders", "Outline the regions on every view (B)");
private final JToggleButton waterToggle = StudioToolbar.toggle("Water", "Overlay water on biome picks (W)");
private final ResourceUsage resourceUsage = new ResourceUsage();
private final MapLegendPanel legendPanel;
private final RegionExplorer regionExplorer;
private final BiomeExplorer biomeExplorer;
private NoiseExplorerGUI paletteExplorer;
private final Map<MapView, ObjectExplorer> objectExplorers = new EnumMap<>(MapView.class);
private NoiseExplorerGUI noiseExplorer;
private JComponent noiseSidebar;
private WorldExplorer worldExplorer;
private ImageMapExplorer imageMapExplorer;
private double worldCenterX;
private double worldCenterZ;
private MapView currentView;
private double paletteCenterX;
private double paletteCenterZ;
private MapWindow(PaletteExplorerStart start, NoiseSelection noiseSelection) {
this.start = start;
this.noiseSelection = noiseSelection;
this.frame = MapHost.createFrame("Iris Map");
this.canvas = new MapCanvas(start.engine(), start.worldId(), start.playerId(), this);
this.viewList = new MapViewList(this::showView);
this.viewListScroll = StudioToolbar.scroll(viewList);
this.legendPanel = new MapLegendPanel(share -> canvas.toggleHighlight(share.label()));
this.regionExplorer = new RegionExplorer(canvas::engine, canvas::previewRegion, canvas::showPicksAs);
this.biomeExplorer = new BiomeExplorer(canvas::engine, canvas::showPicksAs);
canvas.centerOn(start.centerX(), start.centerZ());
buildFrame();
}
public static void openMap(PaletteExplorerStart start) {
openLater(start, MapView.BIOMES);
}
public static void openPalettes(PaletteExplorerStart start) {
openLater(start, MapView.PALETTES);
}
public static void openNoise(PaletteExplorerStart start) {
openLater(start, MapView.NOISE);
}
public static void openNoise(PaletteExplorerStart start, NoiseSelection selection) {
if (!MapHost.isAvailable()) {
return;
}
EventQueue.invokeLater(() -> open(start, MapView.NOISE, selection));
}
public static void openImageMaps(PaletteExplorerStart start) {
openLater(start, MapView.IMAGE_MAPS);
}
private static void openLater(PaletteExplorerStart start, MapView initialView) {
if (!MapHost.isAvailable()) {
return;
}
EventQueue.invokeLater(() -> open(start, initialView, null));
}
private static void open(PaletteExplorerStart start, MapView initialView, NoiseSelection selection) {
if (!MapHost.isAvailable()) {
return;
}
StudioTheme.install();
MapWindow window = new MapWindow(start, selection);
window.frame.setVisible(true);
window.canvas.start();
window.resourceUsage.start();
window.showView(initialView);
}
private void buildFrame() {
styleExplorerSplit();
stylePickInspectorSplit();
content.add(canvas, MAP_CARD);
statusBars.add(canvas.statusBar(), MAP_CARD);
sidebar.add(viewListScroll, BorderLayout.CENTER);
frame.setLayout(new BorderLayout());
frame.add(buildToolbar(), BorderLayout.NORTH);
frame.add(new IslandOverlay(sidebarIsland, content, StudioTheme.scale(SIDEBAR_WIDTH),
StudioTheme.scale(SIDEBAR_MINIMUM_WIDTH), StudioTheme.scale(CONTENT_MINIMUM_WIDTH)), BorderLayout.CENTER);
frame.add(inspectorSlot, BorderLayout.EAST);
frame.add(statusBars, BorderLayout.SOUTH);
installShortcuts(frame.getRootPane());
keepClearOfIsland(canvas::setCoveredLeftWidth);
frame.setSize(MapHost.fitToScreen(StudioTheme.scaledSize(1440, 880)));
frame.setMinimumSize(StudioTheme.scaledSize(820, 520));
frame.setLocationRelativeTo(null);
frame.addWindowListener(new WindowAdapter() {
@Override
public void windowClosed(WindowEvent event) {
close();
}
});
}
private JToolBar buildToolbar() {
viewTitle.setFont(StudioTheme.titleFont());
viewTitle.setBorder(StudioTheme.emptyBorder(0, 6, 0, 14));
toolbar.add(viewTitle);
mapControls.add(StudioToolbar.button("Refresh", "Redraw the map with the latest pack files (R)", canvas::refreshContent));
mapControls.add(StudioToolbar.button("Reset zoom", "Go back to the default zoom (\\)", canvas::resetZoom));
mapControls.add(gridToggle);
mapControls.add(entitiesToggle);
mapControls.add(followToggle);
mapControls.add(toolbarSeparator());
mapControls.add(shadingToggle);
mapControls.add(bordersToggle);
mapControls.add(waterToggle);
gridToggle.addActionListener(event -> canvas.toggleGrid());
entitiesToggle.addActionListener(event -> canvas.toggleEntities());
followToggle.addActionListener(event -> canvas.toggleFollow());
shadingToggle.addActionListener(event -> canvas.toggleShading());
bordersToggle.addActionListener(event -> canvas.toggleRegionBorders());
waterToggle.addActionListener(event -> canvas.toggleWaterOverlay());
mapControls.forEach(toolbar::add);
explorerControls.add(resetViewButton);
explorerControls.forEach(toolbar::add);
toolbar.add(Box.createHorizontalGlue());
toolbar.add(resourceUsage);
toolbar.add(StudioToolbar.themeButton());
JComponent help = StudioToolbar.button("Shortcuts", "Show every mouse and key shortcut (/)", canvas::toggleHelp);
mapControls.add(help);
toolbar.add(help);
onTogglesChanged();
return toolbar;
}
private static JComponent toolbarSeparator() {
JToolBar.Separator separator = new JToolBar.Separator();
separator.setOrientation(SwingConstants.VERTICAL);
return separator;
}
private void showView(MapView view) {
if (view == null || view == currentView) {
return;
}
MapView previousView = currentView;
currentView = view;
viewList.select(view);
viewTitle.setText(view.label());
viewTitle.setToolTipText(view.description());
if (previousView == MapView.WORLD_3D) {
worldExplorer.hide();
}
if (view.isTileMap()) {
showMap(previousView, view);
} else if (view == MapView.WORLD_3D) {
showWorld();
} else if (view == MapView.PALETTES) {
showPalettes(previousView);
} else if (view == MapView.OBJECTS || view == MapView.PROCEDURAL) {
showObjectExplorer(view);
} else if (view == MapView.IMAGE_MAPS) {
showImageMaps();
} else {
showNoise();
}
frame.getRootPane().revalidate();
frame.getRootPane().repaint();
}
private void showMap(MapView previousView, MapView view) {
if (previousView == MapView.PALETTES && paletteExplorer != null) {
canvas.centerOn(paletteExplorer.centerX(), paletteExplorer.centerZ());
}
canvas.showView(view);
canvas.setActive(true);
showControls(mapControls);
waterToggle.setVisible(view == MapView.BIOMES);
if (view == MapView.REGIONS) {
showRegionExplorer(previousView);
} else if (view == MapView.BIOMES) {
showBiomeExplorer();
} else {
useSidebar(viewListScroll);
useInspector(legendPanel);
}
showCard(MAP_CARD);
canvas.requestFocusInWindow();
}
// The region inspector sits above the legend, which then lists the chosen region's biomes on the map.
private void showRegionExplorer(MapView previousView) {
if (previousView != MapView.REGIONS) {
regionExplorer.loadRegions();
}
useSidebar(viewListAbove(regionExplorer.navigator()));
showPickInspector(regionExplorer.inspector());
EventQueue.invokeLater(() -> viewList.select(currentView));
regionExplorer.measure(canvas.viewArea());
}
private void showBiomeExplorer() {
useSidebar(viewListScroll);
showPickInspector(biomeExplorer.inspector());
biomeExplorer.measure(canvas.viewArea());
}
// The region and biome views share how the map draws its picks, so their switches show the same choice.
private void showPickInspector(PickInspectorPanel inspector) {
inspector.selectDisplay(canvas.pickDisplay());
pickInspectorSplit.setTopComponent(StudioTheme.matchTheme(inspector));
pickInspectorSplit.setBottomComponent(StudioTheme.matchTheme(legendPanel));
useInspector(pickInspectorSplit);
}
private void stylePickInspectorSplit() {
pickInspectorSplit.setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, StudioTheme.SEPARATOR));
pickInspectorSplit.setResizeWeight(0.7D);
pickInspectorSplit.setBackground(StudioTheme.PANEL_BACKGROUND);
pickInspectorSplit.setDividerSize(StudioTheme.scale(EXPLORER_DIVIDER_HEIGHT));
pickInspectorSplit.putClientProperty("FlatLaf.style", "style: grip");
}
private void showPalettes(MapView previousView) {
boolean firstOpen = paletteExplorer == null;
if (firstOpen) {
createPaletteExplorer(previousView == null);
} else {
followMapIntoPalettes();
}
canvas.setActive(false);
showControls(explorerControls);
useSidebar(viewListAbove(paletteExplorer.paletteSidebar()));
useInspector(paletteExplorer.paletteInspector());
showCard(PALETTES_CARD);
EventQueue.invokeLater(() -> viewList.select(currentView));
if (firstOpen) {
paletteExplorer.openViewer();
}
}
private void showObjectExplorer(MapView view) {
boolean firstOpen = !objectExplorers.containsKey(view);
ObjectExplorer explorer = objectExplorers.computeIfAbsent(view, this::createObjectExplorer);
canvas.setActive(false);
showControls(explorerControls);
useSidebar(viewListAbove(explorer.browser()));
useInspector(explorer.overview());
showCard(view.name());
EventQueue.invokeLater(() -> viewList.select(currentView));
if (firstOpen) {
explorer.open();
}
}
// The 3D world keeps its camera unless the map was moved since; then it turns around the map's middle.
private void showWorld() {
boolean firstOpen = worldExplorer == null;
if (firstOpen) {
createWorldExplorer();
}
boolean mapMoved = Math.hypot(canvas.centerX() - worldCenterX, canvas.centerZ() - worldCenterZ) >= WORLD_RECENTER_DISTANCE_BLOCKS;
worldCenterX = canvas.centerX();
worldCenterZ = canvas.centerZ();
canvas.setActive(false);
showControls(worldControls, explorerControls);
useSidebar(viewListScroll);
useInspector(worldExplorer.legend());
showCard(WORLD_CARD);
worldExplorer.show(worldCenterX, worldCenterZ, firstOpen || mapMoved);
}
private void createWorldExplorer() {
worldExplorer = new WorldExplorer(canvas::engine);
keepClearOfIsland(worldExplorer::setCoveredLeftWidth);
worldControls.addAll(worldExplorer.controls());
worldControls.forEach(control -> toolbar.add(control, toolbar.getComponentIndex(resetViewButton)));
content.add(worldExplorer.view(), WORLD_CARD);
statusBars.add(worldExplorer.statusBar(), WORLD_CARD);
}
private void keepClearOfIsland(IntConsumer setCoveredLeftWidth) {
setCoveredLeftWidth.accept(sidebarIsland.getWidth());
sidebarIsland.addComponentListener(new ComponentAdapter() {
@Override
public void componentResized(ComponentEvent event) {
setCoveredLeftWidth.accept(sidebarIsland.getWidth());
}
});
}
private ObjectExplorer createObjectExplorer(MapView view) {
ObjectSource source = view == MapView.PROCEDURAL ? new ProceduralObjectSource() : new PackObjectSource();
ObjectExplorer explorer = new ObjectExplorer(start.engine(), start.worldId(), source);
keepClearOfIsland(explorer::setCoveredLeftWidth);
content.add(explorer.view(), view.name());
statusBars.add(explorer.statusBar(), view.name());
return explorer;
}
private void showNoise() {
boolean firstOpen = noiseExplorer == null;
if (firstOpen) {
createNoiseExplorer();
}
canvas.setActive(false);
showControls(noiseControls, explorerControls);
useSidebar(viewListAbove(noiseSidebar));
inspectorSlot.removeAll();
showCard(NOISE_CARD);
EventQueue.invokeLater(() -> viewList.select(currentView));
if (firstOpen) {
noiseExplorer.openViewer();
}
}
private void showImageMaps() {
boolean firstOpen = imageMapExplorer == null;
if (firstOpen) {
createImageMapExplorer();
}
canvas.setActive(false);
showControls(imageMapControls);
useSidebar(viewListScroll);
inspectorSlot.removeAll();
showCard(IMAGE_MAPS_CARD);
if (firstOpen) {
imageMapExplorer.open();
}
}
private void createImageMapExplorer() {
imageMapExplorer = new ImageMapExplorer(start.engine());
keepClearOfIsland(imageMapExplorer::setCoveredLeftWidth);
imageMapControls.addAll(imageMapExplorer.controls());
imageMapControls.forEach(control -> toolbar.add(control, toolbar.getComponentIndex(resetViewButton)));
content.add(imageMapExplorer.view(), IMAGE_MAPS_CARD);
statusBars.add(imageMapExplorer.statusBar(), IMAGE_MAPS_CARD);
}
// Both explorers share one split, so the divider stays where it was dragged when switching between them.
private JSplitPane viewListAbove(JComponent explorerSidebar) {
boolean firstUse = explorerSplit.getBottomComponent() == null;
explorerSplit.setTopComponent(viewListScroll);
explorerSplit.setBottomComponent(StudioTheme.matchTheme(explorerSidebar));
if (firstUse) {
EventQueue.invokeLater(this::fitViewListAboveExplorer);
}
return explorerSplit;
}
private void styleExplorerSplit() {
explorerSplit.setBorder(BorderFactory.createEmptyBorder());
explorerSplit.setResizeWeight(0D);
explorerSplit.setBackground(StudioTheme.PANEL_BACKGROUND);
explorerSplit.setDividerSize(StudioTheme.scale(EXPLORER_DIVIDER_HEIGHT));
explorerSplit.putClientProperty("FlatLaf.style", "style: grip");
}
// Every view stays in sight, so the divider starts below the last one and can only be dragged further down. Measured
// once the sidebar is laid out, since the list's rows are taller before they know their width.
private void fitViewListAboveExplorer() {
int fullHeight = viewList.getPreferredSize().height + VIEW_LIST_SCROLL_INSETS;
viewListScroll.setMinimumSize(new Dimension(0, fullHeight));
explorerSplit.setDividerLocation(fullHeight);
}
private void createNoiseExplorer() {
noiseExplorer = noiseSelection == null
? NoiseExplorerGUI.createNoiseExplorer(start)
: NoiseExplorerGUI.createGeneratorExplorer(start, noiseSelection);
keepClearOfIsland(noiseExplorer::setCoveredLeftWidth);
noiseSidebar = noiseExplorer.noiseSidebar();
noiseControls.addAll(noiseExplorer.noiseControls());
noiseControls.forEach(control -> toolbar.add(control, toolbar.getComponentIndex(resetViewButton)));
content.add(noiseExplorer, NOISE_CARD);
statusBars.add(noiseExplorer.statusBar(), NOISE_CARD);
}
private void createPaletteExplorer(boolean openedOnPalettes) {
PaletteExplorerStart explorerStart = openedOnPalettes
? start
: new PaletteExplorerStart(start.engine(), start.worldId(), start.playerId(), null, canvas.centerX(), canvas.centerZ());
paletteExplorer = NoiseExplorerGUI.createPaletteExplorer(explorerStart);
content.add(paletteExplorer, PALETTES_CARD);
statusBars.add(paletteExplorer.statusBar(), PALETTES_CARD);
rememberPaletteCenter(explorerStart.centerX(), explorerStart.centerZ());
if (!openedOnPalettes) {
showSurfaceBiomePalettes(explorerStart.centerX(), explorerStart.centerZ());
}
}
// Coming back from the map only moves the palettes when the map was moved, so a chosen layer survives a quick look.
private void followMapIntoPalettes() {
double movedX = canvas.centerX() - paletteCenterX;
double movedZ = canvas.centerZ() - paletteCenterZ;
if (Math.hypot(movedX, movedZ) < PALETTE_RECENTER_DISTANCE_BLOCKS) {
return;
}
paletteExplorer.centerOn(canvas.centerX(), canvas.centerZ());
rememberPaletteCenter(canvas.centerX(), canvas.centerZ());
showSurfaceBiomePalettes(canvas.centerX(), canvas.centerZ());
}
@Override
public void onShowPalettesAt(double worldX, double worldZ) {
canvas.centerOn(worldX, worldZ);
showView(MapView.PALETTES);
}
private void showSurfaceBiomePalettes(double worldX, double worldZ) {
canvas.findSurfaceBiome(worldX, worldZ, biomeKey -> {
if (paletteExplorer != null) {
paletteExplorer.showPalettesAt(worldX, worldZ, biomeKey);
}
});
}
private void rememberPaletteCenter(double worldX, double worldZ) {
paletteCenterX = worldX;
paletteCenterZ = worldZ;
}
@Override
public void onTogglesChanged() {
gridToggle.setSelected(canvas.isGridVisible());
entitiesToggle.setSelected(canvas.areEntitiesVisible());
followToggle.setSelected(canvas.isFollowing());
shadingToggle.setSelected(canvas.style().shading());
bordersToggle.setSelected(canvas.style().regionBorders());
waterToggle.setSelected(canvas.isWaterOverlayEnabled());
}
@Override
public void onLegendChanged(String title, List<MapCensus.Share> shares, MapStyle.Highlight highlight) {
legendPanel.show(title, shares, highlight);
if (currentView == MapView.REGIONS) {
regionExplorer.measure(canvas.viewArea());
} else if (currentView == MapView.BIOMES) {
biomeExplorer.measure(canvas.viewArea());
}
}
@Override
public void onContentRefreshed() {
regionExplorer.refresh();
biomeExplorer.refresh();
if (worldExplorer != null) {
worldExplorer.refresh(worldCenterX, worldCenterZ);
}
}
// Only called for the view on screen, whose explorer exists by then.
private void resetExplorerView() {
switch (currentView) {
case WORLD_3D -> worldExplorer.resetCamera();
case OBJECTS, PROCEDURAL -> objectExplorers.get(currentView).resetCamera();
case NOISE -> noiseExplorer.resetViewport();
case PALETTES -> paletteExplorer.resetViewport();
default -> {
}
}
}
private void useSidebar(JComponent component) {
sidebar.removeAll();
sidebar.add(StudioTheme.matchTheme(component), BorderLayout.CENTER);
}
private void useInspector(JComponent component) {
inspectorSlot.removeAll();
inspectorSlot.add(StudioTheme.matchTheme(component), BorderLayout.CENTER);
}
private void showCard(String card) {
((CardLayout) content.getLayout()).show(content, card);
((CardLayout) statusBars.getLayout()).show(statusBars, card);
}
@SafeVarargs
private void showControls(List<JComponent>... visibleControls) {
for (List<JComponent> controls : List.of(mapControls, explorerControls, noiseControls, worldControls, imageMapControls)) {
controls.forEach(control -> control.setVisible(false));
}
for (List<JComponent> controls : visibleControls) {
controls.forEach(control -> control.setVisible(true));
}
}
private void installShortcuts(JRootPane rootPane) {
for (MapView view : MapView.values()) {
bind(rootPane, KeyStroke.getKeyStroke(view.shortcutKey(), 0), "view-" + view.name(), () -> showView(view));
}
bind(rootPane, KeyStroke.getKeyStroke(KeyEvent.VK_M, 0), "next-view", this::showNextView);
bind(rootPane, KeyStroke.getKeyStroke(KeyEvent.VK_R, 0), "refresh", this::refreshOrReset);
bindMap(rootPane, KeyEvent.VK_F, 0, "follow", canvas::toggleFollow);
bindMap(rootPane, KeyEvent.VK_G, 0, "grid", canvas::toggleGrid);
bindMap(rootPane, KeyEvent.VK_E, 0, "entities", canvas::toggleEntities);
bindMap(rootPane, KeyEvent.VK_F3, 0, "debug", canvas::toggleDebug);
bindMap(rootPane, KeyEvent.VK_H, 0, "highlight", canvas::highlightUnderCursor);
bindMap(rootPane, KeyEvent.VK_ESCAPE, 0, "dismiss", canvas::dismissEmphasis);
bindMap(rootPane, KeyEvent.VK_S, 0, "shading", canvas::toggleShading);
bindMap(rootPane, KeyEvent.VK_B, 0, "borders", canvas::toggleRegionBorders);
bindMap(rootPane, KeyEvent.VK_W, 0, "water", canvas::toggleWaterOverlay);
bindMap(rootPane, KeyEvent.VK_EQUALS, 0, "zoom-in", canvas::zoomIn);
bindMap(rootPane, KeyEvent.VK_EQUALS, KeyEvent.SHIFT_DOWN_MASK, "zoom-in-plus", canvas::zoomIn);
bindMap(rootPane, KeyEvent.VK_ADD, 0, "zoom-in-keypad", canvas::zoomIn);
bindMap(rootPane, KeyEvent.VK_MINUS, 0, "zoom-out", canvas::zoomOut);
bindMap(rootPane, KeyEvent.VK_SUBTRACT, 0, "zoom-out-keypad", canvas::zoomOut);
bindMap(rootPane, KeyEvent.VK_BACK_SLASH, 0, "reset-zoom", canvas::resetZoom);
bindMap(rootPane, KeyEvent.VK_SLASH, 0, "help", canvas::toggleHelp);
bindMap(rootPane, KeyEvent.VK_SLASH, KeyEvent.SHIFT_DOWN_MASK, "help-question", canvas::toggleHelp);
bind(rootPane, KeyStroke.getKeyStroke(KeyEvent.VK_SHIFT, KeyEvent.SHIFT_DOWN_MASK, false), "details-on",
() -> canvas.setDetailedHover(true));
bind(rootPane, KeyStroke.getKeyStroke(KeyEvent.VK_SHIFT, 0, true), "details-off", () -> canvas.setDetailedHover(false));
}
private void bindMap(JRootPane rootPane, int keyCode, int modifiers, String name, Runnable action) {
bind(rootPane, KeyStroke.getKeyStroke(keyCode, modifiers), name, () -> {
if (currentView != null && currentView.isTileMap()) {
action.run();
}
});
}
// Keys typed into a search box belong to that box, not to the window's shortcuts.
private static void bind(JRootPane rootPane, KeyStroke keyStroke, String name, Runnable action) {
rootPane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(keyStroke, name);
rootPane.getActionMap().put(name, new AbstractAction() {
@Override
public void actionPerformed(ActionEvent event) {
if (!(KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner() instanceof JTextComponent)) {
action.run();
}
}
});
}
private void showNextView() {
MapView[] views = MapView.values();
showView(views[(currentView.ordinal() + 1) % views.length]);
}
private void refreshOrReset() {
if (currentView.isTileMap()) {
canvas.refreshContent();
} else {
resetExplorerView();
}
}
private void close() {
resourceUsage.stop();
canvas.close();
regionExplorer.close();
biomeExplorer.close();
if (paletteExplorer != null) {
paletteExplorer.close();
}
objectExplorers.values().forEach(ObjectExplorer::close);
if (noiseExplorer != null) {
noiseExplorer.close();
}
if (worldExplorer != null) {
worldExplorer.close();
}
if (imageMapExplorer != null) {
imageMapExplorer.close();
}
}
public record NoiseSelection(String generatorKey, IrisGenerator generator, long seed) {
}
}
@@ -0,0 +1,162 @@
/*
* 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 art.arcane.iris.studio.map;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import art.arcane.iris.generation.biome.IrisBiomeCustom;
import java.awt.Color;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.zip.ZipFile;
// Works out biome tint colours the way Minecraft does, from the colour maps and vanilla biome files in the client jar.
final class MinecraftBiomeColors {
private static final String COLOR_MAP_FOLDER = "assets/minecraft/textures/colormap/";
private static final int COLOR_MAP_SIZE = 256;
private static final int GRASS_FALLBACK = 0xFF00FF;
private static final int FOLIAGE_FALLBACK = 0x48B518;
private static final int DRY_FOLIAGE_FALLBACK = 0x5C3C32;
private static final float UNKNOWN_BIOME_TEMPERATURE = 0.5F;
private static final float UNKNOWN_BIOME_DOWNFALL = 0.5F;
private static final int UNKNOWN_BIOME_WATER = 0x3F76E4;
private static final int UNPARSEABLE_COLOR = 0x000000;
private final ZipFile clientJar;
private final ColorMap grassMap;
private final ColorMap foliageMap;
private final ColorMap dryFoliageMap;
private static final String DEFAULT_NAMESPACE = "minecraft";
private final Map<String, BiomeTintColors> vanillaBiomes = new ConcurrentHashMap<>();
private MinecraftBiomeColors(ZipFile clientJar, ColorMap grassMap, ColorMap foliageMap, ColorMap dryFoliageMap) {
this.clientJar = clientJar;
this.grassMap = grassMap;
this.foliageMap = foliageMap;
this.dryFoliageMap = dryFoliageMap;
}
static MinecraftBiomeColors load(ZipFile clientJar) throws IOException {
return new MinecraftBiomeColors(
clientJar,
ColorMap.read(clientJar, "grass.png", GRASS_FALLBACK),
ColorMap.read(clientJar, "foliage.png", FOLIAGE_FALLBACK),
ColorMap.read(clientJar, "dry_foliage.png", DRY_FOLIAGE_FALLBACK)
);
}
/**
* @param biome a biome key like {@code minecraft:plains}; one without a namespace is taken as Minecraft's
*/
BiomeTintColors vanilla(String biome) {
return vanillaBiomes.computeIfAbsent(biome, this::readVanillaBiome);
}
// Iris registers custom biomes as data pack biomes built from these fields, see IrisBiomeCustom#generateJson.
BiomeTintColors custom(IrisBiomeCustom biome) {
float temperature = (float) biome.getTemperature();
float downfall = (float) biome.getHumidity();
return new BiomeTintColors(
biome.getGrassColor().isEmpty() ? grassMap.colorAt(temperature, downfall) : parseIrisColor(biome.getGrassColor()),
BiomeTintColors.GrassColorModifier.NONE,
biome.getFoliageColor().isEmpty() ? foliageMap.colorAt(temperature, downfall) : parseIrisColor(biome.getFoliageColor()),
dryFoliageMap.colorAt(temperature, downfall),
parseIrisColor(biome.getWaterColor())
);
}
private BiomeTintColors readVanillaBiome(String biome) {
JsonObject definition = readBiomeDefinition(biome);
if (definition == null) {
return fromClimate(UNKNOWN_BIOME_TEMPERATURE, UNKNOWN_BIOME_DOWNFALL, new JsonObject());
}
JsonObject effects = definition.has("effects") ? definition.getAsJsonObject("effects") : new JsonObject();
return fromClimate(definition.get("temperature").getAsFloat(), definition.get("downfall").getAsFloat(), effects);
}
private BiomeTintColors fromClimate(float temperature, float downfall, JsonObject effects) {
return new BiomeTintColors(
effects.has("grass_color") ? parseVanillaColor(effects.get("grass_color")) : grassMap.colorAt(temperature, downfall),
effects.has("grass_color_modifier")
? BiomeTintColors.GrassColorModifier.named(effects.get("grass_color_modifier").getAsString())
: BiomeTintColors.GrassColorModifier.NONE,
effects.has("foliage_color") ? parseVanillaColor(effects.get("foliage_color")) : foliageMap.colorAt(temperature, downfall),
effects.has("dry_foliage_color") ? parseVanillaColor(effects.get("dry_foliage_color")) : dryFoliageMap.colorAt(temperature, downfall),
effects.has("water_color") ? parseVanillaColor(effects.get("water_color")) : UNKNOWN_BIOME_WATER
);
}
private JsonObject readBiomeDefinition(String biome) {
int separator = biome.indexOf(':');
String namespace = separator < 0 ? DEFAULT_NAMESPACE : biome.substring(0, separator);
String path = biome.substring(separator + 1);
try {
return ClientJarFiles.readJson(clientJar, "data/" + namespace + "/worldgen/biome/" + path + ".json");
} catch (IOException | RuntimeException error) {
return null;
}
}
// Older versions store colours as plain numbers, newer ones as "#rrggbb" strings.
private static int parseVanillaColor(JsonElement color) {
if (color.getAsJsonPrimitive().isNumber()) {
return color.getAsInt() & 0xFFFFFF;
}
return Integer.parseInt(color.getAsString().substring(1), 16);
}
private static int parseIrisColor(String color) {
try {
return Color.decode((color.startsWith("#") ? color : "#" + color).trim()).getRGB() & 0xFFFFFF;
} catch (RuntimeException error) {
return UNPARSEABLE_COLOR;
}
}
// Minecraft's ColorMapColorUtil: temperature picks the column and temperature times downfall picks the row.
private record ColorMap(int[] pixels, int fallback) {
private static ColorMap read(ZipFile clientJar, String name, int fallback) throws IOException {
BufferedImage image = ClientJarFiles.readImage(clientJar, COLOR_MAP_FOLDER + name);
if (image == null) {
throw new IOException("Minecraft colour map " + name + " is missing");
}
int[] pixels = new int[COLOR_MAP_SIZE * COLOR_MAP_SIZE];
for (int y = 0; y < COLOR_MAP_SIZE; y++) {
for (int x = 0; x < COLOR_MAP_SIZE; x++) {
pixels[y * COLOR_MAP_SIZE + x] = image.getRGB(x, y) & 0xFFFFFF;
}
}
return new ColorMap(pixels, fallback);
}
private int colorAt(float temperature, float downfall) {
double clampedTemperature = Math.clamp(temperature, 0F, 1F);
double clampedDownfall = Math.clamp(downfall, 0F, 1F) * clampedTemperature;
int x = (int) ((1D - clampedTemperature) * 255D);
int y = (int) ((1D - clampedDownfall) * 255D);
int index = y << 8 | x;
return index >= pixels.length ? fallback : pixels[index];
}
}
}
@@ -0,0 +1,85 @@
/*
* 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 art.arcane.iris.studio.map;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
// Mirrors Minecraft's BlockColors: which colour each tinted face of a block is multiplied with in the world.
final class MinecraftBlockTints {
static final int NO_TINT = 0xFFFFFF;
private static final Set<String> GRASS_TINTED = Set.of(
"grass_block", "short_grass", "fern", "potted_fern", "bush", "tall_grass", "large_fern", "sugar_cane");
private static final Set<String> GRASS_TINTED_ON_SECOND_LAYER = Set.of("pink_petals", "wildflowers");
private static final Set<String> FOLIAGE_TINTED = Set.of(
"oak_leaves", "jungle_leaves", "acacia_leaves", "dark_oak_leaves", "vine", "mangrove_leaves");
private static final Set<String> WATER_TINTED_BY_FLUID_RENDERER = Set.of("water", "bubble_column");
private static final Set<String> GROWING_STEMS = Set.of("melon_stem", "pumpkin_stem");
private static final Map<String, Integer> FIXED_TINTS = Map.of(
"spruce_leaves", 0x619961,
"birch_leaves", 0x80A755,
"attached_melon_stem", 0xE0C71C,
"attached_pumpkin_stem", 0xE0C71C,
"lily_pad", 0x208030
);
private MinecraftBlockTints() {
}
static Optional<BiomeTint> biomeTint(String blockName, int tintIndex) {
if (WATER_TINTED_BY_FLUID_RENDERER.contains(blockName)) {
return Optional.of(BiomeTint.WATER);
}
if (GRASS_TINTED_ON_SECOND_LAYER.contains(blockName)) {
return tintIndex == 1 ? Optional.of(BiomeTint.GRASS) : Optional.empty();
}
if (tintIndex != 0) {
return Optional.empty();
}
if (GRASS_TINTED.contains(blockName)) {
return Optional.of(BiomeTint.GRASS);
}
if (FOLIAGE_TINTED.contains(blockName)) {
return Optional.of(BiomeTint.FOLIAGE);
}
if (blockName.equals("leaf_litter")) {
return Optional.of(BiomeTint.DRY_FOLIAGE);
}
if (blockName.equals("water_cauldron")) {
return Optional.of(BiomeTint.WATER);
}
return Optional.empty();
}
static int fixedTint(String blockName, Map<String, String> properties, int tintIndex) {
if (tintIndex != 0) {
return NO_TINT;
}
if (GROWING_STEMS.contains(blockName)) {
return stemTint(Integer.parseInt(properties.getOrDefault("age", "0")));
}
return FIXED_TINTS.getOrDefault(blockName, NO_TINT);
}
private static int stemTint(int age) {
return (age * 32) << 16 | (255 - age * 8) << 8 | (age * 4);
}
}
@@ -0,0 +1,146 @@
/*
* 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 art.arcane.iris.studio.map;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.HexFormat;
import java.util.concurrent.CompletableFuture;
import java.util.zip.ZipFile;
final class MinecraftClientJar {
private static final URI VERSION_MANIFEST = URI.create("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json");
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(15);
private static final Duration DOWNLOAD_TIMEOUT = Duration.ofMinutes(10);
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(CONNECT_TIMEOUT)
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
private MinecraftClientJar() {
}
static boolean isCached() {
return cachedJar().exists();
}
static CompletableFuture<ZipFile> open() {
return CompletableFuture.supplyAsync(() -> {
try {
File jar = cachedJar();
if (!jar.exists()) {
download(IrisPlatforms.get().minecraftVersion(), jar.toPath());
}
return new ZipFile(jar);
} catch (IOException | InterruptedException error) {
throw new IllegalStateException(readableReason(error), error);
}
}, MinecraftClientJar::runInBackground);
}
private static void runInBackground(Runnable task) {
Thread thread = new Thread(task, "Iris Minecraft Client Download");
thread.setDaemon(true);
thread.start();
}
private static File cachedJar() {
return IrisPlatforms.get().dataFile("cache", "minecraft-client", IrisPlatforms.get().minecraftVersion() + ".jar");
}
private static void download(String version, Path destination) throws IOException, InterruptedException {
JsonObject versionInfo = fetchJson(findVersionUrl(version));
JsonObject client = versionInfo.getAsJsonObject("downloads").getAsJsonObject("client");
URI clientUrl = URI.create(client.get("url").getAsString());
String expectedSha1 = client.get("sha1").getAsString();
Path partial = destination.resolveSibling(destination.getFileName() + ".part");
HttpResponse<InputStream> response = HTTP.send(
HttpRequest.newBuilder(clientUrl).timeout(DOWNLOAD_TIMEOUT).build(),
HttpResponse.BodyHandlers.ofInputStream()
);
requireSuccess(response.statusCode(), clientUrl);
String actualSha1;
try (DigestInputStream body = new DigestInputStream(response.body(), sha1())) {
Files.copy(body, partial, StandardCopyOption.REPLACE_EXISTING);
actualSha1 = HexFormat.of().formatHex(body.getMessageDigest().digest());
}
if (!actualSha1.equalsIgnoreCase(expectedSha1)) {
Files.deleteIfExists(partial);
throw new IOException("the downloaded client jar was damaged (checksum mismatch)");
}
Files.move(partial, destination, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
}
private static URI findVersionUrl(String version) throws IOException, InterruptedException {
for (JsonElement entry : fetchJson(VERSION_MANIFEST).getAsJsonArray("versions")) {
JsonObject candidate = entry.getAsJsonObject();
if (candidate.get("id").getAsString().equals(version)) {
return URI.create(candidate.get("url").getAsString());
}
}
throw new IOException("Mojang doesn't list Minecraft " + version);
}
private static JsonObject fetchJson(URI url) throws IOException, InterruptedException {
HttpResponse<String> response = HTTP.send(
HttpRequest.newBuilder(url).timeout(CONNECT_TIMEOUT).build(),
HttpResponse.BodyHandlers.ofString()
);
requireSuccess(response.statusCode(), url);
return JsonParser.parseString(response.body()).getAsJsonObject();
}
private static void requireSuccess(int statusCode, URI url) throws IOException {
if (statusCode / 100 != 2) {
throw new IOException(url.getHost() + " answered HTTP " + statusCode);
}
}
private static MessageDigest sha1() {
try {
return MessageDigest.getInstance("SHA-1");
} catch (NoSuchAlgorithmException error) {
throw new IllegalStateException(error);
}
}
private static String readableReason(Throwable error) {
String message = error.getMessage();
return message == null || message.isBlank() ? error.getClass().getSimpleName() : message;
}
}
@@ -0,0 +1,43 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.studio.map;
import java.awt.Color;
interface NoiseColorScale {
int colorFinite(double value);
default int colorAt(double value, double worldX, double worldZ) {
return colorFinite(value);
}
int colorInvalid();
default int colorInvalidAt(double worldX, double worldZ) {
return colorInvalid();
}
Color displayColorNormalized(double normalized);
String label();
double minimum();
double maximum();
}
@@ -0,0 +1,42 @@
/*
* 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 art.arcane.iris.studio.map;
import art.arcane.volmlib.util.function.NoiseProvider;
import java.util.function.DoubleUnaryOperator;
/**
* Noise as terrain columns: each value raised to a height and coloured by the noise's colour scale.
*
* @param heightOf turns a noise value into a height in blocks
*/
record NoiseColumns(NoiseProvider noise, DoubleUnaryOperator heightOf, NoiseColorScale colorScale) implements TerrainColumns {
@Override
public void sampleRow(TerrainGrid grid, int row, float[] heights, int[] colors) {
double worldZ = grid.originZ() + (double) row * grid.step();
for (int column = 0; column < grid.columns(); column++) {
double worldX = grid.originX() + (double) column * grid.step();
double value = noise.noise(worldX, worldZ);
int index = row * grid.columns() + column;
heights[index] = Double.isFinite(value) ? (float) heightOf.applyAsDouble(value) : Float.NaN;
colors[index] = Double.isFinite(value) ? colorScale.colorAt(value, worldX, worldZ) : 0;
}
}
}
File diff suppressed because it is too large. Load diff
@@ -1,8 +1,26 @@
package art.arcane.iris.studio.view;
/*
* 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 art.arcane.iris.studio.map;
import java.awt.Color;
enum NoisePalette {
enum NoisePalette implements NoiseColorScale {
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});
@@ -30,7 +48,8 @@ enum NoisePalette {
return colorFinite(value);
}
int colorFinite(double value) {
@Override
public int colorFinite(double value) {
double normalized = (value - minimum) / (maximum - minimum);
return colorNormalized(normalized);
}
@@ -40,20 +59,29 @@ enum NoisePalette {
return lookup[(int) Math.round(clipped * (lookup.length - 1))];
}
Color displayColorNormalized(double normalized) {
@Override
public int colorInvalid() {
return INVALID_COLOR;
}
@Override
public Color displayColorNormalized(double normalized) {
double clipped = Math.max(0D, Math.min(1D, normalized));
return displayColors[(int) Math.round(clipped * (displayColors.length - 1))];
}
String label() {
@Override
public String label() {
return label;
}
double minimum() {
@Override
public double minimum() {
return minimum;
}
double maximum() {
@Override
public double maximum() {
return maximum;
}
@@ -0,0 +1,340 @@
/*
* 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 art.arcane.iris.studio.map;
import javax.swing.JComponent;
import java.awt.Color;
import java.awt.Cursor;
import java.awt.Dimension;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
* 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. A long table shows only
* the options with the most ground in view until asked for all; a noise with one option to pick is a single line.
*/
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 static final int SHORT_TABLE_ROWS = 6;
private final NoisePickSpread spread;
private final List<NoisePickSpread.Row> rows;
private final String optionNoun;
private final Consumer<Boolean> onShowAllRowsChanged;
private boolean showsAllRows;
private Rectangle showAllToggleBounds;
/**
* @param optionNoun what the noise picks, as a table heading: "Regions" or "Biomes"
* @param showsAllRows whether a long table starts with all its rows
* @param onShowAllRowsChanged receives whether the table shows all its rows after the user asked for more or fewer
*/
NoisePickChart(NoisePickSpread spread, String optionNoun, boolean showsAllRows, Consumer<Boolean> onShowAllRowsChanged) {
this.spread = spread;
this.rows = spread.rows();
this.optionNoun = optionNoun;
this.showsAllRows = showsAllRows;
this.onShowAllRowsChanged = onShowAllRowsChanged;
setAlignmentX(LEFT_ALIGNMENT);
listenForShowAllToggle();
}
@Override
public Dimension getPreferredSize() {
if (hasSingleOption()) {
return new Dimension(StudioTheme.scale(PREFERRED_WIDTH), StudioTheme.scale(SECTION_GAP) + tableHeight()
+ StudioTheme.scale(BOTTOM_GAP));
}
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);
if (hasSingleOption()) {
drawSingleOption(canvas, margin, top, width);
return;
}
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 boolean hasSingleOption() {
return rows.size() == 1;
}
private boolean isLongTable() {
return rows.size() > SHORT_TABLE_ROWS;
}
private int tableHeight() {
int toggleRows = isLongTable() ? 1 : 0;
return sectionMetrics().getHeight() + StudioTheme.scale(TITLE_GAP) + rowHeight() * (shownRows().size() + toggleRows);
}
// A short table keeps the options with the most ground in view, still in the order the noise meets them.
private List<NoisePickSpread.Row> shownRows() {
if (showsAllRows || !isLongTable()) {
return rows;
}
Set<NoisePickSpread.Row> largest = rows.stream()
.sorted(Comparator.comparingDouble(NoisePickSpread.Row::actualShare).reversed())
.limit(SHORT_TABLE_ROWS)
.collect(Collectors.toSet());
return rows.stream().filter(largest::contains).toList();
}
private void listenForShowAllToggle() {
MouseAdapter toggle = new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent event) {
if (showAllToggleBounds == null || !showAllToggleBounds.contains(event.getPoint())) {
return;
}
showsAllRows = !showsAllRows;
onShowAllRowsChanged.accept(showsAllRows);
revalidate();
repaint();
}
@Override
public void mouseMoved(MouseEvent event) {
boolean overToggle = showAllToggleBounds != null && showAllToggleBounds.contains(event.getPoint());
setCursor(overToggle ? Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) : null);
}
};
addMouseListener(toggle);
addMouseMotionListener(toggle);
}
private void drawSingleOption(Graphics2D canvas, int x, int top, int width) {
drawSectionTitle(canvas, spread.title(), x, top + sectionMetrics().getAscent());
int rowTop = top + sectionMetrics().getHeight() + StudioTheme.scale(TITLE_GAP);
NoisePickSpread.Row row = rows.getFirst();
String only = "picked everywhere";
int nameRight = x + width - textMetrics().stringWidth(only) - StudioTheme.scale(10);
int baseline = drawOptionName(canvas, row, x, rowTop, nameRight);
canvas.setColor(StudioTheme.MUTED_TEXT);
drawRightAligned(canvas, only, x + width, baseline);
}
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);
int rowHeight = rowHeight();
int rowTop = top + sectionMetrics().getHeight() + StudioTheme.scale(TITLE_GAP);
for (NoisePickSpread.Row row : shownRows()) {
int baseline = drawOptionName(canvas, row, x, rowTop, nameRight);
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;
}
showAllToggleBounds = isLongTable() ? drawShowAllToggle(canvas, x, rowTop) : null;
}
// Returns the row's text baseline.
private int drawOptionName(Graphics2D canvas, NoisePickSpread.Row row, int x, int rowTop, int nameRight) {
FontMetrics text = textMetrics();
int rowHeight = rowHeight();
int swatchSize = text.getAscent() - text.getDescent();
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);
return baseline;
}
private Rectangle drawShowAllToggle(Graphics2D canvas, int x, int rowTop) {
String text = showsAllRows
? "Show fewer"
: "Show all " + rows.size() + " " + optionNoun.toLowerCase(Locale.ROOT);
FontMetrics metrics = textMetrics();
int baseline = rowTop + (rowHeight() + metrics.getAscent() - metrics.getDescent()) / 2;
canvas.setFont(StudioTheme.font());
canvas.setColor(StudioTheme.LINK);
canvas.drawString(text, x, baseline);
return new Rectangle(x, rowTop, metrics.stringWidth(text), 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,158 @@
/*
* 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 art.arcane.iris.studio.map;
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 NoisePickSpread(String title, List<Band> bands) {
this.title = title;
this.bands = List.copyOf(bands);
this.samplesByBand = new long[bands.size()];
}
static NoisePickSpread of(String title, List<Option> options) {
return new NoisePickSpread(title, splitNoise(options));
}
/**
* The band of noise each option gets, in the order the noise meets them.
*/
static List<Band> splitNoise(List<Option> options) {
return bands(options);
}
void add(double noise) {
if (bands.isEmpty() || !Double.isFinite(noise)) {
return;
}
sampleCount++;
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;
}
/**
* 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 RarityStream: 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;
}
/**
* 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;
}
}
}
@@ -1,14 +1,28 @@
package art.arcane.iris.studio.view;
/*
* 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 art.arcane.iris.studio.map;
import art.arcane.iris.generation.runtime.PreservationRegistry;
import art.arcane.iris.spi.IrisServices;
import art.arcane.volmlib.util.function.NoiseProvider;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
import java.util.Objects;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
@@ -19,10 +33,11 @@ import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
final class NoiseRenderCoordinator implements AutoCloseable {
private static final int BANDS_PER_WORKER = 4;
private final int workerCount;
private final Listener listener;
private final ThreadFactory workerThreadFactory;
private final ThreadFactory coordinatorThreadFactory;
private final AtomicReference<RenderGeneration> latestRequested = new AtomicReference<>();
private final AtomicLong latestRevision = new AtomicLong();
private final AtomicBoolean closed = new AtomicBoolean();
@@ -41,27 +56,33 @@ final class NoiseRenderCoordinator implements AutoCloseable {
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) {
synchronized void request(Request request) {
Objects.requireNonNull(request, "request");
if (closed.get()) {
if (closed.get() || request.revision() < latestRevision.get()) {
return;
}
latestRevision.accumulateAndGet(request.revision(), Math::max);
cancelActiveRender();
RenderGeneration generation = createGeneration(request);
ThreadPoolExecutor workers = new ThreadPoolExecutor(workerCount, workerCount, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(), workerThreadFactory);
MapHost.interruptOnPluginShutdown(workers);
RenderGeneration generation = new RenderGeneration(request, workers);
latestRequested.set(generation);
generation.start();
listener.onRenderStarted(request);
for (int band = 0; band < generation.bandCount(); band++) {
int bandIndex = band;
try {
workers.execute(() -> renderBand(generation, bandIndex));
} catch (RejectedExecutionException rejected) {
generation.fail(rejected);
finishBand(generation);
}
}
}
void cancel(long revision) {
synchronized void cancel(long revision) {
latestRevision.accumulateAndGet(revision, Math::max);
cancelActiveRender();
}
@@ -115,7 +136,7 @@ final class NoiseRenderCoordinator implements AutoCloseable {
}
@Override
public void close() {
public synchronized void close() {
if (!closed.compareAndSet(false, true)) {
return;
}
@@ -123,92 +144,52 @@ final class NoiseRenderCoordinator implements AutoCloseable {
cancelActiveRender();
}
private void renderGeneration(RenderGeneration generation) {
Request request = generation.request();
if (!isCurrent(request)) {
generation.close();
return;
}
listener.onRenderStarted(request);
private void renderBand(RenderGeneration generation, int bandIndex) {
try {
Result result = render(generation);
if (result != null && isCurrent(request)) {
listener.onRenderCompleted(result);
if (isCurrent(generation)) {
renderRows(generation, bandIndex);
}
} catch (Throwable error) {
if (isCurrent(request)) {
listener.onRenderFailed(request, error);
generation.fail(error);
} finally {
finishBand(generation);
}
}
private synchronized void finishBand(RenderGeneration generation) {
if (!generation.completeBand()) {
return;
}
generation.executor().shutdown();
if (!isCurrent(generation)) {
return;
}
try {
Throwable failure = generation.failure();
if (failure != null) {
listener.onRenderFailed(generation.request(), failure);
return;
}
listener.onRenderCompleted(generation.result());
} finally {
latestRequested.compareAndSet(generation, null);
generation.close();
}
}
private Result render(RenderGeneration generation) throws InterruptedException {
private void renderRows(RenderGeneration generation, int bandIndex) {
Request request = generation.request();
long started = System.nanoTime();
int outputWidth = Math.max(1, (request.width() + request.sampleStep() - 1) / request.sampleStep());
int outputHeight = Math.max(1, (request.height() + request.sampleStep() - 1) / request.sampleStep());
BufferedImage image = new BufferedImage(outputWidth, outputHeight, BufferedImage.TYPE_INT_RGB);
int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
int bandCount = Math.min(workerCount, outputHeight);
BandStats[] bandStats = new BandStats[bandCount];
CountDownLatch completion = new CountDownLatch(bandCount);
AtomicReference<Throwable> failure = new AtomicReference<>();
for (int band = 0; band < bandCount; band++) {
bandStats[band] = new BandStats();
}
for (int band = 0; band < bandCount; band++) {
int bandIndex = band;
Runnable task = () -> {
try {
renderBand(request, image, pixels, bandIndex, bandCount, bandStats[bandIndex]);
} catch (Throwable error) {
failure.compareAndSet(null, error);
} finally {
completion.countDown();
}
};
try {
generation.executor().execute(task);
} catch (RejectedExecutionException exception) {
failure.compareAndSet(null, exception);
completion.countDown();
}
}
completion.await();
Throwable renderFailure = failure.get();
if (renderFailure != null) {
if (renderFailure instanceof RuntimeException runtimeException) {
throw runtimeException;
}
if (renderFailure instanceof Error error) {
throw error;
}
throw new IllegalStateException("Noise rendering failed", renderFailure);
}
if (!isCurrent(request)) {
return null;
}
RenderStats combined = combine(bandStats);
double milliseconds = (System.nanoTime() - started) / 1_000_000D;
return new Result(request, image, milliseconds, combined.samples, combined.minimum, combined.maximum,
combined.centerValue, combined.underflow, combined.overflow, combined.invalid);
}
private void renderBand(Request request, BufferedImage image, int[] pixels, int bandIndex, int bandCount,
BandStats stats) {
int outputWidth = image.getWidth();
int outputHeight = image.getHeight();
int fromY = (outputHeight * bandIndex) / bandCount;
int toY = (outputHeight * (bandIndex + 1)) / bandCount;
int outputWidth = generation.outputWidth();
int outputHeight = generation.outputHeight();
int[] pixels = generation.pixels();
BandStats stats = generation.bandStats(bandIndex);
int fromY = (outputHeight * bandIndex) / generation.bandCount();
int toY = (outputHeight * (bandIndex + 1)) / generation.bandCount();
double sampleOffset = request.sampleStep() * 0.5D;
double startWorldX = request.viewport().worldX(sampleOffset, request.width());
double worldStep = request.viewport().blocksPerPixel() * request.sampleStep();
NoisePalette palette = request.palette();
double paletteMinimum = palette.minimum();
double paletteMaximum = palette.maximum();
NoiseColorScale colorScale = request.colorScale();
double paletteMinimum = colorScale.minimum();
double paletteMaximum = colorScale.maximum();
for (int y = fromY; y < toY; y++) {
double screenY = (y * (double) request.sampleStep()) + sampleOffset;
double worldZ = request.viewport().worldZ(screenY, request.height());
@@ -216,16 +197,16 @@ final class NoiseRenderCoordinator implements AutoCloseable {
int pixelIndex = y * outputWidth;
boolean centerRow = y == outputHeight / 2;
for (int x = 0; x < outputWidth; x++) {
if ((x & 31) == 0 && (!isCurrent(request) || Thread.currentThread().isInterrupted())) {
if ((x & 31) == 0 && (!isCurrent(generation) || Thread.currentThread().isInterrupted())) {
return;
}
double value = request.sampler().noise(worldX, worldZ);
boolean center = centerRow && x == outputWidth / 2;
if (Double.isFinite(value)) {
pixels[pixelIndex++] = palette.colorFinite(value);
pixels[pixelIndex++] = colorScale.colorAt(value, worldX, worldZ);
stats.acceptFinite(value, center, paletteMinimum, paletteMaximum);
} else {
pixels[pixelIndex++] = NoisePalette.INVALID_COLOR;
pixels[pixelIndex++] = colorScale.colorInvalidAt(worldX, worldZ);
stats.acceptInvalid(value, center);
}
worldX += worldStep;
@@ -233,41 +214,25 @@ final class NoiseRenderCoordinator implements AutoCloseable {
}
}
private boolean isCurrent(Request request) {
private boolean isCurrent(RenderGeneration generation) {
return !closed.get()
&& latestRequested.get() != null
&& latestRequested.get().request() == request
&& request.revision() >= latestRevision.get();
&& latestRequested.get() == generation
&& generation.request().revision() >= latestRevision.get();
}
private void cancelActiveRender() {
RenderGeneration generation = latestRequested.getAndSet(null);
if (generation != null) {
generation.close();
generation.executor().shutdownNow();
}
}
private RenderGeneration createGeneration(Request request) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
workerCount,
workerCount,
0L,
TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(Math.max(1, workerCount)),
workerThreadFactory,
new ThreadPoolExecutor.AbortPolicy()
);
PreservationRegistry preservation = IrisServices.getOrNull(PreservationRegistry.class);
if (preservation != null) {
preservation.register(executor);
}
RenderGeneration generation = new RenderGeneration(request, executor);
Thread actualRunner = coordinatorThreadFactory.newThread(() -> renderGeneration(generation));
generation.setRunner(actualRunner);
if (preservation != null) {
preservation.register(actualRunner);
}
return generation;
private static int outputWidth(Request request) {
return Math.max(1, (request.width() + request.sampleStep() - 1) / request.sampleStep());
}
private static int outputHeight(Request request) {
return Math.max(1, (request.height() + request.sampleStep() - 1) / request.sampleStep());
}
private static RenderStats combine(BandStats[] bands) {
@@ -304,12 +269,12 @@ final class NoiseRenderCoordinator implements AutoCloseable {
void onRenderFailed(Request request, Throwable error);
}
record Request(long revision, NoiseProvider sampler, NoiseViewport viewport, NoisePalette palette,
record Request(long revision, NoiseProvider sampler, NoiseViewport viewport, NoiseColorScale colorScale,
int width, int height, int sampleStep) {
Request {
Objects.requireNonNull(sampler, "sampler");
Objects.requireNonNull(viewport, "viewport");
Objects.requireNonNull(palette, "palette");
Objects.requireNonNull(colorScale, "colorScale");
if (revision < 0L || width < 1 || height < 1 || sampleStep < 1) {
throw new IllegalArgumentException("Invalid noise render request");
}
@@ -323,11 +288,25 @@ final class NoiseRenderCoordinator implements AutoCloseable {
private static final class RenderGeneration {
private final Request request;
private final ThreadPoolExecutor executor;
private volatile Thread runner;
private final long startedNanos = System.nanoTime();
private final int outputWidth;
private final int outputHeight;
private final int[] pixels;
private final BandStats[] bandStats;
private final AtomicInteger remainingBands;
private final AtomicReference<Throwable> failure = new AtomicReference<>();
private RenderGeneration(Request request, ThreadPoolExecutor executor) {
this.request = Objects.requireNonNull(request, "request");
this.executor = Objects.requireNonNull(executor, "executor");
this.request = request;
this.executor = executor;
this.outputWidth = NoiseRenderCoordinator.outputWidth(request);
this.outputHeight = NoiseRenderCoordinator.outputHeight(request);
this.pixels = new int[outputWidth * outputHeight];
this.bandStats = new BandStats[Math.min(executor.getCorePoolSize() * BANDS_PER_WORKER, outputHeight)];
for (int band = 0; band < bandStats.length; band++) {
bandStats[band] = new BandStats();
}
this.remainingBands = new AtomicInteger(bandStats.length);
}
private Request request() {
@@ -338,20 +317,45 @@ final class NoiseRenderCoordinator implements AutoCloseable {
return executor;
}
private void setRunner(Thread runner) {
this.runner = Objects.requireNonNull(runner, "runner");
private int bandCount() {
return bandStats.length;
}
private void start() {
runner.start();
private int outputWidth() {
return outputWidth;
}
private void close() {
Thread activeRunner = runner;
if (activeRunner != null) {
activeRunner.interrupt();
}
executor.shutdownNow();
private int outputHeight() {
return outputHeight;
}
private int[] pixels() {
return pixels;
}
private BandStats bandStats(int band) {
return bandStats[band];
}
private void fail(Throwable error) {
failure.compareAndSet(null, error);
}
private Throwable failure() {
return failure.get();
}
// True for the band that finishes last, which is the one that publishes the frame.
private boolean completeBand() {
return remainingBands.decrementAndGet() == 0;
}
private Result result() {
BufferedImage image = MapHost.uncachedImage(pixels, outputWidth, outputHeight);
RenderStats combined = combine(bandStats);
double milliseconds = (System.nanoTime() - startedNanos) / 1_000_000D;
return new Result(request, image, milliseconds, combined.samples, combined.minimum, combined.maximum,
combined.centerValue, combined.underflow, combined.overflow, combined.invalid);
}
}
@@ -0,0 +1,98 @@
/*
* 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 art.arcane.iris.studio.map;
import java.util.Arrays;
/**
* A stretch of noise or world as terrain at Minecraft scale: one height in blocks and one colour per column, each column
* {@code step} blocks wide and deep. Columns without a valid value are left out.
*/
record NoiseTerrain(int originX, int originZ, int columns, int rows, int step, float[] heights, int[] colors,
double lowest, double highest) {
/**
* Samples every column of the grid.
*/
static NoiseTerrain sample(TerrainSampling sampling, TerrainGrid grid) {
float[] heights = new float[grid.columns() * grid.rows()];
int[] colors = new int[heights.length];
for (int row = 0; row < grid.rows(); row++) {
sampling.columns().sampleRow(grid, row, heights, colors);
}
return of(grid, heights, colors);
}
static NoiseTerrain empty(TerrainGrid grid) {
float[] heights = new float[grid.columns() * grid.rows()];
Arrays.fill(heights, Float.NaN);
return of(grid, heights, new int[heights.length]);
}
private static NoiseTerrain of(TerrainGrid grid, float[] heights, int[] colors) {
double lowest = Double.NaN;
double highest = Double.NaN;
for (float height : heights) {
if (Float.isNaN(height)) {
continue;
}
lowest = Double.isNaN(lowest) ? height : Math.min(lowest, height);
highest = Double.isNaN(highest) ? height : Math.max(highest, height);
}
return new NoiseTerrain(grid.originX(), grid.originZ(), grid.columns(), grid.rows(), grid.step(), heights, colors,
lowest, highest);
}
boolean hasHeight(int column, int row) {
return column >= 0 && row >= 0 && column < columns && row < rows && !Float.isNaN(heights[row * columns + column]);
}
float height(int column, int row) {
return heights[row * columns + column];
}
int color(int column, int row) {
return colors[row * columns + column];
}
// The world position of a column's or row's first block.
double worldX(double column) {
return originX + column * step;
}
double worldZ(double row) {
return originZ + row * step;
}
int sizeX() {
return columns * step;
}
int sizeZ() {
return rows * step;
}
boolean isEmpty() {
return Double.isNaN(lowest);
}
// A block below the lowest point, where sides on the terrain's edge end.
int baseY() {
return (int) Math.floor(lowest) - 1;
}
}
@@ -0,0 +1,440 @@
/*
* 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 art.arcane.iris.studio.map;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
/**
* Draws noise terrain without a graphics card. Block columns get Minecraft's face shading and an outline around every
* block, until blocks get too small on screen to outline. Noise measured in blocks gets see-through water up to its
* level.
* <p>
* Each frame is split into horizontal bands drawn side by side, every band drawing only the pixels in its rows. Block
* faces are found once per terrain; each frame only works out which rows they cover, so bands skip the rest cheaply.
*/
final class NoiseTerrainRenderer {
private static final double TOP_SHADE = 1D;
private static final double NORTH_SOUTH_SHADE = 0.8D;
private static final double EAST_WEST_SHADE = 0.6D;
private static final double LEAST_SIDE_CONTRAST = 0.35D;
// The graphics card draws with these too, so both look alike.
static final int WATER_COLOR = 0x3F76E4;
static final double WATER_OPACITY = 0.55D;
static final double OUTLINE_SHADE = 0.72D;
static final double OUTLINE_LIGHTENING = 0.25D;
static final double DARK_LUMINANCE = 70D;
static final double LEAST_OUTLINE_SPACING_PIXELS = 4D;
static final double FULL_OUTLINE_SPACING_PIXELS = 12D;
static final double OUTLINE_HALF_WIDTH_PIXELS = 0.5D;
// A few slices per worker even out slices that take longer, like bands crossing the busiest part of the terrain.
private static final int SLICES_PER_WORKER = 2;
// Shared edges land exactly on pixel centres now and then; a little overlap keeps them from leaving cracks.
private static final double EDGE_TOLERANCE = 1e-6D;
private static final double EDGE_ON_DETERMINANT = 1e-9D;
private final TerrainLayer layer;
private final NoiseTerrain terrain;
private final OrbitProjection projection;
private final int width;
private final int[] pixels;
private final double[] depths;
private final FaceRows faceRows;
private final int bandTop;
private final int bandBottom;
private final int[] runColors;
private NoiseTerrainRenderer(Frame frame, TerrainLayer layer, FaceRows faceRows, int bandTop, int bandBottom) {
this.layer = layer;
this.terrain = layer.terrain();
this.projection = frame.projection();
this.width = frame.width();
this.pixels = frame.pixels();
this.depths = frame.depths();
this.faceRows = faceRows;
this.bandTop = bandTop;
this.bandBottom = bandBottom;
this.runColors = new int[Math.max(terrain.columns(), terrain.rows())];
}
static OrbitPicture render(NoiseTerrainScene scene, OrbitCamera camera, int width, int height, int coveredLeftWidth) {
BufferedImage image = new BufferedImage(Math.max(1, width), Math.max(1, height), BufferedImage.TYPE_INT_RGB);
Frame frame = Frame.of(image, scene, camera, coveredLeftWidth);
List<FaceRows> faceRows = new ArrayList<>(scene.layers().size());
for (HoledLayer layer : scene.layers()) {
faceRows.add(FaceRows.of(frame, layer.layer(), layer.holeOfAllFillers(), scene));
}
inSlices(image.getHeight(), scene, (top, bottom) -> drawBand(frame, scene, faceRows, top, bottom - 1));
return OrbitPicture.of(image, frame.depths(), frame.projection());
}
// Every layer draws what it has in the band's rows; the water goes over all of them last.
private static void drawBand(Frame frame, NoiseTerrainScene scene, List<FaceRows> faceRows, int bandTop, int bandBottom) {
frame.clearRows(bandTop, bandBottom);
for (int layer = 0; layer < scene.layers().size(); layer++) {
new NoiseTerrainRenderer(frame, scene.layers().get(layer).layer(), faceRows.get(layer), bandTop, bandBottom).drawBlocks();
}
if (scene.hasWater() && !scene.layers().isEmpty()) {
NoiseTerrainScene.Extent extent = scene.extent();
new NoiseTerrainRenderer(frame, scene.layers().get(0).layer(), FaceRows.NONE, bandTop, bandBottom)
.drawWater(extent.minX(), extent.minZ(), extent.sizeX(), extent.sizeZ(), BlockFaces.blockTop(scene.waterLevel()));
}
}
// Splits {@code total} items into a few slices per worker and works on them side by side.
private static void inSlices(int total, NoiseTerrainScene scene, SliceWork work) {
int sliceCount = Math.min(total, scene.workerCount() * SLICES_PER_WORKER);
List<Callable<Void>> slices = new ArrayList<>(sliceCount);
for (int slice = 0; slice < sliceCount; slice++) {
int from = total * slice / sliceCount;
int to = total * (slice + 1) / sliceCount;
slices.add(() -> {
work.doSlice(from, to);
return null;
});
}
try {
for (Future<Void> slice : scene.workers().invokeAll(slices)) {
slice.get();
}
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
} catch (ExecutionException error) {
throw new IllegalStateException("Drawing the terrain failed", error.getCause());
}
}
private void drawBlocks() {
BlockFaces faces = layer.faces();
for (int face = 0; face < faces.count(); face++) {
if (faceRows.crosses(face, bandTop, bandBottom)) {
drawFace(faces, face);
}
}
}
// A face stretching over several columns is still coloured and outlined column by column.
private void drawFace(BlockFaces faces, int face) {
BlockFaces.Facing facing = faces.facing(face);
double shade = faceShadeAt(facing, projection.pixelsPerBlock());
int columns = faces.columns(face);
for (int along = 0; along < columns; along++) {
int column = faces.firstColumn(face) + (facing.runsAlongX() ? along : 0);
int row = faces.firstRow(face) + (facing.runsAlongX() ? 0 : along);
runColors[along] = shade(terrain.color(column, row), shade);
}
fillQuad(faces.originX(face), faces.originY(face), faces.originZ(face), faces.runX(face), 0D, faces.runZ(face),
0D, faces.reachY(face), faces.reachZ(face), facing.normalX, facing.normalY, facing.normalZ,
runColors, columns, faces.height(face));
}
// How much a face's colour is darkened, with blocks drawn {@code pixelsPerBlock} wide.
static double faceShadeAt(BlockFaces.Facing facing, double pixelsPerBlock) {
return withContrast(faceShade(facing), blockSideContrast(pixelsPerBlock));
}
private static double faceShade(BlockFaces.Facing facing) {
return switch (facing) {
case TOP -> TOP_SHADE;
case NORTH, SOUTH -> NORTH_SOUTH_SHADE;
case WEST, EAST -> EAST_WEST_SHADE;
};
}
// Blocks a few pixels wide show their sides as one-pixel slivers on every slope; at full contrast those read as noise.
private static double blockSideContrast(double pixelsPerBlock) {
return LEAST_SIDE_CONTRAST + (1D - LEAST_SIDE_CONTRAST) * outlineStrength(pixelsPerBlock);
}
private static double withContrast(double faceShade, double contrast) {
return 1D - (1D - faceShade) * contrast;
}
// Water is drawn last, blended over whatever lies below its surface, without hiding what is behind it.
private void drawWater(double x, double z, double sizeX, double sizeZ, double level) {
Footprint water = new Footprint(x, level, z, sizeX, 0, 0, 0, 0, sizeZ);
if (water.isEmpty()) {
return;
}
for (int y = water.top; y <= water.bottom; y++) {
water.spanRow(y);
for (int pixelX = water.rowStart; pixelX <= water.rowEnd; pixelX++) {
double alongU = water.alongU(pixelX, y);
double alongV = water.alongV(pixelX, y);
int index = y * width + pixelX;
if (isInside(alongU, alongV) && water.depthAt(alongU, alongV) > depths[index]) {
pixels[index] = blend(WATER_COLOR, pixels[index], WATER_OPACITY);
}
}
}
}
/**
* Fills the parallelogram from {@code origin} along both edges, with lines {@code 1 / units} of the way along
* each edge; no lines when {@code units} is 0. Each of the {@code unitsU} blocks along the first edge takes its
* colour from {@code colorsAlongU}; with no units, the whole face takes the first.
*/
private void fillQuad(double originX, double originY, double originZ, double uX, double uY, double uZ,
double vX, double vY, double vZ, int normalX, int normalY, int normalZ,
int[] colorsAlongU, int unitsU, int unitsV) {
if (!projection.facesCamera(normalX, normalY, normalZ)) {
return;
}
Footprint quad = new Footprint(originX, originY, originZ, uX, uY, uZ, vX, vY, vZ);
if (quad.isEmpty()) {
return;
}
double pixelsPerUnitU = unitsU == 0 ? 0D : quad.lengthU() / unitsU;
double pixelsPerUnitV = unitsV == 0 ? 0D : quad.lengthV() / unitsV;
double outlineStrengthU = outlineStrength(pixelsPerUnitU);
double outlineStrengthV = outlineStrength(pixelsPerUnitV);
int lastUnitU = Math.max(0, unitsU - 1);
for (int y = quad.top; y <= quad.bottom; y++) {
quad.spanRow(y);
for (int x = quad.rowStart; x <= quad.rowEnd; x++) {
double alongU = quad.alongU(x, y);
double alongV = quad.alongV(x, y);
if (!isInside(alongU, alongV)) {
continue;
}
int index = y * width + x;
double depth = quad.depthAt(alongU, alongV);
if (depth < depths[index]) {
continue;
}
depths[index] = depth;
int color = colorsAlongU[Math.max(0, Math.min(lastUnitU, (int) (alongU * unitsU)))];
if (isOnLine(alongU, unitsU, pixelsPerUnitU)) {
pixels[index] = blend(outlineOf(color), color, outlineStrengthU);
} else if (isOnLine(alongV, unitsV, pixelsPerUnitV)) {
pixels[index] = blend(outlineOf(color), color, outlineStrengthV);
} else {
pixels[index] = color;
}
}
}
}
private static boolean isInside(double alongU, double alongV) {
return alongU >= -EDGE_TOLERANCE && alongU <= 1D + EDGE_TOLERANCE && alongV >= -EDGE_TOLERANCE && alongV <= 1D + EDGE_TOLERANCE;
}
// Lines fade in as blocks grow on screen; dense lines at full strength would only speckle the face.
private static double outlineStrength(double pixelsPerUnit) {
double fadeIn = (pixelsPerUnit - LEAST_OUTLINE_SPACING_PIXELS) / (FULL_OUTLINE_SPACING_PIXELS - LEAST_OUTLINE_SPACING_PIXELS);
return Math.max(0D, Math.min(1D, fadeIn));
}
// Lines closer together than a few pixels would only grey the face out, so they are left out.
private static boolean isOnLine(double along, int units, double pixelsPerUnit) {
if (pixelsPerUnit < LEAST_OUTLINE_SPACING_PIXELS) {
return false;
}
double unitsAlong = along * units;
return Math.abs(unitsAlong - Math.rint(unitsAlong)) * pixelsPerUnit < OUTLINE_HALF_WIDTH_PIXELS;
}
// Dark faces get lighter outlines, so the blocks of a black low still show.
private static int outlineOf(int color) {
double luminance = 0.3D * ((color >>> 16) & 0xFF) + 0.59D * ((color >>> 8) & 0xFF) + 0.11D * (color & 0xFF);
return luminance < DARK_LUMINANCE ? blend(0xFFFFFF, color, OUTLINE_LIGHTENING) : shade(color, OUTLINE_SHADE);
}
private static int shade(int color, double shade) {
return BlockPreviewColors.shade(color, shade);
}
private static int blend(int over, int under, double coverage) {
return blendChannel(over, under, coverage, 16) << 16 | blendChannel(over, under, coverage, 8) << 8 | blendChannel(over, under, coverage, 0);
}
private static int blendChannel(int over, int under, double coverage, int shift) {
return (int) Math.round(((over >>> shift) & 0xFF) * coverage + ((under >>> shift) & 0xFF) * (1D - coverage));
}
/**
* What every band of one frame shares: the image's pixels and depths, and where the terrain lands on it.
*/
private record Frame(OrbitProjection projection, int width, int[] pixels, double[] depths) {
static Frame of(BufferedImage image, NoiseTerrainScene scene, OrbitCamera camera, int coveredLeftWidth) {
OrbitProjection projection = scene.projection(camera, image.getWidth(), image.getHeight(), coveredLeftWidth);
int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
return new Frame(projection, image.getWidth(), pixels, new double[pixels.length]);
}
void clearRows(int top, int bottom) {
int from = top * width;
int to = (bottom + 1) * width;
Arrays.fill(pixels, from, to, StudioTheme.EDITOR_BACKGROUND.getRGB() & 0xFFFFFF);
Arrays.fill(depths, from, to, Double.NEGATIVE_INFINITY);
}
}
/**
* The screen rows each block face of a layer spans this frame, worked out once so every band skips the faces it
* doesn't cross without projecting them again.
*/
private record FaceRows(int[] tops, int[] bottoms) {
static final FaceRows NONE = new FaceRows(new int[0], new int[0]);
static FaceRows of(Frame frame, TerrainLayer layer, TerrainHole hole, NoiseTerrainScene scene) {
BlockFaces faces = layer.faces();
FaceRows rows = new FaceRows(new int[faces.count()], new int[faces.count()]);
inSlices(faces.count(), scene, (from, to) -> rows.measure(faces, hole, frame.projection(), from, to));
return rows;
}
// Faces looking away from the camera, or not showing with the hole, are never drawn, so they span no rows.
private void measure(BlockFaces faces, TerrainHole hole, OrbitProjection projection, int fromFace, int toFace) {
for (int face = fromFace; face < toFace; face++) {
BlockFaces.Facing facing = faces.facing(face);
if (!projection.facesCamera(facing.normalX, facing.normalY, facing.normalZ) || !hole.shows(faces, face)) {
tops[face] = Integer.MAX_VALUE;
bottoms[face] = Integer.MIN_VALUE;
continue;
}
double originY = projection.screenY(faces.originX(face), faces.originY(face), faces.originZ(face));
double runY = projection.screenStepY(faces.runX(face), 0D, faces.runZ(face));
double reachY = projection.screenStepY(0D, faces.reachY(face), faces.reachZ(face));
tops[face] = (int) Math.floor(originY + Math.min(0D, runY) + Math.min(0D, reachY));
bottoms[face] = (int) Math.ceil(originY + Math.max(0D, runY) + Math.max(0D, reachY));
}
}
boolean crosses(int face, int top, int bottom) {
return tops[face] <= bottom && bottoms[face] >= top;
}
}
@FunctionalInterface
private interface SliceWork {
void doSlice(int from, int to);
}
/**
* Where a parallelogram, from an origin along two edges, lands in this band: the pixels around it, and how far
* along each edge, and how near, any pixel inside it is.
*/
private final class Footprint {
private final double originX;
private final double originY;
private final double originDepth;
private final double edgeUX;
private final double edgeUY;
private final double edgeUDepth;
private final double edgeVX;
private final double edgeVY;
private final double edgeVDepth;
private final double determinant;
private final int left;
private final int right;
private final int top;
private final int bottom;
private int rowStart;
private int rowEnd;
private Footprint(double x, double y, double z, double uX, double uY, double uZ, double vX, double vY, double vZ) {
originX = projection.screenX(x, y, z);
originY = projection.screenY(x, y, z);
originDepth = projection.depth(x, y, z);
edgeUX = projection.screenStepX(uX, uZ);
edgeUY = projection.screenStepY(uX, uY, uZ);
edgeUDepth = projection.depthStep(uX, uY, uZ);
edgeVX = projection.screenStepX(vX, vZ);
edgeVY = projection.screenStepY(vX, vY, vZ);
edgeVDepth = projection.depthStep(vX, vY, vZ);
determinant = edgeUX * edgeVY - edgeUY * edgeVX;
left = Math.max(0, (int) Math.floor(min(originX, originX + edgeUX, originX + edgeVX, originX + edgeUX + edgeVX)));
right = Math.min(width - 1, (int) Math.ceil(max(originX, originX + edgeUX, originX + edgeVX, originX + edgeUX + edgeVX)));
top = Math.max(bandTop, (int) Math.floor(min(originY, originY + edgeUY, originY + edgeVY, originY + edgeUY + edgeVY)));
bottom = Math.min(bandBottom, (int) Math.ceil(max(originY, originY + edgeUY, originY + edgeVY, originY + edgeUY + edgeVY)));
}
// Edge-on faces cover no pixels, and faces outside the band are another band's work.
private boolean isEmpty() {
return Math.abs(determinant) < EDGE_ON_DETERMINANT || top > bottom || left > right;
}
/**
* Narrows {@link #rowStart} to {@link #rowEnd} to the pixels of row {@code y} the footprint can cover, so long
* thin faces lying across the screen don't cost their whole bounding box. A pixel or so either side is kept;
* the caller still tests every pixel.
*/
private void spanRow(int y) {
double fromOriginY = y + 0.5D - originY;
double slopeU = edgeVY / determinant;
double startU = -fromOriginY * edgeVX / determinant;
double slopeV = -edgeUY / determinant;
double startV = fromOriginY * edgeUX / determinant;
double[] range = {Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY};
keepWhere(range, slopeU, startU, 0D, 1D);
keepWhere(range, slopeV, startV, 0D, 1D);
rowStart = Math.max(left, (int) Math.floor(range[0] + originX - 0.5D));
rowEnd = Math.min(right, (int) Math.ceil(range[1] + originX - 0.5D));
}
// Narrows the range of offsets from the origin to where {@code start + slope * offset} stays within the limits.
private static void keepWhere(double[] range, double slope, double start, double lowest, double highest) {
if (Math.abs(slope) < EDGE_ON_DETERMINANT) {
if (start < lowest - EDGE_TOLERANCE || start > highest + EDGE_TOLERANCE) {
range[0] = Double.POSITIVE_INFINITY;
}
return;
}
double atLowest = (lowest - EDGE_TOLERANCE - start) / slope;
double atHighest = (highest + EDGE_TOLERANCE - start) / slope;
range[0] = Math.max(range[0], Math.min(atLowest, atHighest));
range[1] = Math.min(range[1], Math.max(atLowest, atHighest));
}
private double alongU(int x, int y) {
return ((x + 0.5D - originX) * edgeVY - (y + 0.5D - originY) * edgeVX) / determinant;
}
private double alongV(int x, int y) {
return ((y + 0.5D - originY) * edgeUX - (x + 0.5D - originX) * edgeUY) / determinant;
}
private double depthAt(double alongU, double alongV) {
return originDepth + alongU * edgeUDepth + alongV * edgeVDepth;
}
private double lengthU() {
return Math.hypot(edgeUX, edgeUY);
}
private double lengthV() {
return Math.hypot(edgeVX, edgeVY);
}
private static double min(double a, double b, double c, double d) {
return Math.min(Math.min(a, b), Math.min(c, d));
}
private static double max(double a, double b, double c, double d) {
return Math.max(Math.max(a, b), Math.max(c, d));
}
}
}
@@ -0,0 +1,139 @@
/*
* 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 art.arcane.iris.studio.map;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ExecutorService;
/**
* Endless noise terrain as the orbit viewport shows it: the tiles in sight as block columns, drawn by
* {@code workerCount} of {@code workers} at once on machines without a graphics card. The camera's pivot is given
* from {@code anchor}, and zoom sets pixels per block directly, so tiles coming and going never move the view.
*
* @param floorY where sides on the edge of the sampled ground end
* @param waterLevel the height water fills up to, or NaN when the noise has no water
*/
record NoiseTerrainScene(List<HoledLayer> layers, Vec3 anchor, int floorY, int topY, double waterLevel,
ExecutorService workers, int workerCount) implements OrbitScene {
// Looks further down than the object viewer, so a landscape's shape reads before its sides.
static final OrbitCamera START = new OrbitCamera(0.76D, 0.62D, 1D, Vec3.ZERO);
private static final double VIEW_HEIGHT_BLOCKS_AT_ZOOM_ONE = 384D;
// Looking level would see endless ground, and looking up only its underside.
private static final double LOWEST_PITCH = 0.1D;
private static final double HIGHEST_PITCH = 1.3D;
private static final double TALLEST_VIEW_BLOCKS = 100_000D;
private static final double SHORTEST_VIEW_BLOCKS = 24D;
private static final OrbitLimits LIMITS = new OrbitLimits(LOWEST_PITCH, HIGHEST_PITCH,
VIEW_HEIGHT_BLOCKS_AT_ZOOM_ONE / TALLEST_VIEW_BLOCKS, VIEW_HEIGHT_BLOCKS_AT_ZOOM_ONE / SHORTEST_VIEW_BLOCKS);
static OrbitProjection projectionFrom(Vec3 anchor, OrbitCamera camera, int width, int height, int coveredLeftWidth) {
int covered = Math.max(0, Math.min(width - 1, coveredLeftWidth));
return OrbitProjection.atScale(anchor, camera, height / VIEW_HEIGHT_BLOCKS_AT_ZOOM_ONE, width, height, covered);
}
@Override
public boolean isEmpty() {
return layers.stream().allMatch(layer -> layer.terrain().isEmpty());
}
@Override
public OrbitCamera startingCamera() {
return START;
}
@Override
public OrbitLimits limits() {
return LIMITS;
}
@Override
public OrbitPicture render(OrbitCamera camera, int width, int height, int coveredLeftWidth) {
return renderOnCard(camera, width, height, coveredLeftWidth)
.orElseGet(() -> NoiseTerrainRenderer.render(this, camera, width, height, coveredLeftWidth));
}
private Optional<OrbitPicture> renderOnCard(OrbitCamera camera, int width, int height, int coveredLeftWidth) {
if (!GpuLibrary.isLoaded()) {
return Optional.empty();
}
return GpuTerrainRenderer.render(this, camera, width, height, coveredLeftWidth);
}
@Override
public boolean canRenderOnScreen() {
return GpuLibrary.isLoaded() && GpuFrames.canDrawOnScreen();
}
@Override
public Optional<OrbitPicture> renderOnScreen(ScreenTarget screen, OrbitCamera camera, int width, int height, int coveredLeftWidth) {
return GpuTerrainRenderer.renderOnScreen(this, screen, camera, width, height, coveredLeftWidth);
}
@Override
public OrbitProjection projection(OrbitCamera camera, int width, int height, int coveredLeftWidth) {
return projectionFrom(anchor, camera, width, height, coveredLeftWidth);
}
boolean hasWater() {
return Double.isFinite(waterLevel) && waterLevel > floorY;
}
/**
* Where the layers lie, from the floor to the highest ground, so water can spread under all of them.
*/
Extent extent() {
double minX = Double.POSITIVE_INFINITY;
double minZ = Double.POSITIVE_INFINITY;
double maxX = Double.NEGATIVE_INFINITY;
double maxZ = Double.NEGATIVE_INFINITY;
double lowest = floorY;
double highest = topY;
for (HoledLayer layer : layers) {
NoiseTerrain terrain = layer.terrain();
minX = Math.min(minX, terrain.originX());
minZ = Math.min(minZ, terrain.originZ());
maxX = Math.max(maxX, terrain.originX() + (double) terrain.sizeX());
maxZ = Math.max(maxZ, terrain.originZ() + (double) terrain.sizeZ());
if (!terrain.isEmpty()) {
lowest = Math.min(lowest, terrain.baseY());
highest = Math.max(highest, BlockFaces.blockTop(terrain.highest()));
}
}
return new Extent(minX, lowest, minZ, maxX, highest, maxZ);
}
record Extent(double minX, double minY, double minZ, double maxX, double maxY, double maxZ) {
double sizeX() {
return maxX - minX;
}
double sizeZ() {
return maxZ - minZ;
}
// The farthest any of it lies from a point, which the depth buffer has to reach.
double farthestFrom(Vec3 point) {
double x = Math.max(Math.abs(minX - point.x()), Math.abs(maxX - point.x()));
double y = Math.max(Math.abs(minY - point.y()), Math.abs(maxY - point.y()));
double z = Math.max(Math.abs(minZ - point.z()), Math.abs(maxZ - point.z()));
return Math.sqrt(x * x + y * y + z * z);
}
}
}
@@ -0,0 +1,256 @@
/*
* 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 art.arcane.iris.studio.map;
import art.arcane.iris.spi.IrisLogging;
import javax.swing.Timer;
import java.awt.EventQueue;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* What the 3D noise view shows: endless terrain, sampled tile by tile wherever the camera looks. Whenever the camera
* moves or another tile is sampled, what to draw is worked out again off the Swing thread.
*/
final class NoiseTerrainView {
private final OrbitViewport viewport;
private final ExecutorService renderWorkers;
private final ExecutorService samplers;
private final int workerCount;
private final ExecutorService coverer = Executors.newSingleThreadExecutor(task -> {
Thread thread = new Thread(task, "Iris Noise Terrain Cover");
thread.setDaemon(true);
return thread;
});
private final AtomicBoolean coverQueued = new AtomicBoolean();
private final AtomicBoolean closed = new AtomicBoolean();
private final Timer retryTimer = new Timer(1, event -> requestCover());
private volatile TerrainTiles tiles;
private volatile Vec3 anchor;
private volatile ViewState view;
private volatile boolean hidden = true;
private double centerX;
private double centerZ;
private boolean recentered = true;
private TerrainCover.Result shown;
private Runnable onProgress = () -> {
};
NoiseTerrainView(OrbitViewport viewport, ExecutorService renderWorkers, ExecutorService samplers, int workerCount) {
this.viewport = viewport;
this.renderWorkers = renderWorkers;
this.samplers = samplers;
this.workerCount = workerCount;
retryTimer.setRepeats(false);
MapHost.interruptOnPluginShutdown(coverer);
viewport.onViewChange(this::viewChanged);
}
/**
* Shows the ground around this point next, turning around it.
*/
void centerOn(double worldX, double worldZ) {
centerX = worldX;
centerZ = worldZ;
recentered = true;
if (viewport.isShowingScene()) {
viewport.centerPivot();
}
}
/**
* Samples the terrain anew, as the camera looks at it, keeping the camera if the view already shows some. The ground
* shown so far stays until its tiles are sampled anew.
*/
void show(TerrainSampling sampling) {
if (closed.get()) {
return;
}
retryTimer.stop();
if (recentered || anchor == null) {
anchor = new Vec3(centerX, (sampling.floorY() + sampling.topY()) / 2D, centerZ);
recentered = false;
}
TerrainTiles previous = tiles;
Map<TerrainTile, TerrainLayer> shownBefore = previous == null ? Map.of() : previous.layersInUse();
if (previous != null) {
previous.close();
}
tiles = new TerrainTiles(sampling, samplers, workerCount, this::requestCover, shownBefore);
hidden = false;
viewChanged();
}
/**
* Goes on sampling what the camera looks at after {@link #hide}, with the tiles sampled before.
*/
void resume() {
if (closed.get() || tiles == null) {
return;
}
hidden = false;
viewChanged();
}
// Nothing is sampled for a view no one sees.
void hide() {
hidden = true;
retryTimer.stop();
TerrainTiles current = tiles;
if (current != null) {
current.sampleOnly(List.of(), Set.of());
}
}
void invalidate() {
retryTimer.stop();
TerrainTiles current = tiles;
tiles = null;
shown = null;
if (current != null) {
current.close();
}
}
/**
* @param listener runs on the Swing thread whenever the detail wanted or the tiles still to sample change
*/
void onProgress(Runnable listener) {
onProgress = listener;
}
// How many blocks wide the columns are that the view wants, or 0 before anything was worked out.
int detailStep() {
return shown == null ? 0 : shown.detailStep();
}
int pendingTiles() {
return shown == null ? 0 : shown.pendingTiles();
}
/**
* How the view looks at the ground for its newest camera, or null before it has any ground to look at.
*/
GroundSight groundSight() {
TerrainTiles current = tiles;
ViewState state = view;
Vec3 at = anchor;
if (current == null || state == null || at == null || state.width() < 1 || state.height() < 1) {
return null;
}
OrbitProjection projection = NoiseTerrainScene.projectionFrom(at, state.camera(), state.width(), state.height(), state.coveredLeftWidth());
TerrainSampling sampling = current.sampling();
int left = Math.max(0, Math.min(state.width() - 1, state.coveredLeftWidth()));
return new GroundSight(projection, left, state.width(), state.height(), sampling.floorY(), sampling.topY());
}
void close() {
if (!closed.compareAndSet(false, true)) {
return;
}
hidden = true;
invalidate();
coverer.shutdownNow();
}
// Before the first scene, the viewport still holds whatever camera it had; the scene will start it afresh.
private void viewChanged() {
if (closed.get()) {
return;
}
OrbitCamera camera = viewport.isShowingScene() ? viewport.camera() : NoiseTerrainScene.START;
view = new ViewState(camera, viewport.getWidth(), viewport.getHeight(), viewport.coveredLeftWidth());
requestCover();
}
// Requests while one waits to start are one and the same: it will see the newest view and tiles anyway.
private void requestCover() {
if (closed.get() || hidden || coverQueued.getAndSet(true)) {
return;
}
try {
coverer.execute(() -> {
coverQueued.set(false);
coverSafely();
});
} catch (RejectedExecutionException rejected) {
coverQueued.set(false);
if (!closed.get() && !coverer.isShutdown()) {
throw rejected;
}
}
}
private void coverSafely() {
try {
cover();
} catch (RuntimeException error) {
IrisLogging.reportError(error);
}
}
private void cover() {
TerrainTiles current = tiles;
ViewState state = view;
Vec3 at = anchor;
if (closed.get() || hidden || current == null || state == null || at == null || state.width() < 1 || state.height() < 1) {
return;
}
OrbitProjection projection = NoiseTerrainScene.projectionFrom(at, state.camera(), state.width(), state.height(), state.coveredLeftWidth());
TerrainCover.Result result = TerrainCover.of(current, projection, state.width(), state.height(), state.coveredLeftWidth());
TerrainSampling sampling = current.sampling();
NoiseTerrainScene scene = new NoiseTerrainScene(result.layers(), at, sampling.floorY(), sampling.topY(),
sampling.waterLevel(), renderWorkers, workerCount);
EventQueue.invokeLater(() -> showCovered(current, scene, result));
}
// Until any ground is sampled, the view keeps what it showed before, or its loading animation.
private void showCovered(TerrainTiles coveredTiles, NoiseTerrainScene scene, TerrainCover.Result result) {
if (closed.get() || hidden || coveredTiles != tiles) {
return;
}
long retryDelay = coveredTiles.retryDelayNanos();
if (retryDelay >= 0L) {
retryTimer.setInitialDelay((int) Math.max(1L, TimeUnit.NANOSECONDS.toMillis(retryDelay) + 1L));
retryTimer.restart();
} else {
retryTimer.stop();
}
shown = result;
onProgress.run();
if (scene.isEmpty() && result.pendingTiles() > 0) {
return;
}
if (viewport.isShowingScene()) {
viewport.replaceScene(scene);
} else {
viewport.show(scene);
}
}
private record ViewState(OrbitCamera camera, int width, int height, int coveredLeftWidth) {
}
}
@@ -1,4 +1,22 @@
package art.arcane.iris.studio.view;
/*
* 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 art.arcane.iris.studio.map;
record NoiseViewport(double centerX, double centerZ, double blocksPerPixel) {
static final double MIN_BLOCKS_PER_PIXEL = 0.0001D;
@@ -21,6 +39,14 @@ record NoiseViewport(double centerX, double centerZ, double blocksPerPixel) {
return centerZ + ((screenZ - (height / 2D)) * blocksPerPixel);
}
double screenX(double worldX, int width) {
return ((worldX - centerX) / blocksPerPixel) + (width / 2D);
}
double screenZ(double worldZ, int height) {
return ((worldZ - centerZ) / blocksPerPixel) + (height / 2D);
}
NoiseViewport panPixels(double deltaX, double deltaZ) {
return new NoiseViewport(
centerX - (deltaX * blocksPerPixel),
@@ -0,0 +1,276 @@
/*
* 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 art.arcane.iris.studio.map;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JTextField;
import javax.swing.JTree;
import javax.swing.ToolTipManager;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.tree.DefaultMutableTreeNode;
import javax.swing.tree.DefaultTreeCellRenderer;
import javax.swing.tree.DefaultTreeModel;
import javax.swing.tree.TreePath;
import javax.swing.tree.TreeSelectionModel;
import java.awt.BorderLayout;
import java.awt.Component;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Consumer;
/**
* Every object in the pack as a folder tree, with a search that matches every word typed anywhere in an object's path.
*/
final class ObjectBrowser extends JPanel {
private static final long serialVersionUID = 6795434561028735214L;
private static final int ROW_HEIGHT = 24;
private final Consumer<String> onObjectChosen;
private final JTextField searchField = new JTextField();
private final DefaultTreeModel treeModel = new DefaultTreeModel(new DefaultMutableTreeNode());
private final JTree tree = new JTree(treeModel);
private final JLabel countLabel = new JLabel();
private final String noun;
private List<String> objectKeys = List.of();
private String chosenKey;
private boolean updatingSelection;
/**
* @param noun what one entry is called in the browser's texts, such as "object"
*/
ObjectBrowser(String noun, Consumer<String> onObjectChosen) {
super(new BorderLayout());
this.noun = noun;
this.onObjectChosen = onObjectChosen;
countLabel.setText("Loading " + noun + "s...");
StudioToolbar.makeSearchField(searchField, "Search " + noun + "s...");
searchField.getDocument().addDocumentListener(new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent event) {
rebuildTree();
}
@Override
public void removeUpdate(DocumentEvent event) {
rebuildTree();
}
@Override
public void changedUpdate(DocumentEvent event) {
rebuildTree();
}
});
tree.setRootVisible(false);
tree.setShowsRootHandles(true);
tree.setRowHeight(StudioTheme.scale(ROW_HEIGHT));
tree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION);
tree.setCellRenderer(new EntryRenderer());
ToolTipManager.sharedInstance().registerComponent(tree);
tree.addTreeSelectionListener(event -> {
if (!updatingSelection && nodeAt(event.getPath()) instanceof Entry entry && entry.isObject()) {
chosenKey = entry.key();
onObjectChosen.accept(entry.key());
}
});
countLabel.setFont(StudioTheme.smallFont());
countLabel.setForeground(StudioTheme.SECONDARY_TEXT);
add(StudioToolbar.padded(searchField, 8, 8, 8, 8), BorderLayout.NORTH);
add(StickyFolderHeaders.around(tree, StudioToolbar.scroll(tree)), BorderLayout.CENTER);
add(StudioToolbar.padded(countLabel, 6, 12, 8, 12), BorderLayout.SOUTH);
}
void showObjects(List<String> keys) {
objectKeys = keys.stream().sorted(Comparator.comparing(key -> key.toLowerCase(Locale.ROOT))).toList();
countLabel.setText(objectKeys.size() == 1 ? "1 " + noun : objectKeys.size() + " " + noun + "s");
rebuildTree();
}
void showError(String text) {
countLabel.setText(text);
}
String firstObject() {
return objectKeys.isEmpty() ? null : objectKeys.get(0);
}
private void rebuildTree() {
String[] words = searchField.getText().trim().toLowerCase(Locale.ROOT).split("\\s+");
boolean searching = !searchField.getText().isBlank();
DefaultMutableTreeNode root = searching ? searchResults(words) : folderTree();
treeModel.setRoot(root);
if (searching) {
expandAll();
}
selectChosen();
}
private DefaultMutableTreeNode searchResults(String[] words) {
DefaultMutableTreeNode root = new DefaultMutableTreeNode();
for (String key : objectKeys) {
if (matchesEveryWord(key, words)) {
root.add(new DefaultMutableTreeNode(Entry.object(key, key)));
}
}
if (root.getChildCount() == 0) {
root.add(new DefaultMutableTreeNode(Entry.note("No matching " + noun + "s")));
}
return root;
}
private static boolean matchesEveryWord(String key, String[] words) {
String lowerKey = key.toLowerCase(Locale.ROOT);
for (String word : words) {
if (!lowerKey.contains(word)) {
return false;
}
}
return true;
}
// Folders come before the objects beside them, like a file browser.
private DefaultMutableTreeNode folderTree() {
Folder top = new Folder();
for (String key : objectKeys) {
Folder folder = top;
String[] parts = key.split("/");
for (int index = 0; index < parts.length - 1; index++) {
folder = folder.subfolders.computeIfAbsent(parts[index], name -> new Folder());
}
folder.objects.add(key);
}
DefaultMutableTreeNode root = new DefaultMutableTreeNode();
addFolderContents(root, top, "");
return root;
}
private static void addFolderContents(DefaultMutableTreeNode node, Folder folder, String path) {
for (Map.Entry<String, Folder> subfolder : folder.subfolders.entrySet()) {
String subfolderPath = path + subfolder.getKey() + "/";
DefaultMutableTreeNode child = new DefaultMutableTreeNode(Entry.folder(subfolder.getKey(), subfolderPath));
addFolderContents(child, subfolder.getValue(), subfolderPath);
node.add(child);
}
for (String key : folder.objects) {
node.add(new DefaultMutableTreeNode(Entry.object(key.substring(key.lastIndexOf('/') + 1), key)));
}
}
void select(String key) {
chosenKey = key;
selectChosen();
}
private void selectChosen() {
DefaultMutableTreeNode node = chosenKey == null ? null : findObjectNode((DefaultMutableTreeNode) treeModel.getRoot());
updatingSelection = true;
try {
if (node == null) {
tree.clearSelection();
return;
}
TreePath path = new TreePath(node.getPath());
tree.setSelectionPath(path);
tree.scrollPathToVisible(path);
} finally {
updatingSelection = false;
}
}
private DefaultMutableTreeNode findObjectNode(DefaultMutableTreeNode node) {
if (node.getUserObject() instanceof Entry entry && entry.isObject() && entry.key().equals(chosenKey)) {
return node;
}
for (int index = 0; index < node.getChildCount(); index++) {
DefaultMutableTreeNode found = findObjectNode((DefaultMutableTreeNode) node.getChildAt(index));
if (found != null) {
return found;
}
}
return null;
}
private void expandAll() {
for (int row = 0; row < tree.getRowCount(); row++) {
tree.expandRow(row);
}
}
private static Object nodeAt(TreePath path) {
return path == null ? null : ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
}
private static final class Folder {
private final Map<String, Folder> subfolders = new LinkedHashMap<>();
private final List<String> objects = new ArrayList<>();
}
/**
* A row of the tree: an object with its key, a folder with its path, or a note without either.
*/
private record Entry(String label, String key, String folderPath) {
static Entry object(String label, String key) {
return new Entry(label, key, null);
}
static Entry folder(String label, String path) {
return new Entry(label, null, path);
}
static Entry note(String text) {
return new Entry(text, null, null);
}
boolean isObject() {
return key != null;
}
@Override
public String toString() {
return label;
}
}
private static final class EntryRenderer extends DefaultTreeCellRenderer {
private static final long serialVersionUID = -4316652201793418740L;
private EntryRenderer() {
setLeafIcon(null);
setOpenIcon(null);
setClosedIcon(null);
}
@Override
public Component getTreeCellRendererComponent(JTree tree, Object value, boolean selected, boolean expanded,
boolean leaf, int row, boolean focus) {
Object userObject = ((DefaultMutableTreeNode) value).getUserObject();
super.getTreeCellRendererComponent(tree, userObject == null ? "" : userObject.toString(), selected, expanded, leaf, row, false);
boolean isNote = userObject instanceof Entry entry && !entry.isObject() && entry.folderPath() == null;
setFont(StudioTheme.font());
setForeground(isNote ? StudioTheme.MUTED_TEXT : StudioTheme.TEXT);
setToolTipText(userObject instanceof Entry entry ? entry.isObject() ? entry.key() : entry.folderPath() : null);
return this;
}
}
}
@@ -0,0 +1,649 @@
/*
* 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 art.arcane.iris.studio.map;
import art.arcane.iris.studio.map.BlockTextures.BlockTexture;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.structure.object.IrisObject;
import art.arcane.iris.generation.geometry.IrisBlockVector;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import javax.swing.JComponent;
import javax.swing.JPanel;
import java.awt.CardLayout;
import java.awt.EventQueue;
import java.awt.event.KeyEvent;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.OptionalLong;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicLong;
/**
* The map window's Objects and Procedural views: everything its source offers in a folder tree, the chosen one in 3D,
* and what it is made of. A procedural object's variants can also be compared side by side, baked from any seed.
* Inspired by the Procedural Objects workbench.
*/
final class ObjectExplorer {
private static final String TINT_BIOME = "minecraft:plains";
private static final int UNKNOWN_BLOCK_COLOR = 0x8A8A8A;
private static final String CAMERA_HINT = "Drag to orbit · Right-drag to pan · Scroll to zoom · Page Up/Down cuts layers · R resets";
private static final String ONE_VARIANT_CARD = "one variant";
private static final String ALL_VARIANTS_CARD = "all variants";
private final Engine startEngine;
private final UUID worldId;
private final ObjectSource source;
private final ObjectBrowser browser;
private final OrbitViewport viewport = new OrbitViewport("Iris Object Viewer", "Choose an object");
private final ObjectLayerCut layerCut = new ObjectLayerCut(shownLayers -> showLayerCut());
private final ObjectVariantPicker variantPicker = new ObjectVariantPicker(this::showVariant, this::showSeed, this::toggleComparing);
private final ObjectVariantGallery gallery = new ObjectVariantGallery(this::openVariant, this::redrawThumbnails);
private final CardLayout viewCards = new CardLayout();
private final JPanel views = new JPanel(viewCards);
private final JComponent viewWithOverlays;
private final ObjectOverviewPanel overview;
private final StudioStatusBar statusBar = new StudioStatusBar();
private final LatestMeasurement<BakeTiming> bakeTimer = new LatestMeasurement<>("Iris Bake Timer");
private final ExecutorService loader = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "Iris Object Loader");
thread.setDaemon(true);
thread.setPriority(Thread.MIN_PRIORITY);
return thread;
});
private final ExecutorService thumbnailPainter = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "Iris Variant Thumbnails");
thread.setDaemon(true);
thread.setPriority(Thread.MIN_PRIORITY);
return thread;
});
private final AtomicLong objectRevision = new AtomicLong();
private final AtomicLong variantListRevision = new AtomicLong();
private final AtomicLong meshRevision = new AtomicLong();
private final Map<String, BlockModel> models = new ConcurrentHashMap<>();
private final Runnable hotloadHook = this::reloadPack;
private volatile boolean closed;
private volatile BlockTextures textures;
private volatile BiomeTintColors tintColors;
private String shownKey;
private List<IrisObject> shownVariants = List.of();
private int shownVariantIndex;
private OptionalLong packSeed = OptionalLong.empty();
private OptionalLong shownSeed = OptionalLong.empty();
private boolean comparingVariants;
private long thumbnailsDrawnForRevision = -1;
private LoadedObject shown;
private ObjectMesh wholeMesh;
private Set<String> seeThroughTypes = Set.of();
ObjectExplorer(Engine engine, UUID worldId, ObjectSource source) {
this.startEngine = engine;
this.worldId = worldId;
this.source = source;
this.browser = new ObjectBrowser(source.noun(), this::showObject);
this.overview = new ObjectOverviewPanel(this::showSeeThrough, source.isBaked());
views.add(layerCut.floatingOver(viewport), ONE_VARIANT_CARD);
views.add(gallery, ALL_VARIANTS_CARD);
this.viewWithOverlays = variantPicker.floatingOver(views);
MapHost.interruptOnPluginShutdown(loader);
MapHost.interruptOnPluginShutdown(thumbnailPainter);
MapHost.registerHotloadHook(hotloadHook);
statusBar.showPosition(CAMERA_HINT);
viewport.bindKey(KeyEvent.VK_PAGE_UP, "show-layer-above", () -> layerCut.stepBy(1));
viewport.bindKey(KeyEvent.VK_PAGE_DOWN, "hide-top-layer", () -> layerCut.stepBy(-1));
viewport.bindKey(KeyEvent.VK_OPEN_BRACKET, "previous-variant", () -> variantPicker.stepBy(-1));
viewport.bindKey(KeyEvent.VK_CLOSE_BRACKET, "next-variant", () -> variantPicker.stepBy(1));
viewport.bindKey(KeyEvent.VK_G, "compare-variants", this::toggleComparing);
gallery.bindKey(KeyEvent.VK_OPEN_BRACKET, "previous-variant", () -> variantPicker.stepBy(-1));
gallery.bindKey(KeyEvent.VK_CLOSE_BRACKET, "next-variant", () -> variantPicker.stepBy(1));
gallery.bindKey(KeyEvent.VK_G, "show-one-variant", this::toggleComparing);
gallery.bindKey(KeyEvent.VK_ESCAPE, "close-comparison", this::toggleComparing);
}
JComponent browser() {
return browser;
}
/**
* The 3D view with the slider that cuts away its top layers, or every variant side by side, under the bar that
* steps through variants and seeds.
*/
JComponent view() {
return viewWithOverlays;
}
void setCoveredLeftWidth(int width) {
viewport.setCoveredLeftWidth(width);
gallery.setCoveredLeftWidth(width);
variantPicker.setCoveredLeftWidth(width);
}
JComponent overview() {
return overview;
}
StudioStatusBar statusBar() {
return statusBar;
}
void resetCamera() {
viewport.resetCamera();
}
// Textures load first, since the first object would otherwise be drawn in flat colours.
synchronized void open() {
if (closed) {
return;
}
viewport.showLoading("Loading Minecraft textures");
loader.execute(() -> {
if (!loadTextures() || closed) {
return;
}
List<String> keys = objectKeys();
publish(() -> showCatalog(keys));
});
}
synchronized void close() {
if (closed) {
return;
}
closed = true;
MapHost.unregisterHotloadHook(hotloadHook);
objectRevision.incrementAndGet();
variantListRevision.incrementAndGet();
meshRevision.incrementAndGet();
loader.shutdownNow();
thumbnailPainter.shutdownNow();
bakeTimer.discardPending();
bakeTimer.close();
viewport.close();
gallery.close();
}
private boolean loadTextures() {
try {
BlockTextures loaded = BlockTextures.shared().get();
BiomeTintColors colors = loaded.biomeColors().vanilla(TINT_BIOME);
synchronized (this) {
if (closed) {
return false;
}
tintColors = colors;
textures = loaded;
}
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return false;
} catch (ExecutionException | RuntimeException unavailable) {
publish(() -> statusBar.showError("Minecraft textures are unavailable, objects use plain colours"));
}
return !closed;
}
private void publish(Runnable update) {
if (closed) {
return;
}
EventQueue.invokeLater(() -> {
synchronized (ObjectExplorer.this) {
if (!closed) {
update.run();
}
}
});
}
private List<String> objectKeys() {
return source.keys(engine().getData());
}
private synchronized void showCatalog(List<String> keys) {
if (closed) {
return;
}
statusBar.showActivity("");
browser.showObjects(keys);
String key = shownKey != null && keys.contains(shownKey) ? shownKey : browser.firstObject();
if (key == null) {
viewport.showEmptyState(source.emptyState());
bakeTimer.discardPending();
overview.clear();
layerCut.showHeight(1);
if (comparingVariants) {
toggleComparing();
}
showVariantList(List.of(), OptionalLong.empty(), OptionalLong.empty(), 0);
return;
}
browser.select(key);
showObject(key);
}
/**
* Shows the first variant of the entry, baked from the pack's seed. The pack reloading the shown entry keeps the
* variant and the seed being looked at, unless the pack itself now sets another seed.
*/
private synchronized void showObject(String key) {
if (closed) {
return;
}
boolean reloadingShownObject = key.equals(shownKey);
OptionalLong previousPackSeed = packSeed;
OptionalLong exploredSeed = shownSeed;
int variantIndex = reloadingShownObject ? shownVariantIndex : 0;
shownKey = key;
if (!reloadingShownObject) {
variantPicker.forgetTriedSeeds();
showVariantList(List.of(), OptionalLong.empty(), OptionalLong.empty(), 0);
}
long revision = startBaking(key);
loader.execute(() -> {
if (closed || revision != objectRevision.get()) {
return;
}
OptionalLong pack = source.seed(key);
OptionalLong seed = reloadingShownObject && pack.equals(previousPackSeed) ? exploredSeed : pack;
loadVariants(revision, key, pack, seed, variantIndex);
});
}
// The seed shows at once, so stepping again continues from it even while this one bakes or fails.
private synchronized void showSeed(long seed) {
if (closed) {
return;
}
String key = shownKey;
OptionalLong pack = packSeed;
int variantIndex = shownVariantIndex;
shownSeed = OptionalLong.of(seed);
variantPicker.showSeed(shownSeed, pack);
long revision = startBaking(key + " with seed " + seed);
loader.execute(() -> {
if (!closed && revision == objectRevision.get()) {
loadVariants(revision, key, pack, OptionalLong.of(seed), variantIndex);
}
});
}
// On the loader thread.
private void loadVariants(long revision, String key, OptionalLong pack, OptionalLong seed, int preferredIndex) {
if (closed || revision != objectRevision.get()) {
return;
}
try {
IrisData data = engine().getData();
List<IrisObject> variants = seed.isPresent() ? source.variants(data, key, seed.getAsLong()) : source.variants(data, key);
if (variants.isEmpty()) {
throw new IllegalStateException("Nothing could be loaded for " + key);
}
if (closed || revision != objectRevision.get()) {
return;
}
int index = preferredIndex < variants.size() ? preferredIndex : 0;
LoadedObject loaded = load(variants.get(index));
publish(() -> {
if (closed || revision != objectRevision.get()) {
return;
}
showVariantList(variants, pack, seed, index);
showLoaded(revision, loaded);
timeBake(data, key, seed);
});
} catch (RuntimeException error) {
publish(() -> showFailure(revision, key, error));
}
}
// Off the loader thread, so stepping through variants and seeds never waits on the timing.
private void timeBake(IrisData data, String key, OptionalLong seed) {
if (!source.isBaked() || seed.isEmpty()) {
return;
}
long bakedSeed = seed.getAsLong();
overview.showTimingBake();
bakeTimer.measureUntilSuperseded(stillWanted -> BakeTiming.measure(() -> source.timeBake(data, key, bakedSeed), stillWanted),
timing -> publish(() -> overview.showBakeTiming(timing)));
}
private void showVariantList(List<IrisObject> variants, OptionalLong pack, OptionalLong seed, int index) {
shownVariants = variants;
shownVariantIndex = index;
packSeed = pack;
shownSeed = seed;
variantListRevision.incrementAndGet();
variantPicker.showVariants(variants.size(), index);
variantPicker.showSeed(seed, pack);
if (!comparingVariants) {
return;
}
if (variants.isEmpty()) {
gallery.showLoading();
} else if (variants.size() == 1) {
toggleComparing();
} else {
drawThumbnails();
}
}
private synchronized void showVariant(int index) {
if (closed) {
return;
}
if (index >= shownVariants.size()) {
return;
}
shownVariantIndex = index;
gallery.select(index);
IrisObject variant = shownVariants.get(index);
long revision = startLoading(variant.getLoadKey());
loader.execute(() -> {
if (closed || revision != objectRevision.get()) {
return;
}
LoadedObject loaded = load(variant);
publish(() -> showLoaded(revision, loaded));
});
}
private synchronized void openVariant(int index) {
if (closed) {
return;
}
variantPicker.showVariants(shownVariants.size(), index);
showVariant(index);
toggleComparing();
}
private synchronized void toggleComparing() {
if (closed) {
return;
}
if (!comparingVariants && shownVariants.size() < 2) {
return;
}
comparingVariants = !comparingVariants;
variantPicker.showComparing(comparingVariants);
viewCards.show(views, comparingVariants ? ALL_VARIANTS_CARD : ONE_VARIANT_CARD);
if (comparingVariants) {
drawThumbnails();
gallery.requestFocusInWindow();
} else {
viewport.requestFocusInWindow();
}
}
private synchronized void redrawThumbnails() {
if (closed) {
return;
}
thumbnailsDrawnForRevision = -1;
if (comparingVariants) {
drawThumbnails();
}
}
/**
* Fills the side-by-side view with every shown variant, all drawn at the scale that fits the largest, so their
* sizes compare. Thumbnails arrive one at a time on their own thread, leaving the loader free for the 3D view.
*/
private synchronized void drawThumbnails() {
if (closed) {
return;
}
long revision = variantListRevision.get();
if (revision == thumbnailsDrawnForRevision) {
return;
}
thumbnailsDrawnForRevision = revision;
List<IrisObject> variants = shownVariants;
gallery.showVariants(galleryHeading(), variants.size(), shownVariantIndex);
int size = gallery.thumbnailSize();
thumbnailPainter.execute(() -> {
List<ObjectMesh> meshes = new ArrayList<>();
for (IrisObject variant : variants) {
if (closed || revision != variantListRevision.get()) {
return;
}
meshes.add(ObjectMesh.build(visibleBlocks(variant), this::modelOf));
}
ObjectMesh.Bounds sizeToFit = largestSize(meshes);
for (int index = 0; index < meshes.size() && !closed && revision == variantListRevision.get(); index++) {
BufferedImage thumbnail = ObjectRenderer.renderThumbnail(meshes.get(index), sizeToFit, size, size);
int shownIndex = index;
publish(() -> {
if (revision == variantListRevision.get()) {
gallery.showThumbnail(shownIndex, thumbnail);
}
});
}
});
}
private String galleryHeading() {
return String.format(Locale.ROOT, "%s · %d variants%s", shownKey, shownVariants.size(), seedNote());
}
private static ObjectMesh.Bounds largestSize(List<ObjectMesh> meshes) {
int sizeX = 1;
int sizeY = 1;
int sizeZ = 1;
for (ObjectMesh mesh : meshes) {
sizeX = Math.max(sizeX, mesh.bounds().sizeX());
sizeY = Math.max(sizeY, mesh.bounds().sizeY());
sizeZ = Math.max(sizeZ, mesh.bounds().sizeZ());
}
return new ObjectMesh.Bounds(0, 0, 0, sizeX, sizeY, sizeZ);
}
// A new list of variants is on its way, so every thumbnail is outdated, unlike when stepping to another variant.
private long startBaking(String key) {
if (comparingVariants) {
gallery.showLoading();
}
return startLoading(key);
}
private long startLoading(String key) {
shown = null;
wholeMesh = null;
meshRevision.incrementAndGet();
viewport.showLoading("Loading " + key);
return objectRevision.incrementAndGet();
}
private LoadedObject load(IrisObject object) {
Map<IrisBlockVector, NativeBlockState> blocks = visibleBlocks(object);
ObjectMesh.Bounds extent = ObjectMesh.Bounds.of(blocks.keySet());
ObjectMesh.Bounds bounds = source.groundY(object).stream().mapToObj(extent::standingOn).findFirst().orElse(extent);
ObjectMesh mesh = ObjectMesh.build(blocks, this::modelOf, bounds, block -> false);
ObjectSummary summary = ObjectSummary.of(object.getLoadKey(), mesh.bounds(), blocks.values(), this::iconOf);
return new LoadedObject(blocks, mesh, summary);
}
private static Map<IrisBlockVector, NativeBlockState> visibleBlocks(IrisObject object) {
Map<IrisBlockVector, NativeBlockState> blocks = new HashMap<>();
object.getBlocks().forEach((position, block) -> {
if (!block.isAir()) {
blocks.put(position, block);
}
});
return blocks;
}
private void showLoaded(long revision, LoadedObject loaded) {
if (closed || revision != objectRevision.get()) {
return;
}
shown = loaded;
wholeMesh = loaded.mesh();
seeThroughTypes = Set.of();
statusBar.showActivity("");
viewport.show(loaded.mesh());
overview.show(loaded.summary());
layerCut.showHeight(loaded.mesh().bounds().sizeY());
showMeshSummary(loaded.mesh());
}
private void showMeshSummary(ObjectMesh mesh) {
ObjectSummary summary = shown.summary();
String cut = layerCut.isCutting()
? String.format(Locale.ROOT, " · showing layers 1-%d of %d", layerCut.shownLayerCount(), layerCut.layerCount())
: "";
statusBar.showSummary(String.format(Locale.ROOT, "%s%s · %,d blocks · %,d faces drawn%s",
summary.key(), seedNote(), summary.blockCount(), mesh.faceCount(), cut));
}
// A seed being tried is not in the pack until its "seed" field says so.
private String seedNote() {
if (shownSeed.isEmpty()) {
return "";
}
String note = " · seed " + shownSeed.getAsLong();
return shownSeed.equals(packSeed) ? note : note + " (the pack has " + packSeed.getAsLong() + ")";
}
private synchronized void showSeeThrough(Set<String> blockTypes) {
if (closed) {
return;
}
seeThroughTypes = blockTypes;
rebuildWholeMesh();
}
// Blocks behind see-through ones get their hidden faces back, so the mesh is built again.
private synchronized void rebuildWholeMesh() {
if (closed) {
return;
}
LoadedObject object = shown;
if (object == null) {
return;
}
long revision = meshRevision.incrementAndGet();
Set<String> seeThrough = seeThroughTypes;
loader.execute(() -> {
if (closed || revision != meshRevision.get()) {
return;
}
ObjectMesh mesh = seeThrough.isEmpty()
? object.mesh()
: ObjectMesh.build(object.blocks(), this::modelOf, object.mesh().bounds(),
block -> seeThrough.contains(BlockNames.typeOf(block)));
publish(() -> {
if (revision == meshRevision.get() && shown == object) {
wholeMesh = mesh;
showLayerCut();
}
});
});
}
private synchronized void showLayerCut() {
if (closed) {
return;
}
if (wholeMesh == null) {
return;
}
int highestShownY = wholeMesh.bounds().minY() + layerCut.shownLayerCount() - 1;
ObjectMesh mesh = layerCut.isCutting() ? wholeMesh.cutAbove(highestShownY) : wholeMesh;
viewport.replaceScene(mesh);
showMeshSummary(mesh);
}
private void showFailure(long revision, String key, Throwable error) {
if (closed || revision != objectRevision.get()) {
return;
}
statusBar.showActivity("");
viewport.showMessage("Could not load " + key);
gallery.showMessage("Could not load " + key);
thumbnailsDrawnForRevision = -1;
bakeTimer.discardPending();
overview.clear();
layerCut.showHeight(1);
statusBar.showError(error.getMessage() == null ? error.getClass().getSimpleName() : error.getMessage());
}
// The pack's files may have changed, so the list and the shown object are read again.
private synchronized void reloadPack() {
if (closed) {
return;
}
loader.execute(() -> {
if (closed) {
return;
}
List<String> keys = objectKeys();
publish(() -> showCatalog(keys));
});
}
private Engine engine() {
Engine current = MapHost.findEngine(worldId);
return current != null && !current.isClosed() ? current : startEngine;
}
private BlockModel modelOf(NativeBlockState block) {
return models.computeIfAbsent(block.key(), state -> {
BlockTextures available = textures;
if (available == null) {
return BlockModel.solidColor(UNKNOWN_BLOCK_COLOR);
}
return available.model(block).map(this::withBiomeTint).orElseGet(() -> BlockModel.solidColor(UNKNOWN_BLOCK_COLOR));
});
}
private BufferedImage iconOf(NativeBlockState block) {
BlockTextures available = textures;
if (available == null) {
return null;
}
return available.iconTexture(block).map(this::withBiomeTint).map(BlockTexture::toImage).orElse(null);
}
// Objects have no biome of their own, so grass and leaves get plains' colours.
private BlockModel withBiomeTint(BlockModel model) {
return model.withTextures(this::withBiomeTint);
}
private BlockTexture withBiomeTint(BlockTexture texture) {
BiomeTintColors colors = tintColors;
if (texture.biomeTint() == null || colors == null) {
return texture;
}
return texture.tinted(colors.colorAt(texture.biomeTint(), 0, 0));
}
private record LoadedObject(Map<IrisBlockVector, NativeBlockState> blocks, ObjectMesh mesh, ObjectSummary summary) {
}
}
@@ -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 art.arcane.iris.studio.map;
import javax.swing.JComponent;
import java.util.function.IntConsumer;
/**
* A slider floating on the right of the 3D view that hides an object's top layers, to look inside buildings floor by
* floor. Most objects never need it, so it stays faint until the mouse comes near or a cut is active.
*/
final class ObjectLayerCut {
private static final String TOOLTIP = "Hide the top layers to look inside · Page Up / Page Down";
private final FloatingSlider slider = new FloatingSlider(TOOLTIP, this::showCut);
private final IntConsumer onLayersChanged;
/**
* @param onLayersChanged told how many layers from the bottom stay visible whenever the user moves the slider
*/
ObjectLayerCut(IntConsumer onLayersChanged) {
this.onLayersChanged = onLayersChanged;
showHeight(1);
}
/**
* The view with this slider floating over its right edge, halfway down.
*/
JComponent floatingOver(JComponent view) {
return slider.floatingOver(view);
}
/**
* Shows every layer of a newly chosen object without telling anyone, since nothing is hidden yet.
*/
void showHeight(int layers) {
int layerCount = Math.max(1, layers);
slider.showQuietly(1, layerCount, layerCount);
showCutState();
slider.setVisible(layers > 1);
}
void stepBy(int layers) {
if (slider.isVisible()) {
slider.moveBy(layers);
}
}
boolean isCutting() {
return slider.value() < slider.maximum();
}
int shownLayerCount() {
return slider.value();
}
int layerCount() {
return slider.maximum();
}
private void showCut(int shownLayers) {
showCutState();
onLayersChanged.accept(shownLayers);
}
private void showCutState() {
slider.showLabel(isCutting() ? Integer.toString(slider.value()) : "");
slider.setStandingOut(isCutting());
}
}
@@ -0,0 +1,289 @@
/*
* 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 art.arcane.iris.studio.map;
import art.arcane.iris.generation.decoration.IrisProceduralBlocks;
import art.arcane.iris.generation.geometry.IrisBlockVector;
import art.arcane.iris.studio.map.BlockModel.Face;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Predicate;
/**
* The faces of an object's blocks that can be seen from outside. A face is left out when the neighbour on its side
* covers that side, which is most of them in a large object. See-through faces, like water and stained glass, are
* kept apart because they are drawn last, blended over everything behind them. Top faces covered only by the block
* above are kept aside too, so the top layers can be cut away without building the mesh again; what is cut away stays
* as a faint ghost of the object's shape.
*/
record ObjectMesh(List<Quad> solidQuads, List<Quad> translucentQuads, List<Quad> cutAwayQuads, List<Quad> topsUnderBlockAbove,
Bounds bounds) implements OrbitScene {
private static final double SEE_THROUGH_OPACITY = 0.22D;
private static final double FULL_OPACITY = 1D;
private static final double CUT_AWAY_OPACITY = 0.1D;
static ObjectMesh build(Map<IrisBlockVector, NativeBlockState> blocks, Function<NativeBlockState, BlockModel> modelOf) {
return build(blocks, modelOf, Bounds.of(blocks.keySet()), block -> false);
}
/**
* @param bounds the whole object's extent, kept when only part of it is drawn so the view does not jump
* @param isSeeThrough blocks drawn faintly, which hide nothing behind them, to look past them into the object
*/
static ObjectMesh build(Map<IrisBlockVector, NativeBlockState> blocks, Function<NativeBlockState, BlockModel> modelOf, Bounds bounds,
Predicate<NativeBlockState> isSeeThrough) {
List<Quad> solid = new ArrayList<>();
List<Quad> translucent = new ArrayList<>();
List<Quad> topsUnderBlockAbove = new ArrayList<>();
BlockGrid grid = new BlockGrid(blocks, modelOf, bounds, isSeeThrough);
for (IrisBlockVector position : blocks.keySet()) {
int x = position.getBlockX();
int y = position.getBlockY();
int z = position.getBlockZ();
int cell = grid.cellAt(x, y, z);
for (Face face : grid.modelIn(cell).faces()) {
Quad quad = new Quad(face, x, y, z, grid.isSeeThroughIn(cell) ? SEE_THROUGH_OPACITY : FULL_OPACITY);
if (!grid.isHiddenByNeighbour(x, y, z, face)) {
(quad.isBlended() ? translucent : solid).add(quad);
} else if (face.cullFace() == CubeFace.UP) {
topsUnderBlockAbove.add(quad);
}
}
}
return new ObjectMesh(List.copyOf(solid), List.copyOf(translucent), List.of(), List.copyOf(topsUnderBlockAbove), bounds);
}
/**
* This mesh with the blocks above the given layer faded to a ghost of the object's shape, and the tops of that
* layer's blocks showing again. Quick enough to follow a slider as it moves.
*/
ObjectMesh cutAbove(int highestShownY) {
List<Quad> solid = new ArrayList<>();
List<Quad> translucent = new ArrayList<>();
List<Quad> cutAway = new ArrayList<>();
for (Quad quad : solidQuads) {
keepOrCutAway(quad, highestShownY, solid, cutAway);
}
for (Quad quad : translucentQuads) {
keepOrCutAway(quad, highestShownY, translucent, cutAway);
}
for (Quad quad : topsUnderBlockAbove) {
if (quad.y() == highestShownY) {
(quad.isBlended() ? translucent : solid).add(quad);
}
}
return new ObjectMesh(solid, translucent, cutAway, List.of(), bounds);
}
private static void keepOrCutAway(Quad quad, int highestShownY, List<Quad> kept, List<Quad> cutAway) {
if (quad.y() <= highestShownY) {
kept.add(quad);
} else {
cutAway.add(quad.faded(CUT_AWAY_OPACITY));
}
}
@Override
public boolean isEmpty() {
return solidQuads.isEmpty() && translucentQuads.isEmpty() && cutAwayQuads.isEmpty();
}
@Override
public OrbitPicture render(OrbitCamera camera, int width, int height, int coveredLeftWidth) {
return renderOnCard(camera, width, height, coveredLeftWidth)
.orElseGet(() -> ObjectRenderer.render(this, camera, width, height, coveredLeftWidth));
}
private Optional<OrbitPicture> renderOnCard(OrbitCamera camera, int width, int height, int coveredLeftWidth) {
if (!GpuLibrary.isLoaded()) {
return Optional.empty();
}
return GpuObjectRenderer.render(this, camera, width, height, coveredLeftWidth);
}
@Override
public boolean canRenderOnScreen() {
return GpuLibrary.isLoaded() && GpuFrames.canDrawOnScreen();
}
@Override
public Optional<OrbitPicture> renderOnScreen(ScreenTarget screen, OrbitCamera camera, int width, int height, int coveredLeftWidth) {
return GpuObjectRenderer.renderOnScreen(this, screen, camera, width, height, coveredLeftWidth);
}
@Override
public OrbitProjection projection(OrbitCamera camera, int width, int height, int coveredLeftWidth) {
return ObjectRenderer.projection(this, camera, width, height, coveredLeftWidth);
}
int faceCount() {
return solidQuads.size() + translucentQuads.size();
}
/**
* The object's blocks laid out in an array over its bounds, so finding a block's neighbours is quick. Looking them
* up by {@link BlockVector} in a map is slow, since its hash codes collide a lot.
*/
private static final class BlockGrid {
private final Bounds bounds;
private final NativeBlockState[] blocks;
private final BlockModel[] models;
private final boolean[] seeThrough;
BlockGrid(Map<IrisBlockVector, NativeBlockState> placed, Function<NativeBlockState, BlockModel> modelOf, Bounds bounds,
Predicate<NativeBlockState> isSeeThrough) {
this.bounds = bounds;
int cells = bounds.sizeX() * bounds.sizeY() * bounds.sizeZ();
this.blocks = new NativeBlockState[cells];
this.models = new BlockModel[cells];
this.seeThrough = new boolean[cells];
placed.forEach((position, block) -> {
int cell = cellAt(position.getBlockX(), position.getBlockY(), position.getBlockZ());
blocks[cell] = block;
models[cell] = modelOf.apply(block);
seeThrough[cell] = isSeeThrough.test(block);
});
}
int cellAt(int x, int y, int z) {
return ((y - bounds.minY()) * bounds.sizeZ() + (z - bounds.minZ())) * bounds.sizeX() + (x - bounds.minX());
}
BlockModel modelIn(int cell) {
return models[cell];
}
boolean isSeeThroughIn(int cell) {
return seeThrough[cell];
}
// Glass next to glass and water next to water hide the side between them too, as they do in the game. A
// see-through block only hides sides of other see-through blocks, so whatever it covers shows through it.
boolean isHiddenByNeighbour(int x, int y, int z, Face face) {
CubeFace side = face.cullFace();
if (side == null || !contains(x + side.stepX(), y + side.stepY(), z + side.stepZ())) {
return false;
}
int cell = cellAt(x, y, z);
int neighbourCell = cellAt(x + side.stepX(), y + side.stepY(), z + side.stepZ());
NativeBlockState neighbour = blocks[neighbourCell];
if (neighbour == null || (seeThrough[neighbourCell] && !seeThrough[cell])) {
return false;
}
BlockModel neighbourModel = models[neighbourCell];
if (neighbourModel.coveredSides().contains(side.opposite())) {
return true;
}
return isSameBlockType(neighbour, blocks[cell]) && models[cell].hidesSameBlockSides()
&& neighbourModel.hidesSameBlockSides();
}
private static boolean isSameBlockType(NativeBlockState first, NativeBlockState second) {
return IrisProceduralBlocks.materialKey(first).equals(IrisProceduralBlocks.materialKey(second));
}
private boolean contains(int x, int y, int z) {
return x >= bounds.minX() && x < bounds.maxX() && y >= bounds.minY() && y < bounds.maxY()
&& z >= bounds.minZ() && z < bounds.maxZ();
}
}
/**
* A model face placed at a block in the object, drawn at the given opacity on top of its texture's own.
*/
record Quad(Face face, int x, int y, int z, double opacity) {
boolean isBlended() {
return opacity < FULL_OPACITY || face.texture().isTranslucent();
}
Quad faded(double factor) {
return new Quad(face, x, y, z, opacity * factor);
}
double originX() {
return x + face.origin().x();
}
double originY() {
return y + face.origin().y();
}
double originZ() {
return z + face.origin().z();
}
}
/**
* The blocks' extent, with the maximum one past the last block so a single block spans one unit, and the height the
* terrain surface meets the object at.
*/
record Bounds(int minX, int minY, int minZ, int maxX, int maxY, int maxZ, int groundY) {
private static final Bounds EMPTY = new Bounds(0, 0, 0, 1, 1, 1);
/** An extent standing on its lowest block. */
Bounds(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {
this(minX, minY, minZ, maxX, maxY, maxZ, minY);
}
/** This extent with the terrain surface at {@code groundY}, so anything below it shows as buried. */
Bounds standingOn(int groundY) {
return new Bounds(minX, minY, minZ, maxX, maxY, maxZ, groundY);
}
static Bounds of(Iterable<IrisBlockVector> positions) {
int minX = Integer.MAX_VALUE;
int minY = Integer.MAX_VALUE;
int minZ = Integer.MAX_VALUE;
int maxX = Integer.MIN_VALUE;
int maxY = Integer.MIN_VALUE;
int maxZ = Integer.MIN_VALUE;
for (IrisBlockVector position : positions) {
minX = Math.min(minX, position.getBlockX());
minY = Math.min(minY, position.getBlockY());
minZ = Math.min(minZ, position.getBlockZ());
maxX = Math.max(maxX, position.getBlockX() + 1);
maxY = Math.max(maxY, position.getBlockY() + 1);
maxZ = Math.max(maxZ, position.getBlockZ() + 1);
}
return minX == Integer.MAX_VALUE ? EMPTY : new Bounds(minX, minY, minZ, maxX, maxY, maxZ);
}
int sizeX() {
return maxX - minX;
}
int sizeY() {
return maxY - minY;
}
int sizeZ() {
return maxZ - minZ;
}
int largestSize() {
return Math.max(sizeX(), Math.max(sizeY(), sizeZ()));
}
}
}
@@ -0,0 +1,368 @@
/*
* 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 art.arcane.iris.studio.map;
import javax.swing.BorderFactory;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.SwingUtilities;
import javax.swing.DefaultListCellRenderer;
import javax.swing.DefaultListModel;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JPanel;
import javax.swing.JTextField;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import java.awt.AlphaComposite;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Composite;
import java.awt.Cursor;
import java.awt.Insets;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.GridLayout;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.function.Consumer;
/**
* The chosen object's size and the blocks it is made of, most used first, like the overview in Procedural Objects.
* Clicking a block makes it see-through in the 3D view, to look past walls into the object.
*/
final class ObjectOverviewPanel extends JPanel {
private static final long serialVersionUID = -8811407613645718290L;
private static final int PANEL_WIDTH = 280;
private static final int ROW_HEIGHT = 26;
private static final int ICON_SIZE = 16;
private static final int ICON_GAP = 8;
private static final int ROW_PADDING = 10;
private static final int SHARE_WIDTH = 92;
private static final int SHARE_BAR_HEIGHT = 4;
private static final float SEE_THROUGH_ICON_OPACITY = 0.3F;
private static final String SEE_THROUGH_HINT = "Click a block to see through it";
private final JLabel title = new JLabel(" ");
private final JLabel path = new JLabel(" ");
private final JLabel size = statValue();
private final JLabel blockCount = statValue();
private final JLabel blockTypes = statValue();
private final JLabel filled = statValue();
private final BakeMeter bakeMeter = new BakeMeter();
private final JTextField filterField = new JTextField();
private final DefaultListModel<ObjectSummary.BlockUse> rows = new DefaultListModel<>();
private final JList<ObjectSummary.BlockUse> list = new JList<>(rows) {
@Override
public boolean getScrollableTracksViewportWidth() {
return true;
}
};
private final JLabel seeThroughHint = new JLabel(SEE_THROUGH_HINT);
private final JButton showAllSolid = new JButton("Show all solid");
private final Set<String> seeThroughTypes = new LinkedHashSet<>();
private final Consumer<Set<String>> onSeeThroughChanged;
private ObjectSummary summary;
/**
* @param onSeeThroughChanged told which block types to draw see-through whenever the user changes them
* @param showsBakeTime whether the objects are baked, so the time their bake takes is shown
*/
ObjectOverviewPanel(Consumer<Set<String>> onSeeThroughChanged, boolean showsBakeTime) {
super(new BorderLayout());
this.onSeeThroughChanged = onSeeThroughChanged;
setPreferredSize(StudioTheme.scaledSize(PANEL_WIDTH, 0));
setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, StudioTheme.SEPARATOR));
setBackground(StudioTheme.PANEL_BACKGROUND);
add(buildHeader(showsBakeTime), BorderLayout.NORTH);
add(buildBlockList(), BorderLayout.CENTER);
}
void show(ObjectSummary nextSummary) {
summary = nextSummary;
title.setText(nextSummary.name());
path.setText(nextSummary.key());
path.setToolTipText(nextSummary.key());
size.setText(nextSummary.sizeX() + " × " + nextSummary.sizeY() + " × " + nextSummary.sizeZ());
blockCount.setText(formatCount(nextSummary.blockCount()));
blockTypes.setText(formatCount(nextSummary.blockUses().size()));
filled.setText(String.format(Locale.ROOT, "%.1f%%", nextSummary.filledFraction() * 100D));
seeThroughTypes.clear();
showSeeThroughState();
refilter();
}
void showTimingBake() {
bakeMeter.showTiming();
}
void showBakeTiming(BakeTiming timing) {
bakeMeter.show(timing);
}
void clear() {
summary = null;
title.setText(" ");
path.setText(" ");
for (JLabel value : List.of(size, blockCount, blockTypes, filled)) {
value.setText("—");
}
bakeMeter.clear();
rows.clear();
seeThroughTypes.clear();
showSeeThroughState();
}
private JPanel buildHeader(boolean showsBakeTime) {
JPanel header = new JPanel();
header.setLayout(new BoxLayout(header, BoxLayout.Y_AXIS));
header.setOpaque(false);
header.setBorder(StudioTheme.emptyBorder(12, 16, 8, 16));
title.setFont(StudioTheme.titleFont());
path.setFont(StudioTheme.smallFont());
path.setForeground(StudioTheme.SECONDARY_TEXT);
header.add(leftAligned(title));
header.add(leftAligned(path));
JPanel stats = new JPanel(new GridLayout(2, 2, StudioTheme.scale(12), StudioTheme.scale(8)));
stats.setOpaque(false);
stats.setBorder(StudioTheme.emptyBorder(12, 0, 4, 0));
stats.add(stat("Size · X × Y × Z", size));
stats.add(stat("Blocks", blockCount));
stats.add(stat("Block types", blockTypes));
stats.add(stat("Filled volume", filled));
filled.setToolTipText("How much of the object's bounding box its blocks fill");
header.add(leftAligned(stats));
if (showsBakeTime) {
JPanel bakeTime = stat("Bake time", bakeMeter);
bakeTime.setBorder(StudioTheme.emptyBorder(4, 0, 4, 0));
header.add(leftAligned(bakeTime));
}
clear();
return header;
}
private JPanel buildBlockList() {
JPanel blocks = new JPanel(new BorderLayout());
blocks.setOpaque(false);
StudioToolbar.makeSearchField(filterField, "Find a block...");
filterField.getDocument().addDocumentListener(new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent event) {
refilter();
}
@Override
public void removeUpdate(DocumentEvent event) {
refilter();
}
@Override
public void changedUpdate(DocumentEvent event) {
refilter();
}
});
list.setFocusable(false);
list.setFixedCellHeight(StudioTheme.scale(ROW_HEIGHT));
list.setCellRenderer(new BlockUseRenderer());
list.setBackground(StudioTheme.PANEL_BACKGROUND);
list.setBorder(StudioTheme.emptyBorder(0, 6, 4, 6));
list.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
list.addMouseListener(new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent event) {
int row = list.locationToIndex(event.getPoint());
if (SwingUtilities.isLeftMouseButton(event) && row >= 0 && list.getCellBounds(row, row).contains(event.getPoint())) {
toggleSeeThrough(rows.get(row).blockType());
}
}
});
JPanel top = new JPanel();
top.setLayout(new BoxLayout(top, BoxLayout.Y_AXIS));
top.setOpaque(false);
top.add(leftAligned(StudioToolbar.padded(filterField, 4, 12, 4, 12)));
top.add(leftAligned(StudioToolbar.padded(seeThroughRow(), 0, 14, 6, 12)));
blocks.add(top, BorderLayout.NORTH);
blocks.add(StudioToolbar.scroll(list), BorderLayout.CENTER);
return blocks;
}
private JPanel seeThroughRow() {
seeThroughHint.setFont(StudioTheme.smallFont());
seeThroughHint.setForeground(StudioTheme.MUTED_TEXT);
showAllSolid.setFont(StudioTheme.smallFont());
showAllSolid.setForeground(StudioTheme.LINK);
showAllSolid.setBorderPainted(false);
showAllSolid.setContentAreaFilled(false);
showAllSolid.setFocusable(false);
showAllSolid.setMargin(new Insets(0, 0, 0, 0));
showAllSolid.setBorder(BorderFactory.createEmptyBorder());
showAllSolid.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
showAllSolid.addActionListener(event -> {
seeThroughTypes.clear();
seeThroughChanged();
});
JPanel row = new JPanel(new BorderLayout());
row.setOpaque(false);
row.add(seeThroughHint, BorderLayout.CENTER);
row.add(showAllSolid, BorderLayout.EAST);
showSeeThroughState();
return row;
}
private void toggleSeeThrough(String blockType) {
if (!seeThroughTypes.remove(blockType)) {
seeThroughTypes.add(blockType);
}
seeThroughChanged();
}
private void seeThroughChanged() {
showSeeThroughState();
list.repaint();
onSeeThroughChanged.accept(Set.copyOf(seeThroughTypes));
}
private void showSeeThroughState() {
int count = seeThroughTypes.size();
seeThroughHint.setText(count == 0 ? SEE_THROUGH_HINT : count == 1 ? "1 block see-through" : count + " blocks see-through");
showAllSolid.setVisible(count > 0);
}
private void refilter() {
rows.clear();
if (summary == null) {
return;
}
String filter = filterField.getText().trim().toLowerCase(Locale.ROOT).replace(' ', '_');
for (ObjectSummary.BlockUse use : summary.blockUses()) {
if (use.blockType().contains(filter)) {
rows.addElement(use);
}
}
}
private static JPanel stat(String label, JComponent value) {
JPanel stat = new JPanel(new BorderLayout());
stat.setOpaque(false);
JLabel name = new JLabel(label);
name.setFont(StudioTheme.smallFont());
name.setForeground(StudioTheme.MUTED_TEXT);
stat.add(name, BorderLayout.NORTH);
stat.add(value, BorderLayout.CENTER);
return stat;
}
private static JLabel statValue() {
JLabel value = new JLabel("—");
value.setFont(StudioTheme.boldFont());
return value;
}
private static Component leftAligned(JPanel panel) {
panel.setAlignmentX(LEFT_ALIGNMENT);
return panel;
}
private static Component leftAligned(JLabel label) {
label.setAlignmentX(LEFT_ALIGNMENT);
return label;
}
private static String formatCount(int count) {
return String.format(Locale.ROOT, "%,d", count);
}
private final class BlockUseRenderer extends DefaultListCellRenderer {
private static final long serialVersionUID = 2785337045812613941L;
private transient BufferedImage icon;
private boolean seeThrough;
private double share;
private String percent = "";
@Override
public Component getListCellRendererComponent(JList<?> owner, Object value, int index, boolean selected, boolean focused) {
ObjectSummary.BlockUse use = (ObjectSummary.BlockUse) value;
super.getListCellRendererComponent(owner, use.blockType(), index, false, false);
seeThrough = seeThroughTypes.contains(use.blockType());
setFont(StudioTheme.font());
setForeground(seeThrough ? StudioTheme.MUTED_TEXT : StudioTheme.TEXT);
setBorder(StudioTheme.emptyBorder(0, ROW_PADDING + ICON_SIZE + ICON_GAP, 0, ROW_PADDING + SHARE_WIDTH));
setToolTipText(use.blockType() + " · " + formatCount(use.count()) + " blocks · click to "
+ (seeThrough ? "make solid again" : "see through it"));
icon = use.icon();
share = summary == null || summary.blockCount() == 0 ? 0D : use.count() / (double) summary.blockCount();
percent = share > 0D && share < 0.001D ? "<0.1%" : String.format(Locale.ROOT, "%.1f%%", share * 100D);
return this;
}
@Override
protected void paintComponent(Graphics graphics) {
super.paintComponent(graphics);
Graphics2D canvas = (Graphics2D) graphics.create();
try {
int iconSize = StudioTheme.scale(ICON_SIZE);
int iconX = StudioTheme.scale(ROW_PADDING);
int iconY = (getHeight() - iconSize) / 2;
if (icon != null) {
canvas.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
Composite solid = canvas.getComposite();
if (seeThrough) {
canvas.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, SEE_THROUGH_ICON_OPACITY));
}
canvas.drawImage(icon, iconX, iconY, iconSize, iconSize, null);
canvas.setComposite(solid);
} else {
canvas.setColor(StudioTheme.CONTROL_BORDER);
canvas.drawRect(iconX, iconY, iconSize - 1, iconSize - 1);
}
drawShare(canvas);
} finally {
canvas.dispose();
}
}
private void drawShare(Graphics2D canvas) {
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
canvas.setFont(StudioTheme.smallFont());
int padding = StudioTheme.scale(ROW_PADDING);
int percentWidth = canvas.getFontMetrics().stringWidth("100.0%");
int barWidth = StudioTheme.scale(SHARE_WIDTH) - percentWidth - StudioTheme.scale(6);
int barX = getWidth() - padding - StudioTheme.scale(SHARE_WIDTH);
int barHeight = StudioTheme.scale(SHARE_BAR_HEIGHT);
int barY = (getHeight() - barHeight) / 2;
canvas.setColor(StudioTheme.HOVER_BACKGROUND);
canvas.fillRoundRect(barX, barY, barWidth, barHeight, barHeight, barHeight);
canvas.setColor(seeThrough ? StudioTheme.CONTROL_BORDER : StudioTheme.ACCENT);
canvas.fillRoundRect(barX, barY, Math.max(barHeight, (int) Math.round(barWidth * share)), barHeight, barHeight, barHeight);
canvas.setColor(StudioTheme.SECONDARY_TEXT);
int textWidth = canvas.getFontMetrics().stringWidth(percent);
int baseline = (getHeight() + canvas.getFontMetrics().getAscent() - canvas.getFontMetrics().getDescent()) / 2;
canvas.drawString(percent, getWidth() - padding - textWidth, baseline);
}
}
}
Loaded 100 of 225 files, more files were not shown because too many files have changed in this diff. Show more