diff --git a/core/src/main/java/com/volmit/iris/core/gui/BiomeInfoNoise.java b/core/src/main/java/com/volmit/iris/core/gui/BiomeInfoNoise.java new file mode 100644 index 000000000..dca37d23f --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/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 com.volmit.iris.core.gui; + +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/com/volmit/iris/core/gui/BiomeTint.java b/core/src/main/java/com/volmit/iris/core/gui/BiomeTint.java new file mode 100644 index 000000000..db545c974 --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/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 com.volmit.iris.core.gui; + +enum BiomeTint { + GRASS, + FOLIAGE, + DRY_FOLIAGE, + WATER +} diff --git a/core/src/main/java/com/volmit/iris/core/gui/BiomeTintColors.java b/core/src/main/java/com/volmit/iris/core/gui/BiomeTintColors.java new file mode 100644 index 000000000..047030ef0 --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/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 com.volmit.iris.core.gui; + +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/com/volmit/iris/core/gui/BiomeTintField.java b/core/src/main/java/com/volmit/iris/core/gui/BiomeTintField.java new file mode 100644 index 000000000..3eccd270e --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/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 com.volmit.iris.core.gui; + +import com.volmit.iris.engine.object.IrisBiome; +import com.volmit.iris.engine.object.IrisBiomeCustom; +import com.volmit.iris.util.collection.KList; +import com.volmit.iris.util.math.RNG; +import org.bukkit.block.Biome; + +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.getSkyBiome(biomeRandom, originX, 0, originZ)); + } + + // Follows the same choices as IrisBiome#getSkyBiome 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.getDerivative()); + } + return groundBiomes.size() == 1 ? vanillaColors(groundBiomes.get(0)) : null; + } + + private BiomeTintColors customColors(IrisBiomeCustom customBiome) { + return colorsByCustomBiome.computeIfAbsent(customBiome, minecraftColors::custom); + } + + private BiomeTintColors vanillaColors(Biome vanillaBiome) { + return minecraftColors.vanilla(vanillaBiome.getKey()); + } + + 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/com/volmit/iris/core/gui/BlockTextures.java b/core/src/main/java/com/volmit/iris/core/gui/BlockTextures.java index 0d1601e15..339cd7106 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/BlockTextures.java +++ b/core/src/main/java/com/volmit/iris/core/gui/BlockTextures.java @@ -21,51 +21,56 @@ package com.volmit.iris.core.gui; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; -import com.google.gson.JsonParser; import org.bukkit.block.data.BlockData; -import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; +import java.util.Arrays; import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; -import java.util.zip.ZipEntry; 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; - private static final int GRASS_TINT = 0x91BD59; - private static final int FOLIAGE_TINT = 0x77AB2F; - private static final int BIRCH_FOLIAGE_TINT = 0x80A755; - private static final int SPRUCE_FOLIAGE_TINT = 0x619961; - private static final int WATER_TINT = 0x3F76E4; + private 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> topTextures = new ConcurrentHashMap<>(); - private BlockTextures(ZipFile clientJar) { + 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::new); + 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; + } + Optional topTexture(BlockData block) { return topTextures.computeIfAbsent(block.getAsString(true), this::resolveTopTexture); } @@ -86,8 +91,12 @@ final class BlockTextures { if (image == null) { return Optional.empty(); } - boolean tinted = face.tinted() || state.name().equals("water"); - return Optional.of(BlockTexture.fromImage(image, tinted ? tintFor(state.name()) : BlockTexture.NO_TINT)); + BlockTexture texture = BlockTexture.fromImage( + image, + MinecraftBlockTints.fixedTint(state.name(), state.properties(), face.tintIndex()), + MinecraftBlockTints.biomeTint(state.name(), face.tintIndex()).orElse(null) + ); + return Optional.of(face.isSideOnSprite() ? texture.flattened() : texture); } catch (IOException | RuntimeException error) { return Optional.empty(); } @@ -126,25 +135,31 @@ final class BlockTextures { JsonObject face = elements == null ? null : topmostFace(elements, faceName); if (face == null) { String particle = dereference("#particle", textures); - return particle == null ? null : new ModelFace(particle, elements != null && anyFaceTinted(elements)); + boolean isSideOnSprite = elements != null; + return particle == null ? null : new ModelFace(particle, isSideOnSprite ? firstTintIndex(elements) : NO_TINT_INDEX, isSideOnSprite); } String texture = dereference(face.get("texture").getAsString(), textures); - return texture == null ? null : new ModelFace(texture, face.has("tintindex")); + return texture == null ? null : new ModelFace(texture, tintIndexOf(face), false); } - private static boolean anyFaceTinted(JsonArray elements) { + 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()) { - if (face.getValue().getAsJsonObject().has("tintindex")) { - return true; + int tintIndex = tintIndexOf(face.getValue().getAsJsonObject()); + if (tintIndex != NO_TINT_INDEX) { + return tintIndex; } } } - return false; + return NO_TINT_INDEX; + } + + private static int tintIndexOf(JsonObject face) { + return face.has("tintindex") ? face.get("tintindex").getAsInt() : NO_TINT_INDEX; } private static JsonObject topmostFace(JsonArray elements, String faceName) { @@ -249,22 +264,6 @@ final class BlockTextures { return models.getAsJsonObject(); } - private static int tintFor(String blockName) { - if (blockName.contains("water")) { - return WATER_TINT; - } - if (blockName.equals("birch_leaves")) { - return BIRCH_FOLIAGE_TINT; - } - if (blockName.equals("spruce_leaves")) { - return SPRUCE_FOLIAGE_TINT; - } - if (blockName.contains("leaves") || blockName.contains("vine")) { - return FOLIAGE_TINT; - } - return GRASS_TINT; - } - private static String assetPath(String resource, String folder, String extension) { int separator = resource.indexOf(':'); String namespace = separator < 0 ? DEFAULT_NAMESPACE : resource.substring(0, separator); @@ -273,26 +272,15 @@ final class BlockTextures { } private JsonObject readJson(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(); - } + return ClientJarFiles.readJson(clientJar, path); } private BufferedImage readImage(String path) throws IOException { - ZipEntry entry = clientJar.getEntry(path); - if (entry == null) { - return null; - } - try (InputStream input = clientJar.getInputStream(entry)) { - return ImageIO.read(input); - } + return ClientJarFiles.readImage(clientJar, path); } - private record ModelFace(String texture, boolean tinted) { + // Plants and other blocks without a top face only have a side-on sprite, which would read as stripes from above. + private record ModelFace(String texture, int tintIndex, boolean isSideOnSprite) { } private record BlockStateId(String namespace, String name, Map properties) { @@ -316,13 +304,13 @@ final class BlockTextures { } } - record BlockTexture(int size, int[] pixels, int averageColor) { - static final int NO_TINT = 0xFFFFFF; + // Biome tinted textures keep their grey pixels, because the biome colour changes from column to column. + record BlockTexture(int size, int[] pixels, int averageColor, BiomeTint biomeTint) { private static final int OPAQUE_ALPHA = 128; private static final double GAP_SHADE = 0.55D; // Animated textures stack their frames vertically, so only the first square frame is used. - static BlockTexture fromImage(BufferedImage image, int tint) { + static BlockTexture fromImage(BufferedImage image, int fixedTint, BiomeTint biomeTint) { int size = image.getWidth(); int[] pixels = new int[size * size]; boolean[] opaque = new boolean[pixels.length]; @@ -337,7 +325,7 @@ final class BlockTextures { if ((argb >>> 24) < OPAQUE_ALPHA) { continue; } - int color = applyTint(argb & 0xFFFFFF, tint); + int color = multiply(argb & 0xFFFFFF, fixedTint); pixels[index] = color; opaque[index] = true; red += (color >>> 16) & 0xFF; @@ -356,7 +344,21 @@ final class BlockTextures { pixels[index] = gapColor; } } - return new BlockTexture(size, pixels, average); + return new BlockTexture(size, pixels, average, biomeTint); + } + + BlockTexture flattened() { + int[] flat = new int[pixels.length]; + Arrays.fill(flat, averageColor); + return new BlockTexture(size, flat, 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(size, tintedPixels, multiply(averageColor, tint), null); } int texelAt(double worldX, double worldZ) { @@ -371,11 +373,13 @@ final class BlockTextures { return image; } - private static int applyTint(int color, int tint) { - int red = ((color >>> 16) & 0xFF) * ((tint >>> 16) & 0xFF) / 255; - int green = ((color >>> 8) & 0xFF) * ((tint >>> 8) & 0xFF) / 255; - int blue = (color & 0xFF) * (tint & 0xFF) / 255; - return (red << 16) | (green << 8) | blue; + // 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/com/volmit/iris/core/gui/ClientJarFiles.java b/core/src/main/java/com/volmit/iris/core/gui/ClientJarFiles.java new file mode 100644 index 000000000..1e5b79e3f --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/ClientJarFiles.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 com.volmit.iris.core.gui; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +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)) { + return ImageIO.read(input); + } + } +} diff --git a/core/src/main/java/com/volmit/iris/core/gui/MinecraftBiomeColors.java b/core/src/main/java/com/volmit/iris/core/gui/MinecraftBiomeColors.java new file mode 100644 index 000000000..2181a2aef --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/MinecraftBiomeColors.java @@ -0,0 +1,155 @@ +/* + * 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 com.volmit.iris.core.gui; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.volmit.iris.engine.object.IrisBiomeCustom; +import org.bukkit.NamespacedKey; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.zip.ZipFile; + +// Works out biome tint colours the way Minecraft does, from the colour maps and vanilla biome files in the client jar. +final class MinecraftBiomeColors { + private static final String COLOR_MAP_FOLDER = "assets/minecraft/textures/colormap/"; + private static final int COLOR_MAP_SIZE = 256; + private static final int GRASS_FALLBACK = 0xFF00FF; + private static final int FOLIAGE_FALLBACK = 0x48B518; + private static final int DRY_FOLIAGE_FALLBACK = 0x5C3C32; + private static final float UNKNOWN_BIOME_TEMPERATURE = 0.5F; + private static final float UNKNOWN_BIOME_DOWNFALL = 0.5F; + private static final int UNKNOWN_BIOME_WATER = 0x3F76E4; + private static final int UNPARSEABLE_COLOR = 0x000000; + + private final ZipFile clientJar; + private final ColorMap grassMap; + private final ColorMap foliageMap; + private final ColorMap dryFoliageMap; + private final Map vanillaBiomes = new ConcurrentHashMap<>(); + + private MinecraftBiomeColors(ZipFile clientJar, ColorMap grassMap, ColorMap foliageMap, ColorMap dryFoliageMap) { + this.clientJar = clientJar; + this.grassMap = grassMap; + this.foliageMap = foliageMap; + this.dryFoliageMap = dryFoliageMap; + } + + static MinecraftBiomeColors load(ZipFile clientJar) throws IOException { + return new MinecraftBiomeColors( + clientJar, + ColorMap.read(clientJar, "grass.png", GRASS_FALLBACK), + ColorMap.read(clientJar, "foliage.png", FOLIAGE_FALLBACK), + ColorMap.read(clientJar, "dry_foliage.png", DRY_FOLIAGE_FALLBACK) + ); + } + + BiomeTintColors vanilla(NamespacedKey biome) { + return vanillaBiomes.computeIfAbsent(biome, this::readVanillaBiome); + } + + // Iris registers custom biomes as data pack biomes built from these fields, see IrisBiomeCustom#generateJson. + BiomeTintColors custom(IrisBiomeCustom biome) { + float temperature = (float) biome.getTemperature(); + float downfall = (float) biome.getHumidity(); + return new BiomeTintColors( + biome.getGrassColor().isEmpty() ? grassMap.colorAt(temperature, downfall) : parseIrisColor(biome.getGrassColor()), + BiomeTintColors.GrassColorModifier.NONE, + biome.getFoliageColor().isEmpty() ? foliageMap.colorAt(temperature, downfall) : parseIrisColor(biome.getFoliageColor()), + dryFoliageMap.colorAt(temperature, downfall), + parseIrisColor(biome.getWaterColor()) + ); + } + + private BiomeTintColors readVanillaBiome(NamespacedKey biome) { + JsonObject definition = readBiomeDefinition(biome); + if (definition == null) { + return fromClimate(UNKNOWN_BIOME_TEMPERATURE, UNKNOWN_BIOME_DOWNFALL, new JsonObject()); + } + JsonObject effects = definition.has("effects") ? definition.getAsJsonObject("effects") : new JsonObject(); + return fromClimate(definition.get("temperature").getAsFloat(), definition.get("downfall").getAsFloat(), effects); + } + + private BiomeTintColors fromClimate(float temperature, float downfall, JsonObject effects) { + return new BiomeTintColors( + effects.has("grass_color") ? parseVanillaColor(effects.get("grass_color")) : grassMap.colorAt(temperature, downfall), + effects.has("grass_color_modifier") + ? BiomeTintColors.GrassColorModifier.named(effects.get("grass_color_modifier").getAsString()) + : BiomeTintColors.GrassColorModifier.NONE, + effects.has("foliage_color") ? parseVanillaColor(effects.get("foliage_color")) : foliageMap.colorAt(temperature, downfall), + effects.has("dry_foliage_color") ? parseVanillaColor(effects.get("dry_foliage_color")) : dryFoliageMap.colorAt(temperature, downfall), + effects.has("water_color") ? parseVanillaColor(effects.get("water_color")) : UNKNOWN_BIOME_WATER + ); + } + + private JsonObject readBiomeDefinition(NamespacedKey biome) { + try { + return ClientJarFiles.readJson(clientJar, "data/" + biome.getNamespace() + "/worldgen/biome/" + biome.getKey() + ".json"); + } catch (IOException | RuntimeException error) { + return null; + } + } + + // Older versions store colours as plain numbers, newer ones as "#rrggbb" strings. + private static int parseVanillaColor(JsonElement color) { + if (color.getAsJsonPrimitive().isNumber()) { + return color.getAsInt() & 0xFFFFFF; + } + return Integer.parseInt(color.getAsString().substring(1), 16); + } + + private static int parseIrisColor(String color) { + try { + return Color.decode((color.startsWith("#") ? color : "#" + color).trim()).getRGB() & 0xFFFFFF; + } catch (RuntimeException error) { + return UNPARSEABLE_COLOR; + } + } + + // Minecraft's ColorMapColorUtil: temperature picks the column and temperature times downfall picks the row. + private record ColorMap(int[] pixels, int fallback) { + private static ColorMap read(ZipFile clientJar, String name, int fallback) throws IOException { + BufferedImage image = ClientJarFiles.readImage(clientJar, COLOR_MAP_FOLDER + name); + if (image == null) { + throw new IOException("Minecraft colour map " + name + " is missing"); + } + int[] pixels = new int[COLOR_MAP_SIZE * COLOR_MAP_SIZE]; + for (int y = 0; y < COLOR_MAP_SIZE; y++) { + for (int x = 0; x < COLOR_MAP_SIZE; x++) { + pixels[y * COLOR_MAP_SIZE + x] = image.getRGB(x, y) & 0xFFFFFF; + } + } + return new ColorMap(pixels, fallback); + } + + private int colorAt(float temperature, float downfall) { + double clampedTemperature = Math.clamp(temperature, 0F, 1F); + double clampedDownfall = Math.clamp(downfall, 0F, 1F) * clampedTemperature; + int x = (int) ((1D - clampedTemperature) * 255D); + int y = (int) ((1D - clampedDownfall) * 255D); + int index = y << 8 | x; + return index >= pixels.length ? fallback : pixels[index]; + } + } +} diff --git a/core/src/main/java/com/volmit/iris/core/gui/MinecraftBlockTints.java b/core/src/main/java/com/volmit/iris/core/gui/MinecraftBlockTints.java new file mode 100644 index 000000000..6990f97e9 --- /dev/null +++ b/core/src/main/java/com/volmit/iris/core/gui/MinecraftBlockTints.java @@ -0,0 +1,85 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + + +package com.volmit.iris.core.gui; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +// Mirrors Minecraft's BlockColors: which colour each tinted face of a block is multiplied with in the world. +final class MinecraftBlockTints { + static final int NO_TINT = 0xFFFFFF; + private static final Set GRASS_TINTED = Set.of( + "grass_block", "short_grass", "fern", "potted_fern", "bush", "tall_grass", "large_fern", "sugar_cane"); + private static final Set GRASS_TINTED_ON_SECOND_LAYER = Set.of("pink_petals", "wildflowers"); + private static final Set FOLIAGE_TINTED = Set.of( + "oak_leaves", "jungle_leaves", "acacia_leaves", "dark_oak_leaves", "vine", "mangrove_leaves"); + private static final Set WATER_TINTED_BY_FLUID_RENDERER = Set.of("water", "bubble_column"); + private static final Set GROWING_STEMS = Set.of("melon_stem", "pumpkin_stem"); + private static final Map FIXED_TINTS = Map.of( + "spruce_leaves", 0x619961, + "birch_leaves", 0x80A755, + "attached_melon_stem", 0xE0C71C, + "attached_pumpkin_stem", 0xE0C71C, + "lily_pad", 0x208030 + ); + + private MinecraftBlockTints() { + } + + static Optional biomeTint(String blockName, int tintIndex) { + if (WATER_TINTED_BY_FLUID_RENDERER.contains(blockName)) { + return Optional.of(BiomeTint.WATER); + } + if (GRASS_TINTED_ON_SECOND_LAYER.contains(blockName)) { + return tintIndex == 1 ? Optional.of(BiomeTint.GRASS) : Optional.empty(); + } + if (tintIndex != 0) { + return Optional.empty(); + } + if (GRASS_TINTED.contains(blockName)) { + return Optional.of(BiomeTint.GRASS); + } + if (FOLIAGE_TINTED.contains(blockName)) { + return Optional.of(BiomeTint.FOLIAGE); + } + if (blockName.equals("leaf_litter")) { + return Optional.of(BiomeTint.DRY_FOLIAGE); + } + if (blockName.equals("water_cauldron")) { + return Optional.of(BiomeTint.WATER); + } + return Optional.empty(); + } + + static int fixedTint(String blockName, Map properties, int tintIndex) { + if (tintIndex != 0) { + return NO_TINT; + } + if (GROWING_STEMS.contains(blockName)) { + return stemTint(Integer.parseInt(properties.getOrDefault("age", "0"))); + } + return FIXED_TINTS.getOrDefault(blockName, NO_TINT); + } + + private static int stemTint(int age) { + return (age * 32) << 16 | (255 - age * 8) << 8 | (age * 4); + } +} diff --git a/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerPreview.java b/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerPreview.java index 46acd59eb..bfe714198 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerPreview.java +++ b/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerPreview.java @@ -48,8 +48,10 @@ final class PaletteLayerPreview { private final RNG random; private final IrisData data; private final int[] entryBySlot; - private final int[] colorByEntry; + private final BiomeTintField biomeTints; private final BlockTexture[] textureByEntry; + private final BlockTexture[] legendTextureByEntry; + private final int[] legendColorByEntry; private final List entries; private final SampledBlock[] blockCache = new SampledBlock[BLOCK_CACHE_SIZE]; private final long[] histogram; @@ -57,7 +59,7 @@ final class PaletteLayerPreview { private final double outOfRangeShare; private PaletteLayerPreview(String biomeKey, IrisBiome biome, int layer, int depth, RNG random, IrisData data, - BlockTextures textures) { + BlockTextures textures, BiomeTintField biomeTints) { this.biomeKey = biomeKey; this.biome = biome; this.paletteLayer = biome.getLayers().get(layer); @@ -69,8 +71,10 @@ final class PaletteLayerPreview { if (entryBySlot.length == 0) { throw new IllegalStateException("No palette block in " + biomeKey + " layer " + layer + " could be found"); } + this.biomeTints = biomeTints; this.textureByEntry = resolveEntryTextures(textures); - this.colorByEntry = resolveEntryColors(); + this.legendTextureByEntry = tintForLegend(textureByEntry); + this.legendColorByEntry = resolveLegendColors(); Statistics statistics = measure(); this.histogram = statistics.histogram(); @@ -89,7 +93,10 @@ final class PaletteLayerPreview { throw new IllegalStateException("Palette layer removed during hotload: " + biomeKey + " layer " + layer); } RNG terrainRandom = new RNG(engine.getSeedManager().getTerrain()); - return new PaletteLayerPreview(biomeKey, biome, layer, depth, terrainRandom, data, textures); + BiomeTintField biomeTints = textures == null + ? null + : new BiomeTintField(biome, new RNG(engine.getSeedManager().getBiome()), textures.biomeColors()); + return new PaletteLayerPreview(biomeKey, biome, layer, depth, terrainRandom, data, textures, biomeTints); } // The world picks one block per whole coordinate, and zoomed in, many pixels fall on the same block. @@ -119,11 +126,21 @@ final class PaletteLayerPreview { } int colorForNoise(double noise) { - return colorByEntry[entryIndexForNoise(noise)]; + return legendColorByEntry[entryIndexForNoise(noise)]; } - BlockTexture textureForNoise(double noise) { - return textureByEntry[entryIndexForNoise(noise)]; + int blockColorAt(double noise, double worldX, double worldZ, boolean showTexturePixels) { + int entry = entryIndexForNoise(noise); + BlockTexture texture = textureByEntry[entry]; + if (texture == null) { + return legendColorByEntry[entry]; + } + int untintedColor = showTexturePixels ? texture.texelAt(worldX, worldZ) : texture.averageColor(); + if (texture.biomeTint() == null) { + return untintedColor; + } + int tint = biomeTints.colorAt(texture.biomeTint(), (int) Math.floor(worldX), (int) Math.floor(worldZ)); + return BlockTexture.multiply(untintedColor, tint); } Entry entryForNoise(double noise) { @@ -195,8 +212,20 @@ final class PaletteLayerPreview { return resolved; } + // The legend shows tinted blocks as they look at the world origin, where the statistics are sampled too. + private BlockTexture[] tintForLegend(BlockTexture[] textures) { + BlockTexture[] tinted = new BlockTexture[textures.length]; + for (int entry = 0; entry < textures.length; entry++) { + BlockTexture texture = textures[entry]; + tinted[entry] = texture == null || texture.biomeTint() == null + ? texture + : texture.tinted(biomeTints.colorAt(texture.biomeTint(), 0, 0)); + } + return tinted; + } + // Blocks without a texture, like custom blocks or everything before the textures load, fall back to map colours. - private int[] resolveEntryColors() { + private int[] resolveLegendColors() { KList palette = paletteLayer.getPalette(); List untexturedBlocks = new ArrayList<>(); for (int entry = 0; entry < palette.size(); entry++) { @@ -211,8 +240,8 @@ final class PaletteLayerPreview { BlockData block = palette.get(entry).getBlockData(data); if (block == null) { colors[entry] = MISSING_BLOCK_COLOR; - } else if (textureByEntry[entry] != null) { - colors[entry] = textureByEntry[entry].averageColor(); + } else if (legendTextureByEntry[entry] != null) { + colors[entry] = legendTextureByEntry[entry].averageColor(); } else { colors[entry] = mapColorsByBlock.get(block.getAsString(true)); } @@ -263,8 +292,8 @@ final class PaletteLayerPreview { block.getBlock(), block.getWeight(), block.getBlockData(data) != null, - colorByEntry[entry], - textureByEntry[entry], + legendColorByEntry[entry], + legendTextureByEntry[entry], placed ? (lastSlot - firstSlot + 1) / (double) slotCount : 0D, sharesByEntry[entry], placed ? slotLowerBound(firstSlot, slotCount) : Double.NaN, diff --git a/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerScale.java b/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerScale.java index 12a0cfffd..6269f3315 100644 --- a/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerScale.java +++ b/core/src/main/java/com/volmit/iris/core/gui/PaletteLayerScale.java @@ -38,12 +38,11 @@ final class PaletteLayerScale implements NoiseColorScale { @Override public int colorAt(double value, double worldX, double worldZ) { - BlockTextures.BlockTexture texture = showTexturePixels && view != View.NOISE ? preview.textureForNoise(value) : null; - if (texture == null) { - return colorFinite(value); + if (view == View.NOISE) { + return noisePalette.colorFinite(value); } - int texel = texture.texelAt(worldX, worldZ); - return view == View.BLOCKS ? texel : BlockPreviewColors.shade(texel, overlayShade(value)); + int blockColor = preview.blockColorAt(value, worldX, worldZ, showTexturePixels); + return view == View.BLOCKS ? blockColor : BlockPreviewColors.shade(blockColor, overlayShade(value)); } @Override