diff --git a/.gitignore b/.gitignore index 2246b4e..eae51af 100644 --- a/.gitignore +++ b/.gitignore @@ -169,4 +169,4 @@ gradle-app.setting # End of https://www.toptal.com/developers/gitignore/api/java,intellij,gradle -/local.gradle \ No newline at end of file +/local.gradle diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockEntityBlocks.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockEntityBlocks.java new file mode 100644 index 0000000..809c2a3 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockEntityBlocks.java @@ -0,0 +1,94 @@ +package eu.mhsl.minecraft.pixelpics.assets; + +import java.util.EnumSet; +import org.bukkit.Material; +import org.bukkit.block.data.type.Chest; +import org.bukkit.block.data.type.CopperGolemStatue; +import org.bukkit.block.data.type.HangingSign; +import org.bukkit.block.data.type.Sign; +import org.bukkit.block.data.type.Skull; +import org.bukkit.block.data.type.WallHangingSign; +import org.bukkit.block.data.type.WallSign; +import org.bukkit.block.data.type.WallSkull; + +/** + * The block families drawn by block-entity renderers, shared by the block-model registry and the block-entity + * snapshot. Matched by the material's BlockData type ({@link Material#data}, needs no live registry) where it is + * unique, otherwise by name (banners, shulker boxes). + */ +public final class BlockEntityBlocks { + + private BlockEntityBlocks() {} + + // No block-model geometry, drawn only by the entity scene (classify also covers kinds that keep their model). + private static final EnumSet NO_BLOCK_MODEL = EnumSet.noneOf(Material.class); + + static { + for (Material m : Material.values()) { + if (isSign(m) + || isBanner(m) + || isShulkerBox(m) + || isHead(m) + || isChest(m) + || m == Material.ENDER_CHEST + || m == Material.CONDUIT + || m == Material.DECORATED_POT + || m == Material.BELL + || isCopperGolemStatue(m) + || m == Material.END_PORTAL + || m == Material.END_GATEWAY) { + NO_BLOCK_MODEL.add(m); + } + } + } + + public static boolean hasNoBlockModel(Material m) { + return NO_BLOCK_MODEL.contains(m); + } + + /** Chest, trapped chest and the copper chests (not the ender chest). */ + public static boolean isChest(Material m) { + return m.data == Chest.class; + } + + public static boolean isCopperChest(Material m) { + return isChest(m) && m.name().endsWith("COPPER_CHEST"); + } + + /** Mob heads and skulls, standing or on a wall (not the piston head). */ + public static boolean isHead(Material m) { + return m.data == Skull.class || isWallHead(m); + } + + public static boolean isWallHead(Material m) { + return m.data == WallSkull.class; + } + + public static boolean isSign(Material m) { + return m.data == Sign.class || m.data == WallSign.class || isHangingSign(m); + } + + public static boolean isWallSign(Material m) { + return m.data == WallSign.class; + } + + public static boolean isHangingSign(Material m) { + return m.data == HangingSign.class || m.data == WallHangingSign.class; + } + + public static boolean isBanner(Material m) { + return m.name().endsWith("_BANNER"); + } + + public static boolean isWallBanner(Material m) { + return m.name().endsWith("_WALL_BANNER"); + } + + public static boolean isShulkerBox(Material m) { + return m.name().endsWith("SHULKER_BOX"); + } + + public static boolean isCopperGolemStatue(Material m) { + return m.data == CopperGolemStatue.class; + } +} 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 b8072bc..16f7500 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockModelRegistry.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockModelRegistry.java @@ -31,33 +31,6 @@ public final class BlockModelRegistry { 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; @@ -119,7 +92,7 @@ public final class BlockModelRegistry { if (!elements.isEmpty()) return new ResolvedModel(elements, true); // 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; + if (BlockEntityBlocks.hasNoBlockModel(material)) return ResolvedModel.EMPTY; FlatModel last = variants.isEmpty() ? null : modelResolver.resolve(variants.getLast().model()); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityCube.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityCube.java index f583ce6..c0be5d4 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityCube.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityCube.java @@ -17,6 +17,10 @@ public final class EntityCube { // Block-model element with from > to on some axis (spawner/vault inner faces): its faces point INTO the box // and are back-face culled like vanilla quads (see EntityIntersector); mirrored = toWorld flips handedness. public final boolean inverted, mirrored; + // Row-major map of the face normals: toWorld's linear part while it is a rotation with a uniform scale (same + // direction), else its inverse-transpose (vanilla PoseStack normal matrix for non-uniform scales, e.g. block + // displays scaled (2, 1, 0.5) and rotated). + public final double[] normalMatrix; public final double[] aabbMin = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE}; public final double[] aabbMax = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE}; @@ -28,6 +32,27 @@ public final class EntityCube { this(from, to, faces, toWorld, additive, false); } + /** Whether the columns of {@code r} are orthogonal and of equal length (rotation, reflection, uniform scale). */ + private static boolean conformal(double[] r) { + double xx = r[0] * r[0] + r[3] * r[3] + r[6] * r[6]; + double yy = r[1] * r[1] + r[4] * r[4] + r[7] * r[7]; + double zz = r[2] * r[2] + r[5] * r[5] + r[8] * r[8]; + double xy = r[0] * r[1] + r[3] * r[4] + r[6] * r[7]; + double xz = r[0] * r[2] + r[3] * r[5] + r[6] * r[8]; + double yz = r[1] * r[2] + r[4] * r[5] + r[7] * r[8]; + double tol = 1e-9 * Math.max(xx, Math.max(yy, zz)); + return Math.abs(xx - yy) <= tol + && Math.abs(xx - zz) <= tol + && Math.abs(xy) <= tol + && Math.abs(xz) <= tol + && Math.abs(yz) <= tol; + } + + /** This cube placed by {@code m} (applied after its own map), keeping its render flags. */ + public EntityCube transformed(Affine m) { + return new EntityCube(from, to, faces, m.mul(toWorld), additive, unshaded); + } + public EntityCube(double[] from, double[] to, Face[] faces, Affine toWorld, boolean additive, boolean unshaded) { this.additive = additive; this.unshaded = unshaded; @@ -38,6 +63,9 @@ public final class EntityCube { this.toLocal = toWorld.inverse(); this.inverted = from[0] > to[0] || from[1] > to[1] || from[2] > to[2]; this.mirrored = toWorld.determinant() < 0; + double[] l = toLocal.r; + this.normalMatrix = + conformal(toWorld.r) ? toWorld.r : new double[] {l[0], l[3], l[6], l[1], l[4], l[7], l[2], l[5], l[8]}; double[] r = toWorld.r, t = toWorld.t; for (int i = 0; i < 8; i++) { double x = (i & 1) == 0 ? from[0] : to[0]; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityIntersector.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityIntersector.java index 4e7e28c..c1bbef3 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityIntersector.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityIntersector.java @@ -124,7 +124,7 @@ public final class EntityIntersector { EntityCube cube, Hit h, double ox, double oy, double oz, double dx, double dy, double dz) { double t = h.t; Direction dir = h.dir; - double[] r = cube.toWorld.r; + double[] r = cube.normalMatrix; Vector normal = new Vector( r[0] * dir.nx + r[1] * dir.ny + r[2] * dir.nz, r[3] * dir.nx + r[4] * dir.ny + r[5] * dir.nz, diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityScene.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityScene.java index dff57af..21ea07b 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityScene.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityScene.java @@ -11,8 +11,8 @@ import java.util.logging.Logger; /** * All baked entities (mobs, block-entities, decorations, objects) of one render, as one bounding volume hierarchy * over the cubes' world AABBs (binned SAH, flat arrays), traversed front to back with the best hit as far bound. - * Near-ties (coplanar faces of different cubes) are decided in bake order (see {@link #nearestHit}). Immutable after - * construction; the per-ray scratch is per thread. + * Near-ties (coplanar faces of different cubes) are decided in bake order, the later cube winning (see + * {@link #nearestHit}). Immutable after construction; the per-ray scratch is per thread. */ public final class EntityScene { @@ -184,8 +184,9 @@ public final class EntityScene { if (n == 0) return null; // Pass 2, the tie-break: the near candidates in bake order; a cube counts only if its AABB passes with the - // best t so far as far bound and it is strictly nearer. Coplanar faces of different cubes (equal t up to - // rounding) depend on this order, so it must not change. + // best t so far as far bound and it is not farther. On equal t the later-baked cube wins, like vanilla's + // LEQUAL depth test for later-drawn quads (block-model overlay elements in displays/items, layers on the + // body mesh). Coplanar faces of different cubes depend on this order, so it must not change. int[] candCube = tr.candCube; int[] order = tr.order; int m = 0; @@ -205,7 +206,7 @@ public final class EntityScene { int k = order[j]; double t = tr.cand[k].t; double e = entry(cb, candCube[k], ox, oy, oz, ix, iy, iz, px, py, pz, t0, bestT); - if (e == e && t < bestT) { + if (e == e && t <= bestT) { bestT = t; best = k; } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/BlockEntityBaker.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/BlockEntityBaker.java index de923ea..bf368d2 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/BlockEntityBaker.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/BlockEntityBaker.java @@ -185,9 +185,7 @@ public final class BlockEntityBaker implements EntityBaker { .mul(Affine.rotX(Math.toRadians(-30))) .mul(Affine.scale(scale)) .mul(Affine.translation(-e.x(), -e.y(), -e.z())); - for (EntityCube c : mob.cubes) { - cubes.add(new EntityCube(c.from, c.to, c.faces, m.mul(c.toWorld), c.additive, c.unshaded)); - } + for (EntityCube c : mob.cubes) cubes.add(c.transformed(m)); } // BeaconRenderer: sections up to MAX_RENDER_Y (the last one), solid core radius 0.2 rotated by diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemBaker.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemBaker.java index 4dd236e..8236585 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemBaker.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemBaker.java @@ -128,7 +128,7 @@ public final class CemBaker implements EntityBaker { for (CemGeometry.Baked b : out.glow) { cubes.add(new EntityCube(b.from(), b.to(), b.faces(), place.mul(b.world()), true)); } - for (EntityCube c : itemCubes) cubes.add(new EntityCube(c.from, c.to, c.faces, place.mul(c.toWorld))); + for (EntityCube c : itemCubes) cubes.add(c.transformed(place)); return RenderedEntity.of(cubes); } @@ -886,14 +886,13 @@ public final class CemBaker implements EntityBaker { // the adult layer scaled, adult UVs). boolean babyTextures = baby && !s.typeKey().equals("armor_stand"); String folder = babyTextures ? "humanoid_baby" : "humanoid"; - bakeArmorPiece(eq.head(), set + "head", folder, pose, out); - if (eq.chest() != null && eq.chest().asset().equals("elytra")) { - bakeElytra(eq.chest(), base, baby, pose, out); - } else { - bakeArmorPiece(eq.chest(), set + "chest", folder, pose, out); - } + // Vanilla draw order chest, legs, feet, head (coplanar ties: the later piece wins), then the WingsLayer. + boolean elytra = eq.chest() != null && eq.chest().asset().equals("elytra"); + if (!elytra) bakeArmorPiece(eq.chest(), set + "chest", folder, pose, out); bakeArmorPiece(eq.legs(), set + "legs", babyTextures ? "humanoid_baby" : "humanoid_leggings", pose, out); bakeArmorPiece(eq.feet(), set + "feet", folder, pose, out); + bakeArmorPiece(eq.head(), set + "head", folder, pose, out); + if (elytra) bakeElytra(eq.chest(), base, baby, pose, out); } private void bakeArmorPiece( diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemModelLoader.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemModelLoader.java index 1a61d31..e278d7c 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemModelLoader.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemModelLoader.java @@ -395,7 +395,7 @@ public final class CemModelLoader { new PartPose( po.has("pivot") ? arr3(po, "pivot") : null, po.has("rotate") ? arr3(po, "rotate") : null, - !po.has("visible") || po.get("visible").getAsBoolean(), + po.has("visible") ? po.get("visible").getAsBoolean() : null, po.has("scale") ? arr3(po, "scale") : null, null)); } 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 3f8ef88..c070f51 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 @@ -151,7 +151,7 @@ public final class ItemGeometry { if (state == null || blockEntities == null) return; RenderedEntity r = blockEntities.bake(state); if (r == null) return; - for (EntityCube c : r.cubes) out.add(new EntityCube(c.from, c.to, c.faces, toWorld.mul(c.toWorld))); + for (EntityCube c : r.cubes) out.add(c.transformed(toWorld)); } /** A block entity's bare models (no block pose) mapped by {@code toWorld}. */ diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java index d03f218..96df5fd 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java @@ -1,5 +1,6 @@ package eu.mhsl.minecraft.pixelpics.render.snapshot; +import eu.mhsl.minecraft.pixelpics.assets.BlockEntityBlocks; import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState; import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.BannerPattern; import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.ChestKind; @@ -169,7 +170,7 @@ public final class BlockEntitySnapshotBuilder { Material mat = data.getMaterial(); String n = mat.name(); - Kind chest = n.endsWith("COPPER_CHEST") + Kind chest = BlockEntityBlocks.isCopperChest(mat) ? Kind.COPPER_CHEST : switch (mat) { case CHEST -> Kind.CHEST; @@ -185,7 +186,7 @@ public final class BlockEntitySnapshotBuilder { return b.build(); } - if (n.endsWith("SHULKER_BOX")) { + if (BlockEntityBlocks.isShulkerBox(mat)) { String color = n.equals("SHULKER_BOX") ? null : prefix(n, "_SHULKER_BOX").toLowerCase(Locale.ROOT); String dir = data instanceof Directional d ? d.getFacing().name().toLowerCase(Locale.ROOT) : "up"; @@ -195,8 +196,8 @@ public final class BlockEntitySnapshotBuilder { .build(); } - if (n.endsWith("_BANNER")) { - boolean wall = n.endsWith("_WALL_BANNER"); + if (BlockEntityBlocks.isBanner(mat)) { + boolean wall = BlockEntityBlocks.isWallBanner(mat); Builder b = base(wall ? Kind.WALL_BANNER : Kind.BANNER, bx, by, bz, facingYaw(data)); if (ts instanceof Banner banner) { b.baseColorArgb(ColorUtil.dyeArgb(banner.getBaseColor(), 0xFFFFFFFF)); @@ -208,10 +209,10 @@ public final class BlockEntitySnapshotBuilder { return b.build(); } - if (n.endsWith("_SIGN")) { - Kind kind = n.endsWith("_WALL_SIGN") + if (BlockEntityBlocks.isSign(mat)) { + Kind kind = BlockEntityBlocks.isWallSign(mat) ? Kind.WALL_SIGN - : n.endsWith("_HANGING_SIGN") ? Kind.HANGING_SIGN : Kind.SIGN; + : BlockEntityBlocks.isHangingSign(mat) ? Kind.HANGING_SIGN : Kind.SIGN; Builder b = base(kind, bx, by, bz, facingYaw(data)); if (ts instanceof Sign sign) { b.frontText(signText(sign.getSide(Side.FRONT))); @@ -220,8 +221,8 @@ public final class BlockEntitySnapshotBuilder { return b.build(); } - if (n.endsWith("_SKULL") || n.endsWith("_HEAD")) { - boolean wall = n.contains("_WALL_"); + if (BlockEntityBlocks.isHead(mat)) { + boolean wall = BlockEntityBlocks.isWallHead(mat); String headType = headType(n); Builder b = base(wall ? Kind.WALL_HEAD : Kind.HEAD, bx, by, bz, facingYaw(data)) .headType(headType); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/EntitySnapshotBuilder.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/EntitySnapshotBuilder.java index 78cb40a..0c36b26 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/EntitySnapshotBuilder.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/EntitySnapshotBuilder.java @@ -35,7 +35,6 @@ import org.bukkit.attribute.AttributeInstance; import org.bukkit.block.BlockFace; import org.bukkit.block.data.type.Bed; import org.bukkit.entity.AbstractArrow; -import org.bukkit.entity.AbstractHorse; import org.bukkit.entity.Ageable; import org.bukkit.entity.ArmorStand; import org.bukkit.entity.Arrow; @@ -213,17 +212,11 @@ public final class EntitySnapshotBuilder { } else if (e instanceof Horse h) { variant = keyOf(h.getColor()); markings = markingsKey(h.getStyle()); - saddle = isSaddled(h); - bodyEquip = horseArmorKey(h); } else if (e instanceof Llama l) { variant = keyOf(l.getColor()); chest = l.isCarryingChest(); - bodyEquip = (e instanceof TraderLlama) ? "trader_llama" : carpetKey(l); } else if (e instanceof ChestedHorse ch) { chest = ch.isCarryingChest(); - saddle = isSaddled(ch); - } else if (e instanceof AbstractHorse ah) { - saddle = isSaddled(ah); } else if (e instanceof Fox f) { variant = keyOf(f.getFoxType()); } else if (e instanceof MushroomCow mc) { @@ -257,12 +250,14 @@ public final class EntitySnapshotBuilder { ItemStack body = eq.getItem(EquipmentSlot.BODY); String asset = equipAsset(body); if (asset != null) { - if (bodyEquip == null) bodyEquip = asset; + bodyEquip = asset; bodyDye = dyeOf(body); } } catch (Throwable ignored) { // slot not supported by this entity } + // LlamaDecorLayer: the trader llama's own decor unless it wears a carpet. + if (bodyEquip == null && e instanceof TraderLlama) bodyEquip = "trader_llama"; try { boolean leftHanded = (e instanceof Mob mob && mob.isLeftHanded()) || (e instanceof HumanEntity he && he.getMainHand() == MainHand.LEFT); @@ -275,7 +270,7 @@ public final class EntitySnapshotBuilder { leftHand = leftHanded ? main : off; } ItemStack helmet = eq.getHelmet(); - if (helmet != null && !helmet.getType().isAir() && armorAsset(helmet.getType()) == null) { + if (helmet != null && !helmet.getType().isAir() && armorAsset(helmet, EquipmentSlot.HEAD) == null) { headItem = ItemSpecs.of(helmet); } // Snow golems wear a carved pumpkin unless sheared (vanilla SnowGolemHeadLayer). @@ -603,8 +598,8 @@ public final class EntitySnapshotBuilder { private static EntityState.Equipment captureEquipment(EntityEquipment eq) { try { EntityState.Equipment equip = new EntityState.Equipment( - armorPiece(eq.getHelmet()), armorPiece(eq.getChestplate()), - armorPiece(eq.getLeggings()), armorPiece(eq.getBoots())); + armorPiece(eq.getHelmet(), EquipmentSlot.HEAD), armorPiece(eq.getChestplate(), EquipmentSlot.CHEST), + armorPiece(eq.getLeggings(), EquipmentSlot.LEGS), armorPiece(eq.getBoots(), EquipmentSlot.FEET)); return equip.isEmpty() ? null : equip; } catch (Throwable t) { return null; @@ -612,9 +607,9 @@ public final class EntitySnapshotBuilder { } /** Null for empty / non-armor items. */ - private static EntityState.EquipPiece armorPiece(ItemStack it) { + private static EntityState.EquipPiece armorPiece(ItemStack it, EquipmentSlot slot) { if (it == null || it.getType().isAir()) return null; - String asset = armorAsset(it.getType()); + String asset = armorAsset(it, slot); if (asset == null) return null; // not a humanoid-armor item (e.g. a mob head / pumpkin) int dye = 0; String trimMat = null, trimPat = null; @@ -633,8 +628,25 @@ public final class EntitySnapshotBuilder { private static final String[] ARMOR_SUFFIXES = {"_helmet", "_chestplate", "_leggings", "_boots"}; - /** Item Material -> equipment asset id (= texture name): strips the slot suffix; null if not armor. */ - private static String armorAsset(Material m) { + /** + * Equipment asset id (= texture name) a humanoid armor layer draws for the item worn in {@code slot}: the + * EQUIPPABLE asset when its slot matches (vanilla HumanoidArmorLayer.shouldRender, custom armor included); + * from the item name without the component. Null if none. + */ + private static String armorAsset(ItemStack it, EquipmentSlot slot) { + try { + var comp = it.getData(DataComponentTypes.EQUIPPABLE); + if (comp != null) + return comp.slot() == slot && comp.assetId() != null + ? comp.assetId().value() + : null; + } catch (Throwable ignored) { + } + return armorAssetByName(it.getType()); + } + + /** Item Material -> equipment asset id: strips the slot suffix; null if not armor. */ + private static String armorAssetByName(Material m) { String key = m.getKey().getKey(); if (key.equals("elytra")) return "elytra"; if (key.equals("turtle_helmet")) return "turtle_scute"; @@ -655,21 +667,6 @@ public final class EntitySnapshotBuilder { return style.name().toLowerCase(Locale.ROOT).replace("_", ""); // WHITE_DOTS -> whitedots } - /** Horse armor material -> equipment/horse_body texture key (golden uses the "gold" file); null if none. */ - private static String horseArmorKey(Horse h) { - ItemStack a = h.getInventory().getArmor(); - if (a == null || a.getType().isAir()) return null; - return assetMaterial(a.getType().getKey().getKey().replace("_horse_armor", "")); - } - - /** Llama carpet decor -> equipment/llama_body colour key; null if none. */ - private static String carpetKey(Llama l) { - ItemStack d = l.getInventory().getDecor(); - if (d == null || d.getType().isAir()) return null; - String k = d.getType().getKey().getKey(); - return k.endsWith("_carpet") ? k.substring(0, k.length() - "_carpet".length()) : null; - } - /** Equipment asset id (= equipment/ texture name) from an item's EQUIPPABLE component; null if none. */ private static String equipAsset(ItemStack it) { if (it == null || it.getType().isAir()) return null; @@ -723,11 +720,6 @@ public final class EntitySnapshotBuilder { } } - private static boolean isSaddled(AbstractHorse h) { - ItemStack st = h.getInventory().getSaddle(); - return st != null && !st.getType().isAir(); - } - /** * Registry/Keyed values yield their key path ({@code ":"} outside the minecraft namespace, e.g. * datapack variants); plain enums yield their lower-case name. diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/TintResolver.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/TintResolver.java index 37258b2..7222f7f 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/TintResolver.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/TintResolver.java @@ -1,68 +1,76 @@ package eu.mhsl.minecraft.pixelpics.render.tint; -import java.util.Locale; import org.bukkit.Material; +import org.bukkit.block.data.Ageable; import org.bukkit.block.data.BlockData; import org.bukkit.block.data.type.RedstoneWire; -/** Maps a tinted face (material + tintindex) to its tint colour: a biome channel or a vanilla constant. */ +/** + * Maps a tinted face (block + tintindex) to its in-world tint colour like vanilla BlockColors.createDefault (26.3): + * a biome channel or a constant. Blocks without a registered tint source (bamboo, cherry and pale oak leaves, the + * lava/powder-snow cauldron, the stonecutter, …) and tint layers beyond the registered ones are not tinted. + */ public final class TintResolver { - private static final int BIRCH = 0xFF80A755; private static final int SPRUCE = 0xFF619961; - private static final int LILY_PAD = 0xFF208030; - private static final int STEM = 0xFF60A017; + private static final int BIRCH = 0xFF80A755; + private static final int LILY_PAD_IN_WORLD = 0xFF208030; + private static final int ATTACHED_STEM = 0xFFE0C71C; - private enum Channel { - BIRCH, - SPRUCE, - FOLIAGE, - LILY_PAD, - WATER, - REDSTONE, - STEM, - GRASS + /** RedstoneWireBlock.COLORS: the wire colour per power level, with vanilla's float arithmetic. */ + private static final int[] REDSTONE = new int[16]; + + static { + for (int i = 0; i <= 15; i++) { + float power = i / 15.0F; + float red = power * 0.6F + (power > 0.0F ? 0.4F : 0.3F); + float green = clamp(power * power * 0.7F - 0.5F); + float blue = clamp(power * power * 0.6F - 0.7F); + REDSTONE[i] = 0xFF000000 | channel8(red) << 16 | channel8(green) << 8 | channel8(blue); + } } - // Per Material.ordinal(), filled lazily (idempotent, so the benign race is harmless); called for every tinted hit. - private static final Channel[] CHANNELS = new Channel[Material.values().length]; - private TintResolver() {} /** Returns the ARGB tint to multiply with, or {@code -1} when the face should not be tinted. */ public static int resolve(BlockData data, int tintIndex, BiomeTint biomeTint) { - if (tintIndex < 0) return -1; - Material material = data.getMaterial(); - Channel channel = CHANNELS[material.ordinal()]; - if (channel == null) - CHANNELS[material.ordinal()] = channel = channel(material.name().toLowerCase(Locale.ROOT)); - return switch (channel) { - case BIRCH -> BIRCH; - case SPRUCE -> SPRUCE; - case FOLIAGE -> biomeTint.foliage(); - case LILY_PAD -> LILY_PAD; - case WATER -> biomeTint.water(); - case REDSTONE -> redstone(data); - case STEM -> STEM; - case GRASS -> biomeTint.grass(); + Material m = data.getMaterial(); + // Pink petals and wildflowers: layer 0 (the flowers) is blank, layer 1 (the stems) grass. + if (m == Material.PINK_PETALS || m == Material.WILDFLOWERS) return tintIndex == 1 ? biomeTint.grass() : -1; + if (tintIndex != 0) return -1; // every other source has one layer + return switch (m) { + case GRASS_BLOCK, SHORT_GRASS, FERN, POTTED_FERN -> biomeTint.grass(); + case BUSH, TALL_GRASS, LARGE_FERN, SUGAR_CANE -> biomeTint.grass(); + case OAK_LEAVES, JUNGLE_LEAVES, ACACIA_LEAVES -> biomeTint.foliage(); + case DARK_OAK_LEAVES, MANGROVE_LEAVES, VINE -> biomeTint.foliage(); + case SPRUCE_LEAVES -> SPRUCE; + case BIRCH_LEAVES -> BIRCH; + case LEAF_LITTER -> biomeTint.dryFoliage(); + case WATER_CAULDRON -> biomeTint.water(); + case REDSTONE_WIRE -> redstone(data); + case MELON_STEM, PUMPKIN_STEM -> stem(data); + case ATTACHED_MELON_STEM, ATTACHED_PUMPKIN_STEM -> ATTACHED_STEM; + case LILY_PAD -> LILY_PAD_IN_WORLD; + default -> -1; }; } - private static Channel channel(String name) { - if (name.equals("birch_leaves")) return Channel.BIRCH; - if (name.equals("spruce_leaves")) return Channel.SPRUCE; - if (name.endsWith("leaves") || name.equals("vine")) return Channel.FOLIAGE; - if (name.equals("lily_pad")) return Channel.LILY_PAD; - if (name.equals("water") || name.equals("water_cauldron") || name.equals("bubble_column")) return Channel.WATER; - if (name.equals("redstone_wire")) return Channel.REDSTONE; - if (name.endsWith("stem")) return Channel.STEM; - // grass_block, short/tall grass, ferns, sugar cane, … and any other tinted face (tintindex 0 is mostly grass). - return Channel.GRASS; + private static int redstone(BlockData data) { + return REDSTONE[data instanceof RedstoneWire wire ? Math.clamp(wire.getPower(), 0, 15) : 0]; } - /** Approximation of the redstone wire colour: red rising with the power level. */ - private static int redstone(BlockData data) { - int power = data instanceof RedstoneWire wire ? wire.getPower() : 0; - return 0xFF000000 | (Math.min(255, 75 + power * 12) << 16); + /** BlockTintSources.stem: {@code ARGB.color(age·32, 255 - age·8, age·4)}. */ + private static int stem(BlockData data) { + int age = data instanceof Ageable a ? a.getAge() : 0; + return 0xFF000000 | age * 32 << 16 | (255 - age * 8) << 8 | age * 4; + } + + private static float clamp(float v) { + return v < 0.0F ? 0.0F : Math.min(v, 1.0F); + } + + /** ARGB.as8BitChannel: {@code floor(v·255)}. */ + private static int channel8(float v) { + return (int) Math.floor(v * 255.0F); } } diff --git a/tools/edgecase-plugin/pptest/PPTest.java b/tools/edgecase-plugin/pptest/PPTest.java index 45831e0..387c208 100644 --- a/tools/edgecase-plugin/pptest/PPTest.java +++ b/tools/edgecase-plugin/pptest/PPTest.java @@ -275,6 +275,97 @@ public class PPTest extends JavaPlugin { scene("liq_under_warm_top", o -> liqUnder(o, Biome.WARM_OCEAN), -3, 4, -10, 6, -6, 2); scene("liq_lava", this::liqLava, 4, 4.5, -6, 9, 0, 1); scene("liq_lava_in", this::liqLava, 3.5, -0.5, 1.5, 9, -0.5, 1.5); + // Block tints like vanilla BlockColors: untinted tintindex blocks (cherry/pale oak leaves, bamboo, + // stonecutter, lava/powder-snow cauldron), leaf litter (dry foliage), petals/wildflowers (stems only), melon + // stems age 0-7 + attached, redstone wire power 0-15 (RedStoneWireBlock.COLORS). + scene("tint_blocks", this::tintBlocks, 7.5, 6, -4.5, 7.5, 0, 2.5); + scene("tint_blocks_close", this::tintBlocks, 4, 2.6, -1.6, 4, 0, 1); + // Equipment from the EQUIPPABLE asset: custom-asset armor/horse armor, elytra on the head (an item, not + // armor), carpets on llamas and on a trader llama, saddles on horse/donkey/pig. + scene("equip_assets", this::equipAssets, 5, 2.8, -5.5, 5, 0.9, 1.5); + // 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); + } + + private void tintBlocks(Location o) { + String[] row0 = {"grass_block", "oak_leaves[persistent=true]", "cherry_leaves[persistent=true]", + "pale_oak_leaves[persistent=true]", "bamboo[age=0,leaves=small,stage=0]", "stonecutter[facing=north]", + "lava_cauldron", "powder_snow_cauldron[level=3]", "water_cauldron[level=3]", + "leaf_litter[facing=north,segment_amount=4]", "pink_petals[facing=north,flower_amount=4]", + "wildflowers[facing=north,flower_amount=4]", "fern", "sugar_cane", "vine[north=false,south=true]"}; + for (int i = 0; i < row0.length; i++) put(o, i, 0, 0, row0[i]); + for (int a = 0; a < 8; a++) put(o, a, 0, 2, "melon_stem[age=" + a + "]"); + put(o, 8, 0, 2, "attached_melon_stem[facing=south]"); + put(o, 9, 0, 2, "pumpkin_stem[age=7]"); + put(o, 10, 0, 2, "attached_pumpkin_stem[facing=north]"); + for (int p = 0; p < 16; p++) + put(o, p, 0, 4, "redstone_wire[power=" + p + ",north=side,south=side,east=none,west=none]"); + } + + private static ItemStack withAsset(Material m, EquipmentSlot slot, String asset) { + ItemStack s = it(m); + s.setData(io.papermc.paper.datacomponent.DataComponentTypes.EQUIPPABLE, + io.papermc.paper.datacomponent.item.Equippable.equippable(slot) + .assetId(net.kyori.adventure.key.Key.key("minecraft", asset)) + .build()); + return s; + } + + private void equipAssets(Location o) { + Zombie z = rfSpawn(o, 0, 0, 0, 180, Zombie.class); + z.getEquipment().setChestplate(withAsset(Material.DIAMOND_CHESTPLATE, EquipmentSlot.CHEST, "gold")); + z.getEquipment().setLeggings(withAsset(Material.LEATHER_LEGGINGS, EquipmentSlot.LEGS, "netherite")); + Zombie z2 = rfSpawn(o, 1.3, 0, 0, 180, Zombie.class); + z2.getEquipment().setHelmet(it(Material.ELYTRA)); + z2.getEquipment().setChestplate(it(Material.IRON_CHESTPLATE)); + Horse h = rfSpawn(o, 3, 0, 0, 180, Horse.class); + h.setColor(Horse.Color.WHITE); + h.setStyle(Horse.Style.NONE); + h.getInventory().setArmor(withAsset(Material.IRON_HORSE_ARMOR, EquipmentSlot.BODY, "diamond")); + h.getInventory().setSaddle(it(Material.SADDLE)); + Horse h2 = rfSpawn(o, 5, 0, 0, 180, Horse.class); + h2.setColor(Horse.Color.BROWN); + h2.setStyle(Horse.Style.NONE); + h2.getInventory().setArmor(it(Material.GOLDEN_HORSE_ARMOR)); + h2.getInventory().setSaddle(it(Material.SADDLE)); + Donkey d = rfSpawn(o, 7, 0, 0, 180, Donkey.class); + d.getInventory().setSaddle(it(Material.SADDLE)); + Pig pig = rfSpawn(o, 8.6, 0, 0, 180, Pig.class); + pig.getEquipment().setItem(EquipmentSlot.SADDLE, it(Material.SADDLE)); + Llama l = rfSpawn(o, 0.5, 0, 2.5, 180, Llama.class); + l.setColor(Llama.Color.CREAMY); + l.getInventory().setDecor(it(Material.WHITE_CARPET)); + Llama l2 = rfSpawn(o, 2.5, 0, 2.5, 180, Llama.class); + l2.setColor(Llama.Color.GRAY); + l2.getInventory().setDecor(it(Material.LIME_CARPET)); + TraderLlama t = rfSpawn(o, 4.5, 0, 2.5, 180, TraderLlama.class); + t.setColor(Llama.Color.CREAMY); + t.getInventory().setDecor(it(Material.RED_CARPET)); + TraderLlama t2 = rfSpawn(o, 6.5, 0, 2.5, 180, TraderLlama.class); + t2.setColor(Llama.Color.BROWN); + faceNorth(o); + } + + private void displayFixes(Location o) { + String[] blocks = {"end_portal", "end_gateway", "piston_head[facing=up,short=false,type=normal]", + "piston_head[facing=north,short=false,type=sticky]", "grass_block[snowy=false]"}; + for (int i = 0; i < blocks.length; i++) { + BlockDisplay b = spawn(o, i * 1.6, 0, 0, 0, BlockDisplay.class); + b.setBlock(Bukkit.createBlockData("minecraft:" + blocks[i])); + } + // Non-uniform scale + rotation: the normals need the inverse-transpose (face shading). + Quaternionf rot = new Quaternionf().rotateY((float) Math.toRadians(35)).rotateX((float) Math.toRadians(25)); + BlockDisplay n1 = spawn(o, 0.5, 0.2, 2.5, 0, BlockDisplay.class); + n1.setBlock(Bukkit.createBlockData("minecraft:crafting_table")); + n1.setTransformation(new Transformation(new Vector3f(), rot, new Vector3f(2f, 0.6f, 0.4f), new Quaternionf())); + BlockDisplay n2 = spawn(o, 4, 0.2, 2.5, 0, BlockDisplay.class); + n2.setBlock(Bukkit.createBlockData("minecraft:crafting_table")); + n2.setTransformation(new Transformation(new Vector3f(), rot, new Vector3f(0.8f), new Quaternionf())); + Shulker s1 = rfSpawn(o, 6.5, 0, 2.5, 180, Shulker.class); + s1.setColor(null); + Shulker s2 = rfSpawn(o, 8, 0, 2.5, 180, Shulker.class); + s2.setColor(DyeColor.PURPLE); } private static ItemStack shield(DyeColor base, Object... patterns) { @@ -1491,11 +1582,7 @@ public class PPTest extends JavaPlugin { } private Entity rfSpawn(Location o, double x, double y, double z, float yaw, EntityType type) { - Location l = o.clone().add(x, y, z); - l.setYaw(yaw); - Entity e = w.spawnEntity(l, type, false); - if (e instanceof Mob m) m.setAI(false); - return e; + return rfSpawn(o, x, y, z, yaw, type.getEntityClass()); } private void faceNorth(Location o) {