refactor block entity handling and add support for invisible materials
This commit is contained in:
+1
-1
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+327
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) -> {
|
||||
|
||||
Reference in New Issue
Block a user