diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/AssetPaths.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/AssetPaths.java index d178a3e..c9552af 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/AssetPaths.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/AssetPaths.java @@ -1,36 +1,34 @@ package eu.mhsl.minecraft.pixelpics.assets; -/** - * Builds pack-relative asset paths from namespaced ids, following the vanilla layout - * {@code assets///.}. - */ +/** Pack-relative asset paths in the vanilla layout {@code assets///.}. */ public final class AssetPaths { private AssetPaths() {} - /** {@code assets//blockstates/.json} for a plain block name (no {@code block/} prefix). */ + /** For a plain block name such as {@code oak_stairs}. */ public static String blockState(String blockName) { - return String.format( - "assets/%s/blockstates/%s.json", ResourceLocation.DEFAULT_NAMESPACE, blockName.toLowerCase()); + return "assets/" + ResourceLocation.DEFAULT_NAMESPACE + "/blockstates/" + blockName.toLowerCase() + ".json"; } - /** {@code assets//models/.json}. The id path already contains e.g. {@code block/stone}. */ + /** The id path includes the folder, e.g. {@code block/stone}. */ public static String model(ResourceLocation id) { - return String.format("assets/%s/models/%s.json", id.namespace(), id.path()); + return path(id, "models", ".json"); } - /** {@code assets//textures/.png}. The id path already contains e.g. {@code block/stone}. */ public static String texture(ResourceLocation id) { - return String.format("assets/%s/textures/%s.png", id.namespace(), id.path()); + return path(id, "textures", ".png"); } - /** {@code assets//textures/.png.mcmeta} animation metadata, if present. */ + /** Animation metadata of a texture. */ public static String textureMeta(ResourceLocation id) { return texture(id) + ".mcmeta"; } - /** {@code assets//font/.json}, e.g. for {@code minecraft:default} or {@code minecraft:include/space}. */ public static String font(ResourceLocation id) { - return String.format("assets/%s/font/%s.json", id.namespace(), id.path()); + return path(id, "font", ".json"); + } + + private static String path(ResourceLocation id, String category, String extension) { + return "assets/" + id.namespace() + "/" + category + "/" + id.path() + extension; } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/AssetReader.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/AssetReader.java index b93ded5..68611c1 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/AssetReader.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/AssetReader.java @@ -5,10 +5,9 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; import java.nio.charset.StandardCharsets; import java.util.Optional; +import java.util.function.Function; -/** - * Thin convenience layer over a {@link ResourcePack} for reading JSON assets. - */ +/** Reads JSON assets from a {@link ResourcePack}; unreadable or malformed files are empty. */ public final class AssetReader { private final ResourcePack pack; @@ -19,20 +18,17 @@ public final class AssetReader { } public Optional readJson(String path, Class type) { - return pack.read(path).flatMap(bytes -> { - try { - return Optional.ofNullable(gson.fromJson(new String(bytes, StandardCharsets.UTF_8), type)); - } catch (Exception e) { - return Optional.empty(); - } - }); + return read(path, text -> gson.fromJson(text, type)); } public Optional readJsonObject(String path) { + return read(path, text -> JsonParser.parseString(text).getAsJsonObject()); + } + + private Optional read(String path, Function parser) { return pack.read(path).flatMap(bytes -> { try { - return Optional.of(JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)) - .getAsJsonObject()); + return Optional.ofNullable(parser.apply(new String(bytes, StandardCharsets.UTF_8))); } catch (Exception e) { return Optional.empty(); } 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 400fbf0..b8072bc 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockModelRegistry.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockModelRegistry.java @@ -1,10 +1,10 @@ package eu.mhsl.minecraft.pixelpics.assets; -import eu.mhsl.minecraft.pixelpics.assets.model.AverageColor; import eu.mhsl.minecraft.pixelpics.assets.model.Direction; import eu.mhsl.minecraft.pixelpics.assets.model.Element; import eu.mhsl.minecraft.pixelpics.assets.model.Face; import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel; +import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; import java.util.ArrayList; import java.util.EnumSet; import java.util.List; @@ -14,12 +14,51 @@ import org.bukkit.Material; import org.bukkit.block.data.BlockData; /** - * Top-level entry point that turns a {@link BlockData} into a baked {@link ResolvedModel}, combining - * blockstate resolution, model resolution and geometry baking. Results are cached per BlockData and - * the cache is safe for concurrent access by the parallel renderer. + * 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}. */ public final class BlockModelRegistry { + private static final int FALLBACK_GREY = 0xFF7F7F7F; + + // 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). + private static final EnumSet INVISIBLE_MATERIALS = EnumSet.of( + Material.BARRIER, + Material.LIGHT, + Material.STRUCTURE_VOID, + Material.WATER, + Material.LAVA, + Material.BUBBLE_COLUMN); + + // Drawn by the block-entity renderer (entity scene) and without block-model geometry. Matched by name, not + // Material#isBlock (needs a live registry); only queried with placed block materials. Keep in sync with + // render.snapshot.BlockEntitySnapshotBuilder#classify (which also covers kinds that keep their block model). + private static final EnumSet BLOCK_ENTITY_MATERIALS = EnumSet.noneOf(Material.class); + + static { + for (Material m : Material.values()) { + String n = m.name(); + if (n.endsWith("_SIGN") + || n.endsWith("_BANNER") + || n.endsWith("SHULKER_BOX") + || ((n.endsWith("_SKULL") || n.endsWith("_HEAD")) && m != Material.PISTON_HEAD) + || m == Material.CHEST + || m == Material.TRAPPED_CHEST + || m == Material.ENDER_CHEST + || n.endsWith("COPPER_CHEST") + || m == Material.CONDUIT + || m == Material.DECORATED_POT + || m == Material.BELL + || n.endsWith("COPPER_GOLEM_STATUE") + || m == Material.END_PORTAL + || m == Material.END_GATEWAY) { + BLOCK_ENTITY_MATERIALS.add(m); + } + } + } + + private final AssetReader reader; private final TextureCache textures; private final BlockStateResolver blockStateResolver; private final ModelResolver modelResolver; @@ -27,18 +66,7 @@ public final class BlockModelRegistry { private final Map cache = new ConcurrentHashMap<>(); private final Map modelCache = new ConcurrentHashMap<>(); - - private final AssetReader reader; - - /** The texture cache the models are baked from (also used for the fluid sprites). */ - public TextureCache textures() { - return textures; - } - - /** The asset reader this registry resolves models with (shared by item-model resolution). */ - public AssetReader reader() { - return reader; - } + private final Map> variantCache = new ConcurrentHashMap<>(); public BlockModelRegistry(AssetReader reader, TextureCache textures) { this.reader = reader; @@ -48,128 +76,82 @@ public final class BlockModelRegistry { this.baker = new ModelBaker(textures); } + public TextureCache textures() { + return textures; + } + + public AssetReader reader() { + return reader; + } + public ResolvedModel get(BlockData data) { - return cache.computeIfAbsent(data, this::resolve); + ResolvedModel model = cache.get(data); + return model != null ? model : cache.computeIfAbsent(data, this::resolve); } /** - * A block model by id (e.g. {@code block/item_frame_map}) without a blockstate, unrotated: for models - * vanilla draws from a fake block state (item frames). Empty geometry when the model is missing. + * A block model by id (e.g. {@code block/item_frame_map}) without a blockstate, unrotated: for models vanilla + * draws from a fake block state (item frames). Empty when the model is missing. */ public ResolvedModel model(ResourceLocation modelId) { return modelCache.computeIfAbsent(modelId, id -> { - ModelBaker.BakedGeometry baked = baker.bake(modelResolver.resolve(id), new Variant(id, 0, 0, false)); - return new ResolvedModel(baked.elements(), baked.averageColor(), 0, 0, false, baked.hasGeometry()); + List elements = bake(new Variant(id, 0, 0, false)); + return new ResolvedModel(elements, !elements.isEmpty()); }); } + private List bake(Variant variant) { + return variantCache.computeIfAbsent(variant, v -> baker.bake(modelResolver.resolve(v.model()), v)); + } + private ResolvedModel resolve(BlockData data) { Material material = data.getMaterial(); - // Technical blocks that are invisible in the world (their textures only show held in hand), and liquid - // blocks (vanilla RenderShape.INVISIBLE: water, lava and bubble columns are drawn by the fluid renderer, - // see render.raytrace.FluidRenderer; block displays of them show nothing, like vanilla). - if (INVISIBLE_MATERIALS.contains(material)) { - return new ResolvedModel(List.of(), 0, 0, 0, false, false); - } + if (INVISIBLE_MATERIALS.contains(material)) return ResolvedModel.EMPTY; List variants = blockStateResolver.resolve(data); - - List elements = new ArrayList<>(); - AverageColor.Accumulator avgColor = new AverageColor.Accumulator(); - FlatModel lastFlat = null; - - for (Variant variant : variants) { - FlatModel flat = modelResolver.resolve(variant.model()); - lastFlat = flat; - ModelBaker.BakedGeometry baked = baker.bake(flat, variant); - elements.addAll(baked.elements()); - if (baked.hasGeometry()) { - avgColor.add(baked.averageColor()); - } + List elements; + if (variants.size() == 1) { + elements = bake(variants.getFirst()); // shared with other states of the same variant; never mutated + } else { + elements = new ArrayList<>(); + for (Variant variant : variants) elements.addAll(bake(variant)); } + if (!elements.isEmpty()) return new ResolvedModel(elements, true); - if (!elements.isEmpty()) { - return new ResolvedModel(elements, avgColor.average(0xFF7F7F7F), 0, 0, false, true); - } - - int avg = fallbackColor(lastFlat); - - // Block-entities (chests, banners, heads, shulker boxes, …) use builtin/entity models with no - // geometry; their real shape is rendered separately through the entity scene. Return empty - // geometry so the ray passes through to that geometry instead of drawing a grey fallback cube. - if (isBlockEntityRendered(material)) { - return new ResolvedModel(List.of(), avg, 0, 0, false, false); - } - - // No geometry: render a flat full cube using a fallback average color. - return new ResolvedModel(List.of(solidCube(avg)), avg, 0, 0, false, false); + // Block entities (builtin/entity models) are drawn by the entity scene: no grey cube in front of them. + if (BLOCK_ENTITY_MATERIALS.contains(material)) return ResolvedModel.EMPTY; + FlatModel last = variants.isEmpty() + ? null + : modelResolver.resolve(variants.getLast().model()); + return new ResolvedModel(List.of(solidCube(particleColor(last))), false); } - /** Technical blocks that render nothing in the world (barrier/light/structure void are hand-only). */ - private static final EnumSet INVISIBLE_MATERIALS = buildInvisibleMaterials(); - - private static EnumSet buildInvisibleMaterials() { - EnumSet set = EnumSet.noneOf(Material.class); - for (String n : new String[] {"BARRIER", "LIGHT", "STRUCTURE_VOID", "WATER", "LAVA", "BUBBLE_COLUMN"}) { - try { - set.add(Material.valueOf(n)); - } catch (IllegalArgumentException ignored) { - /* older/newer server */ - } - } - return set; - } - - /** Block materials whose shape is drawn by the block-entity renderer, not the block-model pipeline. */ - private static final EnumSet BLOCK_ENTITY_MATERIALS = buildBlockEntityMaterials(); - - private static boolean isBlockEntityRendered(Material material) { - return BLOCK_ENTITY_MATERIALS.contains(material); - } - - private static EnumSet buildBlockEntityMaterials() { - // Matched purely by name/identity (no Material#isBlock, which needs a live registry): the set is - // only ever queried with placed block materials, so any item-only matches are harmless. - EnumSet set = EnumSet.noneOf(Material.class); - for (Material m : Material.values()) { - String n = m.name(); - boolean match = n.endsWith("_SIGN") // signs: standing/wall/(wall_)hanging - || n.endsWith("_BANNER") // banners: standing/wall - || n.endsWith("SHULKER_BOX") // SHULKER_BOX + _SHULKER_BOX - || ((n.endsWith("_SKULL") || n.endsWith("_HEAD")) && m != Material.PISTON_HEAD) - || m == Material.CHEST - || m == Material.TRAPPED_CHEST - || m == Material.ENDER_CHEST - || n.endsWith("COPPER_CHEST") // copper chests incl. weathered/waxed stages - || m == Material.CONDUIT - || m == Material.DECORATED_POT - || m == Material.BELL - || n.endsWith("COPPER_GOLEM_STATUE") // statue incl. weathered/waxed stages - || m == Material.END_PORTAL - || m == Material.END_GATEWAY; - if (match) set.add(m); - } - return set; - } - - /** A full-block cube whose six faces sample a single solid color (1x1 texture). */ - private Element solidCube(int color) { + /** A full cube whose faces sample one solid colour. */ + private static Element solidCube(int color) { int[][] tex = {{color}}; Face[] faces = new Face[6]; - for (Direction d : Direction.values()) { - faces[d.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, -1); - } - return new Element(new double[] {0, 0, 0}, new double[] {1, 1, 1}, faces, null, -1, 0, false); + for (Direction d : Direction.values()) faces[d.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, -1); + return new Element(new double[] {0, 0, 0}, new double[] {1, 1, 1}, faces); } - private int fallbackColor(FlatModel flat) { - if (flat != null && flat.textures() != null) { - String particle = flat.textures().get("particle"); - if (particle != null && !particle.startsWith("#")) { - int[][] tex = textures.get(ResourceLocation.parse(particle)).orElse(null); - if (tex != null) return AverageColor.of(tex); + /** Average colour of the model's particle texture (texels with alpha > 16), else grey. */ + private int particleColor(FlatModel flat) { + String particle = flat == null ? null : flat.textures().get("particle"); + if (particle == null || particle.startsWith("#")) return FALLBACK_GREY; + int[][] tex = textures.get(ResourceLocation.parse(particle)).orElse(null); + if (tex == null) return FALLBACK_GREY; + long r = 0, g = 0, b = 0; + int count = 0; + for (int[] row : tex) { + for (int argb : row) { + if (!ColorUtil.opaque(argb)) continue; + r += ColorUtil.red(argb); + g += ColorUtil.green(argb); + b += ColorUtil.blue(argb); + count++; } } - return 0xFF7F7F7F; + if (count == 0) return FALLBACK_GREY; + return ColorUtil.argb(0xFF, (int) (r / count), (int) (g / count), (int) (b / count)); } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockStateProperties.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockStateProperties.java deleted file mode 100644 index adb829d..0000000 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockStateProperties.java +++ /dev/null @@ -1,40 +0,0 @@ -package eu.mhsl.minecraft.pixelpics.assets; - -import java.util.LinkedHashMap; -import java.util.Map; -import org.bukkit.block.data.BlockData; - -/** - * Parses the property map and plain block name out of a {@link BlockData} string such as - * {@code minecraft:oak_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]}. - */ -public final class BlockStateProperties { - - private BlockStateProperties() {} - - /** The block name without namespace, e.g. {@code oak_stairs}. */ - public static String blockName(BlockData data) { - String s = data.getAsString(false); - int bracket = s.indexOf('['); - String id = bracket < 0 ? s : s.substring(0, bracket); - int colon = id.indexOf(':'); - return (colon < 0 ? id : id.substring(colon + 1)).trim(); - } - - /** The {@code prop -> value} map (empty when the block has no properties). */ - public static Map properties(BlockData data) { - Map props = new LinkedHashMap<>(); - String s = data.getAsString(false); - int open = s.indexOf('['); - int close = s.lastIndexOf(']'); - if (open < 0 || close < 0 || close <= open) return props; - - String body = s.substring(open + 1, close); - for (String pair : body.split(",")) { - int eq = pair.indexOf('='); - if (eq < 0) continue; - props.put(pair.substring(0, eq).trim(), pair.substring(eq + 1).trim()); - } - return props; - } -} 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 ae1e58d..8e547e6 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockStateResolver.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockStateResolver.java @@ -4,14 +4,12 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import org.bukkit.block.data.BlockData; -/** - * Resolves a {@link BlockData} to the list of model variants vanilla would render, by reading the - * block's {@code blockstates/.json} (either {@code variants} or {@code multipart}). - */ +/** Resolves a {@link BlockData} to the model variants of its {@code blockstates/.json} (variants or multipart). */ public final class BlockStateResolver { private final AssetReader reader; @@ -21,8 +19,21 @@ public final class BlockStateResolver { } public List resolve(BlockData data) { - String name = BlockStateProperties.blockName(data); - Map props = BlockStateProperties.properties(data); + // e.g. minecraft:oak_stairs[facing=east,half=bottom,shape=straight,waterlogged=false] + String state = data.getAsString(false); + int open = state.indexOf('['); + String id = open < 0 ? state : state.substring(0, open); + String name = id.substring(id.indexOf(':') + 1).trim(); + Map props = new HashMap<>(); + int close = state.lastIndexOf(']'); + if (open >= 0 && close > open) { + for (String pair : state.substring(open + 1, close).split(",")) { + int eq = pair.indexOf('='); + if (eq >= 0) + props.put( + pair.substring(0, eq).trim(), pair.substring(eq + 1).trim()); + } + } JsonObject root = reader.readJsonObject(AssetPaths.blockState(name)).orElse(null); if (root == null) return List.of(); @@ -36,7 +47,7 @@ public final class BlockStateResolver { return List.of(); } - private List resolveVariants(JsonObject variants, Map props) { + private static List resolveVariants(JsonObject variants, Map props) { for (Map.Entry entry : variants.entrySet()) { if (variantKeyMatches(entry.getKey(), props)) { return List.of(parseVariant(firstOf(entry.getValue()))); @@ -45,7 +56,7 @@ public final class BlockStateResolver { return List.of(); } - private List resolveMultipart(JsonArray multipart, Map props) { + private static List resolveMultipart(JsonArray multipart, Map props) { List result = new ArrayList<>(); for (JsonElement caseEl : multipart) { JsonObject caseObj = caseEl.getAsJsonObject(); @@ -55,8 +66,8 @@ public final class BlockStateResolver { return result; } - /** A variant key like {@code facing=east,half=bottom} matches when every pair holds for the block. */ - private boolean variantKeyMatches(String key, Map props) { + /** A variant key like {@code facing=east,half=bottom} matches when every pair holds. */ + private static boolean variantKeyMatches(String key, Map props) { if (key.isEmpty()) return true; for (String pair : key.split(",")) { int eq = pair.indexOf('='); @@ -68,7 +79,7 @@ public final class BlockStateResolver { return true; } - private boolean whenMatches(JsonElement when, Map props) { + private static boolean whenMatches(JsonElement when, Map props) { JsonObject obj = when.getAsJsonObject(); if (obj.has("OR")) { for (JsonElement sub : obj.getAsJsonArray("OR")) { @@ -82,7 +93,6 @@ public final class BlockStateResolver { } return true; } - // Implicit AND over property conditions. for (Map.Entry e : obj.entrySet()) { if (!valueMatches(props.get(e.getKey()), e.getValue().getAsString())) return false; } @@ -90,7 +100,7 @@ public final class BlockStateResolver { } /** A condition value may be an OR list like {@code "north|south"}. */ - private boolean valueMatches(String actual, String expected) { + private static boolean valueMatches(String actual, String expected) { if (actual == null) return false; for (String option : expected.split("\\|")) { if (option.equals(actual)) return true; @@ -98,14 +108,15 @@ public final class BlockStateResolver { return false; } - private JsonObject firstOf(JsonElement element) { + /** Weighted variant lists: the first entry. */ + private static JsonObject firstOf(JsonElement element) { if (element.isJsonArray()) { return element.getAsJsonArray().get(0).getAsJsonObject(); } return element.getAsJsonObject(); } - private Variant parseVariant(JsonObject obj) { + private static Variant parseVariant(JsonObject obj) { ResourceLocation model = ResourceLocation.parse(obj.get("model").getAsString()); int x = obj.has("x") ? obj.get("x").getAsInt() : 0; int y = obj.has("y") ? obj.get("y").getAsInt() : 0; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/CompositeResourcePack.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/CompositeResourcePack.java index 3eacafa..c85fffe 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/CompositeResourcePack.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/CompositeResourcePack.java @@ -3,10 +3,7 @@ package eu.mhsl.minecraft.pixelpics.assets; import java.util.List; import java.util.Optional; -/** - * Combines several packs into one; the first pack that contains an asset wins. Lets an admin drop - * both an unpacked directory and one or more {@code .zip} packs. - */ +/** Several packs as one; the first pack containing an asset wins. */ public final class CompositeResourcePack implements ResourcePack { private final List packs; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/DirectoryResourcePack.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/DirectoryResourcePack.java index d928be6..9929bf4 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/DirectoryResourcePack.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/DirectoryResourcePack.java @@ -5,10 +5,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Optional; -/** - * A {@link ResourcePack} backed by a directory on disk. The root typically contains the - * {@code assets/} folder. - */ +/** A {@link ResourcePack} backed by a directory (the one containing {@code assets/}). */ public final class DirectoryResourcePack implements ResourcePack { private final Path root; @@ -19,7 +16,7 @@ public final class DirectoryResourcePack implements ResourcePack { private Optional resolve(String path) { Path resolved = root.resolve(path).normalize(); - // Guard against path traversal outside the pack root. + // No path traversal outside the pack root. if (!resolved.startsWith(root)) return Optional.empty(); return Optional.of(resolved); } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/FlatModel.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/FlatModel.java index d86b302..44dbfc6 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/FlatModel.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/FlatModel.java @@ -5,8 +5,8 @@ import java.util.List; import java.util.Map; /** - * A model with its parent chain flattened: textures merged (child wins) and the nearest non-empty - * {@code elements} list selected (vanilla does not merge elements across parents). + * A model with its parent chain flattened: textures merged (child wins), elements from the nearest model that has + * any (vanilla does not merge elements across parents). */ public record FlatModel(Map textures, List elements) { 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 a580b23..3321208 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ImageCache.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ImageCache.java @@ -1,13 +1,13 @@ package eu.mhsl.minecraft.pixelpics.assets; +import java.awt.image.BufferedImage; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; /** - * Thread-safe cache of ARGB pixel grids keyed by {@code K}. Subclasses implement {@link #load} to - * produce the grid (or {@code null} on failure); a failure is cached as a sentinel so it is not - * retried, and {@link #get} returns empty for it. The grid is indexed {@code [y][x]}, top-left origin. + * Thread-safe cache of ARGB pixel grids ({@code [y][x]}, top-left origin). Failed loads ({@link #load} returns null) + * are cached too and never retried. */ public abstract class ImageCache { @@ -15,7 +15,6 @@ public abstract class ImageCache { private final Map cache = new ConcurrentHashMap<>(); - /** Loads the pixel grid for {@code key}, or returns {@code null} if it cannot be loaded. */ protected abstract int[][] load(K key); public Optional get(K key) { @@ -23,6 +22,13 @@ 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) { + int[][] out = new int[h][]; + for (int y = 0; y < h; y++) out[y] = img.getRGB(0, y, w, 1, null, 0, w); + return out; + } + private int[][] loadOrSentinel(K key) { int[][] loaded = load(key); return loaded == null ? MISSING : loaded; 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 2678342..212c412 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ModelBaker.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ModelBaker.java @@ -1,7 +1,6 @@ package eu.mhsl.minecraft.pixelpics.assets; import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto; -import eu.mhsl.minecraft.pixelpics.assets.model.AverageColor; import eu.mhsl.minecraft.pixelpics.assets.model.Direction; import eu.mhsl.minecraft.pixelpics.assets.model.Element; import eu.mhsl.minecraft.pixelpics.assets.model.Face; @@ -10,11 +9,8 @@ import java.util.List; import java.util.Map; /** - * Bakes a {@link FlatModel} plus a blockstate {@link Variant} into intersectable {@link Element} - * boxes. Texture variables are resolved against the model's merged texture map; default UVs follow - * the vanilla element extents; blockstate {@code x}/{@code y} rotations (90-degree steps) are baked - * into geometry and face directions, while a model element's own rotation is preserved for OBB - * intersection. + * Bakes a {@link FlatModel} plus a blockstate {@link Variant} into {@link Element} boxes. Blockstate x/y rotations + * (90° steps) are applied to box, faces and the element's own rotation; rotated elements keep their model-space copy. */ public final class ModelBaker { @@ -24,26 +20,19 @@ public final class ModelBaker { this.textures = textures; } - public record BakedGeometry(List elements, int averageColor, boolean hasGeometry) {} + public List bake(FlatModel model, Variant variant) { + if (!model.hasElements()) return List.of(); - public BakedGeometry bake(FlatModel model, Variant variant) { - if (!model.hasElements()) { - return new BakedGeometry(List.of(), 0, false); - } - - int xSteps = ((variant.x() / 90) % 4 + 4) % 4; - int ySteps = ((variant.y() / 90) % 4 + 4) % 4; + int xSteps = Math.floorMod(variant.x() / 90, 4); + int ySteps = Math.floorMod(variant.y() / 90, 4); List baked = new ArrayList<>(); - AverageColor.Accumulator avgColor = new AverageColor.Accumulator(); - for (ModelFileDto.ElementDto dto : model.elements()) { if (dto.from == null || dto.to == null) continue; double[] from = {dto.from[0] / 16.0, dto.from[1] / 16.0, dto.from[2] / 16.0}; double[] to = {dto.to[0] / 16.0, dto.to[1] / 16.0, dto.to[2] / 16.0}; - // Build faces (pre variant rotation). Face[] faces = new Face[6]; if (dto.faces != null) { for (Map.Entry e : dto.faces.entrySet()) { @@ -54,7 +43,6 @@ public final class ModelBaker { } } - // Element's own rotation (origin in 0..1, axis index, radians). double[] rotOrigin = null; int rotAxis = -1; double rotAngle = 0; @@ -75,97 +63,61 @@ public final class ModelBaker { } } - Element modelElement = rotMatrix != null - ? new Element(from, to, faces, rotOrigin, rotMatrix, rescale, null, 0, 0) - : new Element(from, to, faces, rotOrigin, rotAxis, rotAngle, rescale); + Element modelElement = + new Element(from, to, faces, rotOrigin, rotAxis, rotAngle, rotMatrix, rescale, null, 0, 0); - // Apply blockstate variant rotation (90-degree steps) to box + faces + element rotation. - for (int i = 0; i < xSteps; i++) { - double[][] r = rotateBoxX(from, to); - from = r[0]; - to = r[1]; - faces = rotateFacesX(faces); - if (rotAxis >= 0) rotOrigin = rotatePointX(rotOrigin); - if (rotAxis >= 0 && rotAxis < Element.AXIS_MATRIX) { - AxisRotation na = rotateAxisX(rotAxis); - rotAxis = na.axis(); - if (na.flip()) rotAngle = -rotAngle; - } - } - for (int i = 0; i < ySteps; i++) { - double[][] r = rotateBoxY(from, to); - from = r[0]; - to = r[1]; - faces = rotateFacesY(faces); - if (rotAxis >= 0) rotOrigin = rotatePointY(rotOrigin); - if (rotAxis >= 0 && rotAxis < Element.AXIS_MATRIX) { - AxisRotation na = rotateAxisY(rotAxis); - rotAxis = na.axis(); - if (na.flip()) rotAngle = -rotAngle; - } - } - - // Rotated variants keep the model-space element; intersectors hit it with the inverse-rotated ray - // (vanilla bakes the same way). Without uvlock the textures thus turn with the model; with uvlock - // each face's final UVs are additionally transformed so the texture stays world-aligned. + // Rotated variants keep the model-space element; intersectors hit it with the inverse-rotated ray (as + // vanilla's FaceBakery, textures turn with the model). Under uvlock its faces get a UV transform instead. + // The world box is min/max-normalised, so only the model-space copy of an inverted element stays inverted. Element unrotated = null; if (xSteps != 0 || ySteps != 0) { unrotated = variant.uvlock() ? uvLocked(modelElement, xSteps, ySteps) : modelElement; + double[][] box = minMax(rotatePoint(from, xSteps, ySteps), rotatePoint(to, xSteps, ySteps)); + from = box[0]; + to = box[1]; + Face[] rotated = new Face[6]; + for (Direction d : Direction.values()) { + rotated[d.rotateX(xSteps).rotateY(ySteps).ordinal()] = faces[d.ordinal()]; + } + faces = rotated; + if (rotAxis >= 0) rotOrigin = rotatePoint(rotOrigin, xSteps, ySteps); + if (rotAxis >= 0 && rotAxis < Element.AXIS_MATRIX) { + Direction axis = + Direction.of(rotAxis, false).rotateX(xSteps).rotateY(ySteps); + rotAxis = axis.axis; + if (axis.nx + axis.ny + axis.nz < 0) rotAngle = -rotAngle; + } } - baked.add( - rotMatrix != null - ? new Element( - from, - to, - faces, - rotOrigin, - conjugate(rotMatrix, xSteps, ySteps), - rescale, - unrotated, - xSteps, - ySteps) - : new Element( - from, to, faces, rotOrigin, rotAxis, rotAngle, rescale, unrotated, xSteps, ySteps)); - - // Accumulate average color from the element's face textures. - for (Face f : faces) { - if (f == null) continue; - avgColor.add(AverageColor.of(f.texture)); - } + baked.add(new Element( + from, + to, + faces, + rotOrigin, + rotAxis, + rotAngle, + rotMatrix == null ? null : conjugate(rotMatrix, xSteps, ySteps), + rescale, + unrotated, + xSteps, + ySteps)); } - - return new BakedGeometry(baked, avgColor.average(0xFF7F7F7F), !baked.isEmpty()); + return baked; } private Face buildFace( Direction dir, ModelFileDto.FaceDto dto, double[] from, double[] to, Map textureVars) { int[][] tex = resolveTexture(dto.texture, textureVars); if (tex == null) return null; - - double u1, v1, u2, v2; - if (dto.uv != null && dto.uv.length == 4) { - u1 = dto.uv[0] / 16.0; - v1 = dto.uv[1] / 16.0; - u2 = dto.uv[2] / 16.0; - v2 = dto.uv[3] / 16.0; - } else { - double[] d = defaultUv(dir, from, to); - u1 = d[0]; - v1 = d[1]; - u2 = d[2]; - v2 = d[3]; - } + double[] uv = dto.uv != null && dto.uv.length == 4 + ? new double[] {dto.uv[0] / 16.0, dto.uv[1] / 16.0, dto.uv[2] / 16.0, dto.uv[3] / 16.0} + : defaultUv(dir, from, to); int tint = dto.tintindex != null ? dto.tintindex : -1; - return new Face(tex, u1, v1, u2, v2, dto.rotation, tint); + return new Face(tex, uv[0], uv[1], uv[2], uv[3], dto.rotation, tint); } - /** - * Default UV (normalized 0..1) from the element extents, matching vanilla. Texture V is top-down, - * so for side faces v1 (texture top) corresponds to the element's high-Y edge: v = [1-to.y, 1-from.y]. - */ - private double[] defaultUv(Direction dir, double[] f, double[] t) { + /** Vanilla {@code BlockElement.uvsByFace}, normalised (texture v is top-down). */ + private static double[] defaultUv(Direction dir, double[] f, double[] t) { return switch (dir) { - // Vanilla BlockElement.uvsByFace (u/v in 0..1). case DOWN -> new double[] {f[0], 1 - t[2], t[0], 1 - f[2]}; case UP -> new double[] {f[0], f[2], t[0], t[2]}; case NORTH -> new double[] {1 - t[0], 1 - t[1], 1 - f[0], 1 - f[1]}; @@ -217,25 +169,21 @@ public final class ModelBaker { return r; } - /** The model-space element with every face's UVs locked to the world under the given rotation. */ private static Element uvLocked(Element e, int xSteps, int ySteps) { Face[] locked = new Face[6]; for (Direction d : Direction.values()) { Face f = e.faces[d.ordinal()]; if (f != null) locked[d.ordinal()] = f.withUvLock(uvLockTransform(d, xSteps, ySteps)); } - if (e.rotMatrix != null) - return new Element(e.from, e.to, locked, e.rotOrigin, e.rotMatrix, e.rescale, null, 0, 0); - return new Element(e.from, e.to, locked, e.rotOrigin, e.rotAxis, e.rotAngleRad, e.rescale); + return e.withFaces(locked); } /** * Vanilla uvlock ({@code BlockMath.getFaceTransformation}, inverted as in {@code FaceBakery}): the final * texture coordinates of model face {@code d}, centred on the texture, are mapped by * {@code M = G2L(d) · R⁻¹ · L2G(D)} where R is the blockstate rotation, D the world face {@code d} turns into, - * and L2G the per-face UV frames (south = identity). For side faces this keeps the texture world-aligned; - * up/down under x rotations follow vanilla's frames (not strictly world-aligned). M is one of the 8 symmetries - * of the unit UV square, returned as the affine of {@link Face#uvLock}. + * and L2G the per-face UV frames (south = identity). Up/down under x rotations follow vanilla's frames (not + * strictly world-aligned). M is one of the 8 symmetries of the unit UV square, as the affine of {@link Face#uvLock}. */ static double[] uvLockTransform(Direction d, int xSteps, int ySteps) { Direction world = d.rotateX(xSteps).rotateY(ySteps); @@ -275,7 +223,7 @@ public final class ModelBaker { return textures.get(ResourceLocation.parse(current)).orElse(null); } - private int axisIndex(String axis) { + private static int axisIndex(String axis) { if (axis == null) return -1; return switch (axis.toLowerCase()) { case "x" -> 0; @@ -285,74 +233,27 @@ public final class ModelBaker { }; } - // --- 90-degree box rotations around the block center (0.5, 0.5, 0.5) --- - - private double[][] rotateBoxY(double[] from, double[] to) { - // 90 deg clockwise viewed from above (vanilla), see rotatePointY. - double[] c1 = rotatePointY(from); - double[] c2 = rotatePointY(to); - return minMax(c1, c2); + /** + * Blockstate rotation of a point about the block centre: x steps, then y steps, both clockwise like vanilla + * (y: north -> east). Re-centred after every step instead of using {@link Element#rotate}: that one adds + * {@code (p - 0.5) + 0.5} on the fixed axis, which is not exact for every coordinate. + */ + private static double[] rotatePoint(double[] p, int xSteps, int ySteps) { + double[] r = p; + for (int i = 0; i < xSteps; i++) { + double y = r[1] - 0.5, z = r[2] - 0.5; + r = new double[] {r[0], 0.5 + z, 0.5 - y}; + } + for (int i = 0; i < ySteps; i++) { + double x = r[0] - 0.5, z = r[2] - 0.5; + r = new double[] {0.5 - z, r[1], 0.5 + x}; + } + return r; } - private double[][] rotateBoxX(double[] from, double[] to) { - double[] c1 = rotatePointX(from); - double[] c2 = rotatePointX(to); - return minMax(c1, c2); - } - - private double[] rotatePointY(double[] p) { - // Vanilla blockstate "y": clockwise seen from above (north -> east, south -> west), i.e. a rotation - // by -90° about +Y: (dx, dz) -> (-dz, dx). Must match Direction.rotateY used for the faces. - double x = p[0] - 0.5, z = p[2] - 0.5; - return new double[] {0.5 - z, p[1], 0.5 + x}; - } - - private double[] rotatePointX(double[] p) { - double y = p[1] - 0.5, z = p[2] - 0.5; - return new double[] {p[0], 0.5 + z, 0.5 - y}; - } - - private double[][] minMax(double[] a, double[] b) { + private static double[][] minMax(double[] a, double[] b) { double[] from = {Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.min(a[2], b[2])}; double[] to = {Math.max(a[0], b[0]), Math.max(a[1], b[1]), Math.max(a[2], b[2])}; return new double[][] {from, to}; } - - private Face[] rotateFacesY(Face[] faces) { - Face[] out = new Face[6]; - for (Direction d : Direction.values()) { - out[d.rotateY(1).ordinal()] = faces[d.ordinal()]; - } - return out; - } - - private Face[] rotateFacesX(Face[] faces) { - Face[] out = new Face[6]; - for (Direction d : Direction.values()) { - out[d.rotateX(1).ordinal()] = faces[d.ordinal()]; - } - return out; - } - - // Rotating an element's own rotation axis under a 90-degree block rotation. - // {@code flip} indicates the angle sign should invert. - private record AxisRotation(int axis, boolean flip) {} - - private AxisRotation rotateAxisY(int axis) { - // Clockwise Y rotation maps +x -> +z and +z -> -x; the y axis is unchanged. - return switch (axis) { - case 0 -> new AxisRotation(2, false); // x -> z - case 2 -> new AxisRotation(0, true); // z -> -x - default -> new AxisRotation(axis, false); - }; - } - - private AxisRotation rotateAxisX(int axis) { - // X rotation maps y<->z; the x axis is unchanged. - return switch (axis) { - case 1 -> new AxisRotation(2, true); // y -> z - case 2 -> new AxisRotation(1, false); // z -> y - default -> new AxisRotation(axis, false); - }; - } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ModelResolver.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ModelResolver.java index 0ed3f59..30f9dd1 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ModelResolver.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ModelResolver.java @@ -1,15 +1,13 @@ package eu.mhsl.minecraft.pixelpics.assets; +import com.google.gson.JsonElement; import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -/** - * Loads block models and flattens their parent chains into {@link FlatModel}s. Results are cached by - * model id. - */ +/** Loads block models and flattens their parent chains into {@link FlatModel}s, cached per model id. */ public final class ModelResolver { private static final int MAX_DEPTH = 16; @@ -22,15 +20,11 @@ public final class ModelResolver { } public FlatModel resolve(ResourceLocation modelId) { - FlatModel cached = cache.get(modelId); - if (cached != null) return cached; - FlatModel resolved = resolve(modelId, 0); - cache.put(modelId, resolved); - return resolved; + return resolve(modelId, 0); } /** A texture value: a plain reference, or the {@code sprite} of a 26.x texture object; null otherwise. */ - public static String textureRef(com.google.gson.JsonElement v) { + public static String textureRef(JsonElement v) { if (v == null) return null; if (v.isJsonPrimitive()) return v.getAsString(); if (v.isJsonObject() && v.getAsJsonObject().has("sprite")) { @@ -39,30 +33,34 @@ public final class ModelResolver { return null; } + // Parents go through the cache too (shared chains like block/cube_all are parsed once). No computeIfAbsent: + // the recursion would modify the map from inside its own compute. depth only guards against parent cycles. private FlatModel resolve(ResourceLocation modelId, int depth) { + FlatModel cached = cache.get(modelId); + if (cached != null) return cached; + ModelFileDto dto = reader.readJson(AssetPaths.model(modelId), ModelFileDto.class).orElse(null); + FlatModel resolved; if (dto == null) { - return new FlatModel(new HashMap<>(), null); - } - - Map textures = new HashMap<>(); - List elements = dto.elements; - - if (dto.parent != null && depth < MAX_DEPTH && !dto.parent.startsWith("builtin/")) { - FlatModel parent = resolve(ResourceLocation.parse(dto.parent), depth + 1); - textures.putAll(parent.textures()); - if (elements == null || elements.isEmpty()) { - elements = parent.elements(); + resolved = new FlatModel(new HashMap<>(), null); + } else { + Map textures = new HashMap<>(); + List elements = dto.elements; + if (dto.parent != null && depth < MAX_DEPTH && !dto.parent.startsWith("builtin/")) { + FlatModel parent = resolve(ResourceLocation.parse(dto.parent), depth + 1); + textures.putAll(parent.textures()); + if (elements == null || elements.isEmpty()) elements = parent.elements(); } + if (dto.textures != null) { + dto.textures.forEach((key, value) -> { + String ref = textureRef(value); + if (ref != null) textures.put(key, ref); + }); + } + resolved = new FlatModel(textures, elements); } - if (dto.textures != null) { - dto.textures.forEach((key, value) -> { - String ref = textureRef(value); - if (ref != null) textures.put(key, ref); - }); - } - - return new FlatModel(textures, elements); + FlatModel raced = cache.putIfAbsent(modelId, resolved); + return raced != null ? raced : resolved; } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ResourceLocation.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ResourceLocation.java index 0e06eec..b78b9e5 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ResourceLocation.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ResourceLocation.java @@ -1,9 +1,6 @@ package eu.mhsl.minecraft.pixelpics.assets; -/** - * A namespaced identifier as used throughout Minecraft assets, e.g. {@code minecraft:block/stone}. - * When no namespace is present the default {@code minecraft} is assumed. - */ +/** A namespaced asset id such as {@code minecraft:block/stone}; lower-cased, namespace defaults to minecraft. */ public record ResourceLocation(String namespace, String path) { public static final String DEFAULT_NAMESPACE = "minecraft"; @@ -13,9 +10,6 @@ public record ResourceLocation(String namespace, String path) { path = path.toLowerCase(); } - /** - * Parses a string like {@code minecraft:block/stone} or {@code block/stone}. - */ public static ResourceLocation parse(String raw) { int colon = raw.indexOf(':'); if (colon < 0) { diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ResourcePack.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ResourcePack.java index d9fabfb..5f9e4bf 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ResourcePack.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ResourcePack.java @@ -4,22 +4,13 @@ import java.io.Closeable; import java.util.Optional; /** - * A read-only source of Minecraft assets. Paths are pack-relative and always use {@code /} as a - * separator, e.g. {@code assets/minecraft/blockstates/oak_fence.json}. - * - *

Implementations must be safe for concurrent {@link #read} calls, since the renderer accesses - * assets from multiple threads. + * A read-only source of assets by pack-relative {@code /}-separated path, e.g. + * {@code assets/minecraft/blockstates/oak_fence.json}. Must be safe for concurrent reads (parallel renderer). */ public interface ResourcePack extends Closeable { - /** - * Reads the raw bytes of an asset, or an empty optional if it does not exist. - */ Optional read(String path); - /** - * Whether the given asset path exists in this pack. - */ boolean exists(String path); @Override 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 1bb1da7..9906e01 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ResourcePackLoader.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ResourcePackLoader.java @@ -11,13 +11,11 @@ import java.util.logging.Logger; import java.util.stream.Stream; /** - * Builds a {@link ResourcePack} from the plugin's {@code resourcepack/} data folder. The folder may - * contain an unpacked pack (a directory with {@code assets/minecraft/...}) and/or one or more - * {@code .zip} packs. Returns an empty optional when nothing usable is found. + * 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. */ public final class ResourcePackLoader { - /** The marker that identifies a valid pack root: {@code /assets/minecraft}. */ private static final String MARKER = "assets/minecraft"; private ResourcePackLoader() {} @@ -29,7 +27,6 @@ public final class ResourcePackLoader { List packs = new ArrayList<>(); - // Directory packs: probe the folder itself and any direct sub-folder for the assets marker. List dirCandidates = new ArrayList<>(); dirCandidates.add(resourcePackDir.toPath()); File[] children = resourcePackDir.listFiles(File::isDirectory); @@ -43,7 +40,6 @@ public final class ResourcePackLoader { } } - // Zip packs anywhere under the resourcepack folder. try (Stream walk = Files.walk(resourcePackDir.toPath())) { List zips = walk.filter(Files::isRegularFile) .filter(p -> p.getFileName().toString().toLowerCase().endsWith(".zip")) @@ -51,7 +47,10 @@ public final class ResourcePackLoader { for (Path zip : zips) { try { ZipResourcePack pack = new ZipResourcePack(zip); - if (pack.exists(MARKER + "/blockstates") || pack.exists("pack.mcmeta") || hasAnyBlockstate(pack)) { + // Zip entries are files only; the client jar has no pack.mcmeta, so probe two vanilla files. + if (pack.exists("pack.mcmeta") + || pack.exists(AssetPaths.blockState("stone")) + || pack.exists(AssetPaths.blockState("dirt"))) { packs.add(pack); logger.info("Loaded resource pack zip: " + zip); } else { @@ -69,9 +68,4 @@ public final class ResourcePackLoader { if (packs.size() == 1) return Optional.of(packs.getFirst()); return Optional.of(new CompositeResourcePack(packs)); } - - private static boolean hasAnyBlockstate(ResourcePack pack) { - // Cheap sanity probe for a couple of guaranteed-present vanilla blockstates. - return pack.exists(AssetPaths.blockState("stone")) || pack.exists(AssetPaths.blockState("dirt")); - } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/SkinCache.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/SkinCache.java index e1aac5b..c385c22 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/SkinCache.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/SkinCache.java @@ -9,9 +9,8 @@ import java.util.Optional; import javax.imageio.ImageIO; /** - * Downloads and caches player skin textures (by URL) as ARGB pixel grids. Legacy 64x32 skins are - * converted to the modern 64x64 layout (so the model's overlay/second-layer bones map correctly). - * Downloads happen off the main thread (from the entity baking step) and are cached. + * Player skins by URL as 64x64 ARGB grids (legacy 64x32 skins converted). Downloaded off the main thread, from + * the baking step. */ public final class SkinCache extends ImageCache { @@ -42,14 +41,11 @@ public final class SkinCache extends ImageCache { /** Reads the image to a 64x64 grid, converting the legacy 64x32 layout (mirrored arm/leg) if needed. */ private int[][] toModern(BufferedImage img) { - int w = img.getWidth(); - int h = img.getHeight(); + int w = Math.min(img.getWidth(), 64); + int h = Math.min(img.getHeight(), 64); int[][] out = new int[64][64]; - for (int y = 0; y < Math.min(h, 64); y++) { - for (int x = 0; x < Math.min(w, 64); x++) { - out[y][x] = img.getRGB(x, y); - } - } + int[][] src = pixels(img, w, h); + for (int y = 0; y < h; y++) System.arraycopy(src[y], 0, out[y], 0, w); if (h <= 32) { // Legacy skin: copy the right arm/leg regions into the modern left arm/leg slots (mirrored). copyMirrored(img, out, 44, 16, 36, 48); // right leg -> left leg (top/quads handled by mirror) 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 b8470aa..e3f97f7 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/TextureCache.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/TextureCache.java @@ -4,15 +4,7 @@ import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; import javax.imageio.ImageIO; -/** - * Loads and caches block textures as raw ARGB pixel grids. Textures are stored unflipped (vanilla - * UV origin is top-left); all orientation is handled by the UV math in the intersector. - * - *

Animated textures (e.g. {@code water_still.png}, a vertical strip of frames) are reduced to their - * first frame — but ONLY when an actual {@code .png.mcmeta} animation file is present. A tall sprite - * without one (e.g. a 64×128 entity texture like the witch/strider) is a real texture, not an animation, - * and must be loaded in full. - */ +/** Pack textures as ARGB grids by id (unflipped; the UV origin is top-left like vanilla). */ public final class TextureCache extends ImageCache { private final ResourcePack pack; @@ -35,17 +27,9 @@ public final class TextureCache extends ImageCache { int width = img.getWidth(); int height = img.getHeight(); - // Reduce animated strips to the first frame — but only a REAL animation (has a .mcmeta); a tall - // sprite without one (e.g. a 64×128 entity texture) is a full texture, not a frame strip. + // 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)); - int frameHeight = animated ? width : height; - - int[][] pixels = new int[frameHeight][width]; - for (int y = 0; y < frameHeight; y++) { - for (int x = 0; x < width; x++) { - pixels[y][x] = img.getRGB(x, y); - } - } - return pixels; + return pixels(img, width, animated ? width : height); } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/Variant.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/Variant.java index 09de069..48daaf0 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/Variant.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/Variant.java @@ -1,7 +1,4 @@ package eu.mhsl.minecraft.pixelpics.assets; -/** - * A resolved blockstate variant: which model to use plus its {@code x}/{@code y} rotation (in - * degrees, multiples of 90) and {@code uvlock}. - */ +/** A blockstate variant: model, {@code x}/{@code y} rotation in degrees (multiples of 90) and {@code uvlock}. */ public record Variant(ResourceLocation model, int x, int y, boolean uvlock) {} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ZipResourcePack.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ZipResourcePack.java index d6d8485..226a1ec 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ZipResourcePack.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ZipResourcePack.java @@ -9,12 +9,7 @@ import java.util.Optional; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; -/** - * A {@link ResourcePack} backed by a {@code .zip} archive (e.g. a vanilla/custom resource pack). - * - *

{@link ZipFile} allows concurrent {@link ZipFile#getInputStream} calls, so reads are thread - * safe. The entry lookup map is built once at construction. - */ +/** A {@link ResourcePack} backed by a {@code .zip}; {@link ZipFile} allows concurrent reads. */ public final class ZipResourcePack implements ResourcePack { private final ZipFile zipFile; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/dto/ModelFileDto.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/dto/ModelFileDto.java index e638a70..e3c6853 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/dto/ModelFileDto.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/dto/ModelFileDto.java @@ -4,10 +4,7 @@ import com.google.gson.JsonElement; import java.util.List; import java.util.Map; -/** - * Gson-bound representation of a vanilla block model JSON - * ({@code assets/minecraft/models/block/*.json}). - */ +/** Gson binding of a block model JSON; coordinates and UVs in 0..16. */ public class ModelFileDto { public String parent; // Values are texture refs ("block/stone", "#side") or, since 26.x, objects {"sprite": …, "force_translucent": …}. @@ -15,26 +12,25 @@ public class ModelFileDto { public List elements; public static class ElementDto { - public double[] from; // 0..16 - public double[] to; // 0..16 - public RotationDto rotation; // optional - public Map faces; // keys: down/up/north/south/west/east + public double[] from; + public double[] to; + public RotationDto rotation; + public Map faces; // keyed by direction name } public static class FaceDto { - public double[] uv; // optional, 0..16 (x1,y1,x2,y2) + public double[] uv; // u1, v1, u2, v2; default from the element extents public String texture; // e.g. "#side" or "minecraft:block/oak_planks" public Integer tintindex; - public String cullface; // ignored by the renderer public int rotation; // 0/90/180/270 } public static class RotationDto { - public double[] origin; // 0..16 - public String axis; // "x" | "y" | "z" - public double angle; // degrees (vanilla models: 22.5 steps) - // Since 26.x alternatively Euler angles in degrees instead of axis/angle (CuboidRotation.EulerXYZRotation, - // matrix Rz·Ry·Rx), e.g. the chains of template_hanging_sign_rot_3. + public double[] origin; + public String axis; + public double angle; // degrees + // Since 26.x alternatively Euler angles in degrees (CuboidRotation.EulerXYZRotation, Rz·Ry·Rx), e.g. the + // chains of template_hanging_sign_rot_3. public Double x, y, z; public boolean rescale; } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/font/BitmapFont.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/font/BitmapFont.java index bf62b56..b6f06f7 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/font/BitmapFont.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/font/BitmapFont.java @@ -2,24 +2,19 @@ package eu.mhsl.minecraft.pixelpics.assets.font; import java.util.Map; -/** - * A Minecraft bitmap font assembled from the resource pack's {@code font/default.json} providers - * (the {@code bitmap} and {@code space} providers; {@code unihex}/{@code legacy_unicode}/{@code ttf} - * are out of scope). Maps codepoints to {@link Glyph}s and supplies advance widths, enough to rasterize - * a line of text. Immutable after construction. - */ +/** Glyphs and advances of the pack's bitmap font (see {@link FontLoader}); immutable. */ public final class BitmapFont { private final Map glyphs; private final Map spaceAdvances; private final int maxAscent; - private final int maxDescent; + private final int lineHeight; public BitmapFont(Map glyphs, Map spaceAdvances, int maxAscent, int maxDescent) { this.glyphs = glyphs; this.spaceAdvances = spaceAdvances; this.maxAscent = maxAscent; - this.maxDescent = maxDescent; + this.lineHeight = maxAscent + maxDescent; } /** The glyph for a codepoint, or null when no bitmap provider supplies it (e.g. CJK via unihex). */ @@ -35,19 +30,14 @@ public final class BitmapFont { return g != null ? g.advance() : 0; } - /** Largest baseline offset across all glyphs (font px) — the common baseline for a rendered line. */ + /** Largest ascent of all glyphs (font px): the common baseline of a line. */ public int maxAscent() { return maxAscent; } - /** Largest below-baseline extent across all glyphs (font px). */ - public int maxDescent() { - return maxDescent; - } - - /** Total vertical extent of one line (ascent + descent, font px). */ + /** Largest ascent + largest descent (font px). */ public int lineHeight() { - return maxAscent + maxDescent; + return lineHeight; } public boolean isEmpty() { diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/font/FontLoader.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/font/FontLoader.java index 4600acf..5af6129 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/font/FontLoader.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/font/FontLoader.java @@ -8,6 +8,8 @@ import eu.mhsl.minecraft.pixelpics.assets.AssetPaths; import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation; import eu.mhsl.minecraft.pixelpics.assets.ResourcePack; import eu.mhsl.minecraft.pixelpics.assets.TextureCache; +import java.io.IOException; +import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.HashSet; @@ -17,19 +19,15 @@ import java.util.Set; import java.util.logging.Logger; /** - * Builds a {@link BitmapFont} from a resource pack's {@code font/default.json}. Resolves {@code reference} - * providers recursively, parses {@code bitmap} providers into per-codepoint {@link Glyph}s (cell size and - * per-glyph pixel width derived from the PNG), and {@code space} providers into advance overrides. - * {@code unihex}/{@code legacy_unicode}/{@code ttf} providers are skipped (CJK etc. render as gaps). - * - *

Provider order is priority order (first match wins, matching vanilla): an earlier provider's glyph is - * never overwritten by a later one. + * Builds a {@link BitmapFont} from the pack's {@code font/default.json}: {@code reference}, {@code bitmap} and + * {@code space} providers; {@code unihex}/{@code legacy_unicode}/{@code ttf} are skipped (CJK etc. render as + * gaps). Earlier providers win, like vanilla. */ public final class FontLoader { private FontLoader() {} - /** Loads {@code minecraft:default}. Returns an empty font (no glyphs) when the pack has no font assets. */ + /** {@code minecraft:default}; a font without glyphs when the pack has no font assets. */ public static BitmapFont load(ResourcePack pack, TextureCache textures, Logger log) { Builder b = new Builder(pack, textures, log); b.loadFont(ResourceLocation.parse("minecraft:default"), new HashSet<>()); @@ -57,10 +55,9 @@ public final class FontLoader { void loadFont(ResourceLocation id, Set visited) { String path = AssetPaths.font(id); - if (!visited.add(path)) return; // cycle guard + if (!visited.add(path)) return; Optional bytes = pack.read(path); - // Packs built from just the textures/models lack font/*.json — use the bundled vanilla font - // definitions (tiny JSON; the glyph sheets still come from the pack's textures/font/). + // Packs without font/*.json: the bundled vanilla definitions (glyph sheets still come from the pack). if (bytes.isEmpty()) bytes = bundled(path); if (bytes.isEmpty()) return; JsonObject root; @@ -68,7 +65,7 @@ public final class FontLoader { root = JsonParser.parseString(new String(bytes.get(), StandardCharsets.UTF_8)) .getAsJsonObject(); } catch (Exception e) { - if (log != null) log.warning("PixelPics: failed to parse font " + path + ": " + e.getMessage()); + log.warning("PixelPics: failed to parse font " + path + ": " + e.getMessage()); return; } JsonArray providers = root.getAsJsonArray("providers"); @@ -80,11 +77,11 @@ public final class FontLoader { } private Optional bundled(String path) { - try (java.io.InputStream in = FontLoader.class.getResourceAsStream("/fallback/" + path)) { + try (InputStream in = FontLoader.class.getResourceAsStream("/fallback/" + path)) { if (in == null) return Optional.empty(); - if (log != null) log.info("PixelPics: resource pack has no " + path + ", using the bundled copy."); + log.info("PixelPics: resource pack has no " + path + ", using the bundled copy."); return Optional.of(in.readAllBytes()); - } catch (java.io.IOException e) { + } catch (IOException e) { return Optional.empty(); } } @@ -97,9 +94,7 @@ public final class FontLoader { } case "space" -> space(p); case "bitmap" -> bitmap(p); - default -> { - /* unihex, legacy_unicode, ttf, … — out of scope */ - } + default -> {} } } @@ -123,7 +118,7 @@ public final class FontLoader { if (file.endsWith(".png")) file = file.substring(0, file.length() - 4); int[][] tex = textures.get(ResourceLocation.parse(file)).orElse(null); if (tex == null || tex.length == 0) { - if (log != null) log.warning("PixelPics: font bitmap missing: " + file); + log.warning("PixelPics: font bitmap missing: " + file); return; } int imgH = tex.length; @@ -132,9 +127,8 @@ public final class FontLoader { JsonArray rows = p.getAsJsonArray("chars"); int nRows = rows.size(); if (nRows == 0) return; - int nCols = rows.get(0) - .getAsString() - .codePointCount(0, rows.get(0).getAsString().length()); + String firstRow = rows.get(0).getAsString(); + int nCols = firstRow.codePointCount(0, firstRow.length()); if (nCols == 0) return; int cellW = imgW / nCols; int cellH = imgH / nRows; @@ -153,7 +147,7 @@ public final class FontLoader { int srcX = thisCol * cellW; int srcY = r * cellH; int glyphPx = rightmostOpaqueColumn(tex, srcX, srcY, cellW, cellH) + 1; - if (glyphPx <= 0) continue; // blank glyph (handled by space provider) + if (glyphPx <= 0) continue; // blank: spaces come from the space provider int advance = (int) (glyphPx * scale + 0.5) + 1; glyphs.put(cp, new Glyph(tex, srcX, srcY, cellW, cellH, glyphPx, height, ascent, advance)); maxAscent = Math.max(maxAscent, ascent); @@ -162,13 +156,13 @@ public final class FontLoader { } } - /** Rightmost column index (0-based, relative to the cell) containing an opaque pixel, or -1 if blank. */ + /** Rightmost cell column with a non-transparent pixel, -1 when blank. */ private static int rightmostOpaqueColumn(int[][] tex, int srcX, int srcY, int cellW, int cellH) { int last = -1; for (int x = 0; x < cellW; x++) { for (int y = 0; y < cellH; y++) { int argb = tex[srcY + y][srcX + x]; - if (((argb >>> 24) & 0xFF) != 0) { + if (argb >>> 24 != 0) { last = x; break; } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/font/Glyph.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/font/Glyph.java index edacf62..7d9ccc0 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/font/Glyph.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/font/Glyph.java @@ -1,19 +1,9 @@ package eu.mhsl.minecraft.pixelpics.assets.font; /** - * A single bitmap-font glyph: a rectangular cell {@code [srcX, srcX+cellW) × [srcY, srcY+cellH)} inside - * its provider PNG ({@code tex}, ARGB top-left), the number of opaque pixel columns to actually draw - * ({@code glyphPx}, measured to the rightmost non-transparent column), the rendered {@code height} and - * {@code ascent} (baseline offset from the top, font px) and the horizontal {@code advance} (font px). - * - *

Glyphs from different providers may declare different cell sizes and ascents (e.g. ascii 8px/ascent - * 7 vs accented 12px/ascent 10); rendering aligns them on a common baseline so umlauts and Latin mix. + * A bitmap-font glyph: its cell in the provider sheet {@code tex}, the drawn width {@code glyphPx} (up to the + * rightmost non-transparent column), and {@code height}, {@code ascent} and {@code advance} in font px. Providers + * differ in cell size and ascent (ascii 8/7, accented 12/10), so lines align glyphs on a common baseline. */ public record Glyph( - int[][] tex, int srcX, int srcY, int cellW, int cellH, int glyphPx, int height, int ascent, int advance) { - - /** Vertical pixels below the baseline (font px). */ - public int descent() { - return height - ascent; - } -} + int[][] tex, int srcX, int srcY, int cellW, int cellH, int glyphPx, int height, int ascent, int advance) {} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/AverageColor.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/AverageColor.java deleted file mode 100644 index 8f5e982..0000000 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/AverageColor.java +++ /dev/null @@ -1,47 +0,0 @@ -package eu.mhsl.minecraft.pixelpics.assets.model; - -import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; - -/** - * Computes the average opaque color of a texture, used as the 100% coverage fallback. - */ -public final class AverageColor { - - private AverageColor() {} - - /** Average ARGB over pixels with alpha > 16; returns opaque gray when fully transparent/empty. */ - public static int of(int[][] texture) { - long r = 0, g = 0, b = 0; - int count = 0; - for (int[] row : texture) { - for (int argb : row) { - if (ColorUtil.alpha(argb) <= 16) continue; - r += ColorUtil.red(argb); - g += ColorUtil.green(argb); - b += ColorUtil.blue(argb); - count++; - } - } - if (count == 0) return 0xFF7F7F7F; - return ColorUtil.argb(0xFF, (int) (r / count), (int) (g / count), (int) (b / count)); - } - - /** Mutable accumulator that averages a set of already-opaque ARGB colors (e.g. per-face/per-variant). */ - public static final class Accumulator { - private long r, g, b; - private int count; - - public void add(int argb) { - r += ColorUtil.red(argb); - g += ColorUtil.green(argb); - b += ColorUtil.blue(argb); - count++; - } - - /** Opaque average of the added colors, or {@code fallback} if none were added. */ - public int average(int fallback) { - if (count == 0) return fallback; - return ColorUtil.argb(0xFF, (int) (r / count), (int) (g / count), (int) (b / count)); - } - } -} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Direction.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Direction.java index d900590..851f1cf 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Direction.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Direction.java @@ -1,9 +1,6 @@ package eu.mhsl.minecraft.pixelpics.assets.model; -/** - * The six block face directions, with unit normals and 90-degree rotation helpers used when baking - * blockstate {@code x}/{@code y} rotations. - */ +/** The six block face directions with unit normals and the blockstate 90° rotations. */ public enum Direction { DOWN(0, -1, 0), UP(0, 1, 0), @@ -54,9 +51,9 @@ public enum Direction { }; } - /** Rotate this direction by {@code steps * 90} degrees around the Y axis (clockwise from above). */ + /** Rotated by {@code steps}·90° about Y, clockwise seen from above (vanilla blockstate {@code y}). */ public Direction rotateY(int steps) { - steps = ((steps % 4) + 4) % 4; + steps = Math.floorMod(steps, 4); Direction d = this; for (int i = 0; i < steps; i++) { d = switch (d) { @@ -64,15 +61,14 @@ public enum Direction { case EAST -> SOUTH; case SOUTH -> WEST; case WEST -> NORTH; - // up/down unchanged default -> d;}; } return d; } - /** Rotate this direction by {@code steps * 90} degrees around the X axis. */ + /** Rotated by {@code steps}·90° about X (vanilla blockstate {@code x}). */ public Direction rotateX(int steps) { - steps = ((steps % 4) + 4) % 4; + steps = Math.floorMod(steps, 4); Direction d = this; for (int i = 0; i < steps; i++) { d = switch (d) { @@ -80,7 +76,6 @@ public enum Direction { case NORTH -> DOWN; case DOWN -> SOUTH; case SOUTH -> UP; - // east/west unchanged default -> d;}; } return d; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Element.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Element.java index c148652..833a171 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Element.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Element.java @@ -1,48 +1,45 @@ package eu.mhsl.minecraft.pixelpics.assets.model; /** - * A single box of a baked block model. Coordinates are normalized to the 0..1 block cube. Faces are - * indexed by {@link Direction#ordinal()} and may be {@code null} when absent. - * - *

An optional element rotation (from the model JSON) is kept as origin/axis/angle so the - * intersector can treat the box as oriented (OBB) when {@code angle != 0}. + * A box of a baked block model in block units (0..1 inside the cell); faces indexed by {@link Direction#ordinal()}, + * null when absent. {@code from > to} on an axis marks an inverted element (inward-facing faces, like vanilla). */ public final class Element { - public final double[] from; // length 3, 0..1 - public final double[] to; // length 3, 0..1 - public final Face[] faces; // length 6, indexed by Direction.ordinal() + public final double[] from; + public final double[] to; + public final Face[] faces; - // Element rotation (0..1 origin), null/zero when axis-aligned. - public final double[] rotOrigin; // length 3, 0..1, may be null - public final int rotAxis; // 0=x,1=y,2=z, AXIS_MATRIX (Euler x/y/z rotation in rotMatrix), -1 when none - public final double rotAngleRad; // radians (single-axis rotations only) + // Own element rotation about rotOrigin: single axis (0=x, 1=y, 2=z) by rotAngleRad, or AXIS_MATRIX for a + // general (Euler x/y/z) rotation in rotMatrix; rotAxis -1 and rotOrigin null when unrotated. + public final double[] rotOrigin; + public final int rotAxis; + public final double rotAngleRad; public final boolean rescale; - /** {@link #rotAxis} of an element rotated by a general (Euler x/y/z) rotation held in {@link #rotMatrix}. */ public static final int AXIS_MATRIX = 3; - // cos/sin of the element rotation (+angle: local -> block) and of its inverse (-angle: block -> local), - // precomputed for the intersector's hot path. + // Precomputed for the intersector's hot path: cos/sin of +angle (local -> block) and -angle (block -> local). public final double rotCos, rotSin, invRotCos, invRotSin; - // General rotation (rotAxis == AXIS_MATRIX): row-major 3x3 local -> block and its inverse (transpose); null - // otherwise. + // Row-major 3x3 local -> block and its transpose, only for AXIS_MATRIX. public final double[] rotMatrix, invRotMatrix; - // Vanilla rescale (CuboidRotation.computeRescale): the element is scaled per LOCAL axis by - // 1 / max|component of R·axis| before the rotation (1/cos 22.5° or 1/cos 45° on the axes the rotation tilts), - // so a rotated cross plane still spans the whole block. Reciprocals (block -> local); null without rescale. + // Vanilla rescale (CuboidRotation.computeRescale): scale per LOCAL axis by 1 / max|component of R·axis| before + // rotating, so a tilted cross plane still spans the block. Stored as reciprocals (block -> local); null without. public final double[] invScale; // Blockstate rotation: the model-space element and the 90° steps (x first, then y) that turn it into this - // world-space one. Intersectors hit the unrotated element with the inverse-rotated ray, so textures stay - // bound to the model (vanilla); under uvlock its faces carry a UV transform keeping them world-aligned. - // Null/0 when unrotated. + // world-space one; null/0 when unrotated. Intersectors hit the unrotated element with the inverse-rotated ray. public final Element unrotated; public final int xSteps; public final int ySteps; + /** An unrotated box. */ + public Element(double[] from, double[] to, Face[] faces) { + this(from, to, faces, null, -1, 0, null, false, null, 0, 0); + } + public Element( double[] from, double[] to, @@ -50,50 +47,11 @@ public final class Element { double[] rotOrigin, int rotAxis, double rotAngleRad, - boolean rescale) { - this(from, to, faces, rotOrigin, rotAxis, rotAngleRad, rescale, null, 0, 0); - } - - /** An element rotated by a general rotation matrix (row-major 3x3, local -> block, e.g. Euler x/y/z). */ - public Element( - double[] from, - double[] to, - Face[] faces, - double[] rotOrigin, double[] rotMatrix, boolean rescale, Element unrotated, int xSteps, int ySteps) { - this(from, to, faces, rotOrigin, AXIS_MATRIX, 0, rescale, unrotated, xSteps, ySteps, rotMatrix); - } - - public Element( - double[] from, - double[] to, - Face[] faces, - double[] rotOrigin, - int rotAxis, - double rotAngleRad, - boolean rescale, - Element unrotated, - int xSteps, - int ySteps) { - this(from, to, faces, rotOrigin, rotAxis, rotAngleRad, rescale, unrotated, xSteps, ySteps, null); - } - - private Element( - double[] from, - double[] to, - Face[] faces, - double[] rotOrigin, - int rotAxis, - double rotAngleRad, - boolean rescale, - Element unrotated, - int xSteps, - int ySteps, - double[] rotMatrix) { this.unrotated = unrotated; this.xSteps = xSteps; this.ySteps = ySteps; @@ -113,6 +71,12 @@ public final class Element { this.invScale = rescale && !isAxisAligned() ? inverseRescale(linearRotation()) : null; } + /** This element with other faces (rotation and blockstate rotation kept). */ + public Element withFaces(Face[] faces) { + return new Element( + from, to, faces, rotOrigin, rotAxis, rotAngleRad, rotMatrix, rescale, unrotated, xSteps, ySteps); + } + /** The rotation as a row-major 3x3 (local -> block), identity when axis-aligned. */ public double[] linearRotation() { if (rotMatrix != null) return rotMatrix.clone(); @@ -125,12 +89,11 @@ public final class Element { }; } - /** Vanilla rescale factors per local axis: 1 / max|component of R·axis| (column of R); as reciprocals. */ + /** Reciprocal rescale per local axis: max|component of R·axis| (column a of R). */ private static double[] inverseRescale(double[] r) { double[] inv = new double[3]; for (int a = 0; a < 3; a++) { - double max = Math.max(Math.max(Math.abs(r[a]), Math.abs(r[3 + a])), Math.abs(r[6 + a])); - inv[a] = max; // 1 / (1 / max) + inv[a] = Math.max(Math.max(Math.abs(r[a]), Math.abs(r[3 + a])), Math.abs(r[6 + a])); } return inv; } @@ -152,7 +115,7 @@ public final class Element { return out; } - /** {@link #rotate} into {@code out} (allocation-free, for the intersectors). */ + /** {@link #rotate} into {@code out} (allocation-free). */ public static void rotate(double[] out, double x, double y, double z, int xSteps, int ySteps, boolean point) { double c = point ? 0.5 : 0; double dx = x - c, dy = y - c, dz = z - c; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Face.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Face.java index cca086f..4f1847b 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Face.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Face.java @@ -1,14 +1,11 @@ package eu.mhsl.minecraft.pixelpics.assets.model; -/** - * A textured face of an {@link Element}. UV coordinates are normalized to 0..1 in texture space with - * the origin at the top-left ({@code v} increasing downwards), matching the vanilla convention. - */ +/** A textured face of an {@link Element}; UVs in 0..1 with a top-left origin (v down), like vanilla. */ public final class Face { public final int[][] texture; // [y][x] ARGB, top-left origin public final double u1, v1, u2, v2; - public final int rotation; // 0/90/180/270, applied to the sampled UV + public final int rotation; // 0/90/180/270 (normalised), applied to the sampled UV public final int tintIndex; // -1 = no tint // uvlock: affine applied to the final texture (u, v) as {a, b, c, d, e, f} with u' = a + b*u + c*v and @@ -27,7 +24,7 @@ public final class Face { this.v1 = v1; this.u2 = u2; this.v2 = v2; - this.rotation = rotation; + this.rotation = Math.floorMod(rotation, 360); this.tintIndex = tintIndex; } @@ -41,14 +38,10 @@ public final class Face { return new Face(texture, u1, v1, u2, v2, rotation, tintIndex, lock); } - /** - * Samples the ARGB pixel for a position within the face, where {@code (s, t)} are in 0..1 across - * the face's two in-plane axes (s = horizontal, t = vertical, top-left origin). - */ + /** The ARGB texel at {@code (s, t)} in 0..1 across the face (s horizontal, t vertical, top-left origin). */ public int sample(double s, double t) { - // Apply face rotation by rotating the (s,t) lookup. double rs = s, rt = t; - switch (((rotation % 360) + 360) % 360) { + switch (rotation) { case 90 -> { rs = t; rt = 1.0 - s; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/ResolvedModel.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/ResolvedModel.java index e75cefe..7f0f147 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/ResolvedModel.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/ResolvedModel.java @@ -3,34 +3,18 @@ package eu.mhsl.minecraft.pixelpics.assets.model; import java.util.List; /** - * A fully baked, intersectable block model: a list of {@link Element} boxes plus rendering hints. - * - *

{@code averageColor} is the 100% coverage fallback used for blocks without geometry - * (builtin/generated models, unresolved blocks) and as a backstop. {@code hasGeometry} is false when - * the model has no usable elements; the renderer then draws a flat shaded cube using - * {@code averageColor}. + * A baked block model. {@code hasGeometry} is false for empty models and for the solid fallback cube of blocks + * without model elements. Deliberately not a record: callers key caches on identity (one instance per BlockData). */ public final class ResolvedModel { + public static final ResolvedModel EMPTY = new ResolvedModel(List.of(), false); + public final List elements; - public final int averageColor; // ARGB - public final double transparency; // 0..1 - public final double reflection; // 0..1 - public final boolean occluding; public final boolean hasGeometry; - public ResolvedModel( - List elements, - int averageColor, - double transparency, - double reflection, - boolean occluding, - boolean hasGeometry) { + public ResolvedModel(List elements, boolean hasGeometry) { this.elements = elements; - this.averageColor = averageColor; - this.transparency = transparency; - this.reflection = reflection; - this.occluding = occluding; this.hasGeometry = hasGeometry; } }