Colour grass, leaves and water in the palette explorer like Minecraft does

Every tinted block used one fixed plains green, so biomes with their own
grass colour looked nothing like the game. Tints now follow Minecraft's
own rules: its block tint table, the grass, foliage and dry foliage
colour maps from the client jar, vanilla biome colour overrides, the
dark forest and swamp grass modifiers with an exact copy of the swamp
noise, and the client's 5x5 biome blend. Custom biomes use the colours
Iris writes into their data pack, and the variant is picked per chunk
the same way Iris generates them.

Plants have no top face, so they now show their average colour instead
of their side-on sprite tiled into stripes.

Model used: Claude Opus 5.5
This commit is contained in:
piexel
2026-10-18 21:59:35 +02:00
parent 143ba4771b
commit 674502eeda
10 changed files with 737 additions and 79 deletions
@@ -0,0 +1,96 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package 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;
}
}
@@ -0,0 +1,27 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.volmit.iris.core.gui;
enum BiomeTint {
GRASS,
FOLIAGE,
DRY_FOLIAGE,
WATER
}
@@ -0,0 +1,68 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package 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;
};
}
}
}
@@ -0,0 +1,138 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package 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<IrisBiomeCustom, BiomeTintColors> colorsByCustomBiome = new ConcurrentHashMap<>();
private final BlendedTint[] blendedTintCache = new BlendedTint[BLENDED_TINT_CACHE_SIZE];
private final ChunkColors[] chunkColorsCache = new ChunkColors[CHUNK_COLORS_CACHE_SIZE];
BiomeTintField(IrisBiome biome, RNG biomeRandom, MinecraftBiomeColors minecraftColors) {
this.biome = biome;
this.biomeRandom = biomeRandom;
this.minecraftColors = minecraftColors;
BiomeTintColors onlyColors = onlyPossibleColors();
this.sameColorsEverywhere = onlyColors == null || onlyColors.variesByPosition() ? null : onlyColors;
}
int colorAt(BiomeTint tint, int blockX, int blockZ) {
if (sameColorsEverywhere != null) {
return sameColorsEverywhere.colorAt(tint, blockX, blockZ);
}
int cacheSlot = cacheSlot(blockX, blockZ, tint.ordinal(), BLENDED_TINT_CACHE_SIZE);
BlendedTint cached = blendedTintCache[cacheSlot];
if (cached != null && cached.x() == blockX && cached.z() == blockZ && cached.tint() == tint) {
return cached.color();
}
int color = blendedColor(tint, blockX, blockZ);
blendedTintCache[cacheSlot] = new BlendedTint(blockX, blockZ, tint, color);
return color;
}
// Minecraft's ClientLevel#calculateBlockTint at the default biome blend radius.
private int blendedColor(BiomeTint tint, int blockX, int blockZ) {
int red = 0;
int green = 0;
int blue = 0;
for (int x = blockX - MINECRAFT_DEFAULT_BLEND_RADIUS; x <= blockX + MINECRAFT_DEFAULT_BLEND_RADIUS; x++) {
for (int z = blockZ - MINECRAFT_DEFAULT_BLEND_RADIUS; z <= blockZ + MINECRAFT_DEFAULT_BLEND_RADIUS; z++) {
int color = chunkColors(x >> 4, z >> 4).colorAt(tint, x, z);
red += (color >>> 16) & 0xFF;
green += (color >>> 8) & 0xFF;
blue += color & 0xFF;
}
}
return (red / BLENDED_COLUMN_COUNT) << 16 | (green / BLENDED_COLUMN_COUNT) << 8 | (blue / BLENDED_COLUMN_COUNT);
}
private BiomeTintColors chunkColors(int chunkX, int chunkZ) {
int cacheSlot = cacheSlot(chunkX, chunkZ, 0, CHUNK_COLORS_CACHE_SIZE);
ChunkColors cached = chunkColorsCache[cacheSlot];
if (cached != null && cached.chunkX() == chunkX && cached.chunkZ() == chunkZ) {
return cached.colors();
}
BiomeTintColors colors = colorsForChunkAt(chunkX << 4, chunkZ << 4);
chunkColorsCache[cacheSlot] = new ChunkColors(chunkX, chunkZ, colors);
return colors;
}
private BiomeTintColors colorsForChunkAt(int originX, int originZ) {
if (biome.isCustom()) {
return customColors(biome.getCustomBiome(biomeRandom, originX, 0, originZ));
}
return vanillaColors(biome.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<IrisBiomeCustom> variants = biome.getCustomDerivitives();
return variants.size() == 1 ? customColors(variants.get(0)) : null;
}
KList<Biome> skyBiomes = biome.getBiomeSkyScatter();
if (!skyBiomes.isEmpty()) {
return skyBiomes.size() == 1 ? vanillaColors(skyBiomes.get(0)) : null;
}
KList<Biome> 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) {
}
}
@@ -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<BlockTextures> shared;
private final ZipFile clientJar;
private final MinecraftBiomeColors biomeColors;
private final Map<String, Optional<BlockTexture>> topTextures = new ConcurrentHashMap<>();
private BlockTextures(ZipFile clientJar) {
private BlockTextures(ZipFile clientJar, MinecraftBiomeColors biomeColors) {
this.clientJar = clientJar;
this.biomeColors = biomeColors;
}
static CompletableFuture<BlockTextures> shared() {
synchronized (SHARED_LOCK) {
if (shared == null || shared.isCompletedExceptionally()) {
shared = MinecraftClientJar.open().thenApply(BlockTextures::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<BlockTexture> 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<String, JsonElement> 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<String, String> 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;
}
}
}
@@ -0,0 +1,57 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package 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);
}
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<NamespacedKey, BiomeTintColors> vanillaBiomes = new ConcurrentHashMap<>();
private MinecraftBiomeColors(ZipFile clientJar, ColorMap grassMap, ColorMap foliageMap, ColorMap dryFoliageMap) {
this.clientJar = clientJar;
this.grassMap = grassMap;
this.foliageMap = foliageMap;
this.dryFoliageMap = dryFoliageMap;
}
static MinecraftBiomeColors load(ZipFile clientJar) throws IOException {
return new MinecraftBiomeColors(
clientJar,
ColorMap.read(clientJar, "grass.png", GRASS_FALLBACK),
ColorMap.read(clientJar, "foliage.png", FOLIAGE_FALLBACK),
ColorMap.read(clientJar, "dry_foliage.png", DRY_FOLIAGE_FALLBACK)
);
}
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];
}
}
}
@@ -0,0 +1,85 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package 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<String> GRASS_TINTED = Set.of(
"grass_block", "short_grass", "fern", "potted_fern", "bush", "tall_grass", "large_fern", "sugar_cane");
private static final Set<String> GRASS_TINTED_ON_SECOND_LAYER = Set.of("pink_petals", "wildflowers");
private static final Set<String> FOLIAGE_TINTED = Set.of(
"oak_leaves", "jungle_leaves", "acacia_leaves", "dark_oak_leaves", "vine", "mangrove_leaves");
private static final Set<String> WATER_TINTED_BY_FLUID_RENDERER = Set.of("water", "bubble_column");
private static final Set<String> GROWING_STEMS = Set.of("melon_stem", "pumpkin_stem");
private static final Map<String, Integer> FIXED_TINTS = Map.of(
"spruce_leaves", 0x619961,
"birch_leaves", 0x80A755,
"attached_melon_stem", 0xE0C71C,
"attached_pumpkin_stem", 0xE0C71C,
"lily_pad", 0x208030
);
private MinecraftBlockTints() {
}
static Optional<BiomeTint> biomeTint(String blockName, int tintIndex) {
if (WATER_TINTED_BY_FLUID_RENDERER.contains(blockName)) {
return Optional.of(BiomeTint.WATER);
}
if (GRASS_TINTED_ON_SECOND_LAYER.contains(blockName)) {
return tintIndex == 1 ? Optional.of(BiomeTint.GRASS) : Optional.empty();
}
if (tintIndex != 0) {
return Optional.empty();
}
if (GRASS_TINTED.contains(blockName)) {
return Optional.of(BiomeTint.GRASS);
}
if (FOLIAGE_TINTED.contains(blockName)) {
return Optional.of(BiomeTint.FOLIAGE);
}
if (blockName.equals("leaf_litter")) {
return Optional.of(BiomeTint.DRY_FOLIAGE);
}
if (blockName.equals("water_cauldron")) {
return Optional.of(BiomeTint.WATER);
}
return Optional.empty();
}
static int fixedTint(String blockName, Map<String, String> properties, int tintIndex) {
if (tintIndex != 0) {
return NO_TINT;
}
if (GROWING_STEMS.contains(blockName)) {
return stemTint(Integer.parseInt(properties.getOrDefault("age", "0")));
}
return FIXED_TINTS.getOrDefault(blockName, NO_TINT);
}
private static int stemTint(int age) {
return (age * 32) << 16 | (255 - age * 8) << 8 | (age * 4);
}
}
@@ -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<Entry> 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<IrisBlockData> palette = paletteLayer.getPalette();
List<BlockData> 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,
@@ -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