refactor block entity handling and add support for invisible materials

This commit is contained in:
2026-06-21 14:22:10 +02:00
parent f1844a9dd9
commit c5d5eae1c1
18 changed files with 1541 additions and 107 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ repositories {
}
dependencies {
compileOnly("io.papermc.paper:paper-api:1.21.7-R0.1-SNAPSHOT")
compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT")
}
def targetJavaVersion = 21
@@ -67,8 +67,10 @@ public final class Main extends JavaPlugin {
eu.mhsl.minecraft.pixelpics.assets.SkinCache skinCache = new eu.mhsl.minecraft.pixelpics.assets.SkinCache();
eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker entityBaker =
new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker(cemLoader, textures, skinCache);
eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker blockEntityBaker =
new eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker(cemLoader, textures, skinCache);
this.screenRenderer = new DefaultScreenRenderer(registry, tintProvider, textures, entityBaker, getLogger());
this.screenRenderer = new DefaultScreenRenderer(registry, tintProvider, textures, entityBaker, blockEntityBaker, getLogger());
// Warm the map palette on the main thread so off-thread dithering never triggers its first init.
eu.mhsl.minecraft.pixelpics.utils.MapColorPalette.size();
getLogger().info("PixelPics renderer initialized with resource pack assets.");
@@ -8,6 +8,7 @@ import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel;
import org.bukkit.Material;
import org.bukkit.block.data.BlockData;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -43,6 +44,10 @@ public final class BlockModelRegistry {
Material material = data.getMaterial();
if (material == Material.WATER) return water();
if (material == Material.LAVA) return lava();
// Technical blocks that are invisible in the world (their textures only show held in hand).
if (INVISIBLE_MATERIALS.contains(material)) {
return new ResolvedModel(List.of(), 0, 0, 0, false, false);
}
List<Variant> variants = blockStateResolver.resolve(data);
@@ -70,11 +75,56 @@ public final class BlockModelRegistry {
return new ResolvedModel(elements, avg, 0, 0, false, true);
}
// No geometry: render a flat full cube using a fallback average color.
int avg = fallbackColor(lastFlat);
// Block-entities (chests, signs, banners, beds, heads, …) 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);
}
/** Technical blocks that render nothing in the world (barrier/light/structure void are hand-only). */
private static final EnumSet<Material> INVISIBLE_MATERIALS = buildInvisibleMaterials();
private static EnumSet<Material> buildInvisibleMaterials() {
EnumSet<Material> set = EnumSet.noneOf(Material.class);
for (String n : new String[]{"BARRIER", "LIGHT", "STRUCTURE_VOID"}) {
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<Material> BLOCK_ENTITY_MATERIALS = buildBlockEntityMaterials();
private static boolean isBlockEntityRendered(Material material) {
return BLOCK_ENTITY_MATERIALS.contains(material);
}
private static EnumSet<Material> 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<Material> 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("_BED")
|| n.endsWith("SHULKER_BOX") // SHULKER_BOX + <color>_SHULKER_BOX
|| ((n.endsWith("_SKULL") || n.endsWith("_HEAD")) && m != Material.PISTON_HEAD)
|| m == Material.CHEST || m == Material.TRAPPED_CHEST || m == Material.ENDER_CHEST
|| m == Material.CONDUIT || m == Material.DECORATED_POT || m == Material.BELL;
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) {
int[][] tex = {{color}};
@@ -0,0 +1,107 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import java.util.ArrayList;
import java.util.List;
/**
* Maps a {@link BlockEntityState} to its bundled CEM model name and ordered texture-path candidates.
* Block-entity geometry ships in the same CEM set as mobs (chest/sign/banner/…); textures live under
* {@code assets/minecraft/textures/entity/}. Mirrors the role {@link EntityModels} plays for mobs.
*/
public final class BlockEntityModels {
private BlockEntityModels() {}
/** The CEM ({@code .jem}) model name for a block-entity. */
public static String cemModel(BlockEntityState s) {
return switch (s.kind()) {
case CHEST, TRAPPED_CHEST, ENDER_CHEST -> switch (s.chestKind() == null ? BlockEntityState.ChestKind.SINGLE : s.chestKind()) {
case LEFT -> "chest_left";
case RIGHT -> "chest_right";
case SINGLE -> "chest";
};
case SIGN -> "sign";
case WALL_SIGN -> "wall_sign";
case HANGING_SIGN -> "hanging_sign";
case BANNER, WALL_BANNER -> "banner";
case BED -> "bed";
case SHULKER_BOX -> "shulker_box";
case CONDUIT -> "conduit";
case DECORATED_POT -> "decorated_pot";
case BELL -> "bell";
case HEAD, WALL_HEAD -> headModel(s.headType());
};
}
private static String headModel(String headType) {
if (headType == null) return "head";
return switch (headType) {
case "dragon" -> "head_dragon";
case "piglin" -> "head_piglin";
case "player" -> "head_player";
case "wither_skeleton" -> "wither_skull";
default -> "head"; // skeleton, zombie, creeper share the plain humanoid skull box
};
}
/** Ordered texture-path candidates; the baker uses the first that loads. */
public static List<ResourceLocation> textureCandidates(BlockEntityState s) {
List<String> paths = new ArrayList<>();
switch (s.kind()) {
case CHEST -> chestTextures(paths, s, "normal");
case TRAPPED_CHEST -> chestTextures(paths, s, "trapped");
case ENDER_CHEST -> paths.add("entity/chest/ender");
case SIGN, WALL_SIGN -> {
String wood = s.wood() == null ? "oak" : s.wood();
paths.add("entity/signs/" + wood);
paths.add("entity/sign"); // legacy single-texture fallback
}
case HANGING_SIGN -> {
String wood = s.wood() == null ? "oak" : s.wood();
paths.add("entity/signs/hanging/" + wood);
}
case BANNER, WALL_BANNER -> paths.add("entity/banner/banner_base"); // tinted by baseColorArgb
case BED -> {
String c = s.colorName() == null ? "red" : s.colorName();
paths.add("entity/bed/" + c);
}
case SHULKER_BOX -> {
if (s.colorName() != null) paths.add("entity/shulker/shulker_" + s.colorName());
paths.add("entity/shulker/shulker"); // uncoloured (purpur) default
}
case CONDUIT -> paths.add("entity/conduit/base");
case DECORATED_POT -> paths.add("entity/decorated_pot/decorated_pot_base");
case BELL -> paths.add("entity/bell/bell_body");
case HEAD, WALL_HEAD -> headTextures(paths, s.headType());
}
List<ResourceLocation> out = new ArrayList<>(paths.size());
for (String p : paths) out.add(ResourceLocation.parse(p));
return out;
}
private static void chestTextures(List<String> paths, BlockEntityState s, String base) {
BlockEntityState.ChestKind ck = s.chestKind() == null ? BlockEntityState.ChestKind.SINGLE : s.chestKind();
switch (ck) {
case LEFT -> paths.add("entity/chest/" + base + "_left");
case RIGHT -> paths.add("entity/chest/" + base + "_right");
case SINGLE -> paths.add("entity/chest/" + base);
}
paths.add("entity/chest/" + base); // fallback to the single texture
}
private static void headTextures(List<String> paths, String headType) {
if (headType == null) { paths.add("entity/skeleton/skeleton"); return; }
switch (headType) {
case "skeleton" -> paths.add("entity/skeleton/skeleton");
case "wither_skeleton" -> paths.add("entity/skeleton/wither_skeleton");
case "zombie" -> paths.add("entity/zombie/zombie");
case "creeper" -> paths.add("entity/creeper/creeper");
case "piglin" -> paths.add("entity/piglin/piglin");
case "dragon" -> paths.add("entity/enderdragon/dragon");
case "player" -> paths.add("entity/player/wide/steve"); // skin handled separately by the baker
default -> paths.add("entity/skeleton/skeleton");
}
}
}
@@ -0,0 +1,46 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
import java.util.List;
/**
* Immutable snapshot of one block-entity captured on the main thread, sufficient to bake and place it
* off-thread. Block-entities (chests, signs, banners, beds, heads, …) use vanilla {@code builtin/entity}
* block models with no geometry; their real shape lives in the bundled CEM models, so they are rendered
* through the same baking/intersection path as mobs. Bukkit-free so the baker needs no world access.
*
* <p>{@code facingDeg} is the world yaw the model should face (already converted from the block's
* facing/rotation, vanilla convention). Type-specific fields are null/0/empty when unused.
*/
public record BlockEntityState(
Kind kind,
int bx, int by, int bz,
float facingDeg,
ChestKind chestKind, // double-chest half (CHEST/TRAPPED_CHEST/ENDER_CHEST)
int baseColorArgb, // banner base tint (white texture); 0 = none
String colorName, // bed/shulker/banner colour variant ("red", "white", …); null = default
String wood, // sign/hanging-sign wood ("oak", "spruce", …); null = default
BedPart bedPart, // bed half
String headType, // "skeleton","wither_skeleton","zombie","creeper","piglin","dragon","player"
String skinUrl, // player-head owner skin URL; null otherwise
List<BannerPattern> patterns, // banner overlay patterns (may be empty)
List<String> sherds, // decorated-pot sherds: front/back/left/right item keys (may be empty)
BellAttach bellAttach // bell attachment; null when not a bell
) {
public enum Kind {
CHEST, TRAPPED_CHEST, ENDER_CHEST,
SIGN, WALL_SIGN, HANGING_SIGN,
BANNER, WALL_BANNER,
BED, SHULKER_BOX,
HEAD, WALL_HEAD,
CONDUIT, DECORATED_POT, BELL
}
public enum ChestKind { SINGLE, LEFT, RIGHT }
public enum BedPart { HEAD, FOOT }
public enum BellAttach { FLOOR, CEILING, SINGLE_WALL, DOUBLE_WALL }
/** One banner overlay layer: a pattern key (e.g. "stripe_top") and the ARGB colour it is dyed with. */
public record BannerPattern(String patternKey, int colorArgb) {}
}
@@ -0,0 +1,168 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import java.util.ArrayList;
import java.util.List;
/**
* Bakes flat wall decorations (paintings, item frames) into world-space {@link EntityCube}s. These are
* not CEM models but simple textured quads, so the geometry is built directly from the captured world
* bounding box and the front-facing direction. Added to the {@link EntityScene} like any other entity.
*/
public final class DecorationBaker {
private static final double MIN_THICKNESS = 0.0625; // 1px, so the slab test never degenerates
private final TextureCache textures;
public DecorationBaker(TextureCache textures) {
this.textures = textures;
}
public RenderedEntity bake(DecorationState s) {
return s.kind() == DecorationState.Kind.PAINTING ? bakePainting(s) : bakeItemFrame(s);
}
private RenderedEntity bakePainting(DecorationState s) {
int[][] art = textures.get(ResourceLocation.parse("painting/" + s.paintingArt())).orElse(null);
if (art == null) return null;
int[][] back = textures.get(ResourceLocation.parse("painting/back")).orElse(art);
double[] from = {s.minX(), s.minY(), s.minZ()};
double[] to = {s.maxX(), s.maxY(), s.maxZ()};
thicken(from, to, s.facing());
Face[] faces = new Face[6];
Direction front = direction(s.facing());
faces[front.ordinal()] = frontFace(art, s.facing());
faces[opposite(front).ordinal()] = new Face(back, 0, 0, 1, 1, 0, -1);
EntityCube cube = new EntityCube(from, to, faces, Affine.identity());
return scene(List.of(cube));
}
/**
* Item frames render as the vanilla geometry: a 12x12 birch border, a recessed 10x10 leather back
* panel ({@code block/item_frame}), and the held item as a centred 8x8 sprite. Built in a canonical
* local frame (front toward +Z, centred on the block face) and oriented to the wall by an affine.
*/
private RenderedEntity bakeItemFrame(DecorationState s) {
String frameTex = s.glow() ? "block/glow_item_frame" : "block/item_frame";
int[][] leather = textures.get(ResourceLocation.parse(frameTex)).orElse(null);
int[][] wood = textures.get(ResourceLocation.parse("block/birch_planks")).orElse(leather);
if (leather == null) return null;
Affine toWorld = Affine.translation(faceCenterX(s), faceCenterY(s), faceCenterZ(s)).mul(facingRotation(s.facing()));
int si = Direction.SOUTH.ordinal();
List<EntityCube> cubes = new ArrayList<>(3);
// Wood border 12x12 (behind), leather back 10x10 (1px proud), item 8x8 (in front). Front = local +Z.
if (wood != null) {
Face[] f = new Face[6];
f[si] = new Face(wood, 1, 0, 0, 1, 0, -1); // tileable; flip matches the others
cubes.add(new EntityCube(px(-6, -6, 0), px(6, 6, 1), f, toWorld));
}
Face[] back = new Face[6];
back[si] = new Face(leather, 13.0 / 16, 3.0 / 16, 3.0 / 16, 13.0 / 16, 0, -1); // centre 10x10, flipped
cubes.add(new EntityCube(px(-5, -5, 1), px(5, 5, 2), back, toWorld));
if (s.itemId() != null) {
int[][] item = resolveItem(s.itemId());
if (item != null) {
int rot = ((Math.round(s.itemRotationDeg() / 90f) * 90) % 360 + 360) % 360;
Face[] f = new Face[6];
f[si] = new Face(item, 1, 0, 0, 1, rot, -1); // full sprite, flipped to read upright
cubes.add(new EntityCube(px(-4, -4, 2), px(4, 4, 3), f, toWorld));
}
}
return scene(cubes);
}
/** A local-space corner in model pixels (1/16 block); z is the outward (front) offset from the wall. */
private static double[] px(double x, double y, double z) {
return new double[]{x / 16.0, y / 16.0, z / 16.0};
}
/** Item sprite: generated items live under item/, block items fall back to block/. */
private int[][] resolveItem(String id) {
int[][] t = textures.get(ResourceLocation.parse("item/" + id)).orElse(null);
if (t != null) return t;
return textures.get(ResourceLocation.parse("block/" + id)).orElse(null);
}
// Frame placement: in-plane centre from the bbox, depth at the outward wall surface.
private static double faceCenterX(DecorationState s) {
return s.facing().axis() == 0 ? (s.facing().sign() > 0 ? s.maxX() : s.minX()) : (s.minX() + s.maxX()) / 2;
}
private static double faceCenterY(DecorationState s) {
return s.facing().axis() == 1 ? (s.facing().sign() > 0 ? s.maxY() : s.minY()) : (s.minY() + s.maxY()) / 2;
}
private static double faceCenterZ(DecorationState s) {
return s.facing().axis() == 2 ? (s.facing().sign() > 0 ? s.maxZ() : s.minZ()) : (s.minZ() + s.maxZ()) / 2;
}
/** Maps the canonical local frame (front +Z, up +Y) onto the wall facing the given direction. */
private static Affine facingRotation(DecorationState.Facing f) {
return switch (f) {
case SOUTH -> Affine.identity();
case NORTH -> Affine.rotY(Math.PI);
case EAST -> Affine.rotY(Math.PI / 2);
case WEST -> Affine.rotY(-Math.PI / 2);
case UP -> Affine.rotX(-Math.PI / 2);
case DOWN -> Affine.rotX(Math.PI / 2);
};
}
/** Front face with the per-facing horizontal flip so the texture reads upright and unmirrored. */
private Face frontFace(int[][] tex, DecorationState.Facing facing) {
boolean flipH = facing == DecorationState.Facing.SOUTH || facing == DecorationState.Facing.WEST;
double u1 = flipH ? 1 : 0, u2 = flipH ? 0 : 1;
return new Face(tex, u1, 0, u2, 1, 0, -1);
}
private void thicken(double[] from, double[] to, DecorationState.Facing facing) {
int a = facing.axis();
if (to[a] - from[a] >= MIN_THICKNESS) return;
if (facing.sign() > 0) from[a] = to[a] - MIN_THICKNESS;
else to[a] = from[a] + MIN_THICKNESS;
}
private static Direction direction(DecorationState.Facing f) {
return switch (f) {
case NORTH -> Direction.NORTH;
case SOUTH -> Direction.SOUTH;
case EAST -> Direction.EAST;
case WEST -> Direction.WEST;
case UP -> Direction.UP;
case DOWN -> Direction.DOWN;
};
}
private static Direction opposite(Direction d) {
return switch (d) {
case NORTH -> Direction.SOUTH;
case SOUTH -> Direction.NORTH;
case EAST -> Direction.WEST;
case WEST -> Direction.EAST;
case UP -> Direction.DOWN;
case DOWN -> Direction.UP;
};
}
private static RenderedEntity scene(List<EntityCube> cubes) {
double[] min = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE};
double[] max = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE};
for (EntityCube c : cubes) {
for (int a = 0; a < 3; a++) {
if (c.aabbMin[a] < min[a]) min[a] = c.aabbMin[a];
if (c.aabbMax[a] > max[a]) max[a] = c.aabbMax[a];
}
}
return new RenderedEntity(cubes, min, max);
}
}
@@ -0,0 +1,41 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
/**
* Immutable snapshot of a flat wall decoration (painting or item frame). These are Bukkit
* {@code Hanging} entities, not block-entities, but render as flat textured quads rather than CEM
* models, so they have their own state/baker. The world-space bounding box is captured directly (it
* already encodes vanilla's awkward multi-block painting offset), avoiding placement math.
*/
public record DecorationState(
Kind kind,
double minX, double minY, double minZ,
double maxX, double maxY, double maxZ,
Facing facing, // direction the front faces (away from the wall)
String paintingArt, // painting art asset key (texture painting/<art>); null for frames
String itemId, // item-frame contents material key (e.g. "diamond"); null if empty
int itemRotationDeg, // item-frame content rotation (0/45/…/315)
boolean glow // glow item frame
) {
public enum Kind { PAINTING, ITEM_FRAME }
public enum Facing {
NORTH, SOUTH, EAST, WEST, UP, DOWN;
/** The axis this face's normal lies on: 0=x, 1=y, 2=z. */
public int axis() {
return switch (this) {
case EAST, WEST -> 0;
case UP, DOWN -> 1;
case NORTH, SOUTH -> 2;
};
}
/** +1 or -1 along {@link #axis()}. */
public int sign() {
return switch (this) {
case SOUTH, UP, EAST -> 1;
case NORTH, DOWN, WEST -> -1;
};
}
}
}
@@ -1,5 +1,6 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker;
import eu.mhsl.minecraft.pixelpics.render.raytrace.FaceHit;
@@ -7,8 +8,10 @@ import java.util.ArrayList;
import java.util.List;
/**
* All baked entities for one render. Provides the nearest entity hit along a ray, using per-entity
* and per-cube AABB broad-phase culling. Immutable after construction → safe for the parallel tracer.
* All baked entities (mobs + block-entities) for one render. Provides the nearest hit along a ray,
* using per-entity and per-cube AABB broad-phase culling. Block-entities are baked into the same list
* and tested identically, so the raytracer needs no special case. Immutable after construction → safe
* for the parallel tracer.
*/
public final class EntityScene {
@@ -16,11 +19,29 @@ public final class EntityScene {
private final List<RenderedEntity> entities;
public EntityScene(List<EntityState> states, CemBaker baker) {
this.entities = new ArrayList<>(states.size());
this(states, baker, List.of(), null, List.of(), null);
}
public EntityScene(List<EntityState> states, CemBaker baker,
List<BlockEntityState> blockEntities, BlockEntityBaker beBaker,
List<DecorationState> decorations, DecorationBaker decoBaker) {
this.entities = new ArrayList<>(states.size() + blockEntities.size() + decorations.size());
for (EntityState s : states) {
RenderedEntity e = baker.bake(s);
if (e != null && !e.cubes.isEmpty()) entities.add(e);
}
if (beBaker != null) {
for (BlockEntityState s : blockEntities) {
RenderedEntity e = beBaker.bake(s);
if (e != null && !e.cubes.isEmpty()) entities.add(e);
}
}
if (decoBaker != null) {
for (DecorationState s : decorations) {
RenderedEntity e = decoBaker.bake(s);
if (e != null && !e.cubes.isEmpty()) entities.add(e);
}
}
}
public boolean isEmpty() {
@@ -0,0 +1,236 @@
package eu.mhsl.minecraft.pixelpics.render.entity.cem;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import eu.mhsl.minecraft.pixelpics.assets.SkinCache;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityModels;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* Bakes a {@link BlockEntityState} into world-space cubes using the bundled CEM models, reusing
* {@link CemGeometry}. Unlike mobs, block-entities are not dropped to the ground by their model bounds;
* each type is placed by an affine that reproduces the vanilla BlockEntityRenderer: the block cell
* centre, a yaw from the block's facing/rotation, and a per-type local offset/scale.
*
* <p>The CEM block-entity models are authored centred on X/Z with their base near Y=0 (after the px→
* block scale), so the placement is {@code T(cell centre) · rotY(yaw) · T(localOffset)} and most types
* only need defaults. Wall-mounted and scaled types (signs, wall heads, beds) override via {@link Place}.
*/
public final class BlockEntityBaker {
private static final double SIGN_SCALE = 0.6666667; // vanilla SignRenderer model scale
private final CemModelLoader models;
private final TextureCache textures;
private final SkinCache skins;
public BlockEntityBaker(CemModelLoader models, TextureCache textures, SkinCache skins) {
this.models = models;
this.textures = textures;
this.skins = skins;
}
/** Returns the baked block-entity, or null when it has no model/texture (then nothing renders). */
public RenderedEntity bake(BlockEntityState s) {
List<Layer> layers = layers(s);
if (layers.isEmpty()) return null;
// Head model depends on the texture aspect (skeleton 64x32 vs zombie/player 64x64), so resolve it
// from the chosen texture rather than statically.
CemModelLoader.CemModel model = models.get(modelName(s, layers.get(0).tex));
if (model == null) return null;
Place p = place(s);
Affine pre = Affine.scale(p.scale / 16.0);
Affine placement = Affine.translation(s.bx() + 0.5, s.by(), s.bz() + 0.5)
.mul(Affine.rotY(Math.toRadians(p.yaw)))
.mul(Affine.translation(p.lx, p.ly, p.lz));
List<EntityCube> cubes = new ArrayList<>();
for (Layer layer : layers) {
for (CemGeometry.Baked b : CemGeometry.bakeModel(model, layer.tex, pre, layer.hidden, layer.texW, layer.texH, layer.boxUv)) {
cubes.add(new EntityCube(b.from(), b.to(), b.faces(), placement.mul(b.world())));
}
}
return cubes.isEmpty() ? null : CemGeometry.finish(cubes);
}
/** The CEM model name; for heads it depends on the texture's aspect (64x32 vs square 64x64). */
private String modelName(BlockEntityState s, int[][] tex) {
if (s.kind() != BlockEntityState.Kind.HEAD && s.kind() != BlockEntityState.Kind.WALL_HEAD) {
return BlockEntityModels.cemModel(s);
}
String t = s.headType();
if ("dragon".equals(t)) return "head_dragon";
if ("piglin".equals(t)) return "head_piglin";
// Square textures (zombie, player) carry a hat overlay → use the 64x64 player-head model; the
// 2:1 skull textures (skeleton, wither_skeleton, creeper) use the 64x32 skull model.
boolean square = tex.length > 0 && tex.length == tex[0].length;
return square ? "head_player" : "head";
}
/** A single bake pass: one texture applied to the parts not in {@code hidden}, with optional UV size
* and a flag forcing box-UV (for standalone part textures not matching the model's per-face UV). */
private record Layer(int[][] tex, Set<String> hidden, int texW, int texH, boolean boxUv) {
Layer(int[][] tex, Set<String> hidden) { this(tex, hidden, 0, 0, false); }
}
/** Some types paint different parts with different textures (pot sherds, conduit cage/heart). */
private List<Layer> layers(BlockEntityState s) {
if (s.kind() == BlockEntityState.Kind.DECORATED_POT) {
return potLayers(s);
}
if (s.kind() == BlockEntityState.Kind.CONDUIT) {
return conduitLayers();
}
int[][] tex = resolveTexture(s);
return tex == null ? List.of() : List.of(new Layer(tex, hiddenParts(s)));
}
private static final Set<String> CONDUIT_PARTS = Set.of("eye", "cage", "base", "wind");
/** The conduit's cage and inner heart use separate textures; the eye/wind (active state) are skipped. */
private List<Layer> conduitLayers() {
int[][] cage = textures.get(ResourceLocation.parse("entity/conduit/cage")).orElse(null);
int[][] base = textures.get(ResourceLocation.parse("entity/conduit/base")).orElse(null);
// Each part's texture is authored at its own native size (32x16) with a box-UV layout, so force
// box-UV and normalise by the texture's own size (the model's per-face UV assumes a combined sheet).
List<Layer> layers = new ArrayList<>(2);
if (cage != null) layers.add(new Layer(cage, onlyPart("cage", CONDUIT_PARTS), cage[0].length, cage.length, true));
if (base != null) layers.add(new Layer(base, onlyPart("base", CONDUIT_PARTS), base[0].length, base.length, true));
return layers;
}
/** Hidden set that leaves only {@code keep} visible out of {@code all}. */
private static Set<String> onlyPart(String keep, Set<String> all) {
Set<String> hidden = new java.util.HashSet<>(all);
hidden.remove(keep);
return hidden;
}
private static final Set<String> POT_PARTS = Set.of("neck", "top", "bottom", "front", "back", "left", "right");
private static final String[] POT_FACES = {"front", "left", "right", "back"}; // matches sherd capture order
private List<Layer> potLayers(BlockEntityState s) {
int[][] base = textures.get(ResourceLocation.parse("entity/decorated_pot/decorated_pot_base")).orElse(null);
int[][] side = textures.get(ResourceLocation.parse("entity/decorated_pot/decorated_pot_side")).orElse(null);
if (base == null) return List.of();
List<Layer> layers = new ArrayList<>();
// Structure (rim/neck/foot) comes from the combined base texture; the four sides are NOT in it.
layers.add(new Layer(base, new java.util.HashSet<>(java.util.List.of("front", "back", "left", "right"))));
// Each side: its sherd pattern if set, else the plain brick side. The model's per-face UV maps the
// centre of the 16x16 texture onto the face (centred, edges intact).
for (int i = 0; i < POT_FACES.length; i++) {
int[][] tex = side;
if (i < s.sherds().size()) {
String sherd = s.sherds().get(i);
if (sherd != null && sherd.endsWith("_pottery_sherd")) {
int[][] pat = textures.get(ResourceLocation.parse(
"entity/decorated_pot/" + sherd.replace("_pottery_sherd", "_pottery_pattern"))).orElse(null);
if (pat != null) tex = pat;
}
}
if (tex == null) continue;
layers.add(new Layer(tex, onlyPart(POT_FACES[i], POT_PARTS)));
}
return layers;
}
// --- placement parameters per type ---
/** Local placement: applied yaw (deg), model scale, and a local-frame offset (blocks). */
private record Place(double yaw, double scale, double lx, double ly, double lz) {}
private Place place(BlockEntityState s) {
double yaw = 180 - s.facingDeg(); // model default faces north; rotate by the block's facing
return switch (s.kind()) {
case SIGN -> new Place(yaw, SIGN_SCALE, 0, 0, 0);
case WALL_SIGN -> new Place(yaw, SIGN_SCALE, 0, -0.3125, 0.4375); // drop to mid-block, push to wall
case HANGING_SIGN -> new Place(yaw, 1.0, 0, 0, 0);
case WALL_HEAD -> new Place(yaw, 1.0, 0, 0.25, 0.25); // mid-height, against the wall
case WALL_BANNER -> new Place(yaw, 1.0, 0, -0.16, 0.4375);
default -> new Place(yaw, 1.0, 0, 0, 0);
};
}
/** Parts to omit (the unused bed half / its legs, the conduit's open-state shell). */
private Set<String> hiddenParts(BlockEntityState s) {
return switch (s.kind()) {
case BED -> s.bedPart() == BlockEntityState.BedPart.HEAD
? Set.of("foot", "leg3", "leg4")
: Set.of("head", "leg1", "leg2");
case CONDUIT -> Set.of("eye", "wind");
default -> Set.of();
};
}
// --- texture resolution ---
private int[][] resolveTexture(BlockEntityState s) {
// Player heads use the owner's skin when available.
if (s.skinUrl() != null && (s.kind() == BlockEntityState.Kind.HEAD || s.kind() == BlockEntityState.Kind.WALL_HEAD)) {
int[][] skin = skins.get(s.skinUrl()).orElse(null);
if (skin != null) return skin;
}
boolean isBanner = s.kind() == BlockEntityState.Kind.BANNER || s.kind() == BlockEntityState.Kind.WALL_BANNER;
for (ResourceLocation rl : BlockEntityModels.textureCandidates(s)) {
int[][] t = textures.get(rl).orElse(null);
if (t == null) continue;
if (isBanner) return bakeBanner(t, s);
return t;
}
return null;
}
/**
* Composites a banner texture: tint the white {@code banner_base} cloth with the base colour, then
* alpha-overlay each pattern mask ({@code entity/banner/<key>}) dyed with its own colour, in order.
*/
private int[][] bakeBanner(int[][] base, BlockEntityState s) {
int[][] out = deepCopy(base);
if (s.baseColorArgb() != 0) CemGeometry.tint(out, s.baseColorArgb());
for (BlockEntityState.BannerPattern pat : s.patterns()) {
int[][] mask = textures.get(ResourceLocation.parse("entity/banner/" + pat.patternKey())).orElse(null);
if (mask == null) continue;
int[][] dyed = deepCopy(mask);
CemGeometry.tint(dyed, pat.colorArgb());
overlay(out, dyed);
}
return out;
}
/** Standard src-over alpha composite of {@code src} onto {@code dst} (same dimensions), in place. */
private static void overlay(int[][] dst, int[][] src) {
int h = Math.min(dst.length, src.length);
for (int y = 0; y < h; y++) {
int w = Math.min(dst[y].length, src[y].length);
for (int x = 0; x < w; x++) {
int sp = src[y][x];
int sa = (sp >>> 24) & 0xFF;
if (sa == 0) continue;
if (sa == 255) { dst[y][x] = sp; continue; }
int dp = dst[y][x];
int da = (dp >>> 24) & 0xFF;
int outA = sa + da * (255 - sa) / 255;
int sr = (sp >> 16) & 0xFF, sg = (sp >> 8) & 0xFF, sb = sp & 0xFF;
int dr = (dp >> 16) & 0xFF, dg = (dp >> 8) & 0xFF, db = dp & 0xFF;
int r = (sr * sa + dr * da * (255 - sa) / 255) / Math.max(1, outA);
int g = (sg * sa + dg * da * (255 - sa) / 255) / Math.max(1, outA);
int b = (sb * sa + db * da * (255 - sa) / 255) / Math.max(1, outA);
dst[y][x] = (outA << 24) | (r << 16) | (g << 8) | b;
}
}
}
private static int[][] deepCopy(int[][] src) {
int[][] out = new int[src.length][];
for (int y = 0; y < src.length; y++) out[y] = src[y].clone();
return out;
}
}
@@ -40,19 +40,6 @@ public final class CemBaker {
this.skins = skins;
}
private record Baked(double[] from, double[] to, Face[] faces, Affine world) {
double minWorldY() {
double m = Double.MAX_VALUE;
for (int i = 0; i < 8; i++) {
double x = (i & 1) == 0 ? from[0] : to[0];
double y = (i & 2) == 0 ? from[1] : to[1];
double z = (i & 4) == 0 ? from[2] : to[2];
m = Math.min(m, world.apply(x, y, z)[1]);
}
return m;
}
}
public RenderedEntity bake(EntityState s) {
int[][] tex = resolveTexture(s);
CemModelLoader.CemModel model = models.get(EntityModels.cemModel(s.typeKey()));
@@ -64,8 +51,7 @@ public final class CemBaker {
Affine pre = Affine.scale(sc / 16.0);
java.util.Set<String> hidden = HIDDEN_PARTS.getOrDefault(EntityModels.cemModel(s.typeKey()), java.util.Set.of());
List<Baked> baked = new ArrayList<>();
bakeModel(model, tex, pre, hidden, baked);
List<CemGeometry.Baked> baked = new ArrayList<>(CemGeometry.bakeModel(model, tex, pre, hidden));
// Sheep: render the inflated, dye-tinted wool fur layer over the body (transparent where the face shows).
if (s.typeKey().equals("sheep")) {
CemModelLoader.CemModel wool = models.get("sheep_wool");
@@ -73,8 +59,8 @@ public final class CemBaker {
if (wool != null && woolTex != null) {
int[][] t = new int[woolTex.length][];
for (int y = 0; y < woolTex.length; y++) t[y] = woolTex[y].clone();
if (s.tint() != 0) tint(t, s.tint());
bakeModel(wool, t, pre, hidden, baked);
if (s.tint() != 0) CemGeometry.tint(t, s.tint());
baked.addAll(CemGeometry.bakeModel(wool, t, pre, hidden));
}
}
// Guardian: the CEM model ships a RIGHT body side-panel but no left one, and the main body box's
@@ -83,62 +69,20 @@ public final class CemBaker {
double[] org = {-8, 2, -6}, size = {2, 12, 12};
ModelCube mc = new ModelCube(org, size, 0, new double[]{0, 28}, true, new double[]{0,0,0}, new double[]{0,0,0});
Face[] faces = BoxUv.build(mc, tex, model.texW(), model.texH());
baked.add(new Baked(org, new double[]{org[0]+size[0], org[1]+size[1], org[2]+size[2]}, faces, pre));
baked.add(new CemGeometry.Baked(org, new double[]{org[0]+size[0], org[1]+size[1], org[2]+size[2]}, faces, pre));
}
if (baked.isEmpty()) return fallbackBox(s, tex);
double minY = Double.MAX_VALUE;
for (Baked b : baked) minY = Math.min(minY, b.minWorldY());
for (CemGeometry.Baked b : baked) minY = Math.min(minY, b.minWorldY());
Affine place = Affine.translation(s.x(), s.y(), s.z())
.mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw())))
.mul(Affine.translation(0, -minY, 0));
List<EntityCube> cubes = new ArrayList<>(baked.size());
for (Baked b : baked) cubes.add(new EntityCube(b.from, b.to, b.faces, place.mul(b.world)));
return finish(cubes);
}
private void bakeModel(CemModelLoader.CemModel model, int[][] tex, Affine pre,
java.util.Set<String> hidden, List<Baked> out) {
for (CemModelLoader.CemPart p : model.parts()) {
double[] o = {-p.translate()[0], -p.translate()[1], -p.translate()[2]};
bakePart(p, pre, o, 0, hidden, model, tex, out);
}
}
/**
* Faithful OptiFine/Blockbench CEM transform: each part is a group whose rotation pivots around its
* origin {@code O} (top-level: {@code -translate}; submodel: {@code translate}, accumulated with the
* parent origin from the 2nd nesting level on). Top-level boxes are absolute; nested boxes are offset
* by their group origin. The group transform is {@code parent · T(O) · R · T(-O)}.
*/
private void bakePart(CemModelLoader.CemPart part, Affine parentWorld, double[] o, int depth,
java.util.Set<String> hidden, CemModelLoader.CemModel model, int[][] tex, List<Baked> out) {
if (hidden.contains(part.name())) return;
Affine world = parentWorld
.mul(Affine.translation(o[0], o[1], o[2]))
.mul(Affine.rotZ(Math.toRadians(part.rotate()[2])))
.mul(Affine.rotY(Math.toRadians(part.rotate()[1])))
.mul(Affine.rotX(Math.toRadians(part.rotate()[0])))
.mul(Affine.translation(-o[0], -o[1], -o[2]));
double ox = depth > 0 ? o[0] : 0, oy = depth > 0 ? o[1] : 0, oz = depth > 0 ? o[2] : 0;
for (CemModelLoader.CemBox b : part.boxes()) {
double inf = b.inflate();
double[] org = {b.origin()[0] + ox, b.origin()[1] + oy, b.origin()[2] + oz};
double[] from = {org[0] - inf, org[1] - inf, org[2] - inf};
double[] to = {org[0] + b.size()[0] + inf, org[1] + b.size()[1] + inf, org[2] + b.size()[2] + inf};
ModelCube mc = new ModelCube(org, b.size(), inf, b.uv(), b.mirror(), new double[]{0,0,0}, new double[]{0,0,0});
Face[] faces = BoxUv.build(mc, tex, model.texW(), model.texH());
out.add(new Baked(from, to, faces, world));
}
for (CemModelLoader.CemPart child : part.children()) {
double[] t = child.translate();
// submodel origin = its translate, accumulated with this group's origin from the 2nd level on.
double[] co = depth >= 1 ? new double[]{t[0] + o[0], t[1] + o[1], t[2] + o[2]} : new double[]{t[0], t[1], t[2]};
bakePart(child, world, co, depth + 1, hidden, model, tex, out);
}
for (CemGeometry.Baked b : baked) cubes.add(new EntityCube(b.from(), b.to(), b.faces(), place.mul(b.world())));
return CemGeometry.finish(cubes);
}
// --- texture resolution (player skin, dyed sheep wool, variant candidates) ---
@@ -170,32 +114,7 @@ public final class CemBaker {
.mul(Affine.scale(1.0 / 16.0));
List<EntityCube> cubes = new ArrayList<>();
cubes.add(new EntityCube(from, to, faces, place));
return finish(cubes);
}
private RenderedEntity finish(List<EntityCube> cubes) {
double[] min = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE};
double[] max = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE};
for (EntityCube c : cubes) {
for (int a = 0; a < 3; a++) {
if (c.aabbMin[a] < min[a]) min[a] = c.aabbMin[a];
if (c.aabbMax[a] > max[a]) max[a] = c.aabbMax[a];
}
}
return new RenderedEntity(cubes, min, max);
}
private static void tint(int[][] tex, int argb) {
int tr = (argb >> 16) & 0xFF, tg = (argb >> 8) & 0xFF, tb = argb & 0xFF;
for (int[] row : tex) {
for (int x = 0; x < row.length; x++) {
int p = row[x];
int a = (p >>> 24) & 0xFF;
if (a == 0) continue;
int r = ((p >> 16) & 0xFF) * tr / 255, g = ((p >> 8) & 0xFF) * tg / 255, b = (p & 0xFF) * tb / 255;
row[x] = (a << 24) | (r << 16) | (g << 8) | b;
}
}
return CemGeometry.finish(cubes);
}
private static int[][] flat(int argb) {
@@ -0,0 +1,132 @@
package eu.mhsl.minecraft.pixelpics.render.entity.cem;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
import eu.mhsl.minecraft.pixelpics.render.entity.BoxUv;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
import eu.mhsl.minecraft.pixelpics.render.entity.ModelCube;
import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* Shared, stateless CEM geometry baking used by both {@link CemBaker} (mobs) and the block-entity
* baker. It turns a {@link CemModelLoader.CemModel} into a list of {@link Baked} boxes (local cube +
* its model→pre-space transform), faithfully reproducing the OptiFine/Blockbench CEM transform. The
* placement into the world (yaw, ground-snap, block cell) is the caller's responsibility.
*/
final class CemGeometry {
private CemGeometry() {}
/** A baked box: local min/max corner, its six faces and the transform mapping it into pre-space. */
record Baked(double[] from, double[] to, Face[] faces, Affine world) {
double minWorldY() {
double m = Double.MAX_VALUE;
for (int i = 0; i < 8; i++) {
double x = (i & 1) == 0 ? from[0] : to[0];
double y = (i & 2) == 0 ? from[1] : to[1];
double z = (i & 4) == 0 ? from[2] : to[2];
m = Math.min(m, world.apply(x, y, z)[1]);
}
return m;
}
}
/** Bake all parts of a model with the given pre-transform; parts in {@code hidden} are skipped. */
static List<Baked> bakeModel(CemModelLoader.CemModel model, int[][] tex, Affine pre, Set<String> hidden) {
return bakeModel(model, tex, pre, hidden, 0, 0);
}
/**
* As {@link #bakeModel(CemModelLoader.CemModel, int[][], Affine, Set)} but with an explicit texture
* size for UV normalisation (use when the applied texture's size differs from the model's declared
* {@code textureSize} in a non-proportional way, e.g. a 16x16 sherd on a 32x32-authored pot face).
* {@code texW}/{@code texH} of 0 fall back to the model's declared size.
*/
static List<Baked> bakeModel(CemModelLoader.CemModel model, int[][] tex, Affine pre, Set<String> hidden,
int texW, int texH) {
return bakeModel(model, tex, pre, hidden, texW, texH, false);
}
/**
* As above, but {@code ignoreFaceUv} forces box-UV even when the model declares per-face UV — used when
* applying a standalone texture (e.g. the conduit cage's own {@code cage.png}) whose layout is box-UV,
* not the combined-sheet layout the model's per-face UV assumes.
*/
static List<Baked> bakeModel(CemModelLoader.CemModel model, int[][] tex, Affine pre, Set<String> hidden,
int texW, int texH, boolean ignoreFaceUv) {
int nw = texW > 0 ? texW : model.texW();
int nh = texH > 0 ? texH : model.texH();
List<Baked> out = new ArrayList<>();
for (CemModelLoader.CemPart p : model.parts()) {
double[] o = {-p.translate()[0], -p.translate()[1], -p.translate()[2]};
bakePart(p, pre, o, 0, hidden, nw, nh, tex, ignoreFaceUv, out);
}
return out;
}
/**
* Faithful OptiFine/Blockbench CEM transform: each part is a group whose rotation pivots around its
* origin {@code O} (top-level: {@code -translate}; submodel: {@code translate}, accumulated with the
* parent origin from the 2nd nesting level on). Top-level boxes are absolute; nested boxes are offset
* by their group origin. The group transform is {@code parent · T(O) · R · T(-O)}.
*/
private static void bakePart(CemModelLoader.CemPart part, Affine parentWorld, double[] o, int depth,
Set<String> hidden, int texW, int texH, int[][] tex, boolean ignoreFaceUv, List<Baked> out) {
if (hidden.contains(part.name())) return;
Affine world = parentWorld
.mul(Affine.translation(o[0], o[1], o[2]))
.mul(Affine.rotZ(Math.toRadians(part.rotate()[2])))
.mul(Affine.rotY(Math.toRadians(part.rotate()[1])))
.mul(Affine.rotX(Math.toRadians(part.rotate()[0])))
.mul(Affine.translation(-o[0], -o[1], -o[2]));
double ox = depth > 0 ? o[0] : 0, oy = depth > 0 ? o[1] : 0, oz = depth > 0 ? o[2] : 0;
for (CemModelLoader.CemBox b : part.boxes()) {
double inf = b.inflate();
double[] org = {b.origin()[0] + ox, b.origin()[1] + oy, b.origin()[2] + oz};
double[] from = {org[0] - inf, org[1] - inf, org[2] - inf};
double[] to = {org[0] + b.size()[0] + inf, org[1] + b.size()[1] + inf, org[2] + b.size()[2] + inf};
double[][] faceUv = ignoreFaceUv ? null : b.faceUv();
ModelCube mc = new ModelCube(org, b.size(), inf, b.uv(), b.mirror(), new double[]{0, 0, 0}, new double[]{0, 0, 0}, faceUv);
Face[] faces = BoxUv.build(mc, tex, texW, texH);
out.add(new Baked(from, to, faces, world));
}
for (CemModelLoader.CemPart child : part.children()) {
double[] t = child.translate();
// submodel origin = its translate, accumulated with this group's origin from the 2nd level on.
double[] co = depth >= 1 ? new double[]{t[0] + o[0], t[1] + o[1], t[2] + o[2]} : new double[]{t[0], t[1], t[2]};
bakePart(child, world, co, depth + 1, hidden, texW, texH, tex, ignoreFaceUv, out);
}
}
/** Multiplies every non-transparent texel by an ARGB tint (in place). */
static void tint(int[][] tex, int argb) {
int tr = (argb >> 16) & 0xFF, tg = (argb >> 8) & 0xFF, tb = argb & 0xFF;
for (int[] row : tex) {
for (int x = 0; x < row.length; x++) {
int p = row[x];
int a = (p >>> 24) & 0xFF;
if (a == 0) continue;
int r = ((p >> 16) & 0xFF) * tr / 255, g = ((p >> 8) & 0xFF) * tg / 255, b = (p & 0xFF) * tb / 255;
row[x] = (a << 24) | (r << 16) | (g << 8) | b;
}
}
}
/** Wraps baked world-space cubes into a {@link RenderedEntity}, computing the overall AABB. */
static RenderedEntity finish(List<EntityCube> cubes) {
double[] min = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE};
double[] max = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE};
for (EntityCube c : cubes) {
for (int a = 0; a < 3; a++) {
if (c.aabbMin[a] < min[a]) min[a] = c.aabbMin[a];
if (c.aabbMax[a] > max[a]) max[a] = c.aabbMax[a];
}
}
return new RenderedEntity(cubes, min, max);
}
}
@@ -24,8 +24,12 @@ import java.util.logging.Logger;
*/
public final class CemModelLoader {
/** A box: absolute min corner + size (px), inflate, box-UV offset (texels), and horizontal texture mirror. */
public record CemBox(double[] origin, double[] size, double inflate, double[] uv, boolean mirror) {}
/**
* A box: absolute min corner + size (px), inflate, box-UV offset (texels), horizontal texture mirror,
* and optional per-face UV ({@code faceUv}, indexed by {@link eu.mhsl.minecraft.pixelpics.assets.model.Direction}
* ordinal, each {@code {x, y, w, h}} texels; null = use box-UV).
*/
public record CemBox(double[] origin, double[] size, double inflate, double[] uv, boolean mirror, double[][] faceUv) {}
/** A model part: its (raw) translate, rotation (deg), boxes and nested submodels. The rotation pivot
* is {@code -(sum of translates from the root to this part)} — accumulated by the baker. */
@@ -81,7 +85,7 @@ public final class CemModelLoader {
double[] uv = b.has("textureOffset")
? new double[]{b.getAsJsonArray("textureOffset").get(0).getAsDouble(), b.getAsJsonArray("textureOffset").get(1).getAsDouble()}
: new double[]{0, 0};
boxes.add(new CemBox(origin, size, inflate, uv, partMirror || mirrorsU(b)));
boxes.add(new CemBox(origin, size, inflate, uv, partMirror || mirrorsU(b), parseFaceUv(b)));
}
}
List<CemPart> children = new ArrayList<>();
@@ -91,6 +95,25 @@ public final class CemModelLoader {
return new CemPart(name, translate, rotate, boxes, children);
}
// CEM per-face UV keys ordered by Direction ordinal (DOWN, UP, NORTH, SOUTH, WEST, EAST).
private static final String[] FACE_UV_KEYS = {"uvDown", "uvUp", "uvNorth", "uvSouth", "uvWest", "uvEast"};
/** Parses per-face UV ({@code uvNorth} etc., each {@code [u1,v1,u2,v2]}) into {@code {x,y,w,h}}, or null. */
private static double[][] parseFaceUv(JsonObject b) {
boolean any = false;
for (String k : FACE_UV_KEYS) if (b.has(k)) { any = true; break; }
if (!any) return null;
double[][] faces = new double[6][];
for (int i = 0; i < FACE_UV_KEYS.length; i++) {
if (!b.has(FACE_UV_KEYS[i])) continue;
JsonArray a = b.getAsJsonArray(FACE_UV_KEYS[i]);
double u1 = a.get(0).getAsDouble(), v1 = a.get(1).getAsDouble();
double u2 = a.get(2).getAsDouble(), v2 = a.get(3).getAsDouble();
faces[i] = new double[]{u1, v1, u2 - u1, v2 - v1};
}
return faces;
}
private static boolean mirrorsU(JsonObject o) {
return o.has("mirrorTexture") && o.get("mirrorTexture").getAsString().contains("u");
}
@@ -2,12 +2,18 @@ package eu.mhsl.minecraft.pixelpics.render.render;
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityScene;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.raytrace.SnapshotRaytracer;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyRenderer;
import eu.mhsl.minecraft.pixelpics.render.snapshot.BlockEntitySnapshotBuilder;
import eu.mhsl.minecraft.pixelpics.render.snapshot.DecorationSnapshotBuilder;
import eu.mhsl.minecraft.pixelpics.render.snapshot.EntitySnapshotBuilder;
import eu.mhsl.minecraft.pixelpics.render.snapshot.SnapshotBuilder;
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
@@ -45,13 +51,18 @@ public class DefaultScreenRenderer implements Renderer {
private final SnapshotRaytracer raytracer;
private final CemBaker entityBaker;
private final BlockEntityBaker blockEntityBaker;
private final DecorationBaker decorationBaker;
private final Logger logger;
public DefaultScreenRenderer(BlockModelRegistry registry, BiomeTintProvider tintProvider,
TextureCache textures, CemBaker entityBaker, Logger logger) {
TextureCache textures, CemBaker entityBaker,
BlockEntityBaker blockEntityBaker, Logger logger) {
SkyRenderer skyRenderer = new SkyRenderer(textures);
this.raytracer = new SnapshotRaytracer(registry, tintProvider, skyRenderer, MAX_DISTANCE, REFLECTION_DEPTH);
this.entityBaker = entityBaker;
this.blockEntityBaker = blockEntityBaker;
this.decorationBaker = new DecorationBaker(textures);
this.logger = logger;
}
@@ -68,6 +79,8 @@ public class DefaultScreenRenderer implements Renderer {
List<Vector> rayMap = buildRayMap(eyeLocation, superW, superH);
WorldSnapshot snapshot = SnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE, logger);
List<EntityState> entities = EntitySnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE, shooter);
List<BlockEntityState> blockEntities = BlockEntitySnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE);
List<DecorationState> decorations = DecorationSnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE);
World world = eyeLocation.getWorld();
long dayTime = world.getTime();
@@ -76,7 +89,7 @@ public class DefaultScreenRenderer implements Renderer {
SkyContext sky = new SkyContext(dayTime, moonPhase, fullTime);
return new RenderJob(snapshot, rayMap, eyeLocation.toVector(),
resolution.getWidth(), resolution.getHeight(), sky, entities);
resolution.getWidth(), resolution.getHeight(), sky, entities, blockEntities, decorations);
}
/** Traces every (super)ray in parallel, then downsamples gamma-correctly. Safe off the main thread. */
@@ -88,7 +101,8 @@ public class DefaultScreenRenderer implements Renderer {
WorldSnapshot snapshot = job.snapshot();
Vector origin = job.origin();
SkyContext sky = job.sky();
EntityScene scene = new EntityScene(job.entities(), entityBaker);
EntityScene scene = new EntityScene(job.entities(), entityBaker, job.blockEntities(), blockEntityBaker,
job.decorations(), decorationBaker);
int[] superBuf = new int[rayMap.size()];
IntStream.range(0, rayMap.size()).parallel().forEach(i ->
@@ -1,5 +1,7 @@
package eu.mhsl.minecraft.pixelpics.render.render;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
@@ -8,10 +10,24 @@ import org.bukkit.util.Vector;
import java.util.List;
/**
* A prepared render: the world snapshot (captured on the main thread) plus the ray map, camera
* origin, the sky context (time of day / moon phase) and the captured entity states.
* {@link DefaultScreenRenderer#execute} can run this off the main thread.
* A prepared render: the world snapshot (captured on the main thread) plus the ray map, camera origin,
* the sky context (time of day / moon phase) and the captured entity, block-entity and decoration
* (painting / item frame) states. {@link DefaultScreenRenderer#execute} can run this off the main thread.
*/
public record RenderJob(WorldSnapshot snapshot, List<Vector> rayMap, Vector origin,
int width, int height, SkyContext sky, List<EntityState> entities) {
int width, int height, SkyContext sky, List<EntityState> entities,
List<BlockEntityState> blockEntities, List<DecorationState> decorations) {
/** Backwards-compatible constructor (no block-entities/decorations), used by the standalone harness. */
public RenderJob(WorldSnapshot snapshot, List<Vector> rayMap, Vector origin,
int width, int height, SkyContext sky, List<EntityState> entities) {
this(snapshot, rayMap, origin, width, height, sky, entities, List.of(), List.of());
}
/** Convenience for callers that supply entities + block-entities but no decorations. */
public RenderJob(WorldSnapshot snapshot, List<Vector> rayMap, Vector origin,
int width, int height, SkyContext sky, List<EntityState> entities,
List<BlockEntityState> blockEntities) {
this(snapshot, rayMap, origin, width, height, sky, entities, blockEntities, List.of());
}
}
@@ -0,0 +1,327 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
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.BedPart;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.BellAttach;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.ChestKind;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.Kind;
import org.bukkit.Color;
import org.bukkit.DyeColor;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Banner;
import org.bukkit.block.BlockFace;
import org.bukkit.block.BlockState;
import org.bukkit.block.DecoratedPot;
import org.bukkit.block.Skull;
import org.bukkit.block.banner.Pattern;
import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.Directional;
import org.bukkit.block.data.Rotatable;
import org.bukkit.util.Vector;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
/**
* Captures block-entities (chests, signs, banners, beds, heads, …) near the view frustum into immutable
* {@link BlockEntityState}s. MUST run on the main thread (live {@link BlockState} access). Reads each
* loaded chunk's tile-entities rather than scanning every block, then keeps only those inside the
* camera's bounding box.
*/
public final class BlockEntitySnapshotBuilder {
private BlockEntitySnapshotBuilder() {}
public static List<BlockEntityState> build(Location eye, List<Vector> rayMap, double maxDistance) {
World world = eye.getWorld();
Vector o = eye.toVector();
double minX = o.getX(), minY = o.getY(), minZ = o.getZ();
double maxX = o.getX(), maxY = o.getY(), maxZ = o.getZ();
for (Vector ray : rayMap) {
minX = Math.min(minX, o.getX() + ray.getX() * maxDistance);
maxX = Math.max(maxX, o.getX() + ray.getX() * maxDistance);
minY = Math.min(minY, o.getY() + ray.getY() * maxDistance);
maxY = Math.max(maxY, o.getY() + ray.getY() * maxDistance);
minZ = Math.min(minZ, o.getZ() + ray.getZ() * maxDistance);
maxZ = Math.max(maxZ, o.getZ() + ray.getZ() * maxDistance);
}
// 1-block margin so block-entities straddling the frustum edge are still captured.
int bMinX = (int) Math.floor(minX) - 1, bMaxX = (int) Math.ceil(maxX) + 1;
int bMinY = (int) Math.floor(minY) - 1, bMaxY = (int) Math.ceil(maxY) + 1;
int bMinZ = (int) Math.floor(minZ) - 1, bMaxZ = (int) Math.ceil(maxZ) + 1;
int minCX = bMinX >> 4, maxCX = bMaxX >> 4, minCZ = bMinZ >> 4, maxCZ = bMaxZ >> 4;
List<BlockEntityState> out = new ArrayList<>();
for (int cx = minCX; cx <= maxCX; cx++) {
for (int cz = minCZ; cz <= maxCZ; cz++) {
if (!world.isChunkLoaded(cx, cz)) continue;
for (BlockState ts : world.getChunkAt(cx, cz).getTileEntities(false)) {
int bx = ts.getX(), by = ts.getY(), bz = ts.getZ();
if (bx < bMinX || bx > bMaxX || by < bMinY || by > bMaxY || bz < bMinZ || bz > bMaxZ) continue;
try {
BlockEntityState s = classify(ts);
if (s != null) out.add(s);
} catch (Throwable ignored) {
// Unsupported on this server version / odd state — skip this one.
}
}
}
}
return out;
}
private static BlockEntityState classify(BlockState ts) {
Material mat = ts.getType();
String n = mat.name();
BlockData data = ts.getBlockData();
int bx = ts.getX(), by = ts.getY(), bz = ts.getZ();
// --- chests ---
if (mat == Material.CHEST || mat == Material.TRAPPED_CHEST || mat == Material.ENDER_CHEST) {
Kind kind = mat == Material.TRAPPED_CHEST ? Kind.TRAPPED_CHEST
: mat == Material.ENDER_CHEST ? Kind.ENDER_CHEST : Kind.CHEST;
ChestKind ck = ChestKind.SINGLE;
if (data instanceof org.bukkit.block.data.type.Chest cd) {
ck = switch (cd.getType()) {
case LEFT -> ChestKind.LEFT;
case RIGHT -> ChestKind.RIGHT;
case SINGLE -> ChestKind.SINGLE;
};
}
return base(kind, bx, by, bz, facingYaw(data)).chestKind(ck).build();
}
// --- beds ---
if (data instanceof org.bukkit.block.data.type.Bed bed) {
BedPart part = bed.getPart() == org.bukkit.block.data.type.Bed.Part.HEAD ? BedPart.HEAD : BedPart.FOOT;
return base(Kind.BED, bx, by, bz, faceToYaw(bed.getFacing()))
.bedPart(part).colorName(stripColor(n, "_BED")).build();
}
// --- shulker boxes ---
if (n.endsWith("SHULKER_BOX")) {
String color = n.equals("SHULKER_BOX") ? null : stripColor(n, "_SHULKER_BOX");
return base(Kind.SHULKER_BOX, bx, by, bz, facingYaw(data)).colorName(color).build();
}
// --- banners ---
if (n.endsWith("_BANNER")) {
boolean wall = n.endsWith("_WALL_BANNER");
float yaw = wall ? facingYaw(data) : rotationYaw(data);
Builder b = base(wall ? Kind.WALL_BANNER : Kind.BANNER, bx, by, bz, yaw);
if (ts instanceof Banner banner) {
DyeColor base = banner.getBaseColor();
b.baseColorArgb(dyeArgb(base));
List<BannerPattern> pats = new ArrayList<>();
for (Pattern p : banner.getPatterns()) {
String key = p.getPattern().key().value();
pats.add(new BannerPattern(key, dyeArgb(p.getColor())));
}
b.patterns(pats);
}
return b.build();
}
// --- signs ---
if (n.endsWith("_SIGN")) {
String wood = signWood(n);
if (n.endsWith("_WALL_SIGN")) {
return base(Kind.WALL_SIGN, bx, by, bz, facingYaw(data)).wood(wood).build();
}
if (n.endsWith("_HANGING_SIGN")) {
boolean wall = n.endsWith("_WALL_HANGING_SIGN");
float yaw = wall ? facingYaw(data) : rotationYaw(data);
return base(Kind.HANGING_SIGN, bx, by, bz, yaw).wood(wood).build();
}
return base(Kind.SIGN, bx, by, bz, rotationYaw(data)).wood(wood).build();
}
// --- heads / skulls ---
if (n.endsWith("_SKULL") || n.endsWith("_HEAD")) {
boolean wall = n.contains("_WALL_");
String headType = headType(n);
float yaw = wall ? facingYaw(data) : rotationYaw(data);
Builder b = base(wall ? Kind.WALL_HEAD : Kind.HEAD, bx, by, bz, yaw).headType(headType);
if ("player".equals(headType) && ts instanceof Skull skull) {
b.skinUrl(skinUrl(skull));
}
return b.build();
}
// --- conduit ---
if (mat == Material.CONDUIT) {
return base(Kind.CONDUIT, bx, by, bz, 0).build();
}
// --- decorated pot ---
if (mat == Material.DECORATED_POT) {
Builder b = base(Kind.DECORATED_POT, bx, by, bz, facingYaw(data));
if (ts instanceof DecoratedPot pot) b.sherds(sherds(pot));
return b.build();
}
// --- bell ---
if (mat == Material.BELL) {
BellAttach attach = BellAttach.FLOOR;
if (data instanceof org.bukkit.block.data.type.Bell bd) {
attach = switch (bd.getAttachment()) {
case FLOOR -> BellAttach.FLOOR;
case CEILING -> BellAttach.CEILING;
case SINGLE_WALL -> BellAttach.SINGLE_WALL;
case DOUBLE_WALL -> BellAttach.DOUBLE_WALL;
};
}
return base(Kind.BELL, bx, by, bz, facingYaw(data)).bellAttach(attach).build();
}
return null;
}
// --- facing / rotation helpers ---
private static float facingYaw(BlockData data) {
if (data instanceof Directional d) return faceToYaw(d.getFacing());
if (data instanceof Rotatable r) return faceToYaw(r.getRotation());
return 0;
}
private static float rotationYaw(BlockData data) {
if (data instanceof Rotatable r) return faceToYaw(r.getRotation());
if (data instanceof Directional d) return faceToYaw(d.getFacing());
return 0;
}
/** Yaw in degrees for a block face, 0 = south increasing clockwise (vanilla rotation convention). */
private static float faceToYaw(BlockFace face) {
Float y = FACE_YAW.get(face);
return y == null ? 0 : y;
}
private static final Map<BlockFace, Float> FACE_YAW = buildFaceYaw();
private static Map<BlockFace, Float> buildFaceYaw() {
Map<BlockFace, Float> m = new EnumMap<>(BlockFace.class);
m.put(BlockFace.SOUTH, 0f);
m.put(BlockFace.SOUTH_SOUTH_WEST, 22.5f);
m.put(BlockFace.SOUTH_WEST, 45f);
m.put(BlockFace.WEST_SOUTH_WEST, 67.5f);
m.put(BlockFace.WEST, 90f);
m.put(BlockFace.WEST_NORTH_WEST, 112.5f);
m.put(BlockFace.NORTH_WEST, 135f);
m.put(BlockFace.NORTH_NORTH_WEST, 157.5f);
m.put(BlockFace.NORTH, 180f);
m.put(BlockFace.NORTH_NORTH_EAST, 202.5f);
m.put(BlockFace.NORTH_EAST, 225f);
m.put(BlockFace.EAST_NORTH_EAST, 247.5f);
m.put(BlockFace.EAST, 270f);
m.put(BlockFace.EAST_SOUTH_EAST, 292.5f);
m.put(BlockFace.SOUTH_EAST, 315f);
m.put(BlockFace.SOUTH_SOUTH_EAST, 337.5f);
return m;
}
// --- data extraction helpers ---
private static String stripColor(String name, String suffix) {
return name.substring(0, name.length() - suffix.length()).toLowerCase(java.util.Locale.ROOT);
}
private static String signWood(String name) {
String s = name;
for (String suf : new String[]{"_WALL_HANGING_SIGN", "_HANGING_SIGN", "_WALL_SIGN", "_SIGN"}) {
if (s.endsWith(suf)) { s = s.substring(0, s.length() - suf.length()); break; }
}
return s.toLowerCase(java.util.Locale.ROOT);
}
private static String headType(String name) {
String s = name.replace("_WALL_", "_");
return switch (s) {
case "PLAYER_HEAD" -> "player";
case "ZOMBIE_HEAD" -> "zombie";
case "CREEPER_HEAD" -> "creeper";
case "DRAGON_HEAD" -> "dragon";
case "PIGLIN_HEAD" -> "piglin";
case "SKELETON_SKULL" -> "skeleton";
case "WITHER_SKELETON_SKULL" -> "wither_skeleton";
default -> "skeleton";
};
}
private static List<String> sherds(DecoratedPot pot) {
// Order: front, left, right, back — matches the CEM decorated_pot face parts.
List<String> out = new ArrayList<>(4);
for (DecoratedPot.Side side : new DecoratedPot.Side[]{
DecoratedPot.Side.FRONT, DecoratedPot.Side.LEFT, DecoratedPot.Side.RIGHT, DecoratedPot.Side.BACK}) {
Material m = pot.getSherd(side);
out.add(m.name().toLowerCase(java.util.Locale.ROOT));
}
return out;
}
private static String skinUrl(Skull skull) {
try {
org.bukkit.profile.PlayerProfile profile = skull.getOwnerProfile();
if (profile != null && profile.getTextures().getSkin() != null) {
return profile.getTextures().getSkin().toString();
}
} catch (Throwable ignored) {
}
return null;
}
/** Opaque ARGB for a dye colour. */
private static int dyeArgb(DyeColor dye) {
if (dye == null) return 0xFFFFFFFF;
Color c = dye.getColor();
return 0xFF000000 | (c.getRed() << 16) | (c.getGreen() << 8) | c.getBlue();
}
// --- small fluent builder to keep the 15-field record construction readable ---
private static Builder base(Kind kind, int bx, int by, int bz, float yaw) {
return new Builder(kind, bx, by, bz, yaw);
}
private static final class Builder {
private final Kind kind;
private final int bx, by, bz;
private final float yaw;
private ChestKind chestKind;
private int baseColorArgb;
private String colorName;
private String wood;
private BedPart bedPart;
private String headType;
private String skinUrl;
private List<BannerPattern> patterns = List.of();
private List<String> sherds = List.of();
private BellAttach bellAttach;
Builder(Kind kind, int bx, int by, int bz, float yaw) {
this.kind = kind; this.bx = bx; this.by = by; this.bz = bz; this.yaw = yaw;
}
Builder chestKind(ChestKind v) { this.chestKind = v; return this; }
Builder baseColorArgb(int v) { this.baseColorArgb = v; return this; }
Builder colorName(String v) { this.colorName = v; return this; }
Builder wood(String v) { this.wood = v; return this; }
Builder bedPart(BedPart v) { this.bedPart = v; return this; }
Builder headType(String v) { this.headType = v; return this; }
Builder skinUrl(String v) { this.skinUrl = v; return this; }
Builder patterns(List<BannerPattern> v) { this.patterns = v; return this; }
Builder sherds(List<String> v) { this.sherds = v; return this; }
Builder bellAttach(BellAttach v) { this.bellAttach = v; return this; }
BlockEntityState build() {
return new BlockEntityState(kind, bx, by, bz, yaw, chestKind, baseColorArgb, colorName, wood,
bedPart, headType, skinUrl, patterns, sherds, bellAttach);
}
}
}
@@ -0,0 +1,90 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState;
import org.bukkit.Location;
import org.bukkit.block.BlockFace;
import org.bukkit.entity.Entity;
import org.bukkit.entity.ItemFrame;
import org.bukkit.entity.Painting;
import org.bukkit.inventory.ItemStack;
import org.bukkit.util.BoundingBox;
import org.bukkit.util.Vector;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
/**
* Captures flat wall decorations (paintings + item frames) near the view frustum into immutable
* {@link DecorationState}s. MUST run on the main thread. Uses each entity's world bounding box directly,
* which already encodes vanilla's painting placement offsets.
*/
public final class DecorationSnapshotBuilder {
private DecorationSnapshotBuilder() {}
public static List<DecorationState> build(Location eye, List<Vector> rayMap, double maxDistance) {
Vector o = eye.toVector();
double minX = o.getX(), minY = o.getY(), minZ = o.getZ();
double maxX = o.getX(), maxY = o.getY(), maxZ = o.getZ();
for (Vector ray : rayMap) {
minX = Math.min(minX, o.getX() + ray.getX() * maxDistance);
maxX = Math.max(maxX, o.getX() + ray.getX() * maxDistance);
minY = Math.min(minY, o.getY() + ray.getY() * maxDistance);
maxY = Math.max(maxY, o.getY() + ray.getY() * maxDistance);
minZ = Math.min(minZ, o.getZ() + ray.getZ() * maxDistance);
maxZ = Math.max(maxZ, o.getZ() + ray.getZ() * maxDistance);
}
Location center = new Location(eye.getWorld(), (minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2);
double hx = (maxX - minX) / 2 + 2, hy = (maxY - minY) / 2 + 2, hz = (maxZ - minZ) / 2 + 2;
Collection<Entity> nearby = eye.getWorld().getNearbyEntities(center, hx, hy, hz);
List<DecorationState> out = new ArrayList<>();
for (Entity e : nearby) {
try {
DecorationState s = toState(e);
if (s != null) out.add(s);
} catch (Throwable ignored) {
}
}
return out;
}
private static DecorationState toState(Entity e) {
if (e instanceof Painting painting) {
BoundingBox bb = e.getBoundingBox();
String art = painting.getArt().assetId().value();
return new DecorationState(DecorationState.Kind.PAINTING,
bb.getMinX(), bb.getMinY(), bb.getMinZ(), bb.getMaxX(), bb.getMaxY(), bb.getMaxZ(),
facing(e.getFacing()), art, null, 0, false);
}
if (e instanceof ItemFrame frame) {
BoundingBox bb = e.getBoundingBox();
boolean glow = e instanceof org.bukkit.entity.GlowItemFrame
|| e.getType().getKey().getKey().equals("glow_item_frame");
String itemId = itemId(frame.getItem());
int rot = frame.getRotation().ordinal() * 45;
return new DecorationState(DecorationState.Kind.ITEM_FRAME,
bb.getMinX(), bb.getMinY(), bb.getMinZ(), bb.getMaxX(), bb.getMaxY(), bb.getMaxZ(),
facing(e.getFacing()), null, itemId, rot, glow);
}
return null;
}
/** The framed item's material key (e.g. "diamond"); the baker resolves item/ or block/ textures from it. */
private static String itemId(ItemStack item) {
if (item == null || item.getType().isAir()) return null;
return item.getType().getKey().getKey();
}
private static DecorationState.Facing facing(BlockFace face) {
return switch (face) {
case NORTH -> DecorationState.Facing.NORTH;
case SOUTH -> DecorationState.Facing.SOUTH;
case EAST -> DecorationState.Facing.EAST;
case WEST -> DecorationState.Facing.WEST;
case UP -> DecorationState.Facing.UP;
default -> DecorationState.Facing.DOWN;
};
}
}
+240
View File
@@ -0,0 +1,240 @@
import eu.mhsl.minecraft.pixelpics.assets.*;
import eu.mhsl.minecraft.pixelpics.render.entity.*;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.*;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.*;
import eu.mhsl.minecraft.pixelpics.render.render.*;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.block.data.BlockData;
import org.bukkit.util.Vector;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.File;
import java.lang.reflect.Proxy;
import java.util.*;
import java.util.List;
import java.util.logging.Logger;
/** Standalone (no server) block-entity renderer: each scene framed and labelled into a contact sheet. */
public class BlockEntityTestRender {
static final String ROOT = "/home/elias/Dokumente/mcTestServer/plugins/PixelPics";
static final double H_FOV_HALF = Math.toRadians(35);
static final Vector BASE = new Vector(1, 0, 0);
static final int SSAA = 3;
static final int TW = 220, TH = 240;
static BlockEntityBaker beBaker;
static DecorationBaker decoBaker;
static BlockEntityState be(Kind kind, int bx, int by, int bz, float yaw) {
return new BlockEntityState(kind, bx, by, bz, yaw, ChestKind.SINGLE, 0, null, "oak",
null, null, null, List.of(), List.of(), null);
}
public static void main(String[] args) throws Exception {
Logger log = Logger.getLogger("test");
ResourcePack pack = ResourcePackLoader.load(new File(ROOT, "resourcepack"), log).orElseThrow();
AssetReader reader = new AssetReader(pack);
TextureCache textures = new TextureCache(pack);
BlockModelRegistry registry = new BlockModelRegistry(reader, textures);
BiomeTintProvider tint = new BiomeTintProvider(textures);
CemModelLoader geo = new CemModelLoader();
geo.load(new java.io.FileInputStream("/tmp/cem_models.json"), log);
SkinCache skins = new SkinCache();
CemBaker baker = new CemBaker(geo, textures, skins);
beBaker = new BlockEntityBaker(geo, textures, skins);
decoBaker = new DecorationBaker(textures);
DefaultScreenRenderer renderer = new DefaultScreenRenderer(registry, tint, textures, baker, beBaker, log);
BlockData air = (BlockData) Proxy.newProxyInstance(BlockEntityTestRender.class.getClassLoader(),
new Class[]{BlockData.class}, (p, m, a) -> {
switch (m.getName()) {
case "getMaterial": return Material.AIR;
case "equals": return p == a[0];
case "hashCode": return System.identityHashCode(p);
case "toString": return "air";
}
Class<?> rt = m.getReturnType();
if (rt == boolean.class) return false;
if (rt.isPrimitive()) return 0;
return null;
});
WorldSnapshot empty = new WorldSnapshot(Map.of(), -64, 320, air);
SkyContext sky = new SkyContext(6000, 0, 6000);
// Scenes: label -> list of block-entity states.
LinkedHashMap<String, List<BlockEntityState>> scenes = new LinkedHashMap<>();
scenes.put("chest_S", List.of(be(Kind.CHEST, 0, 0, 0, 0)));
scenes.put("chest_N", List.of(be(Kind.CHEST, 0, 0, 0, 180)));
scenes.put("chest_E", List.of(be(Kind.CHEST, 0, 0, 0, 270)));
scenes.put("chest_W", List.of(be(Kind.CHEST, 0, 0, 0, 90)));
scenes.put("double_chest", List.of(
new BlockEntityState(Kind.CHEST, 0, 0, 0, 0, ChestKind.LEFT, 0, null, null, null, null, null, List.of(), List.of(), null),
new BlockEntityState(Kind.CHEST, 1, 0, 0, 0, ChestKind.RIGHT, 0, null, null, null, null, null, List.of(), List.of(), null)));
scenes.put("sign", List.of(be(Kind.SIGN, 0, 0, 0, 0)));
scenes.put("wall_sign", List.of(be(Kind.WALL_SIGN, 0, 0, 0, 0)));
scenes.put("hanging_sign", List.of(be(Kind.HANGING_SIGN, 0, 0, 0, 0)));
scenes.put("banner", List.of(new BlockEntityState(Kind.BANNER, 0, 0, 0, 0, null, 0xFFCC2020, null, null, null, null, null, List.of(), List.of(), null)));
scenes.put("banner_patterned", List.of(new BlockEntityState(Kind.BANNER, 0, 0, 0, 0, null, 0xFFFFFFFF, null, null, null, null, null,
List.of(new BannerPattern("stripe_bottom", 0xFFCC2020), new BannerPattern("cross", 0xFF2040CC), new BannerPattern("border", 0xFF20A020)), List.of(), null)));
scenes.put("wall_banner", List.of(new BlockEntityState(Kind.WALL_BANNER, 0, 0, 0, 0, null, 0xFF2040CC, null, null, null, null, null, List.of(), List.of(), null)));
scenes.put("bed", List.of(
new BlockEntityState(Kind.BED, 0, 0, 0, 0, null, 0, "red", null, BedPart.FOOT, null, null, List.of(), List.of(), null),
new BlockEntityState(Kind.BED, 0, 0, 1, 0, null, 0, "red", null, BedPart.HEAD, null, null, List.of(), List.of(), null)));
scenes.put("shulker_box", List.of(new BlockEntityState(Kind.SHULKER_BOX, 0, 0, 0, 0, null, 0, null, null, null, null, null, List.of(), List.of(), null)));
scenes.put("conduit", List.of(be(Kind.CONDUIT, 0, 0, 0, 0)));
scenes.put("decorated_pot", List.of(be(Kind.DECORATED_POT, 0, 0, 0, 0)));
scenes.put("decorated_pot_sherds", List.of(new BlockEntityState(Kind.DECORATED_POT, 0, 0, 0, 0, null, 0, null, null, null, null, null,
List.of(), List.of("angler_pottery_sherd", "heart_pottery_sherd", "skull_pottery_sherd", "brick"), null)));
scenes.put("bell", List.of(be(Kind.BELL, 0, 0, 0, 0)));
scenes.put("head_skeleton", List.of(head("skeleton")));
scenes.put("head_zombie", List.of(head("zombie")));
scenes.put("head_creeper", List.of(head("creeper")));
scenes.put("head_dragon", List.of(head("dragon")));
scenes.put("head_piglin", List.of(head("piglin")));
scenes.put("wither_skull", List.of(headKind(Kind.HEAD, "wither_skeleton")));
scenes.put("wall_head", List.of(headKind(Kind.WALL_HEAD, "skeleton")));
// Decoration scenes (paintings + item frames), rendered via the same scene path.
LinkedHashMap<String, List<DecorationState>> decoScenes = new LinkedHashMap<>();
// kebab is 1x1; place a thin quad facing south spanning the block at z=0..0.0625.
decoScenes.put("painting_kebab_S", List.of(new DecorationState(DecorationState.Kind.PAINTING,
0, 0, 0, 1, 1, 0.0625, DecorationState.Facing.SOUTH, "kebab", null, 0, false)));
decoScenes.put("painting_kebab_N", List.of(new DecorationState(DecorationState.Kind.PAINTING,
0, 0, 0.9375, 1, 1, 1, DecorationState.Facing.NORTH, "kebab", null, 0, false)));
decoScenes.put("painting_pool_S", List.of(new DecorationState(DecorationState.Kind.PAINTING,
0, 0, 0, 2, 1, 0.0625, DecorationState.Facing.SOUTH, "pool", null, 0, false)));
decoScenes.put("item_frame", List.of(new DecorationState(DecorationState.Kind.ITEM_FRAME,
0.0625, 0.0625, 0, 0.9375, 0.9375, 0.0625, DecorationState.Facing.SOUTH, null, "diamond", 0, false)));
decoScenes.put("item_frame_empty", List.of(new DecorationState(DecorationState.Kind.ITEM_FRAME,
0.0625, 0.0625, 0, 0.9375, 0.9375, 0.0625, DecorationState.Facing.SOUTH, null, null, 0, false)));
decoScenes.put("item_frame_E", List.of(new DecorationState(DecorationState.Kind.ITEM_FRAME,
0.9375, 0.0625, 0.0625, 1.0, 0.9375, 0.9375, DecorationState.Facing.EAST, null, "diamond", 0, false)));
List<BufferedImage> cells = new ArrayList<>();
List<String> names = new ArrayList<>(scenes.keySet());
for (String name : names) {
cells.add(labelCell(name, renderScene(renderer, empty, sky, scenes.get(name))));
log.info("rendered " + name);
}
for (String name : decoScenes.keySet()) {
cells.add(labelCell(name, renderDeco(renderer, empty, sky, decoScenes.get(name))));
names.add(name);
log.info("rendered " + name);
}
File outDir = new File("/home/elias/Dokumente/PixelPics-BlockEntity-Renders");
outDir.mkdirs();
for (int i = 0; i < names.size(); i++) ImageIO.write(cells.get(i), "png", new File(outDir, names.get(i) + ".png"));
int cols = 4, cw = cells.get(0).getWidth(), ch = cells.get(0).getHeight();
int rows = (cells.size() + cols - 1) / cols;
BufferedImage sheet = new BufferedImage(cols * cw, rows * ch, BufferedImage.TYPE_INT_RGB);
Graphics2D g = sheet.createGraphics();
g.setColor(new Color(40, 40, 48));
g.fillRect(0, 0, sheet.getWidth(), sheet.getHeight());
for (int i = 0; i < cells.size(); i++) g.drawImage(cells.get(i), (i % cols) * cw, (i / cols) * ch, null);
g.dispose();
File f = new File(outDir, "sheet.png");
ImageIO.write(sheet, "png", f);
System.out.println("WROTE " + f);
}
static BlockEntityState head(String type) { return headKind(Kind.HEAD, type); }
static BlockEntityState headKind(Kind kind, String type) {
return new BlockEntityState(kind, 0, 0, 0, 0, null, 0, null, null, null, type, null, List.of(), List.of(), null);
}
static BufferedImage renderScene(DefaultScreenRenderer renderer, WorldSnapshot world, SkyContext sky,
List<BlockEntityState> states) {
double[] min = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE};
double[] max = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE};
for (BlockEntityState s : states) {
RenderedEntity re = beBaker.bake(s);
if (re == null) continue;
for (int a = 0; a < 3; a++) { min[a] = Math.min(min[a], re.aabbMin[a]); max[a] = Math.max(max[a], re.aabbMax[a]); }
}
if (min[0] > max[0]) { min = new double[]{0, 0, 0}; max = new double[]{1, 1, 1}; }
double cx = (min[0] + max[0]) / 2, cy = (min[1] + max[1]) / 2, cz = (min[2] + max[2]) / 2;
double ext = 0.5;
for (int a = 0; a < 3; a++) ext = Math.max(ext, max[a] - min[a]);
double dist = ext / (2 * Math.tan(H_FOV_HALF)) * 1.4 + 0.6;
Vector center = new Vector(cx, cy, cz);
Vector cam = new Vector(cx - dist, cy + dist * 0.45, cz - dist * 0.35);
Location loc = new Location(null, cam.getX(), cam.getY(), cam.getZ());
loc.setDirection(center.clone().subtract(cam));
List<Vector> rayMap = buildRayMap(loc, TW * SSAA, TH * SSAA);
RenderJob job = new RenderJob(world, rayMap, cam, TW, TH, sky, List.of(), states);
return renderer.execute(job);
}
static BufferedImage renderDeco(DefaultScreenRenderer renderer, WorldSnapshot world, SkyContext sky,
List<DecorationState> states) {
double[] min = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE};
double[] max = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE};
for (DecorationState s : states) {
RenderedEntity re = decoBaker.bake(s);
if (re == null) continue;
for (int a = 0; a < 3; a++) { min[a] = Math.min(min[a], re.aabbMin[a]); max[a] = Math.max(max[a], re.aabbMax[a]); }
}
if (min[0] > max[0]) { min = new double[]{0, 0, 0}; max = new double[]{1, 1, 1}; }
double cx = (min[0] + max[0]) / 2, cy = (min[1] + max[1]) / 2, cz = (min[2] + max[2]) / 2;
double ext = 0.5;
for (int a = 0; a < 3; a++) ext = Math.max(ext, max[a] - min[a]);
double dist = ext / (2 * Math.tan(H_FOV_HALF)) * 1.4 + 0.6;
// View from the front side (along +facing) so the picture is visible, not the back.
DecorationState.Facing f = states.get(0).facing();
double fx = f.axis() == 0 ? f.sign() : 0, fy = f.axis() == 1 ? f.sign() : 0, fz = f.axis() == 2 ? f.sign() : 0;
Vector center = new Vector(cx, cy, cz);
Vector cam = new Vector(cx + fx * dist + 0.1, cy + fy * dist + 0.1, cz + fz * dist + 0.1);
Location loc = new Location(null, cam.getX(), cam.getY(), cam.getZ());
loc.setDirection(center.clone().subtract(cam));
List<Vector> rayMap = buildRayMap(loc, TW * SSAA, TH * SSAA);
RenderJob job = new RenderJob(world, rayMap, cam, TW, TH, sky, List.of(), List.of(), states);
return renderer.execute(job);
}
static BufferedImage labelCell(String key, BufferedImage v) {
int labelH = 20;
BufferedImage cell = new BufferedImage(v.getWidth(), v.getHeight() + labelH, BufferedImage.TYPE_INT_RGB);
Graphics2D g = cell.createGraphics();
g.setColor(new Color(25, 25, 30));
g.fillRect(0, 0, cell.getWidth(), cell.getHeight());
g.drawImage(v, 0, labelH, null);
g.setColor(Color.WHITE);
g.setFont(new Font("SansSerif", Font.BOLD, 13));
g.drawString(key, 4, 15);
g.dispose();
return cell;
}
static List<Vector> buildRayMap(Location eye, int width, int height) {
Vector dir = eye.getDirection();
double angleYaw = Math.atan2(dir.getZ(), dir.getX());
double anglePitch = Math.atan2(dir.getY(), Math.sqrt(dir.getX() * dir.getX() + dir.getZ() * dir.getZ()));
double yawHalf = H_FOV_HALF;
double pitchHalf = Math.atan(Math.tan(yawHalf) * ((double) height / width));
Vector ll = MathUtil.doubleYawPitchRotation(BASE, -yawHalf, -pitchHalf, angleYaw, anglePitch);
Vector ul = MathUtil.doubleYawPitchRotation(BASE, -yawHalf, pitchHalf, angleYaw, anglePitch);
Vector lr = MathUtil.doubleYawPitchRotation(BASE, yawHalf, -pitchHalf, angleYaw, anglePitch);
Vector ur = MathUtil.doubleYawPitchRotation(BASE, yawHalf, pitchHalf, angleYaw, anglePitch);
List<Vector> rayMap = new ArrayList<>(width * height);
Vector leftFrac = ul.clone().subtract(ll).multiply(1.0 / (height - 1));
Vector rightFrac = ur.clone().subtract(lr).multiply(1.0 / (height - 1));
for (int pitch = 0; pitch < height; pitch++) {
Vector leftPitch = ul.clone().subtract(leftFrac.clone().multiply(pitch));
Vector rightPitch = ur.clone().subtract(rightFrac.clone().multiply(pitch));
Vector yawFrac = rightPitch.clone().subtract(leftPitch).multiply(1.0 / (width - 1));
for (int yaw = 0; yaw < width; yaw++) rayMap.add(leftPitch.clone().add(yawFrac.clone().multiply(yaw)).normalize());
}
return rayMap;
}
}
+4 -2
View File
@@ -61,8 +61,10 @@ public class EntityTestRender {
eu.mhsl.minecraft.pixelpics.render.entity.cem.CemModelLoader geo = new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemModelLoader();
int n = geo.load(new java.io.FileInputStream("/tmp/cem_models.json"), log);
log.info("Loaded " + n + " geometries");
eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker baker = new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker(geo, textures, new SkinCache());
DefaultScreenRenderer renderer = new DefaultScreenRenderer(registry, tint, textures, baker, log);
SkinCache skins = new SkinCache();
eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker baker = new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker(geo, textures, skins);
eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker beBaker = new eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker(geo, textures, skins);
DefaultScreenRenderer renderer = new DefaultScreenRenderer(registry, tint, textures, baker, beBaker, log);
BlockData air = (BlockData) Proxy.newProxyInstance(EntityTestRender.class.getClassLoader(),
new Class[]{BlockData.class}, (p, m, a) -> {