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