From 29cca99947caa483c476e5ff1719dbcd4bb5bf37 Mon Sep 17 00:00:00 2001 From: piexel Date: Sun, 25 Oct 2026 15:41:45 +0100 Subject: [PATCH 1/4] Port the v3 map as iris std map - iris std map opens the v3 map window; noise, palettes, objects, procedural and image maps are views in it - Rivers view draws v4 hydrology, with an empty state when the pack has rivers off - 3D views fall back to software when LWJGL isn't on the classpath; modded jars use the game's LWJGL - FlatLaf and LWJGL join core; v4's separate studio windows are removed --- .../src/main/java/art/arcane/iris/Iris.java | 2 + .../arcane/iris/command/CommandStudio.java | 47 +- .../iris/studio/view/BukkitGuiHost.java | 48 +- .../iris/studio/view/BukkitVisionOverlay.java | 71 +- adapters/fabric/build.gradle | 6 +- adapters/forge/build.gradle | 8 +- .../iris/modded/ModdedEngineBootstrap.java | 1 + .../modded/command/ModdedStudioCommands.java | 24 +- .../modded/command/ModdedVisionOverlay.java | 38 +- adapters/neoforge/build.gradle | 6 +- core/build.gradle | 9 + .../iris/configuration/IrisSettings.java | 1 + .../iris/generation/biome/IrisBiome.java | 4 + .../biome/IrisBiomeLayerGenerator.java | 7 +- .../iris/generation/runtime/IrisComplex.java | 123 +- .../art/arcane/iris/studio/map/BakeMeter.java | 154 ++ .../art/arcane/iris/studio/map/BakeRun.java | 25 + .../arcane/iris/studio/map/BakeTiming.java | 71 + .../arcane/iris/studio/map/BakeWeight.java | 74 + .../arcane/iris/studio/map/BiomeExplorer.java | 177 ++ .../iris/studio/map/BiomeInfoNoise.java | 96 + .../arcane/iris/studio/map/BiomePalette.java | 195 ++ .../studio/map/BiomePalettePlacement.java | 220 ++ .../art/arcane/iris/studio/map/BiomeTint.java | 27 + .../iris/studio/map/BiomeTintColors.java | 68 + .../iris/studio/map/BiomeTintField.java | 138 ++ .../arcane/iris/studio/map/BlockFaces.java | 320 +++ .../studio/map/BlockHeightColorScale.java | 47 + .../arcane/iris/studio/map/BlockModel.java | 213 ++ .../iris/studio/map/BlockModelBuilder.java | 351 ++++ .../arcane/iris/studio/map/BlockNames.java | 35 + .../iris/studio/map/BlockPreviewColors.java | 74 + .../arcane/iris/studio/map/BlockTextures.java | 623 ++++++ .../iris/studio/map/ClientJarFiles.java | 82 + .../arcane/iris/studio/map/ColumnArea.java | 25 + .../art/arcane/iris/studio/map/CubeFace.java | 114 ++ .../arcane/iris/studio/map/EmptyState.java | 102 + .../iris/studio/map/EntityBlockShapes.java | 298 +++ .../iris/studio/map/FloatingPanels.java | 81 + .../iris/studio/map/FloatingSlider.java | 175 ++ .../iris/studio/map/FrameRateLabel.java | 64 + .../arcane/iris/studio/map/GlxDisplay.java | 366 ++++ .../art/arcane/iris/studio/map/GpuCache.java | 106 + .../art/arcane/iris/studio/map/GpuCanvas.java | 179 ++ .../art/arcane/iris/studio/map/GpuColors.java | 64 + .../arcane/iris/studio/map/GpuContext.java | 279 +++ .../art/arcane/iris/studio/map/GpuFaces.java | 128 ++ .../iris/studio/map/GpuFrameTarget.java | 67 + .../iris/studio/map/GpuFramebuffer.java | 73 + .../art/arcane/iris/studio/map/GpuFrames.java | 151 ++ .../arcane/iris/studio/map/GpuLibrary.java | 57 + .../arcane/iris/studio/map/GpuObjectMesh.java | 279 +++ .../iris/studio/map/GpuObjectRenderer.java | 279 +++ .../arcane/iris/studio/map/GpuProgram.java | 105 + .../iris/studio/map/GpuTerrainRenderer.java | 382 ++++ .../art/arcane/iris/studio/map/GpuTile.java | 101 + .../arcane/iris/studio/map/GpuTileCache.java | 82 + .../iris/studio/map/GpuWindowFrame.java | 96 + .../arcane/iris/studio/map/GroundSight.java | 130 ++ .../arcane/iris/studio/map/HoledLayer.java | 41 + .../ImageMapExplorer.java} | 351 ++-- .../{view => map}/ImageMapStudioModel.java | 2 +- .../ImageMapStudioPreviewPanel.java | 44 +- .../arcane/iris/studio/map/IslandOverlay.java | 105 + .../arcane/iris/studio/map/IslandPanel.java | 93 + .../iris/studio/map/LatestMeasurement.java | 92 + .../iris/studio/map/LoadingAnimation.java | 159 ++ .../art/arcane/iris/studio/map/MapCanvas.java | 1304 ++++++++++++ .../art/arcane/iris/studio/map/MapCensus.java | 183 ++ .../art/arcane/iris/studio/map/MapHost.java | 122 ++ .../arcane/iris/studio/map/MapHoverCard.java | 538 +++++ .../arcane/iris/studio/map/MapHoverProbe.java | 158 ++ .../iris/studio/map/MapLegendPanel.java | 244 +++ .../art/arcane/iris/studio/map/MapView.java | 123 ++ .../arcane/iris/studio/map/MapViewIcon.java | 54 + .../arcane/iris/studio/map/MapViewList.java | 246 +++ .../art/arcane/iris/studio/map/MapWindow.java | 650 ++++++ .../iris/studio/map/MinecraftBiomeColors.java | 162 ++ .../iris/studio/map/MinecraftBlockTints.java | 85 + .../iris/studio/map/MinecraftClientJar.java | 145 ++ .../iris/studio/map/NoiseColorScale.java | 43 + .../arcane/iris/studio/map/NoiseColumns.java | 42 + .../iris/studio/map/NoiseExplorerGUI.java | 1798 +++++++++++++++++ .../studio/{view => map}/NoisePalette.java | 42 +- .../iris/studio/map/NoisePickChart.java | 340 ++++ .../iris/studio/map/NoisePickSpread.java | 158 ++ .../{view => map}/NoiseRenderCoordinator.java | 279 ++- .../arcane/iris/studio/map/NoiseTerrain.java | 98 + .../iris/studio/map/NoiseTerrainRenderer.java | 440 ++++ .../iris/studio/map/NoiseTerrainScene.java | 139 ++ .../iris/studio/map/NoiseTerrainView.java | 216 ++ .../studio/{view => map}/NoiseViewport.java | 28 +- .../arcane/iris/studio/map/ObjectBrowser.java | 276 +++ .../iris/studio/map/ObjectExplorer.java | 566 ++++++ .../iris/studio/map/ObjectLayerCut.java | 86 + .../arcane/iris/studio/map/ObjectMesh.java | 289 +++ .../iris/studio/map/ObjectOverviewPanel.java | 368 ++++ .../iris/studio/map/ObjectRenderer.java | 305 +++ .../iris/studio/map/ObjectSeedChooser.java | 160 ++ .../arcane/iris/studio/map/ObjectSource.java | 71 + .../arcane/iris/studio/map/ObjectSummary.java | 66 + .../iris/studio/map/ObjectVariantGallery.java | 358 ++++ .../iris/studio/map/ObjectVariantPicker.java | 201 ++ .../arcane/iris/studio/map/OrbitCamera.java | 48 + .../arcane/iris/studio/map/OrbitLimits.java | 34 + .../arcane/iris/studio/map/OrbitPicture.java | 63 + .../iris/studio/map/OrbitProjection.java | 171 ++ .../arcane/iris/studio/map/OrbitScene.java | 63 + .../arcane/iris/studio/map/OrbitViewport.java | 604 ++++++ .../iris/studio/map/PackFileOpener.java | 58 + .../iris/studio/map/PackObjectSource.java | 53 + .../iris/studio/map/PaletteCatalog.java | 263 +++ .../iris/studio/map/PaletteExplorerStart.java | 26 + .../studio/map/PaletteInspectorPanel.java | 401 ++++ .../iris/studio/map/PaletteLayerPreview.java | 425 ++++ .../iris/studio/map/PaletteLayerScale.java | 107 + .../iris/studio/map/PaletteNavigator.java | 527 +++++ .../arcane/iris/studio/map/PaletteSlots.java | 57 + .../iris/studio/map/PickInspectorPanel.java | 388 ++++ .../arcane/iris/studio/map/PickMeasuring.java | 99 + .../arcane/iris/studio/map/PicturePatch.java | 31 + .../studio/map/PlayerLocationTracker.java | 141 ++ .../arcane/iris/studio/map/PlayerMarker.java | 123 ++ .../studio/map/ProceduralObjectSource.java | 171 ++ .../iris/studio/map/RegionExplorer.java | 185 ++ .../iris/studio/map/RegionNavigator.java | 173 ++ .../arcane/iris/studio/map/ResourceUsage.java | 134 ++ .../art/arcane/iris/studio/map/RiverKey.java | 79 + .../arcane/iris/studio/map/ScreenTarget.java | 30 + .../iris/studio/map/StickyFolderHeaders.java | 268 +++ .../arcane/iris/studio/map/StudioOverlay.java | 64 + .../iris/studio/map/StudioStatusBar.java | 110 + .../arcane/iris/studio/map/StudioTheme.java | 291 +++ .../arcane/iris/studio/map/StudioToolbar.java | 144 ++ .../iris/studio/map/SystemAppearance.java | 110 + .../iris/studio/map/TerrainColumns.java | 30 + .../arcane/iris/studio/map/TerrainCover.java | 297 +++ .../arcane/iris/studio/map/TerrainGrid.java | 26 + .../iris/studio/map/TerrainHeightSlider.java | 83 + .../arcane/iris/studio/map/TerrainHole.java | 109 + .../arcane/iris/studio/map/TerrainLayer.java | 33 + .../iris/studio/map/TerrainSampling.java | 43 + .../arcane/iris/studio/map/TerrainTile.java | 78 + .../arcane/iris/studio/map/TerrainTiles.java | 222 ++ .../arcane/iris/studio/map/ThemeColor.java | 55 + .../art/arcane/iris/studio/map/Transform.java | 107 + .../java/art/arcane/iris/studio/map/Vec3.java | 47 + .../iris/studio/map/ViewportWidthPanel.java | 58 + .../studio/map/VisionRenderController.java | 649 ++++++ .../studio/{view => map}/VisionViewport.java | 20 +- .../arcane/iris/studio/map/WorldColoring.java | 78 + .../arcane/iris/studio/map/WorldColumns.java | 43 + .../arcane/iris/studio/map/WorldExplorer.java | 410 ++++ .../studio/map/XwaylandWindowManagerHint.java | 86 + .../iris/studio/map/components/MapLabels.java | 80 + .../iris/studio/map/components/MapLayer.java | 57 + .../iris/studio/map/components/MapStyle.java | 54 + .../iris/studio/map/components/MapTile.java | 226 +++ .../map/components/MapTileRenderer.java | 594 ++++++ .../studio/map/components/PickDisplay.java | 29 + .../iris/studio/map/components/Renderer.java | 26 + .../studio/map/components/TileRender.java | 31 + .../iris/studio/map/components/TileType.java | 37 + .../art/arcane/iris/studio/view/GuiHost.java | 54 + .../arcane/iris/studio/view/GuiMarker.java | 32 + .../arcane/iris/studio/view/GuiOverlay.java | 20 +- .../iris/studio/view/NoiseExplorerGUI.java | 1288 ------------ .../iris/studio/view/OpenerPosition.java | 37 + .../arcane/iris/studio/view/VisionGUI.java | 1288 ------------ .../studio/view/VisionRenderController.java | 665 ------ .../iris/icons/LICENSE-APACHE-2.0.txt | 202 ++ core/src/main/resources/iris/icons/NOTICE.txt | 4 + .../main/resources/iris/icons/lightning.svg | 4 + core/src/main/resources/iris/icons/memory.svg | 7 + .../main/resources/iris/icons/profiler.svg | 5 + .../resources/iris/icons/views/biomes.svg | 7 + .../iris/icons/views/biomes_dark.svg | 7 + .../iris/icons/views/cave-biomes.svg | 4 + .../iris/icons/views/cave-biomes_dark.svg | 4 + .../resources/iris/icons/views/height.svg | 7 + .../iris/icons/views/height_dark.svg | 7 + .../resources/iris/icons/views/image-maps.svg | 5 + .../iris/icons/views/image-maps_dark.svg | 5 + .../iris/icons/views/land-biomes.svg | 6 + .../iris/icons/views/land-biomes_dark.svg | 6 + .../main/resources/iris/icons/views/noise.svg | 5 + .../resources/iris/icons/views/noise_dark.svg | 5 + .../resources/iris/icons/views/objects.svg | 7 + .../iris/icons/views/objects_dark.svg | 7 + .../resources/iris/icons/views/palettes.svg | 6 + .../iris/icons/views/palettes_dark.svg | 6 + .../resources/iris/icons/views/procedural.svg | 7 + .../iris/icons/views/procedural_dark.svg | 7 + .../resources/iris/icons/views/regions.svg | 7 + .../iris/icons/views/regions_dark.svg | 7 + .../resources/iris/icons/views/rivers.svg | 4 + .../iris/icons/views/rivers_dark.svg | 4 + .../resources/iris/icons/views/sea-biomes.svg | 7 + .../iris/icons/views/sea-biomes_dark.svg | 7 + .../resources/iris/icons/views/world-3d.svg | 4 + .../iris/icons/views/world-3d_dark.svg | 4 + .../ImageMapExplorerTest.java} | 6 +- .../ImageMapStudioModelTest.java | 2 +- .../ImageMapStudioPreviewPanelTest.java | 2 +- .../iris/studio/map/NoiseExplorerGUITest.java | 74 + .../{view => map}/NoisePaletteTest.java | 2 +- .../NoiseRenderCoordinatorTest.java | 65 +- .../{view => map}/NoiseViewportTest.java | 2 +- .../{view => map}/VisionViewportTest.java | 2 +- .../studio/view/DesktopGuiLaunchTest.java | 10 +- .../studio/view/NoiseExplorerGUITest.java | 122 -- .../view/VisionRenderControllerTest.java | 370 ---- .../studio/view/VisionRenderTypeTest.java | 40 - gradle/libs.versions.toml | 8 + 214 files changed, 27582 insertions(+), 4368 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/studio/map/BakeMeter.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/BakeRun.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/BakeTiming.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/BakeWeight.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/BiomeExplorer.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/BiomeInfoNoise.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/BiomePalette.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/BiomePalettePlacement.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/BiomeTint.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/BiomeTintColors.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/BiomeTintField.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/BlockFaces.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/BlockHeightColorScale.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/BlockModel.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/BlockModelBuilder.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/BlockNames.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/BlockPreviewColors.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/BlockTextures.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/ClientJarFiles.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/ColumnArea.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/CubeFace.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/EmptyState.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/EntityBlockShapes.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/FloatingPanels.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/FloatingSlider.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/FrameRateLabel.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/GlxDisplay.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/GpuCache.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/GpuCanvas.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/GpuColors.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/GpuContext.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/GpuFaces.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/GpuFrameTarget.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/GpuFramebuffer.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/GpuFrames.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/GpuLibrary.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/GpuObjectMesh.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/GpuObjectRenderer.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/GpuProgram.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/GpuTerrainRenderer.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/GpuTile.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/GpuTileCache.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/GpuWindowFrame.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/GroundSight.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/HoledLayer.java rename core/src/main/java/art/arcane/iris/studio/{view/ImageMapStudioGUI.java => map/ImageMapExplorer.java} (80%) rename core/src/main/java/art/arcane/iris/studio/{view => map}/ImageMapStudioModel.java (99%) rename core/src/main/java/art/arcane/iris/studio/{view => map}/ImageMapStudioPreviewPanel.java (95%) create mode 100644 core/src/main/java/art/arcane/iris/studio/map/IslandOverlay.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/IslandPanel.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/LatestMeasurement.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/LoadingAnimation.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/MapCanvas.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/MapCensus.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/MapHost.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/MapHoverCard.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/MapHoverProbe.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/MapLegendPanel.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/MapView.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/MapViewIcon.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/MapViewList.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/MapWindow.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/MinecraftBiomeColors.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/MinecraftBlockTints.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/MinecraftClientJar.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/NoiseColorScale.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/NoiseColumns.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/NoiseExplorerGUI.java rename core/src/main/java/art/arcane/iris/studio/{view => map}/NoisePalette.java (70%) create mode 100644 core/src/main/java/art/arcane/iris/studio/map/NoisePickChart.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/NoisePickSpread.java rename core/src/main/java/art/arcane/iris/studio/{view => map}/NoiseRenderCoordinator.java (58%) create mode 100644 core/src/main/java/art/arcane/iris/studio/map/NoiseTerrain.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/NoiseTerrainRenderer.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/NoiseTerrainScene.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/NoiseTerrainView.java rename core/src/main/java/art/arcane/iris/studio/{view => map}/NoiseViewport.java (62%) create mode 100644 core/src/main/java/art/arcane/iris/studio/map/ObjectBrowser.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/ObjectExplorer.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/ObjectLayerCut.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/ObjectMesh.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/ObjectOverviewPanel.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/ObjectRenderer.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/ObjectSeedChooser.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/ObjectSource.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/ObjectSummary.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/ObjectVariantGallery.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/ObjectVariantPicker.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/OrbitCamera.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/OrbitLimits.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/OrbitPicture.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/OrbitProjection.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/OrbitScene.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/OrbitViewport.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/PackFileOpener.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/PackObjectSource.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/PaletteCatalog.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/PaletteExplorerStart.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/PaletteInspectorPanel.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/PaletteLayerPreview.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/PaletteLayerScale.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/PaletteNavigator.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/PaletteSlots.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/PickInspectorPanel.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/PickMeasuring.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/PicturePatch.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/PlayerLocationTracker.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/PlayerMarker.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/ProceduralObjectSource.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/RegionExplorer.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/RegionNavigator.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/ResourceUsage.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/RiverKey.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/ScreenTarget.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/StickyFolderHeaders.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/StudioOverlay.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/StudioStatusBar.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/StudioTheme.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/StudioToolbar.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/SystemAppearance.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/TerrainColumns.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/TerrainCover.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/TerrainGrid.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/TerrainHeightSlider.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/TerrainHole.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/TerrainLayer.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/TerrainSampling.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/TerrainTile.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/TerrainTiles.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/ThemeColor.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/Transform.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/Vec3.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/ViewportWidthPanel.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/VisionRenderController.java rename core/src/main/java/art/arcane/iris/studio/{view => map}/VisionViewport.java (65%) create mode 100644 core/src/main/java/art/arcane/iris/studio/map/WorldColoring.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/WorldColumns.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/WorldExplorer.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/XwaylandWindowManagerHint.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/components/MapLabels.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/components/MapLayer.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/components/MapStyle.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/components/MapTile.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/components/MapTileRenderer.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/components/PickDisplay.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/components/Renderer.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/components/TileRender.java create mode 100644 core/src/main/java/art/arcane/iris/studio/map/components/TileType.java create mode 100644 core/src/main/java/art/arcane/iris/studio/view/GuiMarker.java delete mode 100644 core/src/main/java/art/arcane/iris/studio/view/NoiseExplorerGUI.java create mode 100644 core/src/main/java/art/arcane/iris/studio/view/OpenerPosition.java delete mode 100644 core/src/main/java/art/arcane/iris/studio/view/VisionGUI.java delete mode 100644 core/src/main/java/art/arcane/iris/studio/view/VisionRenderController.java create mode 100644 core/src/main/resources/iris/icons/LICENSE-APACHE-2.0.txt create mode 100644 core/src/main/resources/iris/icons/NOTICE.txt create mode 100644 core/src/main/resources/iris/icons/lightning.svg create mode 100644 core/src/main/resources/iris/icons/memory.svg create mode 100644 core/src/main/resources/iris/icons/profiler.svg create mode 100644 core/src/main/resources/iris/icons/views/biomes.svg create mode 100644 core/src/main/resources/iris/icons/views/biomes_dark.svg create mode 100644 core/src/main/resources/iris/icons/views/cave-biomes.svg create mode 100644 core/src/main/resources/iris/icons/views/cave-biomes_dark.svg create mode 100644 core/src/main/resources/iris/icons/views/height.svg create mode 100644 core/src/main/resources/iris/icons/views/height_dark.svg create mode 100644 core/src/main/resources/iris/icons/views/image-maps.svg create mode 100644 core/src/main/resources/iris/icons/views/image-maps_dark.svg create mode 100644 core/src/main/resources/iris/icons/views/land-biomes.svg create mode 100644 core/src/main/resources/iris/icons/views/land-biomes_dark.svg create mode 100644 core/src/main/resources/iris/icons/views/noise.svg create mode 100644 core/src/main/resources/iris/icons/views/noise_dark.svg create mode 100644 core/src/main/resources/iris/icons/views/objects.svg create mode 100644 core/src/main/resources/iris/icons/views/objects_dark.svg create mode 100644 core/src/main/resources/iris/icons/views/palettes.svg create mode 100644 core/src/main/resources/iris/icons/views/palettes_dark.svg create mode 100644 core/src/main/resources/iris/icons/views/procedural.svg create mode 100644 core/src/main/resources/iris/icons/views/procedural_dark.svg create mode 100644 core/src/main/resources/iris/icons/views/regions.svg create mode 100644 core/src/main/resources/iris/icons/views/regions_dark.svg create mode 100644 core/src/main/resources/iris/icons/views/rivers.svg create mode 100644 core/src/main/resources/iris/icons/views/rivers_dark.svg create mode 100644 core/src/main/resources/iris/icons/views/sea-biomes.svg create mode 100644 core/src/main/resources/iris/icons/views/sea-biomes_dark.svg create mode 100644 core/src/main/resources/iris/icons/views/world-3d.svg create mode 100644 core/src/main/resources/iris/icons/views/world-3d_dark.svg rename core/src/test/java/art/arcane/iris/studio/{view/ImageMapStudioGUITest.java => map/ImageMapExplorerTest.java} (77%) rename core/src/test/java/art/arcane/iris/studio/{view => map}/ImageMapStudioModelTest.java (99%) rename core/src/test/java/art/arcane/iris/studio/{view => map}/ImageMapStudioPreviewPanelTest.java (99%) create mode 100644 core/src/test/java/art/arcane/iris/studio/map/NoiseExplorerGUITest.java rename core/src/test/java/art/arcane/iris/studio/{view => map}/NoisePaletteTest.java (97%) rename core/src/test/java/art/arcane/iris/studio/{view => map}/NoiseRenderCoordinatorTest.java (85%) rename core/src/test/java/art/arcane/iris/studio/{view => map}/NoiseViewportTest.java (96%) rename core/src/test/java/art/arcane/iris/studio/{view => map}/VisionViewportTest.java (97%) delete mode 100644 core/src/test/java/art/arcane/iris/studio/view/NoiseExplorerGUITest.java delete mode 100644 core/src/test/java/art/arcane/iris/studio/view/VisionRenderControllerTest.java delete mode 100644 core/src/test/java/art/arcane/iris/studio/view/VisionRenderTypeTest.java diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index 02974e32a..1aa3dafe9 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -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) { diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandStudio.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandStudio.java index f5074e0c7..afd8ad4a8 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandStudio.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandStudio.java @@ -21,10 +21,10 @@ 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.NoiseExplorerGUI; +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; @@ -255,15 +255,19 @@ 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(); + Engine playerEngine = engine(); + Engine engine = playerEngine != null ? playerEngine : GuiHost.get().findActiveEngine(); + if (generator != null) { + sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_NOISE_EXPLORER)); + NoiseExplorerGUI.launchGeneratorKey(engine, generator.getLoadKey(), generator, seed); return; } - - String generatorKey = generator.getLoadKey(); - NoiseExplorerGUI.launchGeneratorKey(generatorKey, generator, seed); + if (engine == null) { + sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_YOU_NEED_BE_SPECIFY_IRIS_GENERATED_WORLD)); + return; + } + sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_NOISE_EXPLORER)); + MapWindow.openNoise(playerEngine != null ? mapStartIn(player().getWorld()) : mapStartWithoutWorld(engine)); } @Director(description = "Open the image-map studio (External GUI)", descriptionKey = "iris.director.commandstudio.director.open_image_map_studio_external_gui", aliases = {"imap"}) @@ -279,8 +283,7 @@ public class CommandStudio implements DirectorExecutor { return; } - Engine activeEngine = IrisToolbelt.access(world).getEngine(); - ImageMapStudioGUI.launch(activeEngine); + MapWindow.openImageMaps(mapStartIn(world)); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_IMAGE_MAP_STUDIO)); } @@ -420,11 +423,27 @@ public class CommandStudio implements DirectorExecutor { return; } - UUID openerId = sender().isPlayer() ? player().getUniqueId() : null; - VisionGUI.launch(IrisToolbelt.access(world).getEngine(), openerId); + MapWindow.openMap(mapStartIn(world)); 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) { + Location center = sender().isPlayer() && player().getWorld().equals(world) ? player().getLocation() : null; + return new PaletteExplorerStart( + IrisToolbelt.access(world).getEngine(), + world.getUID(), + sender().isPlayer() ? player().getUniqueId() : null, + null, + center == null ? 0D : center.getX(), + center == null ? 0D : center.getZ() + ); + } + + private PaletteExplorerStart mapStartWithoutWorld(Engine engine) { + return new PaletteExplorerStart(engine, null, sender().isPlayer() ? player().getUniqueId() : null, null, 0D, 0D); + } + @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) diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/studio/view/BukkitGuiHost.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/studio/view/BukkitGuiHost.java index 72c267439..b4f8bf270 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/studio/view/BukkitGuiHost.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/studio/view/BukkitGuiHost.java @@ -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); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/studio/view/BukkitVisionOverlay.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/studio/view/BukkitVisionOverlay.java index 3bcc4956c..83f52c95a 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/studio/view/BukkitVisionOverlay.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/studio/view/BukkitVisionOverlay.java @@ -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 latestTeleport = new AtomicReference<>(); - private volatile List playerMarkers = List.of(); + private volatile List 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 players() { + public List players() { queuePlayerRefresh(); return playerMarkers; } @@ -80,10 +76,10 @@ public final class BukkitVisionOverlay implements GuiOverlay { boolean scheduled = J.runGlobal(() -> { try { - List markers = new ArrayList<>(); + List markers = new ArrayList<>(); for (Player player : BukkitWorldBinding.players(engine.getWorld())) { Location at = player.getLocation(); - markers.add(WorldMarker.player(player.getName(), at.getX(), at.getZ())); + markers.add(GuiMarker.player(player.getName(), at.getX(), at.getZ(), at.getYaw())); } playerMarkers = List.copyOf(markers); } finally { @@ -97,7 +93,7 @@ public final class BukkitVisionOverlay implements GuiOverlay { } @Override - public void requestEntities(Consumer> sink) { + public void requestEntities(Consumer> sink) { J.runGlobal(() -> { IrisWorld target = engine.getWorld(); World world = BukkitWorldBinding.world(target); @@ -118,7 +114,7 @@ public final class BukkitVisionOverlay implements GuiOverlay { return; } - List collected = Collections.synchronizedList(new ArrayList<>(living.size())); + List collected = Collections.synchronizedList(new ArrayList<>(living.size())); AtomicInteger pending = new AtomicInteger(living.size()); Runnable complete = () -> { if (pending.decrementAndGet() == 0) { @@ -144,22 +140,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 +208,36 @@ public final class BukkitVisionOverlay implements GuiOverlay { } } + @Override + public void requestOpenerPosition(Consumer sink) { + if (openerId == null) { + sink.accept(OpenerPosition.OFFLINE); + return; + } + boolean scheduled = J.runGlobal(() -> { + Player player = Bukkit.getPlayer(openerId); + if (player == null) { + sink.accept(OpenerPosition.OFFLINE); + return; + } + World world = BukkitWorldBinding.world(engine.getWorld()); + if (!J.runEntity(player, () -> sink.accept(positionOf(player, world)))) { + sink.accept(OpenerPosition.OFFLINE); + } + }); + if (!scheduled) { + sink.accept(OpenerPosition.OFFLINE); + } + } + + private static OpenerPosition positionOf(Player player, World world) { + 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 players) { if (openerId == null) { return players.isEmpty() ? null : players.get(0); @@ -316,21 +342,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; diff --git a/adapters/fabric/build.gradle b/adapters/fabric/build.gradle index 648adc762..2af0dc441 100644 --- a/adapters/fabric/build.gradle +++ b/adapters/fabric/build.gradle @@ -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 -> diff --git a/adapters/forge/build.gradle b/adapters/forge/build.gradle index d69d925bb..8ed0469bb 100644 --- a/adapters/forge/build.gradle +++ b/adapters/forge/build.gradle @@ -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 -> diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java index 829ef890c..a1dfeb7b7 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java @@ -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); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java index 2a1ae18b6..a1de3b2b4 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java @@ -23,10 +23,10 @@ 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.NoiseExplorerGUI; +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; @@ -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())); 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); + NoiseExplorerGUI.launchGeneratorKey(engine, 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,16 @@ 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) { + return new PaletteExplorerStart(engine, null, player == null ? null : player.id(), null, 0D, 0D); + } + private static int imageMap(NativeCommandSource source) { NativeWorld level = source.world(); Engine engine = IrisModdedCommands.engineFor(level); @@ -294,13 +300,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()) diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java index 64460392a..c72436aa2 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java @@ -18,17 +18,12 @@ 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.generation.runtime.Engine; -import art.arcane.iris.studio.render.RenderType; import art.arcane.iris.modded.ModdedIrisLog; import art.arcane.iris.modded.ModdedTeleportDeadline; -import java.awt.Desktop; -import java.io.File; import java.util.List; import java.util.Optional; import art.arcane.volmlib.nativelib.view.WorldView; @@ -46,14 +41,20 @@ 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 players() { - return world.players(); + public List players() { + return world.players().stream() + .map(player -> GuiMarker.player(player.label(), player.worldX(), player.worldZ(), 0F)) + .toList(); } @Override - public void requestEntities(Consumer> sink) { - world.requestEntities(sink); + public void requestEntities(Consumer> 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 @@ -80,23 +81,6 @@ public final class ModdedVisionOverlay implements GuiOverlay { }); } - @Override - public String openInEditor(double worldX, double worldZ, RenderType type) { - if (!GuiHost.isAvailable() || !Desktop.isDesktopSupported()) { - return null; - } - 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(); - } public record Context(WorldView world, Engine engine, UUID opener) { } diff --git a/adapters/neoforge/build.gradle b/adapters/neoforge/build.gradle index 2314edb3a..fcff7be6f 100644 --- a/adapters/neoforge/build.gradle +++ b/adapters/neoforge/build.gradle @@ -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') { diff --git a/core/build.gradle b/core/build.gradle index e18d4d249..1970cc8df 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -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('org.mockito:mockito-core:5.23.0') diff --git a/core/src/main/java/art/arcane/iris/configuration/IrisSettings.java b/core/src/main/java/art/arcane/iris/configuration/IrisSettings.java index bdf114107..78d41ba1e 100644 --- a/core/src/main/java/art/arcane/iris/configuration/IrisSettings.java +++ b/core/src/main/java/art/arcane/iris/configuration/IrisSettings.java @@ -373,6 +373,7 @@ public class IrisSettings { public boolean useServerLaunchedGuis = true; public boolean maximumPregenGuiFPS = false; public boolean colorMode = true; + public String theme = "auto"; } @Data diff --git a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiome.java b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiome.java index b1e0cd05e..783358a27 100644 --- a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiome.java +++ b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiome.java @@ -580,6 +580,10 @@ public class IrisBiome extends IrisRegistrant implements Rarity { return IrisBiomeLayerGenerator.getLayerSeaHeightGenerators(this, rng, data); } + public KList 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); } diff --git a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java index 85c0fa754..65308ec43 100644 --- a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java +++ b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java @@ -148,8 +148,7 @@ final class IrisBiomeLayerGenerator { return data; } - KList heightGenerators = getHeightGenerators( - biome.getLayerCeilingHeightGenerators(), ceilingLayers, 7235, random, rdata); + KList 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 getCaveCeilingLayerHeightGenerators(IrisBiome biome, RNG rng, IrisData data) { + return getHeightGenerators(biome.getLayerCeilingHeightGenerators(), biome.getCaveCeilingLayers(), 7235, rng, data); + } + private static KList getHeightGenerators(IrisBiomeLayerHeightCache cache, KList layers, int seedOffset, RNG rng, IrisData data) { diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java index 587b24daf..11361616f 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java @@ -1176,33 +1176,118 @@ public class IrisComplex implements DataProvider { InferredType inferredType, IrisBiome emptyBiome ) { + ProceduralStream picked = new RarityStream<>(biomePickNoiseStream(engine.getDimension(), region, inferredType), + loadInferredBiomes(biomeKeys(region, inferredType), inferredType)); + return inferredType == InferredType.CAVE ? picked.onNull(emptyBiome) : picked; + } + + private ProceduralStream 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 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 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 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 noisePickedRegions() { + KList 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 static boolean hydrologyActive(IrisHydrology hydrology) { if (hydrology == null) { return false; diff --git a/core/src/main/java/art/arcane/iris/studio/map/BakeMeter.java b/core/src/main/java/art/arcane/iris/studio/map/BakeMeter.java new file mode 100644 index 000000000..9e6c53c01 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/BakeMeter.java @@ -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 . + */ + +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; + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/BakeRun.java b/core/src/main/java/art/arcane/iris/studio/map/BakeRun.java new file mode 100644 index 000000000..33c9de8d9 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/BakeRun.java @@ -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 . + */ + +package art.arcane.iris.studio.map; + +/** + * How long one bake of a procedural object's variants took. + */ +record BakeRun(long totalNanos, int variantCount) { +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/BakeTiming.java b/core/src/main/java/art/arcane/iris/studio/map/BakeTiming.java new file mode 100644 index 000000000..5713c3635 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/BakeTiming.java @@ -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 . + */ + +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 bake, BooleanSupplier stillWanted) { + List 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 "" + variants + " in " + formatMillis(totalMillis()) + " · median of " + RUN_COUNT + " runs
" + + "Paid once per pack load, by the first chunk placing it
" + + BakeWeight.scale() + ""; + } + + 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); + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/BakeWeight.java b/core/src/main/java/art/arcane/iris/studio/map/BakeWeight.java new file mode 100644 index 000000000..99fb999e2 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/BakeWeight.java @@ -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 . + */ + +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; + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/BiomeExplorer.java b/core/src/main/java/art/arcane/iris/studio/map/BiomeExplorer.java new file mode 100644 index 000000000..bbdd6568c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/BiomeExplorer.java @@ -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 . + */ + +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 engineSource; + private final PickInspectorPanel inspector; + private final LatestMeasurement 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 engineSource, Consumer 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 sections = new ArrayList<>(); + for (RegionPicks region : measurement.regions()) { + List 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 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 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 spreads; + private final Map> 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 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 spreads) { + } + + private record Measurement(List regions) { + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/BiomeInfoNoise.java b/core/src/main/java/art/arcane/iris/studio/map/BiomeInfoNoise.java new file mode 100644 index 000000000..98188adfc --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/BiomeInfoNoise.java @@ -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 . + */ + + +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; + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/BiomePalette.java b/core/src/main/java/art/arcane/iris/studio/map/BiomePalette.java new file mode 100644 index 000000000..29b09ea4b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/BiomePalette.java @@ -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 . + */ + +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 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> blockListsOf(IrisBiome biome) { + if (this != DECORATOR) { + return layersOf(biome).stream().map(IrisBiomePaletteLayer::getPalette).toList(); + } + return biome.getDecorators().stream() + .map(decorator -> decorator == null ? new KList() : 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 allOf(String biomeKey, IrisBiome biome) { + List palettes = new ArrayList<>(); + for (Kind kind : Kind.values()) { + List> 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 blocksIn(IrisBiome biome) { + List> 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); + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/BiomePalettePlacement.java b/core/src/main/java/art/arcane/iris/studio/map/BiomePalettePlacement.java new file mode 100644 index 000000000..51b339b15 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/BiomePalettePlacement.java @@ -0,0 +1,220 @@ +/* + * 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 . + */ + +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(); + IrisBiomePaletteLayer layer = palette.layerIn(biome); + return switch (palette.kind()) { + case LAYER -> new StackedLayer(layer, index, biome.getLayerHeightGenerators(terrainRandom, data), terrainRandom, data); + case SEA_LAYER -> new StackedLayer(layer, index, biome.getLayerSeaHeightGenerators(terrainRandom, data), terrainRandom, data); + case CAVE_CEILING_LAYER -> new StackedLayer(layer, index, biome.getCaveCeilingLayerHeightGenerators(carveRandom, data), + carveRandom, data); + case WALL -> new SingleBlocks(layer, data, carveRandom, seaLevel); + case SLAB -> new SingleBlocks(layer, 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 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 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 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 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); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/BiomeTint.java b/core/src/main/java/art/arcane/iris/studio/map/BiomeTint.java new file mode 100644 index 000000000..aa96ee848 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/BiomeTint.java @@ -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 . + */ + + +package art.arcane.iris.studio.map; + +enum BiomeTint { + GRASS, + FOLIAGE, + DRY_FOLIAGE, + WATER +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/BiomeTintColors.java b/core/src/main/java/art/arcane/iris/studio/map/BiomeTintColors.java new file mode 100644 index 000000000..97077fa1a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/BiomeTintColors.java @@ -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 . + */ + + +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; + }; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/BiomeTintField.java b/core/src/main/java/art/arcane/iris/studio/map/BiomeTintField.java new file mode 100644 index 000000000..3a20b60fa --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/BiomeTintField.java @@ -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 . + */ + + +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 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 variants = biome.getCustomDerivitives(); + return variants.size() == 1 ? customColors(variants.get(0)) : null; + } + KList skyBiomes = biome.getBiomeSkyScatter(); + if (!skyBiomes.isEmpty()) { + return skyBiomes.size() == 1 ? vanillaColors(skyBiomes.get(0)) : null; + } + KList 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) { + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/BlockFaces.java b/core/src/main/java/art/arcane/iris/studio/map/BlockFaces.java new file mode 100644 index 000000000..abc7dfa84 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/BlockFaces.java @@ -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 . + */ + +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. + *

+ * 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. + *

+ * 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 + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/BlockHeightColorScale.java b/core/src/main/java/art/arcane/iris/studio/map/BlockHeightColorScale.java new file mode 100644 index 000000000..14c2b722a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/BlockHeightColorScale.java @@ -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 . + */ + +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"; + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/BlockModel.java b/core/src/main/java/art/arcane/iris/studio/map/BlockModel.java new file mode 100644 index 000000000..a95bf0cf9 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/BlockModel.java @@ -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 . + */ + + +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 faces, Set 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 faces) { + Set 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 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 change) { + Map changed = new IdentityHashMap<>(); + List 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 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; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/BlockModelBuilder.java b/core/src/main/java/art/arcane/iris/studio/map/BlockModelBuilder.java new file mode 100644 index 000000000..2990b72ca --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/BlockModelBuilder.java @@ -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 . + */ + + +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 build(String blockState) { + try { + BlockStateId state = BlockStateId.parse(blockState); + List placements = placementsOf(state); + if (placements == null) { + return Optional.empty(); + } + List 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 box(Vec3 fromPixels, Vec3 toPixels, BlockTexture texture, boolean culledBySides) { + List 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 placementsOf(BlockStateId state) throws IOException { + JsonObject definition = textures.readJson(BlockTextures.assetPath(state.namespace() + ":" + state.name(), "blockstates", ".json")); + if (definition == null) { + return null; + } + List placements = new ArrayList<>(); + if (definition.has("variants")) { + for (Map.Entry 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 placements, JsonElement models) { + JsonObject first = BlockTextures.firstModel(models); + if (first != null && first.has("model")) { + placements.add(first); + } + } + + private List 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 faces = new ArrayList<>(); + for (JsonElement element : model.elements()) { + addElementFaces(faces, state, model, element.getAsJsonObject(), rotation); + } + return faces; + } + + private void addElementFaces(List 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 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 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); + }; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/BlockNames.java b/core/src/main/java/art/arcane/iris/studio/map/BlockNames.java new file mode 100644 index 000000000..3537015da --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/BlockNames.java @@ -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 . + */ + +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); + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/BlockPreviewColors.java b/core/src/main/java/art/arcane/iris/studio/map/BlockPreviewColors.java new file mode 100644 index 000000000..de1b4e52f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/BlockPreviewColors.java @@ -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 . + */ + +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 colorsByBlock(List blocks) { + Map colors = new HashMap<>(); + List 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 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; + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/BlockTextures.java b/core/src/main/java/art/arcane/iris/studio/map/BlockTextures.java new file mode 100644 index 000000000..e1827820a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/BlockTextures.java @@ -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 . + */ + +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 shared; + + private final ZipFile clientJar; + private final MinecraftBiomeColors biomeColors; + private final Map> resolvedTextures = new ConcurrentHashMap<>(); + private final Map> resolvedModels = new ConcurrentHashMap<>(); + private final Map> faceTextures = new ConcurrentHashMap<>(); + private final BlockModelBuilder modelBuilder = new BlockModelBuilder(this); + + private BlockTextures(ZipFile clientJar, MinecraftBiomeColors biomeColors) { + this.clientJar = clientJar; + this.biomeColors = biomeColors; + } + + static CompletableFuture 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 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 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 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 model(NativeBlockState block) { + return resolvedModels.computeIfAbsent(block.key(), modelBuilder::build); + } + + private static Optional 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 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 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 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 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 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 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 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 spriteLayers(JsonArray elements, Map textures) { + Set layers = new LinkedHashSet<>(); + for (JsonElement element : elements) { + JsonObject faces = element.getAsJsonObject().getAsJsonObject("faces"); + if (faces == null) { + continue; + } + for (Map.Entry 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 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 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 properties) { + if (definition.has("variants")) { + for (Map.Entry 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 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 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 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 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 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 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 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 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; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/ClientJarFiles.java b/core/src/main/java/art/arcane/iris/studio/map/ClientJarFiles.java new file mode 100644 index 000000000..d458c5804 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/ClientJarFiles.java @@ -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 . + */ + + +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; + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/ColumnArea.java b/core/src/main/java/art/arcane/iris/studio/map/ColumnArea.java new file mode 100644 index 000000000..5cb250e38 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/ColumnArea.java @@ -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 . + */ + +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) { +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/CubeFace.java b/core/src/main/java/art/arcane/iris/studio/map/CubeFace.java new file mode 100644 index 000000000..e864dbfd4 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/CubeFace.java @@ -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 . + */ + + +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; + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/EmptyState.java b/core/src/main/java/art/arcane/iris/studio/map/EmptyState.java new file mode 100644 index 000000000..65391817e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/EmptyState.java @@ -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 . + */ + +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(); + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/EntityBlockShapes.java b/core/src/main/java/art/arcane/iris/studio/map/EntityBlockShapes.java new file mode 100644 index 000000000..893b70e2f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/EntityBlockShapes.java @@ -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 . + */ + + +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 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 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 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 facesAddedTo(BlockStateId state) { + if (!state.name().equals("bell")) { + return List.of(); + } + Optional texture = textures.entityTexture("entity/bell/bell_body", MinecraftBlockTints.NO_TINT); + if (texture.isEmpty()) { + return List.of(); + } + List 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 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 decoratedPot(BlockStateId state, ModelDefinition model) { + List 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 chest(BlockStateId state) { + String type = state.properties().getOrDefault("type", "single"); + String suffix = type.equals("single") ? "" : "_" + type; + Optional 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 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 head(BlockStateId state, ModelDefinition model) { + boolean onWall = state.name().contains("_wall_"); + String kind = state.name().replace("_wall_", "_"); + Optional look = HeadLook.of(kind); + Optional 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 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 frame = textures.entityTexture("entity/banner/banner_base", MinecraftBlockTints.NO_TINT); + Optional 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 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 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 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 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; + }); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/FloatingPanels.java b/core/src/main/java/art/arcane/iris/studio/map/FloatingPanels.java new file mode 100644 index 000000000..c3e293e1a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/FloatingPanels.java @@ -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 . + */ + +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 seeThroughParts(); + } + + /** + * @return where what the window shows has to show through the panels over it, in the window's coordinates + */ + static List seeThroughOver(Component window) { + List 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 parts) { + List 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); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/FloatingSlider.java b/core/src/main/java/art/arcane/iris/studio/map/FloatingSlider.java new file mode 100644 index 000000000..484628fd6 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/FloatingSlider.java @@ -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 . + */ + +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(); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/FrameRateLabel.java b/core/src/main/java/art/arcane/iris/studio/map/FrameRateLabel.java new file mode 100644 index 000000000..5eca6f1b4 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/FrameRateLabel.java @@ -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 . + */ + +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()); + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/GlxDisplay.java b/core/src/main/java/art/arcane/iris/studio/map/GlxDisplay.java new file mode 100644 index 000000000..227df5730 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/GlxDisplay.java @@ -0,0 +1,366 @@ +/* + * 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 . + */ + +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.*; +import static org.lwjgl.opengl.GLXARBCreateContext.*; +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 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 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 watchedDisplays = ConcurrentHashMap.newKeySet(); + private static final Set 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); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/GpuCache.java b/core/src/main/java/art/arcane/iris/studio/map/GpuCache.java new file mode 100644 index 000000000..10ab045f9 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/GpuCache.java @@ -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 . + */ + +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 { + private final LinkedHashMap leastRecentlyUsedFirst = new LinkedHashMap<>(16, 0.75F, true); + private final ReferenceQueue 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 kept) { + long bytes = 0L; + for (U upload : leastRecentlyUsedFirst.values()) { + bytes += upload.bytes(); + } + Iterator> oldestFirst = leastRecentlyUsedFirst.entrySet().iterator(); + while (bytes > mostBytes && oldestFirst.hasNext()) { + Map.Entry 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 { + private final int identityHash; + + private Source(Object source, ReferenceQueue 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(); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/GpuCanvas.java b/core/src/main/java/art/arcane/iris/studio/map/GpuCanvas.java new file mode 100644 index 000000000..474c56a0d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/GpuCanvas.java @@ -0,0 +1,179 @@ +/* + * 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 . + */ + +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.*; + +/** + * 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)); + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/GpuColors.java b/core/src/main/java/art/arcane/iris/studio/map/GpuColors.java new file mode 100644 index 000000000..eb60ac855 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/GpuColors.java @@ -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 . + */ + +package art.arcane.iris.studio.map; + +import static org.lwjgl.opengl.GL12.*; + +/** + * 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); + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/GpuContext.java b/core/src/main/java/art/arcane/iris/studio/map/GpuContext.java new file mode 100644 index 000000000..80fe41d71 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/GpuContext.java @@ -0,0 +1,279 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +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.*; +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 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 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 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 call(Callable 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; + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/GpuFaces.java b/core/src/main/java/art/arcane/iris/studio/map/GpuFaces.java new file mode 100644 index 000000000..009a62da4 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/GpuFaces.java @@ -0,0 +1,128 @@ +/* + * 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 . + */ + +package art.arcane.iris.studio.map; + +import org.lwjgl.system.MemoryUtil; + +import java.nio.IntBuffer; + +import static org.lwjgl.opengl.GL33.*; + +/** + * 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); + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/GpuFrameTarget.java b/core/src/main/java/art/arcane/iris/studio/map/GpuFrameTarget.java new file mode 100644 index 000000000..b8320e513 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/GpuFrameTarget.java @@ -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 . + */ + +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; + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/GpuFramebuffer.java b/core/src/main/java/art/arcane/iris/studio/map/GpuFramebuffer.java new file mode 100644 index 000000000..1133c83ab --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/GpuFramebuffer.java @@ -0,0 +1,73 @@ +/* + * 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 . + */ + +package art.arcane.iris.studio.map; + +import java.awt.image.BufferedImage; +import java.awt.image.DataBufferInt; + +import static org.lwjgl.opengl.GL30.*; + +/** + * 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)); + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/GpuFrames.java b/core/src/main/java/art/arcane/iris/studio/map/GpuFrames.java new file mode 100644 index 000000000..acc472b47 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/GpuFrames.java @@ -0,0 +1,151 @@ +/* + * 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 . + */ + +package art.arcane.iris.studio.map; + +import art.arcane.iris.spi.IrisLogging; + +import java.util.Optional; + +import static org.lwjgl.opengl.GL33.*; + +/** + * 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 drawInto(GpuFrameTarget target); + } + + /** + * @return the picture, or nothing when the caller should draw it without a card + */ + static Optional 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 drawOnScreen(ScreenTarget screen, Drawing drawing) { + return onCard(gpu -> { + Optional 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(windows.get(), window, screen.seeThrough())); + } catch (RuntimeException failure) { + gpu.stopDrawingIntoWindows(failure); + return Optional.empty(); + } + }); + } + + 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 Optional onCard(CardWork work) { + if (failed) { + return Optional.empty(); + } + Optional 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 { + Optional drawOn(GpuContext gpu); + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/GpuLibrary.java b/core/src/main/java/art/arcane/iris/studio/map/GpuLibrary.java new file mode 100644 index 000000000..dc4c79236 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/GpuLibrary.java @@ -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 . + */ + +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 is on the classpath. 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 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; + } + } + return true; + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/GpuObjectMesh.java b/core/src/main/java/art/arcane/iris/studio/map/GpuObjectMesh.java new file mode 100644 index 000000000..2d3c29bb9 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/GpuObjectMesh.java @@ -0,0 +1,279 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +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.*; + +/** + * 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 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 offsets) { + int texelCount = 0; + for (List 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 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 textureOffsets) { + FloatBuffer packed = MemoryUtil.memAllocFloat(Math.max(1, quadCount()) * FLOATS_PER_QUAD); + try { + for (List 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 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}); + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/GpuObjectRenderer.java b/core/src/main/java/art/arcane/iris/studio/map/GpuObjectRenderer.java new file mode 100644 index 000000000..cafcb821e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/GpuObjectRenderer.java @@ -0,0 +1,279 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.studio.map; + +import java.util.Optional; +import java.util.Set; + +import static org.lwjgl.opengl.GL33.*; + +/** + * 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 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 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 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 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); + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/GpuProgram.java b/core/src/main/java/art/arcane/iris/studio/map/GpuProgram.java new file mode 100644 index 000000000..160d85636 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/GpuProgram.java @@ -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 . + */ + +package art.arcane.iris.studio.map; + +import java.util.HashMap; +import java.util.Map; + +import static org.lwjgl.opengl.GL20.*; + +/** + * 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 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)); + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/GpuTerrainRenderer.java b/core/src/main/java/art/arcane/iris/studio/map/GpuTerrainRenderer.java new file mode 100644 index 000000000..21686d9a7 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/GpuTerrainRenderer.java @@ -0,0 +1,382 @@ +/* + * 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 . + */ + +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.*; + +/** + * 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 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 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 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 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 layers = layersOnCard(scene.layers()); + Set 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 layers) { + long deadline = System.nanoTime() + UPLOAD_NANOS_PER_FRAME; + List 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 layersOnCard(List layers) { + List 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 layers, boolean complete) { + } + + private enum UploadProgress { + COMPLETE, + CATCHING_UP, + TOO_LARGE + } + + private record UploadedLayer(NoiseTerrain terrain, GpuFaces faces, GpuColors colors, TerrainHole hole) { + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/GpuTile.java b/core/src/main/java/art/arcane/iris/studio/map/GpuTile.java new file mode 100644 index 000000000..e0dad7359 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/GpuTile.java @@ -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 . + */ + +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 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(); + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/GpuTileCache.java b/core/src/main/java/art/arcane/iris/studio/map/GpuTileCache.java new file mode 100644 index 000000000..63698660f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/GpuTileCache.java @@ -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 . + */ + +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 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 shown) { + long bytes = 0L; + Iterator> oldestFirst = leastRecentlyUsedFirst.entrySet().iterator(); + while (oldestFirst.hasNext()) { + Map.Entry 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 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()); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/GpuWindowFrame.java b/core/src/main/java/art/arcane/iris/studio/map/GpuWindowFrame.java new file mode 100644 index 000000000..530b01c32 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/GpuWindowFrame.java @@ -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 . + */ + +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.*; + +/** + * 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. Only for the GPU thread. + */ +final class GpuWindowFrame implements GpuFrameTarget { + private static final int WINDOW_FRAMEBUFFER = 0; + + private final GlxDisplay windows; + private final long window; + private final List 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(GlxDisplay windows, long window, List seeThrough) { + this.windows = windows; + this.window = window; + 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) { + OrbitPicture picture = OrbitPicture.onScreen(width, height, readPatches(), + GpuFrameTarget.depthInMiddle(projection, depthRange, width, height)); + if (!windows.show(window)) { + throw new IllegalStateException("The window didn't take the frame"); + } + return picture; + } + + private List readPatches() { + Rectangle frame = new Rectangle(0, 0, width, height); + List 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); + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/GroundSight.java b/core/src/main/java/art/arcane/iris/studio/map/GroundSight.java new file mode 100644 index 000000000..cc5bbff93 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/GroundSight.java @@ -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 . + */ + +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 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 groundInView(int most, DoubleBinaryOperator surfaceY) { + Reach reach = reach(); + double spacing = gridSpacing(reach, most); + List 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) { + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/HoledLayer.java b/core/src/main/java/art/arcane/iris/studio/map/HoledLayer.java new file mode 100644 index 000000000..351d3f59e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/HoledLayer.java @@ -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 . + */ + +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 fillers) { + TerrainHole holeAmong(Predicate drawable) { + return TerrainHole.of(tile, fillers.stream().filter(drawable).toList()); + } + + TerrainHole holeOfAllFillers() { + return TerrainHole.of(tile, fillers); + } + + NoiseTerrain terrain() { + return layer.terrain(); + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/view/ImageMapStudioGUI.java b/core/src/main/java/art/arcane/iris/studio/map/ImageMapExplorer.java similarity index 80% rename from core/src/main/java/art/arcane/iris/studio/view/ImageMapStudioGUI.java rename to core/src/main/java/art/arcane/iris/studio/map/ImageMapExplorer.java index 2eb2648df..f6be87580 100644 --- a/core/src/main/java/art/arcane/iris/studio/view/ImageMapStudioGUI.java +++ b/core/src/main/java/art/arcane/iris/studio/map/ImageMapExplorer.java @@ -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 controls = new ArrayList<>(); + private final JSplitPane view; private final JComboBox 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 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 JComboBox combo(T[] values) { JComboBox 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) { diff --git a/core/src/main/java/art/arcane/iris/studio/view/ImageMapStudioModel.java b/core/src/main/java/art/arcane/iris/studio/map/ImageMapStudioModel.java similarity index 99% rename from core/src/main/java/art/arcane/iris/studio/view/ImageMapStudioModel.java rename to core/src/main/java/art/arcane/iris/studio/map/ImageMapStudioModel.java index 45abc7418..d581de6ec 100644 --- a/core/src/main/java/art/arcane/iris/studio/view/ImageMapStudioModel.java +++ b/core/src/main/java/art/arcane/iris/studio/map/ImageMapStudioModel.java @@ -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; diff --git a/core/src/main/java/art/arcane/iris/studio/view/ImageMapStudioPreviewPanel.java b/core/src/main/java/art/arcane/iris/studio/map/ImageMapStudioPreviewPanel.java similarity index 95% rename from core/src/main/java/art/arcane/iris/studio/view/ImageMapStudioPreviewPanel.java rename to core/src/main/java/art/arcane/iris/studio/map/ImageMapStudioPreviewPanel.java index bbf0e1aea..fc9746532 100644 --- a/core/src/main/java/art/arcane/iris/studio/view/ImageMapStudioPreviewPanel.java +++ b/core/src/main/java/art/arcane/iris/studio/map/ImageMapStudioPreviewPanel.java @@ -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); diff --git a/core/src/main/java/art/arcane/iris/studio/map/IslandOverlay.java b/core/src/main/java/art/arcane/iris/studio/map/IslandOverlay.java new file mode 100644 index 000000000..f3edf1e66 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/IslandOverlay.java @@ -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 . + */ + + +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); + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/IslandPanel.java b/core/src/main/java/art/arcane/iris/studio/map/IslandPanel.java new file mode 100644 index 000000000..0a92f0826 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/IslandPanel.java @@ -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 . + */ + + +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 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); + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/LatestMeasurement.java b/core/src/main/java/art/arcane/iris/studio/map/LatestMeasurement.java new file mode 100644 index 000000000..6dbffa27b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/LatestMeasurement.java @@ -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 . + */ + +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 { + 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 measurement, Consumer 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 measurement, Consumer 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 measureSafely(Supplier measurement) { + try { + return measurement.get(); + } catch (Throwable error) { + IrisLogging.reportError(error); + return null; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/LoadingAnimation.java b/core/src/main/java/art/arcane/iris/studio/map/LoadingAnimation.java new file mode 100644 index 000000000..5367a3611 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/LoadingAnimation.java @@ -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 . + */ +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)); + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/MapCanvas.java b/core/src/main/java/art/arcane/iris/studio/map/MapCanvas.java new file mode 100644 index 000000000..456521dda --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/MapCanvas.java @@ -0,0 +1,1304 @@ +/* + * 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 . + */ + +package art.arcane.iris.studio.map; + +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.studio.map.components.MapTileRenderer; +import art.arcane.iris.studio.map.components.MapLabels; +import art.arcane.iris.studio.map.components.MapLayer; +import art.arcane.iris.studio.map.components.MapStyle; +import art.arcane.iris.studio.map.components.PickDisplay; +import art.arcane.iris.studio.map.components.TileType; +import art.arcane.iris.studio.view.GuiMarker; +import art.arcane.iris.studio.view.GuiOverlay; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.volmlib.util.format.Form; + +import javax.swing.JMenuItem; +import javax.swing.JPanel; +import javax.swing.JPopupMenu; +import javax.swing.SwingUtilities; +import javax.swing.Timer; +import java.awt.Color; +import java.awt.EventQueue; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Point; +import java.awt.RenderingHints; +import java.awt.Toolkit; +import java.awt.datatransfer.StringSelection; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.event.MouseWheelEvent; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.function.Consumer; + +/** + * The tile map of the map window: draws the chosen view of the world and lets you drag, zoom and inspect it. + */ +final class MapCanvas extends JPanel { + private static final long serialVersionUID = 2094606939770332040L; + private static final Color MOB_COLOR = StudioTheme.ERROR; + private static final Color GRID_MINOR = new Color(255, 255, 255, 18); + private static final Color GRID_MAJOR = new Color(255, 255, 255, 38); + private static final Color TILE_EMPTY_A = new ThemeColor(0xF0F1F3, 0x232427); + private static final Color TILE_EMPTY_B = new ThemeColor(0xEBECEF, 0x26272B); + private static final int CARD_PADDING = 12; + private static final int RIVER_KEY_SWATCH = 12; + private static final int RIVER_KEY_SWATCH_GAP = 8; + private static final int EMPTY_ICON_SIZE = 40; + private static final int NOTIFICATION_MILLIS = 2_500; + private static final double DEFAULT_BLOCKS_PER_PIXEL = 4D; + private static final double WHEEL_ZOOM_EXPONENT = 0.08D; + private static final double KEYBOARD_ZOOM_FACTOR = 1.189207115002721D; + private static final int LEGEND_REFRESH_MILLIS = 200; + + private final UUID worldId; + private final UUID openerId; + private final Listener listener; + private final VisionRenderController controller; + private final StudioStatusBar statusBar = new StudioStatusBar(); + private final Runnable hotloadHook; + private final Timer hoverTimer; + private final Timer markerTimer; + private final Timer notificationTimer; + private final Timer legendTimer; + private final Map notifications = new LinkedHashMap<>(); + + private Engine engine; + private MapTileRenderer renderer; + private GuiOverlay overlay; + private VisionRenderController.Frame renderFrame; + private BufferedImage tileLayer; + private boolean tileLayerStale = true; + private MapStyle style = MapStyle.PLAIN; + private TileType currentType = TileType.BIOME; + private String previewRegionKey; + private PickDisplay pickDisplay = PickDisplay.PICKS; + private String viewLabel = MapView.BIOMES.label(); + private List players = List.of(); + private List entities = List.of(); + private QuickHover quickHover; + private MapHoverProbe.Details hoverDetails; + private Point cursor; + private Point dragOrigin; + private double centerX; + private double centerZ; + private long contentRevision = 1L; + private long previousPaintStartedNanos; + private double blocksPerPixel = DEFAULT_BLOCKS_PER_PIXEL; + private int paintCadenceFps; + private boolean grid; + private boolean entitiesVisible; + private boolean follow; + private boolean help; + private int coveredLeftWidth; + private boolean debug; + private boolean detailedHover; + private boolean active = true; + private boolean closed; + + MapCanvas(Engine engine, UUID worldId, UUID openerId, Listener listener) { + this.engine = Objects.requireNonNull(engine, "engine"); + this.worldId = worldId; + this.openerId = openerId; + this.listener = Objects.requireNonNull(listener, "listener"); + this.renderer = new MapTileRenderer(engine); + this.overlay = MapHost.overlayFor(engine, openerId); + this.controller = new VisionRenderController(this::onTilesChanged); + this.hotloadHook = () -> EventQueue.invokeLater(this::refreshContent); + + setBackground(StudioTheme.EDITOR_BACKGROUND); + setFocusable(true); + installMouseHandlers(); + addComponentListener(new ComponentAdapter() { + @Override + public void componentResized(ComponentEvent event) { + requestRender(); + } + }); + + this.hoverTimer = singleShotTimer(90, this::requestHover); + this.legendTimer = singleShotTimer(LEGEND_REFRESH_MILLIS, this::publishLegend); + this.markerTimer = new Timer(650, event -> refreshMarkers()); + this.markerTimer.setCoalesce(true); + this.notificationTimer = new Timer(200, event -> expireNotifications()); + this.notificationTimer.setCoalesce(true); + MapHost.registerHotloadHook(hotloadHook); + markerTimer.start(); + notificationTimer.start(); + } + + StudioStatusBar statusBar() { + return statusBar; + } + + Engine engine() { + return engine; + } + + double centerX() { + return centerX; + } + + double centerZ() { + return centerZ; + } + + boolean isGridVisible() { + return grid; + } + + boolean areEntitiesVisible() { + return entitiesVisible; + } + + boolean isFollowing() { + return follow; + } + + MapStyle style() { + return style; + } + + void start() { + refreshMarkers(); + requestRender(); + } + + void centerOn(double worldX, double worldZ) { + centerX = worldX; + centerZ = worldZ; + requestRender(); + } + + void showView(MapView view) { + if (!view.isTileMap()) { + return; + } + viewLabel = view.label(); + TileType nextType = view == MapView.REGIONS && previewRegionKey != null ? TileType.REGION_PREVIEW : view.renderType(); + if (nextType == currentType) { + return; + } + currentType = nextType; + if (currentType != TileType.HEIGHT) { + style = style.withHeightRange(null); + } + requestRender(); + } + + /** + * Shows the given region's biomes everywhere in the region view, as if every spot belonged to it; null shows the + * regions themselves again. + */ + void previewRegion(String regionKey) { + if (Objects.equals(regionKey, previewRegionKey)) { + return; + } + previewRegionKey = regionKey; + if (currentType.isRegionView()) { + currentType = regionKey == null ? TileType.REGION : TileType.REGION_PREVIEW; + requestRender(); + } + } + + PickDisplay pickDisplay() { + return pickDisplay; + } + + /** + * Draws the region or biome view's picks, the noise that picks them, or both. + */ + void showPicksAs(PickDisplay display) { + if (display == pickDisplay) { + return; + } + pickDisplay = display; + if (currentType.drawsPickNoise()) { + requestRender(); + } + } + + MapCensus.ViewArea viewArea() { + return new MapCensus.ViewArea(screenToWorldX(0D), screenToWorldZ(0D), screenToWorldX(getWidth()), + screenToWorldZ(getHeight())); + } + + private MapLayer currentLayer() { + return switch (currentType) { + case BIOME -> MapLayer.biomes(pickDisplay); + case REGION -> MapLayer.regions(pickDisplay); + case REGION_PREVIEW -> MapLayer.regionPreview(previewRegionKey, pickDisplay); + default -> MapLayer.of(currentType); + }; + } + + String legendTitle() { + return currentType == TileType.REGION ? "Regions in view" : "Biomes in view"; + } + + // Tiles keep loading in the background only for the view on screen; a hidden canvas stops asking for new ones. + void setActive(boolean nextActive) { + active = nextActive; + if (active) { + requestRender(); + } + } + + void refreshContent() { + if (closed) { + return; + } + if (!ensureEngine()) { + notifyUser("No active Iris engine"); + return; + } + renderer = new MapTileRenderer(engine); + overlay = MapHost.overlayFor(engine, openerId); + contentRevision++; + requestRender(); + notifyUser("Refreshing"); + listener.onTogglesChanged(); + listener.onContentRefreshed(); + } + + void toggleShading() { + applyStyle(style.withShading(!style.shading())); + notifyUser(style.shading() ? "Terrain shading on" : "Terrain shading off"); + listener.onTogglesChanged(); + } + + void toggleRegionBorders() { + applyStyle(style.withRegionBorders(!style.regionBorders())); + notifyUser(style.regionBorders() ? "Region borders on" : "Region borders off"); + listener.onTogglesChanged(); + } + + /** + * Highlights the biome or region, or clears the highlight when it is the one already highlighted. + */ + void toggleHighlight(MapLabels.Label label) { + if (label == null) { + return; + } + MapStyle.Highlight current = style.highlight(); + if (current != null && current.kind() == label.kind() && current.key().equals(label.key())) { + clearHighlight(); + return; + } + applyStyle(style.withHighlight(new MapStyle.Highlight(label.kind(), label.key(), label.name()))); + notifyUser("Highlighting " + label.name() + " · Esc clears"); + } + + void highlightUnderCursor() { + VisionRenderController.TilePixel pixel = cursor == null ? null : tilePixelAt(cursor); + if (pixel == null) { + notifyUser("Point at the map to highlight what is there"); + return; + } + toggleHighlight(pixel.subject()); + } + + /** + * Esc steps back one layer of emphasis: the highlight first, then the help. + */ + void dismissEmphasis() { + if (style.highlight() != null) { + clearHighlight(); + } else if (help) { + toggleHelp(); + } + } + + private void clearHighlight() { + applyStyle(style.withHighlight(null)); + notifyUser("Highlight cleared"); + } + + private void applyStyle(MapStyle next) { + boolean heightsWereNeeded = needsHeights(); + style = next; + if (needsHeights() != heightsWereNeeded) { + requestRender(); + } else { + tileLayerStale = true; + repaint(); + } + scheduleLegendUpdate(); + } + + private boolean needsHeights() { + return style.shading() || currentType == TileType.HEIGHT; + } + + void toggleGrid() { + grid = !grid; + repaint(); + notifyUser(grid ? "Grid on" : "Grid off"); + listener.onTogglesChanged(); + } + + void toggleEntities() { + entitiesVisible = !entitiesVisible; + if (entitiesVisible) { + refreshMarkers(); + } else { + entities = List.of(); + repaint(); + } + notifyUser(entitiesVisible ? "Entities on" : "Entities off"); + listener.onTogglesChanged(); + } + + void toggleFollow() { + follow = !follow; + if (follow && players.isEmpty()) { + follow = false; + notifyUser("No player in this world"); + } else if (follow) { + GuiMarker player = players.get(0); + centerOn(player.worldX(), player.worldZ()); + notifyUser("Following " + player.label()); + } else { + notifyUser("Stopped following"); + } + listener.onTogglesChanged(); + } + + /** + * Keeps the help, debug and notification cards clear of a panel floating over the map's left side. + */ + void setCoveredLeftWidth(int width) { + if (width == coveredLeftWidth) { + return; + } + coveredLeftWidth = width; + repaint(); + } + + void toggleHelp() { + help = !help; + repaint(); + } + + void toggleDebug() { + debug = !debug; + repaint(); + } + + void setDetailedHover(boolean detailed) { + if (detailedHover != detailed) { + detailedHover = detailed; + repaint(); + } + } + + void zoomIn() { + changeZoom(1D / KEYBOARD_ZOOM_FACTOR, cursor); + } + + void zoomOut() { + changeZoom(KEYBOARD_ZOOM_FACTOR, cursor); + } + + void resetZoom() { + changeZoom(DEFAULT_BLOCKS_PER_PIXEL / blocksPerPixel, null); + notifyUser("Zoom reset"); + } + + void close() { + if (closed) { + return; + } + closed = true; + hoverTimer.stop(); + legendTimer.stop(); + markerTimer.stop(); + notificationTimer.stop(); + MapHost.unregisterHotloadHook(hotloadHook); + controller.close(); + } + + private static Timer singleShotTimer(int delay, Runnable task) { + Timer timer = new Timer(delay, event -> task.run()); + timer.setRepeats(false); + timer.setCoalesce(true); + return timer; + } + + private void installMouseHandlers() { + MouseAdapter handler = new MouseAdapter() { + @Override + public void mouseMoved(MouseEvent event) { + cursor = event.getPoint(); + setDetailedHover(event.isShiftDown()); + showQuickHover(); + hoverTimer.restart(); + refreshStatus(); + } + + @Override + public void mouseDragged(MouseEvent event) { + cursor = event.getPoint(); + if (dragOrigin == null) { + return; + } + centerX += (dragOrigin.x - event.getX()) * blocksPerPixel; + centerZ += (dragOrigin.y - event.getY()) * blocksPerPixel; + dragOrigin = event.getPoint(); + stopFollowing(); + clearHover(); + requestRender(); + } + + @Override + public void mouseWheelMoved(MouseWheelEvent event) { + cursor = event.getPoint(); + changeZoom(Math.pow(2D, event.getPreciseWheelRotation() * WHEEL_ZOOM_EXPONENT), cursor); + event.consume(); + } + + @Override + public void mousePressed(MouseEvent event) { + requestFocusInWindow(); + cursor = event.getPoint(); + if (event.isPopupTrigger()) { + showContextMenu(event); + } else if (SwingUtilities.isLeftMouseButton(event)) { + dragOrigin = event.getPoint(); + } + } + + @Override + public void mouseReleased(MouseEvent event) { + dragOrigin = null; + if (event.isPopupTrigger()) { + showContextMenu(event); + } + } + + @Override + public void mouseClicked(MouseEvent event) { + cursor = event.getPoint(); + if (!SwingUtilities.isLeftMouseButton(event)) { + return; + } + if (event.isControlDown()) { + teleportTo(event.getPoint()); + } else if (event.isAltDown()) { + openInEditor(event.getPoint()); + } else if (event.getClickCount() == 2) { + showPalettesAt(event.getPoint()); + } + } + + @Override + public void mouseEntered(MouseEvent event) { + cursor = event.getPoint(); + } + + @Override + public void mouseExited(MouseEvent event) { + cursor = null; + clearHover(); + hoverTimer.stop(); + refreshStatus(); + repaint(); + } + }; + addMouseListener(handler); + addMouseMotionListener(handler); + addMouseWheelListener(handler); + } + + private void showContextMenu(MouseEvent event) { + Point point = event.getPoint(); + int blockX = floorWorldCoordinate(screenToWorldX(point.x)); + int blockZ = floorWorldCoordinate(screenToWorldZ(point.y)); + JPopupMenu menu = new JPopupMenu(); + addHighlightItems(menu, tilePixelAt(point)); + menu.add(menuItem("Show palettes here", () -> showPalettesAt(point))); + menu.add(menuItem("Teleport here", () -> teleportTo(point))); + menu.add(menuItem(currentType == TileType.REGION ? "Open region file" : "Open biome file", () -> openInEditor(point))); + menu.addSeparator(); + menu.add(menuItem("Copy coordinates (" + blockX + ", " + blockZ + ")", () -> copyCoordinates(blockX, blockZ))); + menu.show(this, point.x, point.y); + } + + private void addHighlightItems(JPopupMenu menu, VisionRenderController.TilePixel pixel) { + MapLabels.Label subject = pixel == null ? null : pixel.subject(); + MapLabels.Label region = pixel == null ? null : pixel.region(); + if (subject != null) { + menu.add(menuItem(highlightText(subject), () -> toggleHighlight(subject))); + } + if (region != null && subject != null && subject.kind() != MapLabels.Kind.REGION) { + menu.add(menuItem(highlightText(region), () -> toggleHighlight(region))); + } + if (style.highlight() != null) { + menu.add(menuItem("Clear highlight", this::clearHighlight)); + } + if (menu.getComponentCount() > 0) { + menu.addSeparator(); + } + } + + private String highlightText(MapLabels.Label label) { + MapStyle.Highlight current = style.highlight(); + boolean highlighted = current != null && current.kind() == label.kind() && current.key().equals(label.key()); + String kind = label.kind() == MapLabels.Kind.REGION ? "region " : ""; + return (highlighted ? "Stop highlighting " : "Highlight ") + kind + label.name(); + } + + private static JMenuItem menuItem(String text, Runnable action) { + JMenuItem item = new JMenuItem(text); + item.addActionListener(event -> action.run()); + return item; + } + + private void copyCoordinates(int blockX, int blockZ) { + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(blockX + " " + blockZ), null); + notifyUser("Copied " + blockX + ", " + blockZ); + } + + private void showPalettesAt(Point point) { + listener.onShowPalettesAt(screenToWorldX(point.x), screenToWorldZ(point.y)); + } + + void findSurfaceBiome(double worldX, double worldZ, Consumer onFound) { + Engine probeEngine = engine; + controller.submitProbe(renderFrame, () -> { + IrisBiome biome = probeEngine.getSurfaceBiome(floorWorldCoordinate(worldX), floorWorldCoordinate(worldZ)); + return biome == null ? null : biome.getLoadKey(); + }, onFound); + } + + @Override + protected void paintComponent(Graphics graphics) { + long started = System.nanoTime(); + if (previousPaintStartedNanos > 0L) { + long interval = started - previousPaintStartedNanos; + paintCadenceFps = interval > 2_000_000_000L + ? 0 + : (int) Math.min(240L, Math.round(1_000_000_000D / Math.max(1L, interval))); + } + previousPaintStartedNanos = started; + super.paintComponent(graphics); + Graphics2D canvas = (Graphics2D) graphics.create(); + try { + canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + if (showsRiversWithoutHydrology()) { + renderNoHydrology(canvas); + return; + } + renderTiles(canvas); + if (grid) { + renderGrid(canvas); + } + canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + renderMarkers(canvas); + renderLegend(canvas); + if (help) { + renderHelp(canvas); + } else if (debug) { + renderDebug(canvas); + } + renderHover(canvas); + renderNotifications(canvas); + } catch (Throwable error) { + IrisLogging.debug("Vision paint failed: " + error.getClass().getSimpleName() + ": " + error.getMessage()); + } finally { + canvas.dispose(); + } + } + + private boolean showsRiversWithoutHydrology() { + return currentType == TileType.RIVER && engine.getComplex().getHydrologyRuntime() == null; + } + + private void renderNoHydrology(Graphics2D canvas) { + canvas.setColor(StudioTheme.EDITOR_BACKGROUND); + canvas.fillRect(0, 0, getWidth(), getHeight()); + EmptyState empty = new EmptyState(MapViewIcon.large(MapView.RIVERS, EMPTY_ICON_SIZE), "No rivers planned", + "Turn rivers on under hydrology in", "dimensions/" + engine.getDimension().getLoadKey() + ".json", + "They appear here once the pack reloads."); + empty.paint(canvas, this, coveredLeftWidth + (getWidth() - coveredLeftWidth) / 2, getHeight() / 2); + } + + // The display server gets one finished picture per paint: handing it every tile separately made the map lag. + private void renderTiles(Graphics2D canvas) { + canvas.drawImage(currentTileLayer(), 0, 0, null); + } + + // The theme changed: the cached layer holds the old theme's background. + @Override + public void updateUI() { + super.updateUI(); + tileLayerStale = true; + } + + private BufferedImage currentTileLayer() { + int width = Math.max(1, getWidth()); + int height = Math.max(1, getHeight()); + if (tileLayer == null || tileLayer.getWidth() != width || tileLayer.getHeight() != height) { + tileLayer = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + tileLayer.setAccelerationPriority(0F); + tileLayerStale = true; + } + if (tileLayerStale) { + Graphics2D layer = tileLayer.createGraphics(); + try { + composeTiles(layer); + } finally { + layer.dispose(); + } + tileLayerStale = false; + } + return tileLayer; + } + + private void composeTiles(Graphics2D canvas) { + VisionRenderController.Frame frame = renderFrame; + canvas.setColor(StudioTheme.EDITOR_BACKGROUND); + canvas.fillRect(0, 0, getWidth(), getHeight()); + if (frame == null) { + return; + } + canvas.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR); + double frameTileSpan = VisionRenderController.TILE_PIXELS * frame.renderBlocksPerPixel(); + for (VisionRenderController.VisibleTile tile : frame.tiles()) { + double worldX = tile.tileX() * frameTileSpan; + double worldZ = tile.tileZ() * frameTileSpan; + int screenX = (int) Math.round(worldToScreenX(worldX)); + int screenY = (int) Math.round(worldToScreenZ(worldZ)); + int tileWidth = Math.max(1, (int) Math.round(worldToScreenX(worldX + frameTileSpan)) - screenX); + int tileHeight = Math.max(1, (int) Math.round(worldToScreenZ(worldZ + frameTileSpan)) - screenY); + if (tile.tile() != null) { + canvas.drawImage(tile.tile().image(style), screenX, screenY, tileWidth, tileHeight, null); + continue; + } + canvas.setColor(((tile.tileX() ^ tile.tileZ()) & 1L) == 0L ? TILE_EMPTY_A : TILE_EMPTY_B); + canvas.fillRect(screenX, screenY, tileWidth, tileHeight); + drawFallback(canvas, controller.fallback(frame, tile), screenX, screenY, tileWidth, tileHeight); + } + } + + private void drawFallback(Graphics2D canvas, VisionRenderController.Fallback fallback, int screenX, int screenY, + int tileWidth, int tileHeight) { + if (fallback == null) { + return; + } + for (VisionRenderController.FallbackPiece piece : fallback.pieces()) { + int left = screenX + (int) Math.round(piece.offsetX() * tileWidth); + int top = screenY + (int) Math.round(piece.offsetZ() * tileHeight); + int right = screenX + (int) Math.round((piece.offsetX() + piece.size()) * tileWidth); + int bottom = screenY + (int) Math.round((piece.offsetZ() + piece.size()) * tileHeight); + canvas.drawImage(piece.tile().image(style), left, top, right, bottom, + piece.sourceX(), piece.sourceZ(), piece.sourceX() + piece.sourceSize(), piece.sourceZ() + piece.sourceSize(), null); + } + } + + private void renderGrid(Graphics2D canvas) { + double chunkPixels = 16D / blocksPerPixel; + if (chunkPixels >= 8D) { + canvas.setColor(GRID_MINOR); + drawWorldGrid(canvas, 16D, chunkPixels); + } + canvas.setColor(GRID_MAJOR); + double regionPixels = 512D / blocksPerPixel; + if (regionPixels >= 8D) { + drawWorldGrid(canvas, 512D, regionPixels); + } + } + + private void drawWorldGrid(Graphics2D canvas, double blockSpacing, double pixelSpacing) { + double firstX = Math.floor(screenToWorldX(0D) / blockSpacing) * blockSpacing; + double firstZ = Math.floor(screenToWorldZ(0D) / blockSpacing) * blockSpacing; + for (double screen = worldToScreenX(firstX); screen <= getWidth(); screen += pixelSpacing) { + int screenX = (int) Math.round(screen); + canvas.drawLine(screenX, 0, screenX, getHeight()); + } + for (double screen = worldToScreenZ(firstZ); screen <= getHeight(); screen += pixelSpacing) { + int screenY = (int) Math.round(screen); + canvas.drawLine(0, screenY, getWidth(), screenY); + } + } + + private void renderMarkers(Graphics2D canvas) { + if (entitiesVisible) { + canvas.setColor(MOB_COLOR); + for (GuiMarker marker : entities) { + int screenX = (int) Math.round(worldToScreenX(marker.worldX())); + int screenY = (int) Math.round(worldToScreenZ(marker.worldZ())); + fillDot(canvas, screenX, screenY, 3); + } + } + + for (GuiMarker marker : players) { + int screenX = (int) Math.round(worldToScreenX(marker.worldX())); + int screenY = (int) Math.round(worldToScreenZ(marker.worldZ())); + PlayerMarker.paint(canvas, screenX, screenY, marker); + } + if (entitiesVisible && detailedHover) { + renderNearestEntity(canvas); + } + } + + private static void fillDot(Graphics2D canvas, int centerX, int centerY, int logicalRadius) { + int radius = StudioTheme.scale(logicalRadius); + canvas.fillOval(centerX - radius, centerY - radius, radius * 2, radius * 2); + } + + private void renderNearestEntity(Graphics2D canvas) { + Point point = cursor; + if (point == null || entities.isEmpty()) { + return; + } + double worldX = screenToWorldX(point.x); + double worldZ = screenToWorldZ(point.y); + GuiMarker nearest = null; + double nearestDistance = Double.MAX_VALUE; + for (GuiMarker marker : entities) { + double deltaX = marker.worldX() - worldX; + double deltaZ = marker.worldZ() - worldZ; + double distance = deltaX * deltaX + deltaZ * deltaZ; + if (distance < nearestDistance) { + nearestDistance = distance; + nearest = marker; + } + } + if (nearest == null) { + return; + } + ArrayList lines = new ArrayList<>(); + lines.add(nearest.label()); + lines.add("Position " + ((int) nearest.worldX()) + ", " + ((int) nearest.worldY()) + ", " + ((int) nearest.worldZ())); + if (nearest.maxHealth() > 0D) { + lines.add("Health " + Form.f(nearest.health(), 1) + " / " + Form.f(nearest.maxHealth(), 1)); + } + int padding = StudioTheme.scale(CARD_PADDING); + drawCard(canvas, getWidth() - padding, padding, 1D, 0D, lines); + } + + private void renderLegend(Graphics2D canvas) { + switch (currentType) { + case HEIGHT -> renderHeightKey(canvas); + case RIVER -> renderRiverKey(canvas); + default -> { + } + } + } + + private void renderRiverKey(Graphics2D canvas) { + canvas.setFont(StudioTheme.smallFont()); + FontMetrics metrics = canvas.getFontMetrics(); + int padding = StudioTheme.scale(CARD_PADDING); + int swatch = StudioTheme.scale(RIVER_KEY_SWATCH); + int swatchGap = StudioTheme.scale(RIVER_KEY_SWATCH_GAP); + int rowHeight = Math.max(swatch, metrics.getHeight()); + List rows = RiverKey.rows(); + int widestLabel = rows.stream().mapToInt(row -> metrics.stringWidth(row.label())).max().orElse(0); + int width = padding * 2 + swatch + swatchGap + widestLabel; + int height = padding * 2 + rowHeight * rows.size(); + int x = getWidth() - width - padding; + int y = getHeight() - height - padding; + StudioOverlay.drawCard(canvas, x, y, width, height); + for (int index = 0; index < rows.size(); index++) { + RiverKey.Row row = rows.get(index); + int rowY = y + padding + index * rowHeight; + canvas.setColor(new Color(row.color())); + canvas.fillRect(x + padding, rowY + (rowHeight - swatch) / 2, swatch, swatch); + canvas.setColor(StudioTheme.SECONDARY_TEXT); + canvas.drawString(row.label(), x + padding + swatch + swatchGap, + rowY + (rowHeight - metrics.getHeight()) / 2 + metrics.getAscent()); + } + } + + private void renderHeightKey(Graphics2D canvas) { + MapStyle.HeightRange range = currentHeightRange(); + canvas.setFont(StudioTheme.smallFont()); + int padding = StudioTheme.scale(CARD_PADDING); + int gradientHeight = StudioTheme.scale(14); + int labelGap = StudioTheme.scale(4); + int width = StudioTheme.scale(244); + int height = padding * 2 + gradientHeight + labelGap + canvas.getFontMetrics().getHeight(); + int x = getWidth() - width - padding; + int y = getHeight() - height - padding; + StudioOverlay.drawCard(canvas, x, y, width, height); + int gradientX = x + padding; + int gradientY = y + padding; + int gradientWidth = width - padding * 2; + double span = Math.max(1D, range.highest() - range.lowest()); + for (int pixel = 0; pixel < gradientWidth; pixel++) { + double sampleHeight = range.lowest() + span * pixel / Math.max(1D, gradientWidth - 1D); + canvas.setColor(new Color(MapTileRenderer.heightColor(sampleHeight, range))); + canvas.drawLine(gradientX + pixel, gradientY, gradientX + pixel, gradientY + gradientHeight); + } + canvas.setColor(StudioTheme.SECONDARY_TEXT); + int labelBaseline = gradientY + gradientHeight + labelGap + canvas.getFontMetrics().getAscent(); + String minimum = Integer.toString((int) Math.round(range.lowest())); + String sea = "sea " + Math.round(range.seaLevel()); + String maximum = Integer.toString((int) Math.round(range.highest())); + int minimumRight = gradientX + canvas.getFontMetrics().stringWidth(minimum); + int maximumX = gradientX + gradientWidth - canvas.getFontMetrics().stringWidth(maximum); + int seaWidth = canvas.getFontMetrics().stringWidth(sea); + int seaX = gradientX + (int) Math.round(gradientWidth * (range.seaLevel() - range.lowest()) / span) - seaWidth / 2; + canvas.drawString(minimum, gradientX, labelBaseline); + if (seaX > minimumRight + labelGap && seaX + seaWidth < maximumX - labelGap) { + canvas.drawString(sea, seaX, labelBaseline); + } + canvas.drawString(maximum, maximumX, labelBaseline); + } + + private void renderHover(Graphics2D canvas) { + QuickHover quick = quickHover; + if (quick == null) { + return; + } + MapHoverProbe.Details details = hoverDetails; + MapHoverCard card = MapHoverCard.of(quick.subject(), quick.region(), details, currentType, detailedHover, + quick.worldX(), quick.worldZ()); + int screenX = (int) Math.round(worldToScreenX(quick.worldX())) + StudioTheme.scale(16); + int screenY = (int) Math.round(worldToScreenZ(quick.worldZ())); + card.paint(canvas, screenX, screenY, getWidth(), getHeight()); + } + + private void renderHelp(Graphics2D canvas) { + List keys = List.of("Drag", "Wheel / + -", "\\", "Double-click", "Right-click", "Ctrl+Click", "Alt+Click", + "Shift", "H", "Esc", "S", "B", "R", "F", "G", "E", "M", "1 to 0, P", "/"); + List descriptions = List.of( + "Move the map", + "Zoom in and out", + "Reset zoom", + "Show the palettes of that spot", + "More actions for that spot", + "Teleport there", + "Open the biome file", + "More details while hovering", + "Highlight what is under the cursor", + "Clear the highlight", + "Terrain shading", + "Region borders", + "Redraw the map", + "Follow your player", + "Chunk and region grid", + "Show entities", + "Next view", + "Pick a view", + "Hide this help" + ); + canvas.setFont(StudioTheme.boldFont()); + int keyWidth = 0; + for (String key : keys) { + keyWidth = Math.max(keyWidth, canvas.getFontMetrics().stringWidth(key)); + } + canvas.setFont(StudioTheme.font()); + int descriptionWidth = 0; + for (String description : descriptions) { + descriptionWidth = Math.max(descriptionWidth, canvas.getFontMetrics().stringWidth(description)); + } + int padding = StudioTheme.scale(CARD_PADDING); + int columnGap = StudioTheme.scale(18); + int lineHeight = canvas.getFontMetrics().getHeight() + StudioTheme.scale(4); + int width = keyWidth + columnGap + descriptionWidth + padding * 2; + int height = keys.size() * lineHeight + padding * 2; + int left = coveredLeftWidth + padding; + StudioOverlay.drawCard(canvas, left, padding, width, height); + for (int index = 0; index < keys.size(); index++) { + int y = padding * 2 + canvas.getFontMetrics().getAscent() + index * lineHeight; + canvas.setFont(StudioTheme.boldFont()); + canvas.setColor(StudioTheme.LINK); + canvas.drawString(keys.get(index), left + padding, y); + canvas.setFont(StudioTheme.font()); + canvas.setColor(StudioTheme.SECONDARY_TEXT); + canvas.drawString(descriptions.get(index), left + padding + keyWidth + columnGap, y); + } + } + + private void renderDebug(Graphics2D canvas) { + VisionRenderController.Progress progress = controller.progress(renderFrame); + VisionRenderController.Frame frame = renderFrame; + ArrayList lines = new ArrayList<>(); + lines.add("Tiles " + progress.ready() + " / " + progress.total() + " drawn"); + lines.add("Render threads busy " + progress.activeWorkers()); + lines.add("Tile scale " + (frame == null ? "-" : Form.f(frame.renderBlocksPerPixel(), 0)) + " blocks/px"); + lines.add(paintCadenceFps + " paints per second"); + int padding = StudioTheme.scale(CARD_PADDING); + drawCard(canvas, coveredLeftWidth + padding, getHeight() - padding, 0D, 1D, lines); + } + + private void renderNotifications(Graphics2D canvas) { + if (notifications.isEmpty()) { + return; + } + String text = String.join(" · ", notifications.keySet()); + canvas.setFont(StudioTheme.font()); + int textWidth = canvas.getFontMetrics().stringWidth(text); + int padding = StudioTheme.scale(CARD_PADDING); + int width = textWidth + padding * 2; + int height = canvas.getFontMetrics().getHeight() + padding; + int x = coveredLeftWidth + (getWidth() - coveredLeftWidth - width) / 2; + int y = getHeight() - height - StudioTheme.scale(16); + StudioOverlay.drawCard(canvas, x, y, width, height); + canvas.setColor(StudioTheme.TEXT); + canvas.drawString(text, x + padding, y + padding / 2 + canvas.getFontMetrics().getAscent()); + } + + private void drawCard(Graphics2D canvas, int anchorX, int anchorY, double pushX, double pushY, List lines) { + canvas.setFont(StudioTheme.font()); + int lineHeight = canvas.getFontMetrics().getHeight(); + int width = 0; + for (int index = 0; index < lines.size(); index++) { + canvas.setFont(index == 0 ? StudioTheme.boldFont() : StudioTheme.font()); + width = Math.max(width, canvas.getFontMetrics().stringWidth(lines.get(index))); + } + int padding = StudioTheme.scale(CARD_PADDING); + width += padding * 2; + int height = lines.size() * lineHeight + padding * 2; + int x = (int) Math.round(anchorX - width * pushX); + int y = (int) Math.round(anchorY - height * pushY); + x = Math.max(padding, Math.min(getWidth() - width - padding, x)); + y = Math.max(padding, Math.min(getHeight() - height - padding, y)); + StudioOverlay.drawCard(canvas, x, y, width, height); + for (int index = 0; index < lines.size(); index++) { + boolean title = index == 0; + canvas.setFont(title ? StudioTheme.boldFont() : StudioTheme.font()); + canvas.setColor(title ? StudioTheme.TEXT : StudioTheme.SECONDARY_TEXT); + canvas.drawString(lines.get(index), x + padding, y + padding + canvas.getFontMetrics().getAscent() + index * lineHeight); + } + } + + private void onTilesChanged() { + tileLayerStale = true; + repaint(); + refreshStatus(); + scheduleLegendUpdate(); + if (quickHover != null && quickHover.subject() == null) { + showQuickHover(); + } + } + + // At most one count per interval however fast tiles arrive, so the legend stays live without costing frames. + private void scheduleLegendUpdate() { + if (!legendTimer.isRunning()) { + legendTimer.start(); + } + } + + private void publishLegend() { + if (closed || !active) { + return; + } + MapTileRenderer legendRenderer = renderer; + TileType type = currentType; + MapCensus.ViewArea area = viewArea(); + List shares = MapCensus.count(renderFrame, area, label -> legendRenderer.legendColor(label, type)); + listener.onLegendChanged(legendTitle(), shares, style.highlight()); + if (type == TileType.HEIGHT) { + fitHeightColorsToView(area); + } + } + + // The height colours stretch over the heights on screen, so plains and mountains don't share one shade of green. + private void fitHeightColorsToView(MapCensus.ViewArea area) { + MapStyle.HeightRange measured = MapCensus.heightRange(renderFrame, area, renderer.defaultHeightRange().seaLevel()); + if (measured != null && !measured.equals(style.heightRange())) { + applyStyle(style.withHeightRange(measured)); + } + } + + private MapStyle.HeightRange currentHeightRange() { + return style.heightRange() == null ? renderer.defaultHeightRange() : style.heightRange(); + } + + private void requestRender() { + tileLayerStale = true; + if (closed || !active || getWidth() < 1 || getHeight() < 1 || !ensureEngine()) { + repaint(); + return; + } + clearHover(); + VisionRenderController.RenderSpec spec = new VisionRenderController.RenderSpec( + renderer, + currentLayer(), + contentRevision, + centerX, + centerZ, + blocksPerPixel, + getWidth(), + getHeight(), + needsHeights() + ); + renderFrame = controller.request(spec); + repaint(); + refreshStatus(); + scheduleLegendUpdate(); + } + + private void refreshStatus() { + int viewWidthBlocks = (int) (blocksPerPixel * getWidth()); + int viewHeightBlocks = (int) (blocksPerPixel * getHeight()); + statusBar.showSummary(viewLabel + " " + formatScale(blocksPerPixel) + " blocks/px " + + Form.f(viewWidthBlocks) + " × " + Form.f(viewHeightBlocks) + " blocks"); + VisionRenderController.Progress progress = controller.progress(renderFrame); + boolean drawing = progress.completion() < 1D; + statusBar.showActivity(drawing ? "Drawing " + progress.ready() + " of " + progress.total() + " tiles" : ""); + statusBar.showProgress(progress.completion()); + Point point = cursor; + double worldX = point == null ? centerX : screenToWorldX(point.x); + double worldZ = point == null ? centerZ : screenToWorldZ(point.y); + statusBar.showPosition("X " + Form.f(floorWorldCoordinate(worldX)) + " Z " + Form.f(floorWorldCoordinate(worldZ))); + } + + private static String formatScale(double blocksPerPixel) { + if (blocksPerPixel >= 1D) { + return Form.f(blocksPerPixel, 1); + } + return "1/" + String.format(Locale.ROOT, "%.0f", 1D / blocksPerPixel); + } + + private boolean ensureEngine() { + Engine activeEngine = engine; + if (activeEngine != null && !activeEngine.isClosed() && activeEngine.getComplex() != null) { + return true; + } + try { + Engine reacquired = MapHost.findEngine(worldId); + if (reacquired == null || reacquired.isClosed() || reacquired.getComplex() == null) { + return false; + } + engine = reacquired; + renderer = new MapTileRenderer(reacquired); + overlay = MapHost.overlayFor(reacquired, openerId); + contentRevision++; + return true; + } catch (Throwable error) { + IrisLogging.debug("Vision engine reacquisition failed: " + error.getClass().getSimpleName() + ": " + error.getMessage()); + return false; + } + } + + private void refreshMarkers() { + if (closed || overlay == null) { + return; + } + try { + List nextPlayers = overlay.players(); + players = nextPlayers == null ? List.of() : List.copyOf(nextPlayers); + if (entitiesVisible) { + overlay.requestEntities(next -> EventQueue.invokeLater(() -> { + if (!closed && entitiesVisible) { + entities = next == null ? List.of() : List.copyOf(next); + repaint(); + } + })); + } else if (!entities.isEmpty()) { + entities = List.of(); + } + if (follow && !players.isEmpty()) { + GuiMarker player = players.get(0); + if (Math.abs(centerX - player.worldX()) > 0.5D || Math.abs(centerZ - player.worldZ()) > 0.5D) { + centerOn(player.worldX(), player.worldZ()); + } + } + if (active) { + repaint(); + } + } catch (Throwable error) { + IrisLogging.debug("Vision marker refresh failed: " + error.getClass().getSimpleName() + ": " + error.getMessage()); + } + } + + private void requestHover() { + Point point = cursor; + VisionRenderController.Frame frame = renderFrame; + Engine probeEngine = engine; + if (closed || !active || point == null || frame == null || probeEngine == null || probeEngine.getComplex() == null) { + return; + } + double worldX = screenToWorldX(point.x); + double worldZ = screenToWorldZ(point.y); + controller.submitProbe( + frame, + () -> MapHoverProbe.sample(probeEngine, worldX, worldZ, previewedRegion(), hoverExplainsBiomePick()), + details -> { + QuickHover quick = quickHover; + if (quick != null && quick.worldX() == details.worldX() && quick.worldZ() == details.worldZ()) { + hoverDetails = details; + repaint(); + } + } + ); + } + + // These views' hover card names the surface biome, so the whole way to it is worth looking up. + private boolean hoverExplainsBiomePick() { + return currentType.drawsPickNoise() || currentType == TileType.HEIGHT; + } + + private String previewedRegion() { + return currentType == TileType.REGION_PREVIEW ? previewRegionKey : null; + } + + // The drawn tiles already know the biome and region under the cursor, so the card follows the mouse without waiting. + private void showQuickHover() { + Point point = cursor; + if (point == null || dragOrigin != null) { + return; + } + double worldX = screenToWorldX(point.x); + double worldZ = screenToWorldZ(point.y); + VisionRenderController.TilePixel pixel = tilePixelAt(point); + quickHover = new QuickHover(worldX, worldZ, pixel == null ? null : pixel.subject(), pixel == null ? null : pixel.region()); + repaint(); + } + + private void clearHover() { + quickHover = null; + hoverDetails = null; + } + + private VisionRenderController.TilePixel tilePixelAt(Point point) { + VisionRenderController.Frame frame = renderFrame; + return frame == null ? null : frame.pixelAt(screenToWorldX(point.x), screenToWorldZ(point.y)); + } + + private void stopFollowing() { + if (follow) { + follow = false; + listener.onTogglesChanged(); + } + } + + private void notifyUser(String message) { + notifications.put(message, System.currentTimeMillis() + NOTIFICATION_MILLIS); + repaint(); + } + + private void expireNotifications() { + long now = System.currentTimeMillis(); + boolean changed = false; + Iterator> iterator = notifications.entrySet().iterator(); + while (iterator.hasNext()) { + if (iterator.next().getValue() <= now) { + iterator.remove(); + changed = true; + } + } + if (changed) { + repaint(); + } + } + + private void changeZoom(double factor, Point anchor) { + if (!Double.isFinite(factor) || factor <= 0D) { + return; + } + Point effectiveAnchor = anchor == null ? new Point(getWidth() / 2, getHeight() / 2) : anchor; + VisionViewport next = new VisionViewport(centerX, centerZ, blocksPerPixel).zoomAt( + effectiveAnchor.x, + effectiveAnchor.y, + getWidth(), + getHeight(), + factor, + VisionRenderController.MINIMUM_VIEW_BLOCKS_PER_PIXEL, + VisionRenderController.MAXIMUM_BLOCKS_PER_PIXEL + ); + if (next.blocksPerPixel() == blocksPerPixel) { + return; + } + centerX = next.centerX(); + centerZ = next.centerZ(); + blocksPerPixel = next.blocksPerPixel(); + stopFollowing(); + clearHover(); + requestRender(); + } + + private double screenToWorldX(double screenX) { + return centerX + (screenX - getWidth() * 0.5D) * blocksPerPixel; + } + + private double screenToWorldZ(double screenY) { + return centerZ + (screenY - getHeight() * 0.5D) * blocksPerPixel; + } + + private double worldToScreenX(double worldX) { + return getWidth() * 0.5D + (worldX - centerX) / blocksPerPixel; + } + + private double worldToScreenZ(double worldZ) { + return getHeight() * 0.5D + (worldZ - centerZ) / blocksPerPixel; + } + + private void openInEditor(Point point) { + Engine activeEngine = engine; + if (activeEngine == null || point == null) { + return; + } + double worldX = screenToWorldX(point.x); + double worldZ = screenToWorldZ(point.y); + MapLayer layer = currentLayer(); + controller.submitProbe( + renderFrame, + () -> PackFileOpener.openAt(activeEngine, worldX, worldZ, layer), + opened -> opened.ifPresent(fileName -> notifyUser("Opened " + fileName)) + ); + } + + private void teleportTo(Point point) { + if (overlay == null || point == null) { + notifyUser("No player in this world"); + return; + } + int worldX = floorWorldCoordinate(screenToWorldX(point.x)); + int worldZ = floorWorldCoordinate(screenToWorldZ(point.y)); + overlay.teleport(worldX, worldZ); + notifyUser("Teleporting to " + worldX + ", " + worldZ); + } + + static int floorWorldCoordinate(double coordinate) { + return (int) StrictMath.floor(coordinate); + } + + interface Listener { + void onTogglesChanged(); + + void onContentRefreshed(); + + void onShowPalettesAt(double worldX, double worldZ); + + void onLegendChanged(String title, List shares, MapStyle.Highlight highlight); + } + + private record QuickHover(double worldX, double worldZ, MapLabels.Label subject, MapLabels.Label region) { + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/MapCensus.java b/core/src/main/java/art/arcane/iris/studio/map/MapCensus.java new file mode 100644 index 000000000..a8283a4ba --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/MapCensus.java @@ -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 . + */ + +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 count(VisionRenderController.Frame frame, ViewArea area, ToIntFunction colorOf) { + Map 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 shares(Map counts, MapLabels labels, long counted, + ToIntFunction colorOf) { + if (labels == null || counted == 0L) { + return List.of(); + } + List shares = new ArrayList<>(counts.size()); + for (Map.Entry 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; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/MapHost.java b/core/src/main/java/art/arcane/iris/studio/map/MapHost.java new file mode 100644 index 000000000..bf09df18c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/MapHost.java @@ -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 . + */ + +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; + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/MapHoverCard.java b/core/src/main/java/art/arcane/iris/studio/map/MapHoverCard.java new file mode 100644 index 000000000..af10fb530 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/MapHoverCard.java @@ -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 . + */ + +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 noiseRows, List groups, List 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 path = details == null ? List.of() : details.pickPath(); + IrisBiome biome = cardBiome(subject, details, type); + List 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 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 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 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 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 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 parentOfChild(List 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 noiseRows(List path) { + List 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 groups, MapHoverProbe.Details details, TileType type) { + if (details == null) { + return; + } + List 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 groups, IrisBiome biome, Object region) { + List 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 hints(boolean detailed) { + List 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 bands, double noise, String note, String value) { + boolean showsSegments() { + return !bands.isEmpty(); + } + } + + record Group(String title, List rows) { + } + + record Row(String label, String value, boolean link) { + } + + record Hint(String key, String action) { + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/MapHoverProbe.java b/core/src/main/java/art/arcane/iris/studio/map/MapHoverProbe.java new file mode 100644 index 000000000..6d22ffd87 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/MapHoverProbe.java @@ -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 . + */ + +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 pickPath(Engine engine, IrisRegion region, boolean previewed, + IrisComplex.BiomePick surfacePick, double x, double z) { + List 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