diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockModelRegistry.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockModelRegistry.java
index 16f7500..9869811 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockModelRegistry.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockModelRegistry.java
@@ -11,15 +11,32 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.Material;
+import org.bukkit.block.BlockFace;
+import org.bukkit.block.data.Bisected;
import org.bukkit.block.data.BlockData;
+import org.bukkit.block.data.type.Bed;
+import org.bukkit.block.data.type.Stairs;
+import org.bukkit.block.data.type.TrapDoor;
/**
* Turns a {@link BlockData} into a baked {@link ResolvedModel} (blockstate -> variants -> flattened models ->
- * elements). Thread-safe; models are cached per BlockData and the baked elements per {@link Variant}.
+ * elements). Thread-safe; models are cached per BlockData and choice of weighted variants, the baked elements per
+ * {@link Variant}.
+ *
+ *
Weighted variant lists (stone, grass, dirt, sand, … in four rotations; bamboo/chorus/fire parts) are picked
+ * like vanilla 26.3: blocks in the world with {@code random.setSeed(state.getSeed(pos))} ({@code Mth.getSeed},
+ * doors/double plants by their lower half, beds by their head; {@code SectionCompiler} →
+ * {@code ModelBlockRenderer.tesselateBlock}), then {@code WeightedList.getRandomOrThrow} = {@code nextInt(total
+ * weight)}; multipart draws one {@code nextLong} and re-seeds every part with it ({@code MultiPartModel}). Block
+ * models outside the world (block displays, minecart blocks; {@code BlockModelResolver}) use the fixed seed 42.
*/
public final class BlockModelRegistry {
private static final int FALLBACK_GREY = 0xFF7F7F7F;
+ /** {@code BlockModelResolver.MODEL_SEED}: block models drawn outside the world (displays, minecart contents). */
+ public static final long MODEL_SEED = 42L;
+ // Combinations of weighted choices beyond this are cached in a map instead of an array.
+ private static final int MAX_COMBO_ARRAY = 4096;
// Render nothing in the world: hand-only technical blocks, and liquids (vanilla RenderShape.INVISIBLE; drawn by
// render.raytrace.FluidRenderer, block displays of them show nothing like vanilla).
@@ -37,7 +54,7 @@ public final class BlockModelRegistry {
private final ModelResolver modelResolver;
private final ModelBaker baker;
- private final Map cache = new ConcurrentHashMap<>();
+ private final Map cache = new ConcurrentHashMap<>();
private final Map modelCache = new ConcurrentHashMap<>();
private final Map> variantCache = new ConcurrentHashMap<>();
@@ -57,9 +74,210 @@ public final class BlockModelRegistry {
return reader;
}
+ /** The model of {@code data} at world position (x, y, z) (picks weighted variants like vanilla's chunks). */
+ public ResolvedModel get(BlockData data, int x, int y, int z) {
+ States states = states(data);
+ if (states.fixed != null) return states.fixed;
+ return states.pick(this, mthSeed(x + states.seedDx, y + states.seedDy, z + states.seedDz));
+ }
+
+ /** The model of {@code data} outside the world ({@link #MODEL_SEED}): block displays, minecart blocks, … */
public ResolvedModel get(BlockData data) {
- ResolvedModel model = cache.get(data);
- return model != null ? model : cache.computeIfAbsent(data, this::resolve);
+ return get(data, MODEL_SEED);
+ }
+
+ /** The model of {@code data} with the weighted variants picked by {@code seed} (vanilla's model random seed). */
+ public ResolvedModel get(BlockData data, long seed) {
+ States states = states(data);
+ return states.fixed != null ? states.fixed : states.pick(this, seed);
+ }
+
+ /**
+ * The model of {@code data} with the FIRST entry of every weighted list: block items drawn from their blockstate
+ * (vanilla draws the item model, which for weighted blocks is the first, unrotated variant's model).
+ */
+ public ResolvedModel firstVariants(BlockData data) {
+ States states = states(data);
+ return states.fixed != null ? states.fixed : states.model(this, 0);
+ }
+
+ private States states(BlockData data) {
+ States states = cache.get(data);
+ return states != null ? states : cache.computeIfAbsent(data, this::resolveStates);
+ }
+
+ /**
+ * Vanilla {@code BlockState.getSeed(pos)}: {@code Mth.getSeed(pos)}; doors and double plants
+ * ({@code DoublePlantBlock}: tall grass/flowers/seagrass, small dripleaf, pitcher crop) use their lower half,
+ * beds their head ({@code AbstractBedBlock}).
+ */
+ public static long seed(BlockData data, int x, int y, int z) {
+ int[] d = seedOffset(data);
+ return mthSeed(x + d[0], y + d[1], z + d[2]);
+ }
+
+ /** The position offset of {@link #seed}: lower half of doors/double plants, head of a bed foot. */
+ private static int[] seedOffset(BlockData data) {
+ if (data instanceof Bisected b
+ && !(data instanceof Stairs)
+ && !(data instanceof TrapDoor)
+ && b.getHalf() == Bisected.Half.TOP) {
+ return new int[] {0, -1, 0};
+ }
+ if (data instanceof Bed bed && bed.getPart() == Bed.Part.FOOT) {
+ BlockFace f = bed.getFacing();
+ return new int[] {f.getModX(), 0, f.getModZ()};
+ }
+ return new int[3];
+ }
+
+ /** {@code Mth.getSeed(x, y, z)}, including its int overflow of {@code x * 3129871}. */
+ static long mthSeed(int x, int y, int z) {
+ long seed = (long) (x * 3129871) ^ z * 116129781L ^ y;
+ seed = seed * seed * 42317861L + seed * 11L;
+ return seed >> 16;
+ }
+
+ /**
+ * The picked entry of every part, as a mixed-radix index over the parts' list sizes (first part most
+ * significant): {@code ModelBlockRenderer} seeds the random, {@code WeightedVariants} draws
+ * {@code nextInt(totalWeight)}, {@code MultiPartModel} first draws a {@code nextLong} and re-seeds with it before
+ * every part.
+ */
+ static long comboIndex(boolean multipart, BlockStateResolver.Choice[] parts, long seed) {
+ if (!multipart) {
+ // Hot path (stone, grass, dirt, … in every chunk): one list, the first draw of a fresh seed.
+ BlockStateResolver.Choice c = parts[0];
+ return c.index(Lcg.firstNextInt(seed, c.totalWeight()));
+ }
+ Lcg random = new Lcg(seed);
+ long partSeed = multipart ? random.nextLong() : 0;
+ long index = 0;
+ for (BlockStateResolver.Choice c : parts) {
+ if (multipart) random.setSeed(partSeed);
+ // WeightedVariants draws even for a one-entry list, SingleVariant does not: the same pick either way.
+ int pick = c.variants().size() == 1 ? 0 : c.index(random.nextInt(c.totalWeight()));
+ index = index * c.variants().size() + pick;
+ }
+ return index;
+ }
+
+ /** Vanilla's {@code LegacyRandomSource}/{@code SingleThreadedRandomSource} (java.util.Random's LCG). */
+ private static final class Lcg {
+ private static final long MULTIPLIER = 0x5DEECE66DL;
+ private static final long MASK = (1L << 48) - 1;
+ private long seed;
+
+ Lcg(long seed) {
+ setSeed(seed);
+ }
+
+ void setSeed(long seed) {
+ this.seed = (seed ^ MULTIPLIER) & MASK;
+ }
+
+ /** {@code setSeed(seed); nextInt(bound)} without an instance. */
+ static int firstNextInt(long seed, int bound) {
+ long s = (seed ^ MULTIPLIER) & MASK;
+ s = s * MULTIPLIER + 11L & MASK;
+ int sample = (int) (s >> 17);
+ if ((bound & bound - 1) == 0) return (int) ((long) bound * sample >> 31);
+ int modulo = sample % bound;
+ while (sample - modulo + (bound - 1) < 0) {
+ s = s * MULTIPLIER + 11L & MASK;
+ sample = (int) (s >> 17);
+ modulo = sample % bound;
+ }
+ return modulo;
+ }
+
+ private int next(int bits) {
+ seed = seed * MULTIPLIER + 11L & MASK;
+ return (int) (seed >> 48 - bits);
+ }
+
+ /** {@code BitRandomSource.nextInt(bound)}. */
+ int nextInt(int bound) {
+ if ((bound & bound - 1) == 0) return (int) ((long) bound * next(31) >> 31);
+ int sample, modulo;
+ do {
+ sample = next(31);
+ modulo = sample % bound;
+ } while (sample - modulo + (bound - 1) < 0);
+ return modulo;
+ }
+
+ long nextLong() {
+ int upper = next(32);
+ int lower = next(32);
+ return ((long) upper << 32) + lower;
+ }
+ }
+
+ /**
+ * The model choices of one BlockData: {@code fixed} when nothing is random (every choice has one entry),
+ * else the choices and the models per combination of picked entries (built on first use).
+ */
+ private static final class States {
+ final ResolvedModel fixed;
+ final BlockData data;
+ final boolean multipart;
+ final BlockStateResolver.Choice[] parts;
+ final ResolvedModel[] combos; // index = mixed radix over the parts' picks; null when too many
+ final Map comboMap;
+ final int seedDx, seedDy, seedDz; // seedOffset of data
+
+ States(ResolvedModel fixed) {
+ this.seedDx = this.seedDy = this.seedDz = 0;
+ this.fixed = fixed;
+ this.data = null;
+ this.multipart = false;
+ this.parts = null;
+ this.combos = null;
+ this.comboMap = null;
+ }
+
+ States(BlockData data, BlockStateResolver.StateModel model) {
+ int[] d = seedOffset(data);
+ this.seedDx = d[0];
+ this.seedDy = d[1];
+ this.seedDz = d[2];
+ this.fixed = null;
+ this.data = data;
+ this.multipart = model.multipart();
+ this.parts = model.parts().toArray(BlockStateResolver.Choice[]::new);
+ long count = 1;
+ for (BlockStateResolver.Choice c : parts)
+ count = Math.min(count * c.variants().size(), Long.MAX_VALUE / 64);
+ this.combos = count <= MAX_COMBO_ARRAY ? new ResolvedModel[(int) count] : null;
+ this.comboMap = combos == null ? new ConcurrentHashMap<>() : null;
+ }
+
+ ResolvedModel pick(BlockModelRegistry registry, long seed) {
+ return model(registry, comboIndex(multipart, parts, seed));
+ }
+
+ /** The model of a combination index (0 = the first entry of every list). */
+ ResolvedModel model(BlockModelRegistry registry, long index) {
+ if (combos != null) {
+ ResolvedModel m = combos[(int) index];
+ if (m == null) combos[(int) index] = m = registry.resolve(data, variants(index)); // immutable, racy ok
+ return m;
+ }
+ long key = index;
+ return comboMap.computeIfAbsent(key, k -> registry.resolve(data, variants(k)));
+ }
+
+ /** The picked variant of every part for a combination index. */
+ private List variants(long index) {
+ Variant[] picked = new Variant[parts.length];
+ for (int i = parts.length - 1; i >= 0; i--) {
+ int n = parts[i].variants().size();
+ picked[i] = parts[i].variants().get((int) (index % n));
+ index /= n;
+ }
+ return List.of(picked);
+ }
}
/**
@@ -77,11 +295,21 @@ public final class BlockModelRegistry {
return variantCache.computeIfAbsent(variant, v -> baker.bake(modelResolver.resolve(v.model()), v));
}
- private ResolvedModel resolve(BlockData data) {
- Material material = data.getMaterial();
- if (INVISIBLE_MATERIALS.contains(material)) return ResolvedModel.EMPTY;
+ private States resolveStates(BlockData data) {
+ if (INVISIBLE_MATERIALS.contains(data.getMaterial())) return new States(ResolvedModel.EMPTY);
+ BlockStateResolver.StateModel model = blockStateResolver.resolve(data);
+ boolean random = false;
+ for (BlockStateResolver.Choice c : model.parts()) random |= c.variants().size() > 1;
+ if (random) return new States(data, model);
+ List variants = new ArrayList<>(model.parts().size());
+ for (BlockStateResolver.Choice c : model.parts())
+ variants.add(c.variants().getFirst());
+ return new States(resolve(data, variants));
+ }
- List variants = blockStateResolver.resolve(data);
+ /** The model of {@code data} from the picked {@code variants} (one per part). */
+ private ResolvedModel resolve(BlockData data, List variants) {
+ Material material = data.getMaterial();
List elements;
if (variants.size() == 1) {
elements = bake(variants.getFirst()); // shared with other states of the same variant; never mutated
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockStateResolver.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockStateResolver.java
index 8e547e6..5c2d68b 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockStateResolver.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockStateResolver.java
@@ -9,16 +9,41 @@ import java.util.List;
import java.util.Map;
import org.bukkit.block.data.BlockData;
-/** Resolves a {@link BlockData} to the model variants of its {@code blockstates/.json} (variants or multipart). */
+/**
+ * Resolves a {@link BlockData} to the model choices of its {@code blockstates/.json}: the matching entry of
+ * {@code variants}, or every applying {@code multipart} case. Each choice is a weighted list (vanilla
+ * {@code WeightedVariants}; a single object = one entry); {@link BlockModelRegistry} picks per position.
+ */
public final class BlockStateResolver {
+ /** A weighted variant list (vanilla {@code WeightedVariants}); a single variant has one entry. */
+ public record Choice(List variants, int[] weights, int totalWeight) {
+
+ /** {@code WeightedList.Selector.get}: the entry whose weight range contains {@code selection}. */
+ public int index(int selection) {
+ for (int i = 0; i < weights.length; i++) {
+ selection -= weights[i];
+ if (selection < 0) return i;
+ }
+ return weights.length - 1;
+ }
+ }
+
+ /**
+ * The model of a block state: {@code multipart} (vanilla {@code MultiPartModel}: every part picks with the
+ * same seed) or a single {@code variants} entry ({@code parts} has one choice); no parts = no model.
+ */
+ public record StateModel(boolean multipart, List parts) {
+ static final StateModel NONE = new StateModel(false, List.of());
+ }
+
private final AssetReader reader;
public BlockStateResolver(AssetReader reader) {
this.reader = reader;
}
- public List resolve(BlockData data) {
+ public StateModel resolve(BlockData data) {
// e.g. minecraft:oak_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]
String state = data.getAsString(false);
int open = state.indexOf('[');
@@ -36,7 +61,7 @@ public final class BlockStateResolver {
}
JsonObject root = reader.readJsonObject(AssetPaths.blockState(name)).orElse(null);
- if (root == null) return List.of();
+ if (root == null) return StateModel.NONE;
if (root.has("variants")) {
return resolveVariants(root.getAsJsonObject("variants"), props);
@@ -44,26 +69,26 @@ public final class BlockStateResolver {
if (root.has("multipart")) {
return resolveMultipart(root.getAsJsonArray("multipart"), props);
}
- return List.of();
+ return StateModel.NONE;
}
- private static List resolveVariants(JsonObject variants, Map props) {
+ private static StateModel resolveVariants(JsonObject variants, Map props) {
for (Map.Entry entry : variants.entrySet()) {
if (variantKeyMatches(entry.getKey(), props)) {
- return List.of(parseVariant(firstOf(entry.getValue())));
+ return new StateModel(false, List.of(choice(entry.getValue())));
}
}
- return List.of();
+ return StateModel.NONE;
}
- private static List resolveMultipart(JsonArray multipart, Map props) {
- List result = new ArrayList<>();
+ private static StateModel resolveMultipart(JsonArray multipart, Map props) {
+ List result = new ArrayList<>();
for (JsonElement caseEl : multipart) {
JsonObject caseObj = caseEl.getAsJsonObject();
if (caseObj.has("when") && !whenMatches(caseObj.get("when"), props)) continue;
- result.add(parseVariant(firstOf(caseObj.get("apply"))));
+ result.add(choice(caseObj.get("apply")));
}
- return result;
+ return new StateModel(true, result);
}
/** A variant key like {@code facing=east,half=bottom} matches when every pair holds. */
@@ -108,12 +133,21 @@ public final class BlockStateResolver {
return false;
}
- /** Weighted variant lists: the first entry. */
- private static JsonObject firstOf(JsonElement element) {
- if (element.isJsonArray()) {
- return element.getAsJsonArray().get(0).getAsJsonObject();
+ /** A variant object or a weighted list of them ({@code "weight"}, default 1). */
+ private static Choice choice(JsonElement element) {
+ if (!element.isJsonArray())
+ return new Choice(List.of(parseVariant(element.getAsJsonObject())), new int[] {1}, 1);
+ JsonArray array = element.getAsJsonArray();
+ List variants = new ArrayList<>(array.size());
+ int[] weights = new int[array.size()];
+ int total = 0;
+ for (int i = 0; i < array.size(); i++) {
+ JsonObject obj = array.get(i).getAsJsonObject();
+ variants.add(parseVariant(obj));
+ weights[i] = obj.has("weight") ? Math.max(1, obj.get("weight").getAsInt()) : 1;
+ total += weights[i];
}
- return element.getAsJsonObject();
+ return new Choice(List.copyOf(variants), weights, total);
}
private static Variant parseVariant(JsonObject obj) {
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ImageCache.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ImageCache.java
index 3321208..182d7c4 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ImageCache.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ImageCache.java
@@ -22,10 +22,10 @@ public abstract class ImageCache {
return result == MISSING ? Optional.empty() : Optional.of(result);
}
- /** The top-left {@code w×h} pixels of an image as ARGB rows. */
- protected static int[][] pixels(BufferedImage img, int w, int h) {
+ /** The {@code w×h} pixels of an image at ({@code x0}, {@code y0}) as ARGB rows. */
+ protected static int[][] pixels(BufferedImage img, int x0, int y0, int w, int h) {
int[][] out = new int[h][];
- for (int y = 0; y < h; y++) out[y] = img.getRGB(0, y, w, 1, null, 0, w);
+ for (int y = 0; y < h; y++) out[y] = img.getRGB(x0, y0 + y, w, 1, null, 0, w);
return out;
}
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ModelBaker.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ModelBaker.java
index 212c412..4c8b273 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ModelBaker.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ModelBaker.java
@@ -211,16 +211,22 @@ public final class ModelBaker {
};
}
+ /**
+ * The texture of a face: like vanilla 26.3 {@code TextureSlots.getMaterial} the face's {@code texture} always
+ * names a texture slot, with or without the leading {@code #} (heavy_core uses {@code "all"}); slot values
+ * starting with {@code #} refer to other slots. An unresolved slot or a missing/unreadable texture is the
+ * magenta-black missing texture ({@link TextureCache#MISSINGNO}), not a hole.
+ */
private int[][] resolveTexture(String ref, Map vars) {
- if (ref == null) return null;
- String current = ref;
+ if (ref == null || ref.isEmpty()) return null;
+ String current = ref.startsWith("#") ? ref : "#" + ref;
int guard = 0;
while (current.startsWith("#") && guard++ < 16) {
current = vars.get(current.substring(1));
- if (current == null) return null;
+ if (current == null) return TextureCache.MISSINGNO;
}
- if (current.startsWith("#")) return null;
- return textures.get(ResourceLocation.parse(current)).orElse(null);
+ if (current.startsWith("#")) return TextureCache.MISSINGNO;
+ return textures.get(ResourceLocation.parse(current)).orElse(TextureCache.MISSINGNO);
}
private static int axisIndex(String axis) {
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ResourcePackLoader.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ResourcePackLoader.java
index 9906e01..7669016 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ResourcePackLoader.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ResourcePackLoader.java
@@ -5,6 +5,8 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.logging.Logger;
@@ -12,7 +14,12 @@ import java.util.stream.Stream;
/**
* Builds a {@link ResourcePack} from the plugin's {@code resourcepack/} folder: unpacked packs (the folder itself or
- * a direct sub-folder with {@code assets/minecraft}) and {@code .zip} packs anywhere below it, in that priority.
+ * a direct sub-folder with {@code assets/minecraft}) and {@code .zip} packs anywhere below it. Priority (the first
+ * pack containing an asset wins) is deterministic, independent of the file system's listing order: the folder
+ * itself, then its sub-folders sorted by name, then the zips sorted by their path relative to the folder ('/'
+ * separated, plain {@code String} order: case-sensitive, digits before letters). To let a pack override another,
+ * give it a name that sorts first (e.g. {@code 0-custom.zip} before {@code vanilla.zip}). The packs are logged in
+ * that order.
*/
public final class ResourcePackLoader {
@@ -20,6 +27,10 @@ public final class ResourcePackLoader {
private ResourcePackLoader() {}
+ private static String relative(Path root, Path path) {
+ return root.relativize(path).toString().replace(File.separatorChar, '/');
+ }
+
public static Optional load(File resourcePackDir, Logger logger) {
if (!resourcePackDir.isDirectory()) {
return Optional.empty();
@@ -31,6 +42,7 @@ public final class ResourcePackLoader {
dirCandidates.add(resourcePackDir.toPath());
File[] children = resourcePackDir.listFiles(File::isDirectory);
if (children != null) {
+ Arrays.sort(children, Comparator.comparing(File::getName));
for (File child : children) dirCandidates.add(child.toPath());
}
for (Path candidate : dirCandidates) {
@@ -40,9 +52,11 @@ public final class ResourcePackLoader {
}
}
- try (Stream walk = Files.walk(resourcePackDir.toPath())) {
+ Path root = resourcePackDir.toPath();
+ try (Stream walk = Files.walk(root)) {
List zips = walk.filter(Files::isRegularFile)
.filter(p -> p.getFileName().toString().toLowerCase().endsWith(".zip"))
+ .sorted(Comparator.comparing(p -> relative(root, p)))
.toList();
for (Path zip : zips) {
try {
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/TextureCache.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/TextureCache.java
index e3f97f7..c6362b9 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/TextureCache.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/TextureCache.java
@@ -1,18 +1,41 @@
package eu.mhsl.minecraft.pixelpics.assets;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Optional;
import javax.imageio.ImageIO;
-/** Pack textures as ARGB grids by id (unflipped; the UV origin is top-left like vanilla). */
+/**
+ * Pack textures as ARGB grids by id (unflipped; the UV origin is top-left like vanilla). Animated textures (a
+ * {@code .mcmeta} with an {@code animation} section) keep the frame vanilla shows first.
+ */
public final class TextureCache extends ImageCache {
+ /**
+ * Vanilla {@code MissingTextureAtlasSprite} (16x16): magenta #F800F8 / black quarters, black top-left. Shared:
+ * never mutate.
+ */
+ public static final int[][] MISSINGNO = missingno();
+
private final ResourcePack pack;
public TextureCache(ResourcePack pack) {
this.pack = pack;
}
+ private static int[][] missingno() {
+ int[][] t = new int[16][16];
+ for (int y = 0; y < 16; y++) {
+ for (int x = 0; x < 16; x++) t[y][x] = (y < 8 ^ x < 8) ? 0xFFF800F8 : 0xFF000000;
+ }
+ return t;
+ }
+
@Override
protected int[][] load(ResourceLocation id) {
var bytes = pack.read(AssetPaths.texture(id));
@@ -27,9 +50,77 @@ public final class TextureCache extends ImageCache {
int width = img.getWidth();
int height = img.getHeight();
- // Animated strips keep their first frame, but only real animations (with a .mcmeta like vanilla): tall
- // entity textures (64×128 witch/strider) are full textures.
- boolean animated = height > width && height % width == 0 && pack.exists(AssetPaths.textureMeta(id));
- return pixels(img, width, animated ? width : height);
+ JsonObject animation = animation(id);
+ if (animation == null) return pixels(img, 0, 0, width, height);
+ try {
+ return firstFrame(img, animation, width, height);
+ } catch (RuntimeException e) {
+ return null; // unreadable animation metadata: vanilla uses the missing texture
+ }
+ }
+
+ /** The {@code animation} section of the texture's {@code .mcmeta}, or null (none, or not parseable). */
+ private JsonObject animation(ResourceLocation id) {
+ Optional meta = pack.read(AssetPaths.textureMeta(id));
+ if (meta.isEmpty()) return null;
+ try {
+ JsonElement root = JsonParser.parseString(new String(meta.get(), StandardCharsets.UTF_8));
+ if (!root.isJsonObject()) return null;
+ JsonElement anim = root.getAsJsonObject().get("animation");
+ return anim != null && anim.isJsonObject() ? anim.getAsJsonObject() : null;
+ } catch (RuntimeException e) {
+ return null;
+ }
+ }
+
+ /**
+ * The frame vanilla's animation starts with (26.3 {@code SpriteResourceLoader} + {@code SpriteContents}): frame
+ * size from {@code width}/{@code height} (each defaulting to the image's; both absent = the smaller image side),
+ * frames numbered row by row; the first valid entry of {@code frames} (index in range, time > 0), else frame 0
+ * (also with at most one valid entry: then vanilla does not animate and the atlas keeps the top-left frame).
+ * An image that is not a multiple of the frame size is the missing texture in vanilla (null here).
+ */
+ private static int[][] firstFrame(BufferedImage img, JsonObject anim, int width, int height) {
+ Integer fw = positiveInt(anim, "width"), fh = positiveInt(anim, "height");
+ int frameW, frameH;
+ if (fw != null) {
+ frameW = fw;
+ frameH = fh != null ? fh : height;
+ } else if (fh != null) {
+ frameW = width;
+ frameH = fh;
+ } else {
+ frameW = frameH = Math.min(width, height);
+ }
+ if (width % frameW != 0 || height % frameH != 0) return null;
+ int perRow = width / frameW;
+ int total = perRow * (height / frameH);
+ int defaultTime = anim.has("frametime") ? anim.get("frametime").getAsInt() : 1;
+ int index = 0, valid = 0;
+ JsonElement frames = anim.get("frames");
+ if (frames != null && frames.isJsonArray()) {
+ int first = 0;
+ for (JsonElement f : (JsonArray) frames) {
+ int i, time = defaultTime;
+ if (f.isJsonObject()) {
+ JsonObject o = f.getAsJsonObject();
+ i = o.get("index").getAsInt();
+ if (o.has("time")) time = o.get("time").getAsInt();
+ } else {
+ i = f.getAsInt();
+ }
+ if (time > 0 && i >= 0 && i < total && valid++ == 0) first = i;
+ }
+ // One valid frame or none: no animation, the atlas keeps the top-left frame.
+ if (valid > 1) index = first;
+ }
+ return pixels(img, index % perRow * frameW, index / perRow * frameH, frameW, frameH);
+ }
+
+ private static Integer positiveInt(JsonObject o, String key) {
+ if (!o.has(key)) return null;
+ int v = o.get(key).getAsInt();
+ if (v <= 0) throw new IllegalArgumentException(key + " must be positive");
+ return v;
}
}
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ObjectBaker.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ObjectBaker.java
index dab6135..d605d26 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ObjectBaker.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ObjectBaker.java
@@ -35,7 +35,7 @@ public final class ObjectBaker implements EntityBaker {
switch (s.kind()) {
case BLOCK -> {
// Block model plus the block-entity renderer's parts (chest bodies, lectern book, bell, …).
- items.bakeBlock(s.block(), s.toWorld(), cubes);
+ items.bakeBlock(s.block(), s.modelSeed(), s.toWorld(), cubes);
items.bakeBlockEntity(s.blockEntity(), s.toWorld(), cubes);
}
case ITEM -> {
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ObjectState.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ObjectState.java
index 065b923..bbcb97f 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ObjectState.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ObjectState.java
@@ -1,5 +1,6 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
+import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
import java.util.List;
import org.bukkit.block.data.BlockData;
@@ -24,15 +25,23 @@ public record ObjectState(
int lineWidth, // TEXT: wrap width in font px
int textArgb, // TEXT: text colour (alpha = text opacity)
int backgroundArgb, // TEXT: background colour (0 = none)
- boolean shadow) { // TEXT: drop shadow
+ boolean shadow, // TEXT: drop shadow
+ long modelSeed) { // BLOCK: random seed of weighted blockstate variants
+ /** A block outside the world (display, minecart block): vanilla's fixed model seed. */
public static ObjectState block(Affine toWorld, BlockData block, BlockEntityState blockEntity) {
+ return block(toWorld, block, blockEntity, BlockModelRegistry.MODEL_SEED);
+ }
+
+ /** A block with the model seed {@code modelSeed} (falling blocks: {@code getSeed} of their start position). */
+ public static ObjectState block(Affine toWorld, BlockData block, BlockEntityState blockEntity, long modelSeed) {
return new ObjectState(
- Kind.BLOCK, toWorld, block, blockEntity, null, null, false, null, List.of(), 0, 0, 0, false);
+ Kind.BLOCK, toWorld, block, blockEntity, null, null, false, null, List.of(), 0, 0, 0, false, modelSeed);
}
public static ObjectState item(Affine toWorld, ItemSpec item, String context, boolean dropped) {
- return new ObjectState(Kind.ITEM, toWorld, null, null, item, context, dropped, null, List.of(), 0, 0, 0, false);
+ return new ObjectState(
+ Kind.ITEM, toWorld, null, null, item, context, dropped, null, List.of(), 0, 0, 0, false, 0);
}
public static ObjectState text(
@@ -50,12 +59,13 @@ public record ObjectState(
lineWidth,
textArgb,
backgroundArgb,
- shadow);
+ shadow,
+ 0);
}
public static ObjectState leash(double[] points) {
return new ObjectState(
- Kind.LEASH, Affine.identity(), null, null, null, null, false, points, List.of(), 0, 0, 0, false);
+ Kind.LEASH, Affine.identity(), null, null, null, null, false, points, List.of(), 0, 0, 0, false, 0);
}
public enum Kind {
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/ItemGeometry.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/ItemGeometry.java
index c070f51..f7d255e 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/ItemGeometry.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/ItemGeometry.java
@@ -92,7 +92,10 @@ public final class ItemGeometry {
int before = out.size();
if (info.special != null) bakeSpecial(item, info, m, out);
if (out.size() == before && !info.layers.isEmpty()) bakeGenerated(info, m, out);
- if (out.size() == before && item.block() != null) bakeBlock(item.block(), m, out);
+ if (out.size() == before && item.block() != null) {
+ // An item, not a placed block: the first (unrotated) variant like its item model, no random pick.
+ bakeModel(registry.firstVariants(item.block()), item.block(), m, out);
+ }
// Placed block-entity fallback only for block items (a shield carries its patterns as a banner state).
if (out.size() == before && item.block() != null && item.blockEntity() != null) {
bakeBlockEntity(item.blockEntity(), m, out);
@@ -123,10 +126,13 @@ public final class ItemGeometry {
return new double[] {r.aabbMin[0], r.aabbMin[1], r.aabbMin[2], r.aabbMax[0], r.aabbMax[1], r.aabbMax[2]};
}
- /** A block's model elements in the block cube [0,1]³. */
- public void bakeBlock(BlockData data, Affine toWorld, List out) {
+ /**
+ * A block's model elements in the block cube [0,1]³, weighted variants picked by {@code modelSeed}
+ * ({@link BlockModelRegistry#MODEL_SEED} for displays/minecart blocks).
+ */
+ public void bakeBlock(BlockData data, long modelSeed, Affine toWorld, List out) {
if (data == null) return;
- bakeModel(registry.get(data), data, toWorld, out);
+ bakeModel(registry.get(data, modelSeed), data, toWorld, out);
}
/** A block model's elements in the block cube [0,1]³; {@code data} = tint context (plains colours) or null. */
@@ -468,7 +474,7 @@ public final class ItemGeometry {
int[][] composed = null;
for (int i = 0; i < info.layers.size(); i++) {
int[][] t = texture(info.layers.get(i));
- if (t == null) continue;
+ if (t == null) t = TextureCache.MISSINGNO; // vanilla: the missing texture, not a hole
int tint = i < info.tints.size() ? info.tints.get(i) : 0;
if (composed == null) {
composed = tint != 0 ? TextureOps.tinted(t, tint) : TextureOps.deepCopy(t);
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/CameraMedium.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/CameraMedium.java
index 9c7d9f5..3860b6a 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/CameraMedium.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/CameraMedium.java
@@ -40,9 +40,8 @@ public record CameraMedium(int fluid, int fogColor, double fogStart, double fogE
return fluid != FluidRenderer.NONE;
}
- static CameraMedium at(WorldSnapshot snapshot, Vector eye, BiomeTintProvider tints) {
+ static CameraMedium at(WorldSnapshot snapshot, Vector eye, int fluid, BiomeTintProvider tints) {
double x = eye.getX(), y = eye.getY(), z = eye.getZ();
- int fluid = FluidRenderer.cameraFluid(snapshot::getBlockData, x, y, z);
if (fluid == FluidRenderer.LAVA) return new CameraMedium(fluid, 0x991A00, 0.25, 1.0);
if (fluid != FluidRenderer.WATER) return AIR;
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/FluidRenderer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/FluidRenderer.java
index 032e7d0..1e9a281 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/FluidRenderer.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/FluidRenderer.java
@@ -194,6 +194,7 @@ public final class FluidRenderer {
private int fullFaces(BlockData data) {
if (data.getMaterial().isAir()) return 0;
+ // Any pick of weighted variants: vanilla occludes by the state's occlusion shape, not by the drawn variant.
return fullFaces(registry.get(data));
}
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/SnapshotRaytracer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/SnapshotRaytracer.java
index df4c3fd..c3d3c4a 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/SnapshotRaytracer.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/SnapshotRaytracer.java
@@ -26,10 +26,13 @@ public final class SnapshotRaytracer {
private static final double[] AO_BRIGHTNESS = {0.55, 0.70, 0.85, 1.0};
// Texels with alpha >= OPAQUE_ALPHA end the ray; translucent ones are blended over what lies behind them.
- // Bounded so a stack of glass costs at most a few extra intersection rounds per ray.
+ // A surface after which less than MIN_TRANSMITTANCE (one 8-bit step) of the background would show through ends
+ // the ray too (error <= 1 colour level): water faces (alpha 180/255) stay see-through up to the 5th one, e.g.
+ // through two water blocks with air between them (4 faces) or a waterfall in front of a pool. Bounded so a stack
+ // of glass costs at most a few extra intersection rounds per ray; only rays through that many faces pay.
private static final int OPAQUE_ALPHA = 250;
- private static final int MAX_TRANSLUCENT_LAYERS = 6;
- private static final double MIN_TRANSMITTANCE = 0.03;
+ private static final int MAX_TRANSLUCENT_LAYERS = 8;
+ private static final double MIN_TRANSMITTANCE = 1 / 255.0;
private static final double LAYER_EPS = 1e-5;
// layerAlpha marker of an additive (emissive) layer: its colour is added to what lies behind.
private static final double ADDITIVE = -1;
@@ -79,9 +82,12 @@ public final class SnapshotRaytracer {
return new BiomeBlend(snapshot, tintProvider);
}
- /** The medium (air or fluid with its fog) the camera at {@code origin} is in; once per photo. */
- public CameraMedium cameraMedium(WorldSnapshot snapshot, Vector origin) {
- return CameraMedium.at(snapshot, origin, tintProvider);
+ /**
+ * The medium (air or {@code fluid} with its fog) of the camera at {@code origin}; once per photo. {@code fluid} =
+ * {@link FluidRenderer#cameraFluid} (determined once per photo, also for the FOV).
+ */
+ public CameraMedium cameraMedium(WorldSnapshot snapshot, Vector origin, int fluid) {
+ return CameraMedium.at(snapshot, origin, fluid, tintProvider);
}
public int trace(
@@ -223,9 +229,11 @@ public final class SnapshotRaytracer {
private int steps;
private boolean done;
- // The ray met water (a water cell, or the camera is in water): surfaces may need the underwater light.
- // Checked when composing, after the whole walk.
- private boolean sawWater;
+ // Ray parameter from which on the ray is in water (camera in water: -inf; never: +inf): surfaces behind it
+ // may need the underwater light, surfaces in front of it (also entities and layers composed after the whole
+ // walk) never do. Set by the first water cell the walk enters: the water face it enters through, else the
+ // cell entry.
+ private double waterT;
private boolean reflected;
BlockCursor(
@@ -252,7 +260,7 @@ public final class SnapshotRaytracer {
this.dz = direction.getZ();
this.blocks = snapshot::getBlockData;
this.dda = new VoxelDDA(ox, oy, oz, dx, dy, dz);
- this.sawWater = medium.fluid() == FluidRenderer.WATER;
+ this.waterT = medium.fluid() == FluidRenderer.WATER ? Double.NEGATIVE_INFINITY : Double.POSITIVE_INFINITY;
}
/** The next block surface with {@code t > minT}, or null when the ray leaves the render distance. */
@@ -269,15 +277,18 @@ public final class SnapshotRaytracer {
advance();
continue;
}
- ResolvedModel model = registry.get(data);
+ ResolvedModel model = registry.get(data, bx, by, bz);
FaceHit hit =
ElementIntersector.intersect(model, ox - bx, oy - by, oz - bz, dx, dy, dz, bx, by, bz, minT);
int fluidState = FluidRenderer.state(data);
if (fluidState != 0) {
int type = FluidRenderer.type(fluidState);
- if (type == FluidRenderer.WATER) sawWater = true;
FluidRenderer.Crossing c =
fluids.nearest(blocks, bx, by, bz, model, fluidState, ox, oy, oz, dx, dy, dz, minT);
+ if (type == FluidRenderer.WATER && waterT == Double.POSITIVE_INFINITY) {
+ // An exit face first: the ray was in the water from the cell entry on.
+ waterT = c != null && c.entering ? c.t : dda.tCurrent;
+ }
if (c != null && (hit == null || c.t < hit.t())) return fluidHit(c, type, bx, by, bz);
}
if (hit == null) {
@@ -341,10 +352,10 @@ public final class SnapshotRaytracer {
/**
* {@code color} of a surface at ray parameter {@code t} dimmed by the sky light of the block in front of it
- * (back along the ray), when the ray met water.
+ * (back along the ray), when the ray is in water there.
*/
int underwaterLight(int color, double t) {
- if (!sawWater) return color;
+ if (t < waterT) return color;
double back = 1e-4 / Math.sqrt(dx * dx + dy * dy + dz * dz);
int bx = (int) Math.floor(ox + dx * (t - back));
int by = (int) Math.floor(oy + dy * (t - back));
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java
index 090823e..87c7e9b 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java
@@ -112,12 +112,16 @@ public class DefaultScreenRenderer {
int superW = resolution.width() * SSAA;
int superH = resolution.height() * SSAA;
World world = eyeLocation.getWorld();
- boolean inFluid = FluidRenderer.cameraFluid(
- world::getBlockData, eyeLocation.getX(), eyeLocation.getY(), eyeLocation.getZ())
- != FluidRenderer.NONE;
- RayMap rayMap = RayMap.of(eyeLocation, superW, superH, inFluid ? V_FOV_DEG * FLUID_FOV_SCALE : V_FOV_DEG);
+ // Determined once, here: the FOV needs it before the snapshot exists, and execute's fog uses the same value.
+ int cameraFluid = FluidRenderer.cameraFluid(
+ world::getBlockData, eyeLocation.getX(), eyeLocation.getY(), eyeLocation.getZ());
+ RayMap rayMap = RayMap.of(
+ eyeLocation,
+ superW,
+ superH,
+ cameraFluid != FluidRenderer.NONE ? V_FOV_DEG * FLUID_FOV_SCALE : V_FOV_DEG);
FrustumBounds bounds = FrustumBounds.of(eyeLocation, rayMap, MAX_DISTANCE);
- WorldSnapshot snapshot = SnapshotBuilder.build(world, bounds, logger);
+ WorldSnapshot snapshot = SnapshotBuilder.build(world, eyeLocation, bounds, logger);
Collection nearby = bounds.nearbyEntities(world);
List entities = EntitySnapshotBuilder.build(nearby, shooter);
List blockEntities = BlockEntitySnapshotBuilder.build(eyeLocation, bounds);
@@ -133,6 +137,7 @@ public class DefaultScreenRenderer {
snapshot,
rayMap,
eyeLocation.toVector(),
+ cameraFluid,
resolution.width(),
resolution.height(),
sky,
@@ -173,7 +178,7 @@ public class DefaultScreenRenderer {
job.objects(),
objectBaker));
- CameraMedium medium = raytracer.cameraMedium(snapshot, origin);
+ CameraMedium medium = raytracer.cameraMedium(snapshot, origin, job.cameraFluid());
BiomeBlend tints = raytracer.biomeBlend(snapshot);
int[] superBuf = new int[rayMap.size()];
runParallel(() -> IntStream.range(0, rayMap.size()).parallel().forEach(i -> {
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/RenderJob.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/RenderJob.java
index a237b38..61c18b0 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/RenderJob.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/RenderJob.java
@@ -11,13 +11,18 @@ import java.util.List;
import org.bukkit.util.Vector;
/**
- * A prepared render: everything captured on the main thread (world snapshot, ray map, camera origin, sky state,
- * entity/block-entity/decoration/object states, the time for date-dependent assets). {@link DefaultScreenRenderer#execute} runs it off the main thread.
+ * A prepared render: everything captured on the main thread (world snapshot, ray map, camera origin and the fluid
+ * it is in, sky state, entity/block-entity/decoration/object states, the time for date-dependent assets).
+ * {@link DefaultScreenRenderer#execute} runs it off the main thread.
+ *
+ * @param cameraFluid {@code FluidRenderer.cameraFluid} at the origin (NONE/WATER/LAVA), determined once in
+ * {@code prepare}: it sets the FOV of the ray map and the fog
*/
public record RenderJob(
WorldSnapshot snapshot,
RayMap rayMap,
Vector origin,
+ int cameraFluid,
int width,
int height,
SkyContext sky,
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/ObjectSnapshotBuilder.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/ObjectSnapshotBuilder.java
index a0dc6d3..5f4c37d 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/ObjectSnapshotBuilder.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/ObjectSnapshotBuilder.java
@@ -1,5 +1,6 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
+import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec;
import eu.mhsl.minecraft.pixelpics.render.entity.ObjectState;
@@ -76,9 +77,21 @@ public final class ObjectSnapshotBuilder {
}
return null;
}
- if (e instanceof FallingBlock || e instanceof TNTPrimed) {
- BlockData data = e instanceof FallingBlock fb ? fb.getBlockData() : Material.TNT.createBlockData();
- return block(at.mul(Affine.translation(-0.5, 0, -0.5)), data);
+ if (e instanceof FallingBlock fb) {
+ // FallingBlockRenderer: weighted variants seeded by getSeed(startPos) (the block it fell from; Paper's
+ // origin = the spawn position at the block centre).
+ BlockData data = fb.getBlockData();
+ if (data == null || data.getMaterial().isAir()) return null;
+ Location start = fb.getOrigin() != null ? fb.getOrigin() : fb.getLocation();
+ long seed = BlockModelRegistry.seed(data, start.getBlockX(), start.getBlockY(), start.getBlockZ());
+ return ObjectState.block(
+ at.mul(Affine.translation(-0.5, 0, -0.5)),
+ data,
+ BlockEntitySnapshotBuilder.fromBlockData(data),
+ seed);
+ }
+ if (e instanceof TNTPrimed) {
+ return block(at.mul(Affine.translation(-0.5, 0, -0.5)), Material.TNT.createBlockData());
}
if (e instanceof Item it) {
// GROUND transform, lifted by the baker. The spin (vanilla: age/20 + random bobOffset) is frozen from
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/SnapshotBuilder.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/SnapshotBuilder.java
index e3c0f16..3b5084d 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/SnapshotBuilder.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/SnapshotBuilder.java
@@ -4,28 +4,40 @@ import java.util.HashMap;
import java.util.Map;
import java.util.logging.Logger;
import org.bukkit.ChunkSnapshot;
+import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
/**
* Captures the world region covered by the camera frustum into a {@link WorldSnapshot}. MUST run on the main
* thread. Only already-loaded chunks are captured (no forced loading), rays into unloaded areas hit sky.
+ *
+ * Chunk snapshots always hold the full world height, so the snapshot keeps all of it: the sky light under water
+ * counts the water column up to its surface ({@code FluidRenderer.skyLevel}), which may lie above the frustum. The
+ * chunks around the camera ({@link #CAMERA_MARGIN}) are captured too, also those behind it: the underwater fog
+ * blends the biomes around the eye.
*/
public final class SnapshotBuilder {
/** Safety cap on captured chunks (memory/latency). */
private static final int MAX_CHUNKS = 4096;
+ /**
+ * Blocks around the eye always captured: the water fog's Gaussian biome blend reads quart cells up to 12 blocks
+ * (+3 within the cell) from the eye ({@code CameraMedium}).
+ */
+ private static final int CAMERA_MARGIN = 16;
+
private SnapshotBuilder() {}
- public static WorldSnapshot build(World world, FrustumBounds bounds, Logger logger) {
- int minY = Math.max(world.getMinHeight(), (int) Math.floor(bounds.minY) - 1);
- int maxY = Math.min(world.getMaxHeight(), (int) Math.ceil(bounds.maxY) + 1);
+ public static WorldSnapshot build(World world, Location eye, FrustumBounds bounds, Logger logger) {
+ int minY = world.getMinHeight();
+ int maxY = world.getMaxHeight();
- int minCX = (int) Math.floor(bounds.minX) >> 4;
- int maxCX = (int) Math.floor(bounds.maxX) >> 4;
- int minCZ = (int) Math.floor(bounds.minZ) >> 4;
- int maxCZ = (int) Math.floor(bounds.maxZ) >> 4;
+ int minCX = (int) Math.floor(Math.min(bounds.minX, eye.getX() - CAMERA_MARGIN)) >> 4;
+ int maxCX = (int) Math.floor(Math.max(bounds.maxX, eye.getX() + CAMERA_MARGIN)) >> 4;
+ int minCZ = (int) Math.floor(Math.min(bounds.minZ, eye.getZ() - CAMERA_MARGIN)) >> 4;
+ int maxCZ = (int) Math.floor(Math.max(bounds.maxZ, eye.getZ() + CAMERA_MARGIN)) >> 4;
Map chunks = new HashMap<>();
int skipped = 0;
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeClimate.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeClimate.java
index 13705c5..1021d1c 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeClimate.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeClimate.java
@@ -3,6 +3,7 @@ package eu.mhsl.minecraft.pixelpics.render.tint;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
+import com.google.gson.JsonPrimitive;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
@@ -211,7 +212,7 @@ public final class BiomeClimate {
color(effects, "foliage_color", NONE),
color(effects, "dry_foliage_color", NONE),
modifier != null && modifier.isJsonPrimitive() ? modifier.getAsString() : null,
- color(attributes, "minecraft:visual/water_fog_color", DEFAULT_WATER_FOG_COLOR),
+ rgbAttribute(attributes, "minecraft:visual/water_fog_color", DEFAULT_WATER_FOG_COLOR),
floatAttribute(attributes, "minecraft:visual/water_fog_start_distance", DEFAULT_WATER_FOG_START),
floatAttribute(attributes, "minecraft:visual/water_fog_end_distance", DEFAULT_WATER_FOG_END)));
} catch (Exception e) {
@@ -221,8 +222,8 @@ public final class BiomeClimate {
/**
* A float environment attribute of a biome applied to the default: a plain value overrides it, an object
- * {@code {"argument": a, "modifier": op}} applies vanilla's float modifier (override/add/subtract/multiply/
- * minimum/maximum).
+ * {@code {"argument": a, "modifier": op}} applies vanilla's float modifier (override/alpha_blend/add/subtract/
+ * multiply/minimum/maximum).
*/
private static double floatAttribute(JsonObject attributes, String key, double base) {
JsonElement el = attributes.get(key);
@@ -231,8 +232,16 @@ public final class BiomeClimate {
if (el.isJsonPrimitive()) return el.getAsDouble();
if (!el.isJsonObject()) return base;
JsonObject o = el.getAsJsonObject();
- double a = o.get("argument").getAsDouble();
String op = o.has("modifier") ? o.get("modifier").getAsString() : "override";
+ JsonElement arg = o.get("argument");
+ if (op.equals("alpha_blend")) {
+ // FloatWithAlpha: a plain value (alpha 1) or {"value", "alpha"}; lerp(alpha, base, value).
+ if (!arg.isJsonObject()) return arg.getAsDouble();
+ JsonObject v = arg.getAsJsonObject();
+ double alpha = v.has("alpha") ? v.get("alpha").getAsDouble() : 1;
+ return base + alpha * (v.get("value").getAsDouble() - base);
+ }
+ double a = arg.getAsDouble();
return switch (op) {
case "add" -> base + a;
case "subtract" -> base - a;
@@ -246,6 +255,106 @@ public final class BiomeClimate {
}
}
+ /**
+ * An RGB colour environment attribute of a biome applied to the default {@code base}: a plain colour overrides
+ * it, an object {@code {"argument": a, "modifier": op}} applies vanilla's {@code ColorModifier} (26.3
+ * {@code AttributeModifier.RGB_COLOR_LIBRARY}): override, alpha_blend (argument ARGB), add, subtract, multiply
+ * (argument RGB), blend_to_gray ({@code {brightness, factor}}). Channels as floats 0..1 like vanilla.
+ */
+ static int rgbAttribute(JsonObject attributes, String key, int base) {
+ JsonElement el = attributes.get(key);
+ if (el == null) return base;
+ try {
+ if (!el.isJsonObject()) return toRgb(rgb(el));
+ JsonObject o = el.getAsJsonObject();
+ JsonElement arg = o.get("argument");
+ String op = o.has("modifier") ? o.get("modifier").getAsString() : "override";
+ float[] c = rgb(new JsonPrimitive(base));
+ float[] r =
+ switch (op) {
+ case "override" -> rgb(arg);
+ case "alpha_blend" -> {
+ float[] a = argb(arg); // ARGB.alphaBlend: lerp towards the argument by its alpha
+ yield a[3] == 0 ? c : lerp(a[3], c, new float[] {a[0], a[1], a[2]});
+ }
+ case "add" -> {
+ float[] a = rgb(arg);
+ yield new float[] {
+ Math.min(c[0] + a[0], 1), Math.min(c[1] + a[1], 1), Math.min(c[2] + a[2], 1)
+ };
+ }
+ case "subtract" -> {
+ float[] a = rgb(arg);
+ yield new float[] {
+ Math.max(c[0] - a[0], 0), Math.max(c[1] - a[1], 0), Math.max(c[2] - a[2], 0)
+ };
+ }
+ case "multiply" -> {
+ float[] a = rgb(arg);
+ yield new float[] {c[0] * a[0], c[1] * a[1], c[2] * a[2]};
+ }
+ case "blend_to_gray" -> {
+ JsonObject a = arg.getAsJsonObject();
+ float brightness = a.get("brightness").getAsFloat();
+ float factor = a.get("factor").getAsFloat();
+ float g = Math.clamp((c[0] * 0.3F + c[1] * 0.59F + c[2] * 0.11F) * brightness, 0, 1);
+ yield lerp(factor, c, new float[] {g, g, g});
+ }
+ // Unknown operation: vanilla rejects the biome; keep the default.
+ default -> c;
+ };
+ return toRgb(r);
+ } catch (RuntimeException e) {
+ return base;
+ }
+ }
+
+ private static float[] lerp(float t, float[] from, float[] to) {
+ return new float[] {
+ from[0] + t * (to[0] - from[0]), from[1] + t * (to[1] - from[1]), from[2] + t * (to[2] - from[2])
+ };
+ }
+
+ /** ExtraCodecs.STRING_RGB_VEC3_COLOR: "#rrggbb", a packed int or [r, g, b] floats. */
+ private static float[] rgb(JsonElement el) {
+ if (el.isJsonArray()) {
+ var a = el.getAsJsonArray();
+ return new float[] {
+ a.get(0).getAsFloat(), a.get(1).getAsFloat(), a.get(2).getAsFloat()
+ };
+ }
+ int v = el.getAsJsonPrimitive().isNumber() ? el.getAsInt() : hex(el.getAsString(), 6);
+ return new float[] {(v >> 16 & 0xFF) / 255F, (v >> 8 & 0xFF) / 255F, (v & 0xFF) / 255F};
+ }
+
+ /** ExtraCodecs.STRING_ARGB_VEC4_COLOR as {r, g, b, a}: "#aarrggbb", a packed int or [r, g, b, a] floats. */
+ private static float[] argb(JsonElement el) {
+ if (el.isJsonArray()) {
+ var a = el.getAsJsonArray();
+ return new float[] {
+ a.get(0).getAsFloat(),
+ a.get(1).getAsFloat(),
+ a.get(2).getAsFloat(),
+ a.get(3).getAsFloat()
+ };
+ }
+ int v = el.getAsJsonPrimitive().isNumber() ? el.getAsInt() : hex(el.getAsString(), 8);
+ return new float[] {(v >> 16 & 0xFF) / 255F, (v >> 8 & 0xFF) / 255F, (v & 0xFF) / 255F, (v >>> 24) / 255F};
+ }
+
+ private static int hex(String s, int digits) {
+ if (!s.startsWith("#") || s.length() != digits + 1) throw new IllegalArgumentException(s);
+ return (int) Long.parseLong(s.substring(1), 16);
+ }
+
+ private static int toRgb(float[] c) {
+ return channel(c[0]) << 16 | channel(c[1]) << 8 | channel(c[2]);
+ }
+
+ private static int channel(float v) {
+ return Math.clamp(Math.round(v * 255), 0, 255);
+ }
+
/** Colors are either "#rrggbb" strings or packed integers, depending on the data version. */
private static int color(JsonObject obj, String key, int fallback) {
JsonElement el = obj.get(key);
diff --git a/tools/Harness.java b/tools/Harness.java
index 2f8b1c5..a3a1e40 100644
--- a/tools/Harness.java
+++ b/tools/Harness.java
@@ -7,6 +7,7 @@ import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemModelLoader;
import eu.mhsl.minecraft.pixelpics.render.render.DefaultScreenRenderer;
import eu.mhsl.minecraft.pixelpics.render.render.RayMap;
import eu.mhsl.minecraft.pixelpics.render.render.RenderJob;
+import eu.mhsl.minecraft.pixelpics.render.raytrace.FluidRenderer;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
@@ -104,7 +105,9 @@ final class Harness {
Location loc = new Location(null, cam.getX(), cam.getY(), cam.getZ());
loc.setDirection(look.clone().subtract(cam));
RayMap rayMap = DefaultScreenRenderer.buildRayMap(loc, w * SSAA, h * SSAA);
- return new RenderJob(world, rayMap, cam, w, h, NOON, mobs, bes, decos, objects, SpecialDates.now());
+ // The harness scenes have no fluids: the camera is always in air.
+ return new RenderJob(world, rayMap, cam, FluidRenderer.NONE, w, h, NOON, mobs, bes, decos, objects,
+ SpecialDates.now());
}
/** Distance at which a box of extent {@code ext} (at least 0.5) fills the view, scaled {@code mul}, plus {@code add}. */
diff --git a/tools/edgecase-plugin/pptest/PPTest.java b/tools/edgecase-plugin/pptest/PPTest.java
index 387c208..73d87e7 100644
--- a/tools/edgecase-plugin/pptest/PPTest.java
+++ b/tools/edgecase-plugin/pptest/PPTest.java
@@ -286,6 +286,101 @@ public class PPTest extends JavaPlugin {
// Block displays: end portal/gateway (unshaded), piston head (no skull), a non-uniformly scaled + rotated
// log (normals), grass block (side overlay); shulkers without and with colour.
scene("display_fixes", this::displayFixes, 4.5, 3.2, -5, 4.5, 0.8, 1.5);
+ // F3b fixes. fix_deep_*: 7x7 shaft, 12 deep, camera 6 under the surface looking straight down (the frustum
+ // ends at the eye: sky light must still count the water above it) and sideways (same floor, reference).
+ scene("fix_deep_down", this::deepShaft, 3.5, -6, 3.2, 3.5, -12, 3.9);
+ scene("fix_deep_side", this::deepShaft, 0.4, -6, 3.5, 6.4, -9, 3.5);
+ // fix_layers: two/three water blocks with air between (4/6 water faces) in front of red walls; a stained
+ // glass pane under a water block with water behind it vs the same pane without water behind (the pane is in
+ // front of the water: not dimmed by the underwater light); a white stained glass + water stack.
+ scene("fix_layers", this::fluidLayers, 4, 1.8, -4.5, 4, 1.2, 3);
+ // fix_fog_swamp: swamp tank, camera near its west wall looking east: the biomes behind the camera (outside
+ // the frustum) are part of the water fog blend.
+ scene("fix_fog_swamp", this::fogSwamp, -3.5, -3, 0.5, 12, -4, 0.5);
+ // fix_weighted: weighted blockstate variants picked per position (stone, grass, dirt, sand, netherrack,
+ // bedrock, mycelium, podzol fields), a stone/grass block display (seed 42); fix_textures: heavy_core (face
+ // texture "all" without '#'), animated textures (fire first frame = frames[0], sea lantern, magma, prismarine).
+ scene("fix_weighted", this::weighted, 7.5, 6, -5, 7.5, 0, 3);
+ scene("fix_textures", this::texturesScene, 3.5, 2.2, -3, 3.5, 0.6, 1);
+ }
+
+ private void deepShaft(Location o) {
+ biome(o, Biome.PLAINS);
+ for (int x = -1; x <= 7; x++)
+ for (int z = -1; z <= 7; z++)
+ for (int y = -13; y <= -1; y++) {
+ boolean wall = x == -1 || x == 7 || z == -1 || z == 7;
+ put(o, x, y, z, wall ? "stone_bricks" : y == -13 ? "sand" : "water");
+ }
+ String[] marks = {"red_concrete", "yellow_concrete", "lime_concrete", "white_concrete"};
+ for (int i = 0; i < marks.length; i++) put(o, 1 + i * 1, -12, 2 + (i % 2) * 2, marks[i]);
+ put(o, 5, -12, 5, "gold_block");
+ }
+
+ private void fluidLayers(Location o) {
+ biome(o, Biome.PLAINS);
+ // Two water blocks (4 faces) and three (6 faces) with air gaps, red/blue walls behind.
+ put(o, 0, 1, 1, "water");
+ put(o, 0, 1, 3, "water");
+ for (int y = 0; y <= 2; y++) for (int x = -1; x <= 1; x++) put(o, x, y, 6, "red_concrete");
+ put(o, 2, 1, 0, "water");
+ put(o, 2, 1, 2, "water");
+ put(o, 2, 1, 4, "water");
+ for (int y = 0; y <= 2; y++) put(o, 2, y, 6, "blue_concrete");
+ // Stained glass pane under a water block, water behind (x=4) / nothing behind (x=6).
+ put(o, 4, 1, 1, "light_blue_stained_glass_pane[north=false,south=false,east=true,west=true]");
+ put(o, 4, 2, 1, "water");
+ put(o, 4, 1, 3, "water");
+ put(o, 6, 1, 1, "light_blue_stained_glass_pane[north=false,south=false,east=true,west=true]");
+ put(o, 6, 2, 1, "water");
+ for (int y = 0; y <= 3; y++) for (int x = 3; x <= 7; x++) put(o, x, y, 6, "yellow_concrete");
+ // White stained glass, water, white stained glass, water in a row.
+ put(o, 9, 1, 0, "white_stained_glass");
+ put(o, 9, 1, 1, "water");
+ put(o, 9, 1, 3, "white_stained_glass");
+ put(o, 9, 1, 4, "water");
+ for (int y = 0; y <= 2; y++) put(o, 9, y, 6, "lime_concrete");
+ }
+
+ private void fogSwamp(Location o) {
+ for (int x = -40; x < 40; x += 2)
+ for (int z = -40; z < 40; z += 2)
+ for (int y = -16; y < 16; y += 2)
+ w.setBiome(o.getBlockX() + x, o.getBlockY() + y, o.getBlockZ() + z, Biome.SWAMP);
+ for (int x = -5; x <= 17; x++)
+ for (int z = -8; z <= 8; z++)
+ for (int y = -9; y <= -1; y++) {
+ boolean wall = x == -5 || x == 17 || z == -8 || z == 8;
+ put(o, x, y, z, wall ? "stone_bricks" : y == -9 ? "sand" : "water");
+ }
+ String[] pillars = {"red_concrete", "yellow_concrete", "lime_concrete", "white_concrete"};
+ for (int i = 0; i < pillars.length; i++)
+ for (int y = -8; y <= -4; y++) put(o, 2 + i * 4, y, -3 + (i % 2) * 6, pillars[i]);
+ }
+
+ private void weighted(Location o) {
+ biome(o, Biome.PLAINS);
+ String[] fields = {"stone", "grass_block[snowy=false]", "dirt", "sand", "netherrack", "bedrock",
+ "mycelium[snowy=false]", "podzol[snowy=false]"};
+ for (int i = 0; i < fields.length; i++)
+ for (int x = 0; x < 4; x++)
+ for (int z = 0; z < 4; z++) put(o, (i % 4) * 4 + x, -1, (i / 4) * 4 + z, fields[i]);
+ BlockDisplay stone = spawn(o, 4, 0, 9, 0, BlockDisplay.class);
+ stone.setBlock(Bukkit.createBlockData("minecraft:stone"));
+ BlockDisplay grass = spawn(o, 10, 0, 9, 0, BlockDisplay.class);
+ grass.setBlock(Bukkit.createBlockData("minecraft:grass_block[snowy=false]"));
+ }
+
+ private void texturesScene(Location o) {
+ put(o, 0, 0, 1, "heavy_core");
+ put(o, 1, 0, 1, "netherrack");
+ put(o, 1, 1, 1, "fire");
+ put(o, 2, 0, 1, "soul_sand");
+ put(o, 2, 1, 1, "soul_fire");
+ put(o, 3, 0, 1, "sea_lantern");
+ put(o, 4, 0, 1, "magma_block");
+ put(o, 5, 0, 1, "prismarine");
+ put(o, 6, 0, 1, "lava");
}
private void tintBlocks(Location o) {