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) {