simplify entity, tint and util code: shared direction helpers (axis, of, opposite), one inverted-face test and frac for block and entity intersectors, element rotate/unrotate without allocating copies, entity scene without the per-entity list and with a single child push, cube corners and hit normal without temp arrays, tint channel cached per material, redstone power from the block data, shared leash faces and frame cards, texture ops on colour helpers, simpler text and chest/shulker/statue texture code, dead helper and dead null checks removed, comments trimmed

This commit is contained in:
MineTec committed 2026-11-05 00:20:32 +01:00
1 parent 2a3db3d7aa
commit cbc859b565
28 files changed
+466 -615

No files matched your search

@@ -12,11 +12,34 @@ public enum Direction {
WEST(-1, 0, 0),
EAST(1, 0, 0);
public final int nx, ny, nz;
/** The axis of the normal: 0 = x, 1 = y, 2 = z. */
public final int axis;
Direction(int nx, int ny, int nz) {
this.nx = nx;
this.ny = ny;
this.nz = nz;
this.axis = nx != 0 ? 0 : ny != 0 ? 1 : 2;
}
/** The face of a box on {@code axis}: the low-coordinate one (WEST/DOWN/NORTH) when {@code negative}. */
public static Direction of(int axis, boolean negative) {
return switch (axis) {
case 0 -> negative ? WEST : EAST;
case 1 -> negative ? DOWN : UP;
default -> negative ? NORTH : SOUTH;
};
}
public Direction opposite() {
return switch (this) {
case DOWN -> UP;
case UP -> DOWN;
case NORTH -> SOUTH;
case SOUTH -> NORTH;
case WEST -> EAST;
case EAST -> WEST;
};
}
public static Direction fromName(String name) {
@@ -147,6 +147,13 @@ public final class Element {
/** Model space -> world: vanilla blockstate rotation (x steps, then y steps; clockwise about the centre). */
public static double[] rotate(double x, double y, double z, int xSteps, int ySteps, boolean point) {
double[] out = new double[3];
rotate(out, x, y, z, xSteps, ySteps, point);
return out;
}
/** {@link #rotate} into {@code out} (allocation-free, for the intersectors). */
public static void rotate(double[] out, double x, double y, double z, int xSteps, int ySteps, boolean point) {
double c = point ? 0.5 : 0;
double dx = x - c, dy = y - c, dz = z - c;
for (int i = 0; i < xSteps; i++) {
@@ -159,13 +166,22 @@ public final class Element {
dx = nx;
dz = nz;
}
return new double[] {dx + c, dy + c, dz + c};
out[0] = dx + c;
out[1] = dy + c;
out[2] = dz + c;
}
/** World -> model space: the inverse of {@link #rotate}. */
public static double[] unrotate(double x, double y, double z, int xSteps, int ySteps, boolean point) {
double[] p = rotate(x, y, z, 0, (4 - ySteps) % 4, point);
return rotate(p[0], p[1], p[2], (4 - xSteps) % 4, 0, point);
double[] out = new double[3];
unrotate(out, x, y, z, xSteps, ySteps, point);
return out;
}
/** {@link #unrotate} into {@code out}. */
public static void unrotate(double[] out, double x, double y, double z, int xSteps, int ySteps, boolean point) {
rotate(out, x, y, z, 0, (4 - ySteps) % 4, point);
rotate(out, out[0], out[1], out[2], (4 - xSteps) % 4, 0, point);
}
public boolean isAxisAligned() {
@@ -1,13 +1,11 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
/**
* A 3x3 linear transform plus translation (affine), used to compose entity bone hierarchies and to
* map a cube's local space to world space. The {@code apply} parameter {@code t} of a ray is
* preserved under affine maps, so ray distances stay consistent between world and local space.
* A 3x3 linear transform (row-major) plus translation. Ray parameters {@code t} are preserved under affine maps, so
* distances stay consistent between world and local space.
*/
public final class Affine {
// row-major 3x3
public final double[] r;
public final double[] t;
@@ -16,8 +14,13 @@ public final class Affine {
this.t = t;
}
/** A linear map (no translation) from its row-major matrix. */
private static Affine linear(double... r) {
return new Affine(r, new double[] {0, 0, 0});
}
public static Affine identity() {
return new Affine(new double[] {1, 0, 0, 0, 1, 0, 0, 0, 1}, new double[] {0, 0, 0});
return linear(1, 0, 0, 0, 1, 0, 0, 0, 1);
}
public static Affine translation(double x, double y, double z) {
@@ -39,63 +42,62 @@ public final class Affine {
y /= n;
z /= n;
w /= n;
return new Affine(
new double[] {
1 - 2 * (y * y + z * z),
2 * (x * y - z * w),
2 * (x * z + y * w),
2 * (x * y + z * w),
1 - 2 * (x * x + z * z),
2 * (y * z - x * w),
2 * (x * z - y * w),
2 * (y * z + x * w),
1 - 2 * (x * x + y * y)
},
new double[] {0, 0, 0});
return linear(
1 - 2 * (y * y + z * z),
2 * (x * y - z * w),
2 * (x * z + y * w),
2 * (x * y + z * w),
1 - 2 * (x * x + z * z),
2 * (y * z - x * w),
2 * (x * z - y * w),
2 * (y * z + x * w),
1 - 2 * (x * x + y * y));
}
/** Right-handed rotation by {@code a} radians about the unit axis {@code (x, y, z)} (Rodrigues). */
public static Affine rotAxis(double x, double y, double z, double a) {
double c = Math.cos(a), s = Math.sin(a), k = 1 - c;
return new Affine(
new double[] {
c + x * x * k,
x * y * k - z * s,
x * z * k + y * s,
y * x * k + z * s,
c + y * y * k,
y * z * k - x * s,
z * x * k - y * s,
z * y * k + x * s,
c + z * z * k
},
new double[] {0, 0, 0});
return linear(
c + x * x * k,
x * y * k - z * s,
x * z * k + y * s,
y * x * k + z * s,
c + y * y * k,
y * z * k - x * s,
z * x * k - y * s,
z * y * k + x * s,
c + z * z * k);
}
public static Affine scale(double s) {
return new Affine(new double[] {s, 0, 0, 0, s, 0, 0, 0, s}, new double[] {0, 0, 0});
return linear(s, 0, 0, 0, s, 0, 0, 0, s);
}
public static Affine scale(double sx, double sy, double sz) {
return new Affine(new double[] {sx, 0, 0, 0, sy, 0, 0, 0, sz}, new double[] {0, 0, 0});
return linear(sx, 0, 0, 0, sy, 0, 0, 0, sz);
}
public static Affine rotX(double a) {
double c = Math.cos(a), s = Math.sin(a);
return new Affine(new double[] {1, 0, 0, 0, c, -s, 0, s, c}, new double[] {0, 0, 0});
return linear(1, 0, 0, 0, c, -s, 0, s, c);
}
public static Affine rotY(double a) {
double c = Math.cos(a), s = Math.sin(a);
return new Affine(new double[] {c, 0, s, 0, 1, 0, -s, 0, c}, new double[] {0, 0, 0});
return linear(c, 0, s, 0, 1, 0, -s, 0, c);
}
public static Affine rotZ(double a) {
double c = Math.cos(a), s = Math.sin(a);
return new Affine(new double[] {c, -s, 0, s, c, 0, 0, 0, 1}, new double[] {0, 0, 0});
return linear(c, -s, 0, s, c, 0, 0, 0, 1);
}
/** this ∘ o (apply o first, then this). */
/** JOML {@code rotationAround}: {@code T(c) · rot · T(-c)}. */
public static Affine rotationAround(Affine rot, double cx, double cy, double cz) {
return translation(cx, cy, cz).mul(rot).mul(translation(-cx, -cy, -cz));
}
/** this ∘ o (apply o first, then this). */
public Affine mul(Affine o) {
double[] a = this.r, b = o.r;
double[] nr = new double[9];
@@ -121,17 +123,16 @@ public final class Affine {
};
}
/** Linear part only (for directions). */
public double[] applyLinear(double x, double y, double z) {
return new double[] {
r[0] * x + r[1] * y + r[2] * z, r[3] * x + r[4] * y + r[5] * z, r[6] * x + r[7] * y + r[8] * z
};
public double determinant() {
return r[0] * (r[4] * r[8] - r[5] * r[7])
- r[1] * (r[3] * r[8] - r[5] * r[6])
+ r[2] * (r[3] * r[7] - r[4] * r[6]);
}
/** General affine inverse (3x3 inverse + translation). */
/** Affine inverse; a singular matrix yields a zero linear part. */
public Affine inverse() {
double a = r[0], b = r[1], c = r[2], d = r[3], e = r[4], f = r[5], g = r[6], h = r[7], i = r[8];
double det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
double det = determinant();
double inv = Math.abs(det) < 1e-12 ? 0 : 1.0 / det;
double[] ir = new double[] {
(e * i - f * h) * inv, (c * h - b * i) * inv, (b * f - c * e) * inv,
@@ -6,16 +6,10 @@ import java.util.List;
import java.util.Set;
/**
* Maps a {@link BlockEntityState} to the models its vanilla BlockEntityRenderer draws (from the client
* dump, see {@code tools/VanillaModelDump.java}) and to the renderer's PoseStack. Every pose below is
* transcribed from the decompiled 26.3 client renderers (ChestRenderer, SkullBlockRenderer,
* BannerRenderer, ShulkerBoxRenderer, DecoratedPotRenderer, BellRenderer, ConduitRenderer,
* LecternRenderer, EnchantTableRenderer, CopperGolemStatueBlockRenderer, Standing/HangingSignRenderer) — no
* calibration offsets. Mirrors the role
* {@link EntityModels} plays for mobs.
*
* <p>Beds and sign boards are plain block models since 26.x (no renderer model): the block-model
* pipeline draws them; only the sign TEXT is added here.
* Maps a {@link BlockEntityState} to the dumped models its vanilla BlockEntityRenderer draws (see
* {@code tools/VanillaModelDump.java}) and to the renderer's PoseStack. Every pose is transcribed from the decompiled
* 26.3 client renderers, without calibration offsets. Beds and sign boards are block models since 26.x; only the sign
* text is added here.
*/
public final class BlockEntityModels {
@@ -38,7 +32,11 @@ public final class BlockEntityModels {
*/
public record Part(String model, List<String> textures, Set<String> hidden, Paint paint) {
static Part of(String model, String texture) {
return new Part(model, List.of(texture), Set.of(), Paint.PLAIN);
return of(model, List.of(texture));
}
static Part of(String model, List<String> textures) {
return new Part(model, textures, Set.of(), Paint.PLAIN);
}
}
@@ -63,8 +61,8 @@ public final class BlockEntityModels {
case SINGLE -> "layer/chest#main";
};
yield new VanillaModel(
rotationAround(Affine.rotY(Math.toRadians(-deg)), 0.5, 0, 0.5),
List.of(new Part(layer, chestTextures(s), Set.of(), Paint.PLAIN)));
Affine.rotationAround(Affine.rotY(Math.toRadians(-deg)), 0.5, 0, 0.5),
List.of(Part.of(layer, chestTextures(s))));
}
// SkullBlockRenderer ground: T(0.5,0,0.5)·rotY(-segment·22.5)·S(-1,-1,1); wall: T(0.5 - step·0.25,
// 0.25, …)·rotY(-opposite.toYRot())·S(-1,-1,1).
@@ -105,17 +103,12 @@ public final class BlockEntityModels {
.mul(directionRotation(s.direction()))
.mul(Affine.scale(1, -1, -1))
.mul(Affine.translation(0, -1, 0)),
List.of(new Part(
"layer/shulker_box#main",
s.colorName() == null
? List.of("entity/shulker/shulker")
: List.of("entity/shulker/shulker_" + s.colorName(), "entity/shulker/shulker"),
Set.of(),
Paint.PLAIN)));
List.of(Part.of(
"layer/shulker_box#main", variantThenBase("entity/shulker/shulker", s.colorName()))));
// DecoratedPotRenderer: rotateAround(Y, 180 - facing.toYRot(), .5,.5,.5); neck/top/bottom from the
// base sheet, each side its sherd pattern (or the blank side).
case DECORATED_POT -> new VanillaModel(
rotationAround(Affine.rotY(Math.toRadians(180 - deg)), 0.5, 0.5, 0.5), potParts(s));
Affine.rotationAround(Affine.rotY(Math.toRadians(180 - deg)), 0.5, 0.5, 0.5), potParts(s));
// BellRenderer: the model as is (pivot 8,12,8 in block px); the frame is the block model.
case BELL -> new VanillaModel(
Affine.identity(), List.of(Part.of("layer/bell#main", "entity/bell/bell_body")));
@@ -151,15 +144,9 @@ public final class BlockEntityModels {
// (CopperGolemStatueModel.setupAnim: root.y 0, root.zRot π), texture per weathering stage.
case COPPER_GOLEM_STATUE -> new VanillaModel(
Affine.translation(0.5, 0, 0.5).mul(Affine.rotY(Math.toRadians(-(deg + 180)))),
List.of(new Part(
List.of(Part.of(
"posed/copper_golem_statue_" + (s.pose() == null ? "standing" : s.pose()) + "#main",
s.colorName() == null
? List.of("entity/copper_golem/copper_golem")
: List.of(
"entity/copper_golem/copper_golem_" + s.colorName(),
"entity/copper_golem/copper_golem"),
Set.of(),
Paint.PLAIN)));
variantThenBase("entity/copper_golem/copper_golem", s.colorName()))));
// Signs: text only (BlockEntityBaker). Items, spawner mobs, beams and portals are not entity models
// (BlockEntityBaker.addContents).
case SIGN,
@@ -176,10 +163,9 @@ public final class BlockEntityModels {
}
/**
* The sign renderer's text transformation (Standing/HangingSignRenderer.textTransformation) WITHOUT
* its final {@code scale(s, -s, s)}: returns the frame whose origin is the text anchor (centre of the
* four lines), x right / y up / +z towards the reader, and the font-pixel size {@code s} in blocks via
* {@code out[0]}. Back text is the front transform turned by 180°.
* Standing/HangingSignRenderer.textTransformation WITHOUT its final {@code scale(s, -s, s)}: the frame whose
* origin is the text anchor (centre of the four lines), x right / y up / +z towards the reader; the font-pixel
* size {@code s} in blocks goes to {@code pxSize[0]}. Back text is the front transform turned by 180°.
*/
public static Affine signTextFrame(BlockEntityState s, boolean front, double[] pxSize) {
double deg = s.facingDeg();
@@ -243,15 +229,14 @@ public final class BlockEntityModels {
* trapped chests between 24 and 26 December ({@link SpecialDates#isExtendedChristmas}), else normal/trapped.
*/
private static List<String> chestTextures(BlockEntityState s) {
if (s.kind() == BlockEntityState.Kind.ENDER_CHEST) return List.of("entity/chest/ender");
boolean xmas = SpecialDates.isExtendedChristmas();
String base =
switch (s.kind()) {
case TRAPPED_CHEST -> xmas ? "christmas" : "trapped";
case ENDER_CHEST -> "ender";
case COPPER_CHEST -> s.colorName() == null ? "copper" : "copper_" + s.colorName();
default -> xmas ? "christmas" : "normal";
};
if (s.kind() == BlockEntityState.Kind.ENDER_CHEST) return List.of("entity/chest/ender");
return switch (chestKind(s)) {
case LEFT -> List.of("entity/chest/" + base + "_left", "entity/chest/" + base);
case RIGHT -> List.of("entity/chest/" + base + "_right", "entity/chest/" + base);
@@ -259,9 +244,9 @@ public final class BlockEntityModels {
};
}
/** JOML {@code rotationAround}: {@code T(c) · rot · T(-c)}. */
private static Affine rotationAround(Affine rot, double cx, double cy, double cz) {
return Affine.translation(cx, cy, cz).mul(rot).mul(Affine.translation(-cx, -cy, -cz));
/** {@code <base>_<variant>} first, then the base texture; just the base without a variant. */
private static List<String> variantThenBase(String base, String variant) {
return variant == null ? List.of(base) : List.of(base + "_" + variant, base);
}
/** Minecraft {@code Direction.getRotation()} (JOML rotationXYZ = Rx·Ry·Rz); null/unknown = UP. */
@@ -3,14 +3,9 @@ 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 drawn by a vanilla BlockEntityRenderer (chests, banners, heads, shulker
* boxes, …) have no block-model geometry; their shape is the renderer's entity model (dumped from the
* client, see {@link BlockEntityModels}), rendered through the same baking/intersection path as mobs.
* Bukkit-free so the baker needs no world access.
*
* <p>{@code facingDeg} is the vanilla yaw of the block's facing/rotation (0 = south, 90 = west; a
* 16-step rotation is {@code segment · 22.5}). Type-specific fields are null/0/false/empty when unused.
* Bukkit-free snapshot of one block-entity, captured on the main thread and baked off-thread through the entity path
* (see {@link BlockEntityModels}). {@code facingDeg} is the vanilla yaw of the facing/rotation (0 = south, 90 = west;
* 16-step rotations are {@code segment · 22.5}). Type-specific fields are null/0/false/empty when unused.
*/
public record BlockEntityState(
Kind kind,
@@ -35,7 +30,7 @@ public record BlockEntityState(
String pose // copper golem statue pose ("standing", "running", "sitting", "star"); null otherwise
) {
/** The pre-contents layout (no items/entity/beam/pose): chests, signs, banners, heads, … */
/** Without contents (items, display entity, beam, pose); used by the test harnesses. */
public BlockEntityState(
Kind kind,
int bx,
@@ -4,18 +4,16 @@ import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
/**
* Unwraps a {@link ModelCube} into six {@link Face}s using the standard Minecraft/Bedrock box-UV
* layout (faces packed in the canonical cross around the {@code uv} offset). Mirror swaps the
* left/right faces and flips each face horizontally.
* Unwraps a {@link ModelCube} into six {@link Face}s with the Java box-UV layout (the cross around the {@code uv}
* offset). Mirror swaps the left/right faces and flips each face horizontally.
*/
public final class BoxUv {
private BoxUv() {}
/**
* Returns Faces indexed by {@link Direction#ordinal()}. UVs are normalized by the model's DECLARED
* texel size (so a higher-res pack texture — e.g. a 128x128 sheet for a model authored at 64x64 —
* still maps proportionally, same layout). Falls back to the actual texture size if undeclared.
* Faces by {@link Direction#ordinal()}. UVs are normalised by the model's DECLARED texture size, so a
* higher-resolution pack texture maps proportionally; the actual size is used when undeclared.
*/
public static Face[] build(ModelCube cube, int[][] texture, int declaredW, int declaredH) {
int texW = texture.length > 0 ? texture[0].length : 64;
@@ -23,7 +21,6 @@ public final class BoxUv {
int nW = declaredW > 0 ? declaredW : texW;
int nH = declaredH > 0 ? declaredH : texH;
// Modern per-face UV: each face carries its own {u, v, w, h} rect directly.
if (cube.faceUv != null) {
Face[] faces = new Face[6];
for (int i = 0; i < 6; i++) {
@@ -35,9 +32,8 @@ public final class BoxUv {
double dx = cube.size[0], dy = cube.size[1], dz = cube.size[2];
double u = cube.uv[0], v = cube.uv[1];
// rect = {x, y, w, h} in texels, SIGNED — a negative width/height flips that axis. These match
// the OptiFine/Blockbench box-UV layout EXACTLY (up/down are flipped), paired with the (s,t) the
// EntityIntersector feeds in, so every face's texture orientation matches vanilla Java.
// {x, y, w, h} texels, SIGNED (negative = flipped axis): the Blockbench/OptiFine box-UV layout (up/down
// flipped). Must stay in sync with the face (s, t) orientation of ElementIntersector.faceS/faceT.
double[] east = {u, v + dz, dz, dy};
double[] west = {u + dz + dx, v + dz, dz, dy};
double[] north = {u + dz, v + dz, dx, dy};
@@ -10,9 +10,8 @@ import java.util.ArrayList;
import java.util.List;
/**
* Bakes wall decorations into world-space {@link EntityCube}s: paintings as flat textured quads built from the
* captured world bounding box, item frames as their vanilla frame block model plus the framed item (item
* model geometry) or map. Added to the {@link EntityScene} like any other entity.
* Bakes wall decorations: paintings as a thin box over the captured world bounding box, item frames as their vanilla
* frame block model plus the framed item or map.
*/
public final class DecorationBaker implements EntityBaker<DecorationState> {
@@ -44,9 +43,9 @@ public final class DecorationBaker implements EntityBaker<DecorationState> {
thicken(from, to, s.facing());
Face[] faces = new Face[6];
Direction front = direction(s.facing());
Direction front = Direction.valueOf(s.facing().name());
faces[front.ordinal()] = frontFace(art, s.facing());
faces[opposite(front).ordinal()] = new Face(back, 0, 0, 1, 1, 0, -1);
faces[front.opposite().ordinal()] = new Face(back, 0, 0, 1, 1, 0, -1);
EntityCube cube = new EntityCube(from, to, faces, Affine.identity());
return RenderedEntity.of(List.of(cube));
}
@@ -81,7 +80,7 @@ public final class DecorationBaker implements EntityBaker<DecorationState> {
List<EntityCube> cubes = new ArrayList<>();
boolean map = s.mapPixels() != null;
if (!s.invisible() && registry != null && items != null) {
if (!s.invisible()) {
String model = "block/" + (s.glow() ? "glow_item_frame" : "item_frame") + (map ? "_map" : "");
items.bakeModel(
registry.model(ResourceLocation.parse(model)),
@@ -95,13 +94,13 @@ public final class DecorationBaker implements EntityBaker<DecorationState> {
.mul(Affine.rotZ(Math.PI))
.mul(Affine.scale(1 / 128.0))
.mul(Affine.translation(-64, -64, -1));
// Quad (0,0)..(128,128) at z −0.01 facing local −Z (away from the wall); u along +x, v along +y.
// Quad (0,0)..(128,128) facing local −Z (away from the wall); u along +x, v along +y.
Face[] faces = new Face[6];
faces[Direction.NORTH.ordinal()] = new Face(s.mapPixels(), 1, 1, 0, 0, 0, -1);
faces[Direction.SOUTH.ordinal()] = new Face(s.mapPixels(), 0, 1, 1, 0, 0, -1);
cubes.add(new EntityCube(new double[] {0, 0, -0.01}, new double[] {128, 128, -0.01}, faces, m));
addMapMarkers(s, m, cubes);
} else if (s.item() != null && items != null) {
} else if (s.item() != null) {
Affine m =
content.mul(Affine.rotZ(Math.toRadians(s.rotation() * 45))).mul(Affine.scale(0.5));
items.bake(s.item(), "fixed", false, m, cubes);
@@ -115,22 +114,18 @@ public final class DecorationBaker implements EntityBaker<DecorationState> {
* (-1,1)..(1,-1) at z = n·-0.001 (n-th drawn decoration), {@code map/decorations/<asset>} with v0 at +y.
*/
private void addMapMarkers(DecorationState s, Affine map, List<EntityCube> cubes) {
int count = 0;
for (DecorationState.MapMarker mk : s.mapMarkers()) {
List<DecorationState.MapMarker> markers = s.mapMarkers();
for (int n = 0; n < markers.size(); n++) {
DecorationState.MapMarker mk = markers.get(n);
int[][] sprite = textures.get(ResourceLocation.parse("map/decorations/" + mk.asset()))
.orElse(null);
if (sprite != null) {
Affine m = map.mul(Affine.translation(mk.x() / 2.0 + 64, mk.y() / 2.0 + 64, -0.02))
.mul(Affine.rotZ(Math.toRadians(mk.rot() * 360 / 16.0)))
.mul(Affine.scale(4, 4, 3))
.mul(Affine.translation(-0.125, 0.125, 0));
double z = count * -0.001;
Face[] faces = new Face[6];
faces[Direction.SOUTH.ordinal()] = new Face(sprite, 0, 0, 1, 1, 0, -1);
faces[Direction.NORTH.ordinal()] = new Face(sprite, 1, 0, 0, 1, 0, -1);
cubes.add(new EntityCube(new double[] {-1, -1, z}, new double[] {1, 1, z}, faces, m));
}
count++;
if (sprite == null) continue;
Affine m = map.mul(Affine.translation(mk.x() / 2.0 + 64, mk.y() / 2.0 + 64, -0.02))
.mul(Affine.rotZ(Math.toRadians(mk.rot() * 360 / 16.0)))
.mul(Affine.scale(4, 4, 3))
.mul(Affine.translation(-0.125, 0.125, 0));
double z = n * -0.001;
cubes.add(new EntityCube(new double[] {-1, -1, z}, new double[] {1, 1, z}, ObjectBaker.card(sprite), m));
}
}
@@ -145,38 +140,16 @@ public final class DecorationBaker implements EntityBaker<DecorationState> {
}
/** Front face with the per-facing horizontal flip so the texture reads upright and unmirrored. */
private Face frontFace(int[][] tex, DecorationState.Facing facing) {
private static 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) {
private static 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;
};
}
}
@@ -3,11 +3,8 @@ package eu.mhsl.minecraft.pixelpics.render.entity;
import java.util.List;
/**
* 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. Item frames use
* its centre (the vanilla entity position).
* Snapshot of a wall decoration (painting or item frame). The world bounding box is captured directly: it already
* encodes vanilla's multi-block painting offset, and its centre is the item frame's entity position.
*/
public record DecorationState(
Kind kind,
@@ -27,7 +24,7 @@ public record DecorationState(
List<MapMarker> mapMarkers // framed map's decorations shown on item frames (banners, frames, …); may be empty
) {
/** The layout without map markers (paintings, frames without a map or with an undecorated one). */
/** Without map markers; used by the test harnesses. */
public DecorationState(
Kind kind,
double minX,
@@ -7,16 +7,11 @@ import java.util.Map;
import java.util.function.Supplier;
/**
* Bounded, thread-safe LRU cache of DERIVED textures — tinted / composited copies the bakers build per entity
* (sheep wool colours, villager type + profession + level overlays, dyed leather armor, collars, banner
* flags, …) — so identical mobs share one texture instead of re-tinting it on every bake.
* Bounded, thread-safe LRU cache of derived (tinted/composited) textures, so identical mobs share one texture.
*
* <p>Keys are built with {@link #key}: a value list of the inputs that determine the result. Source pixel
* grids may be part of the key; arrays compare by identity, which is exact because no grid is mutated once
* it is built (texture-cache grids live for the plugin's lifetime). Cached grids are shared and must not be
* mutated by callers.
* The computation runs outside the lock; concurrent misses on the same key may compute twice, the first
* stored result wins.
* <p>Keys ({@link #key}) list the inputs that determine the result; source grids compare by identity, which is exact
* because no grid is mutated once built. Cached grids are shared: callers must not mutate them. The computation runs
* outside the lock; concurrent misses may compute twice, the first stored result wins.
*/
public final class DerivedTextureCache {
@@ -1,10 +1,6 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
/**
* Bakes a captured state {@code S} (mob, block-entity or decoration) into a {@link RenderedEntity},
* or returns {@code null} when it has no renderable geometry. Lets {@link EntityScene} treat all three
* render paths uniformly.
*/
/** Bakes a captured state into a {@link RenderedEntity}, or null when it has no geometry. */
public interface EntityBaker<S> {
RenderedEntity bake(S state);
}
@@ -3,17 +3,16 @@ package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
/**
* A baked entity cube in world space: a local box (model pixels) plus the affine transform mapping it
* into the world, its six faces, the precomputed inverse transform and a world-space AABB for
* broad-phase culling.
* A baked entity cube: a local box, the affine map into the world, its six faces (by {@code Direction.ordinal()},
* null = not drawn), the inverse map and the world-space AABB.
*/
public final class EntityCube {
public final double[] from; // local min (px, inflated)
public final double[] from; // local min (inflated)
public final double[] to; // local max
public final Face[] faces; // by Direction.ordinal()
public final Face[] faces;
public final Affine toWorld;
public final Affine toLocal; // inverse
public final boolean additive; // emissive, added to what lies behind (energy swirl) instead of blended
public final Affine toLocal;
public final boolean additive; // added to what lies behind (energy swirl) instead of blended
public final boolean unshaded; // no directional face shading (vanilla NO_CARDINAL_LIGHTING: eyes, breeze wind)
// Block-model element with from > to on some axis (spawner/vault inner faces): its faces point INTO the box
// and are back-face culled like vanilla quads (see EntityIntersector); mirrored = toWorld flips handedness.
@@ -38,19 +37,16 @@ public final class EntityCube {
this.toWorld = toWorld;
this.toLocal = toWorld.inverse();
this.inverted = from[0] > to[0] || from[1] > to[1] || from[2] > to[2];
double[] r = toWorld.r;
double det = r[0] * (r[4] * r[8] - r[5] * r[7])
- r[1] * (r[3] * r[8] - r[5] * r[6])
+ r[2] * (r[3] * r[7] - r[4] * r[6]);
this.mirrored = det < 0;
this.mirrored = toWorld.determinant() < 0;
double[] r = toWorld.r, t = toWorld.t;
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];
double[] w = toWorld.apply(x, y, z);
for (int a = 0; a < 3; a++) {
if (w[a] < aabbMin[a]) aabbMin[a] = w[a];
if (w[a] > aabbMax[a]) aabbMax[a] = w[a];
double w = r[a * 3] * x + r[a * 3 + 1] * y + r[a * 3 + 2] * z + t[a];
if (w < aabbMin[a]) aabbMin[a] = w;
if (w > aabbMax[a]) aabbMax[a] = w;
}
}
}
@@ -8,11 +8,10 @@ import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import org.bukkit.util.Vector;
/**
* Intersects a world-space ray with a single {@link EntityCube} (oriented box). The ray is mapped
* into the cube's local frame, slab-tested, and the entry face is sampled. Fully transparent texels
* are treated as holes (alpha cutout); partially transparent ones keep their alpha for the tracer to blend.
* The {@code t} is a world-space distance. The test writes into a caller-owned {@link Hit} and allocates
* nothing; {@link #toFaceHit} builds the result object only for the hit the caller finally keeps.
* Intersects a world-space ray with a single {@link EntityCube}: the ray is mapped into the cube's local frame and
* slab-tested (t stays the world-space one). Cut-out texels are holes; partially transparent ones keep their alpha
* for the tracer to blend. The test writes into a caller-owned {@link Hit} and allocates nothing; {@link #toFaceHit}
* builds the result only for the hit the caller keeps.
*/
public final class EntityIntersector {
@@ -25,16 +24,13 @@ public final class EntityIntersector {
public static final class Hit {
public double t;
public int color;
int axis;
boolean neg;
Direction dir;
final double[] o = new double[3], d = new double[3];
}
/**
* Nearest drawn face of the cube with {@code t > minT} (entry face first, else the far face); {@code minT}
* lets the tracer continue behind a translucent hit. Cubes whose entry lies beyond {@code maxT} are
* rejected early (no texture lookup); a hit at exactly {@code maxT} is still reported. On a hit the face
* is left in {@code out}.
* Nearest drawn face with {@code t > minT} (entry face first, else the far face). Cubes entered beyond
* {@code maxT} are rejected before any texture lookup; a hit at exactly {@code maxT} still counts.
*/
public static boolean intersect(
EntityCube cube,
@@ -55,9 +51,10 @@ public final class EntityIntersector {
d[0] = r[0] * dx + r[1] * dy + r[2] * dz;
d[1] = r[3] * dx + r[4] * dy + r[5] * dz;
d[2] = r[6] * dx + r[7] * dy + r[8] * dz;
double[] from = cube.from, to = cube.to;
if (cube.inverted) return intersectInverted(cube, o, d, Math.max(EPS, minT), maxT, out);
double lo = Math.max(EPS, minT);
if (cube.inverted) return intersectInverted(cube, o, d, lo, maxT, out);
double[] from = cube.from, to = cube.to;
double tmin = Double.NEGATIVE_INFINITY, tmax = Double.POSITIVE_INFINITY;
int inAxis = -1, outAxis = -1;
boolean inNeg = false, outNeg = false;
@@ -67,9 +64,7 @@ public final class EntityIntersector {
continue;
}
double inv = 1.0 / d[a];
// Moving towards +a the ray enters through the min ("negative") face and leaves through the
// max face. Decided by the direction, not by comparing t1/t2: for a zero-thickness box
// (wing membranes, fins) both are equal and the comparison can't tell the sides apart.
// Entry side from the direction, not from t1 > t2: both are equal for zero-thickness boxes (wings, fins).
boolean forward = inv > 0;
double tNear = ((forward ? from[a] : to[a]) - o[a]) * inv;
double tFar = ((forward ? to[a] : from[a]) - o[a]) * inv;
@@ -85,105 +80,65 @@ public final class EntityIntersector {
}
if (tmin > tmax + EPS) return false;
}
double lo = Math.max(EPS, minT);
if (inAxis < 0 || tmax <= lo || tmin > maxT) return false;
// Vanilla draws entity models without back-face culling: a ray looking through a transparent
// texel of the entry face still sees the inside of the far face (and both sides of a plane).
if (tmin > lo && sample(cube, o, d, tmin, inAxis, inNeg, out)) return true;
return outAxis >= 0 && sample(cube, o, d, tmax, outAxis, outNeg, out);
// Vanilla draws entity models without back-face culling: through a transparent texel of the entry face the
// ray sees the inside of the far face (and both sides of a plane).
if (tmin > lo && sample(cube, o, d, tmin, Direction.of(inAxis, inNeg), out)) return true;
return outAxis >= 0 && sample(cube, o, d, tmax, Direction.of(outAxis, outNeg), out);
}
private static boolean sample(EntityCube cube, double[] o, double[] d, double t, int axis, boolean neg, Hit out) {
Direction dir = direction(axis, neg);
private static boolean sample(EntityCube cube, double[] o, double[] d, double t, Direction dir, Hit out) {
Face face = cube.faces[dir.ordinal()];
if (face == null) return false;
double px = o[0] + d[0] * t, py = o[1] + d[1] * t, pz = o[2] + d[2] * t;
double fx = frac(px, cube.from[0], cube.to[0]);
double fy = frac(py, cube.from[1], cube.to[1]);
double fz = frac(pz, cube.from[2], cube.to[2]);
// Vanilla face-UV orientation, shared by block-model faces and entity box-UV (see BoxUv).
double fx = ElementIntersector.frac(o[0] + d[0] * t, cube.from[0], cube.to[0]);
double fy = ElementIntersector.frac(o[1] + d[1] * t, cube.from[1], cube.to[1]);
double fz = ElementIntersector.frac(o[2] + d[2] * t, cube.from[2], cube.to[2]);
int color = face.sample(ElementIntersector.faceS(dir, fx, fy, fz), ElementIntersector.faceT(dir, fy, fz));
if (!ColorUtil.opaque(color)) return false;
out.t = t;
out.color = color;
out.axis = axis;
out.neg = neg;
out.dir = dir;
return true;
}
/**
* A block-model element with {@code from > to} on some axis (spawner/vault/trial-spawner inner faces) baked as
* an item or display: like {@code ElementIntersector.invertedBox}, vanilla puts the WEST/DOWN/NORTH face at
* {@code from} and EAST/UP/SOUTH at {@code to} of its axis, and each inverted OTHER axis mirrors the quad and
* flips its winding — the faces point into the box and are back-face culled (seen through the cage: the far
* wall's inside). Nearest visible opaque face in {@code (lo, maxT]} wins; UVs follow the mirrored corners.
* A block-model element with {@code from > to} on some axis (spawner/vault inner faces) baked as an item or
* display: back-face-culled inward quads like {@link ElementIntersector} (a mirroring {@code toWorld} flips their
* winding too). The nearest opaque face in {@code (lo, maxT]} wins.
*/
private static boolean intersectInverted(EntityCube cube, double[] o, double[] d, double lo, double maxT, Hit out) {
double[] from = cube.from, to = cube.to;
double best = Double.POSITIVE_INFINITY;
for (Direction dir : DIRECTIONS) {
int a = dir.nx != 0 ? 0 : dir.ny != 0 ? 1 : 2;
if (Math.abs(d[a]) < EPS) continue;
boolean neg = dir.nx + dir.ny + dir.nz < 0;
int b = (a + 1) % 3, c = (a + 2) % 3;
int flips = (from[b] > to[b] ? 1 : 0) + (from[c] > to[c] ? 1 : 0);
double sign = (neg ? -1 : 1) * (flips % 2 == 0 ? 1 : -1) * (cube.mirrored ? -1 : 1);
if (sign * d[a] >= 0) continue; // back face
double t = ((neg ? from[a] : to[a]) - o[a]) / d[a];
if (Math.abs(d[dir.axis]) < EPS) continue;
double t = ElementIntersector.invertedFaceT(dir, from, to, o, d, cube.mirrored);
if (!(t > lo) || t > maxT || t >= best) continue;
double pb = o[b] + d[b] * t, pc = o[c] + d[c] * t;
if (pb < Math.min(from[b], to[b]) - EPS || pb > Math.max(from[b], to[b]) + EPS) continue;
if (pc < Math.min(from[c], to[c]) - EPS || pc > Math.max(from[c], to[c]) + EPS) continue;
Face face = cube.faces[dir.ordinal()];
if (face == null) continue;
double fx = signedFrac(o[0] + d[0] * t, from[0], to[0]);
double fy = signedFrac(o[1] + d[1] * t, from[1], to[1]);
double fz = signedFrac(o[2] + d[2] * t, from[2], to[2]);
int color = face.sample(ElementIntersector.faceS(dir, fx, fy, fz), ElementIntersector.faceT(dir, fy, fz));
if (!ColorUtil.opaque(color)) continue;
best = t;
out.t = t;
out.color = color;
out.axis = a;
out.neg = neg;
if (sample(cube, o, d, t, dir, out)) best = t;
}
return best < Double.POSITIVE_INFINITY;
}
/** Position between {@code from} (0) and {@code to} (1), also when {@code from > to}; clamped to 0..1. */
private static double signedFrac(double v, double from, double to) {
double span = to - from;
if (Math.abs(span) < 1e-9) return 0;
double f = (v - from) / span;
return f < 0 ? 0 : Math.min(f, 1);
}
/** The result object for a hit of {@code cube} recorded by {@link #intersect} (world point + normal). */
public static FaceHit toFaceHit(
EntityCube cube, Hit h, double ox, double oy, double oz, double dx, double dy, double dz) {
double t = h.t;
Direction dir = direction(h.axis, h.neg);
Vector world = new Vector(ox + dx * t, oy + dy * t, oz + dz * t);
double[] n = cube.toWorld.applyLinear(dir.nx, dir.ny, dir.nz);
Vector normal = new Vector(n[0], n[1], n[2]).normalize();
Direction dir = h.dir;
double[] r = cube.toWorld.r;
Vector normal = new Vector(
r[0] * dir.nx + r[1] * dir.ny + r[2] * dir.nz,
r[3] * dir.nx + r[4] * dir.ny + r[5] * dir.nz,
r[6] * dir.nx + r[7] * dir.ny + r[8] * dir.nz)
.normalize();
// A far/back face is seen from inside: shade it as facing the viewer.
if (normal.getX() * dx + normal.getY() * dy + normal.getZ() * dz > 0) normal.multiply(-1);
return new FaceHit(t, world, normal, h.color, -1, cube.additive, cube.unshaded);
}
private static Direction direction(int axis, boolean neg) {
return switch (axis) {
case 0 -> neg ? Direction.WEST : Direction.EAST;
case 1 -> neg ? Direction.DOWN : Direction.UP;
default -> neg ? Direction.NORTH : Direction.SOUTH;
};
}
private static double frac(double v, double lo, double hi) {
double span = hi - lo;
if (span < 1e-6) return 0;
double f = (v - lo) / span;
return f < 0 ? 0 : Math.min(f, 1);
return new FaceHit(
t,
new Vector(ox + dx * t, oy + dy * t, oz + dz * t),
normal,
h.color,
-1,
cube.additive,
cube.unshaded);
}
}
@@ -6,16 +6,14 @@ import java.util.List;
import java.util.Map;
/**
* Maps an entity type to its CEM ({@code .jem}) Java model name and candidate texture paths. Most types
* use the type key directly for both; small override maps handle the exceptions. The models are vanilla
* Java models (already posed); variant-specific textures (cow/sheep colour, etc.) are handled here.
* Entity type -> CEM template model name (legacy fallback) and candidate texture paths. Most types use the type key
* directly; small override maps handle the exceptions.
*/
public final class EntityModels {
private EntityModels() {}
// Type key -> CEM (.jem) model name. Default is the type key itself; these are the exceptions
// (mob reuses another mob's model, or the CEM set only ships a version-suffixed/renamed name).
// Exceptions to "model name = type key": reused models and renamed/version-suffixed CEM names.
private static final Map<String, String> CEM_OVERRIDE = Map.ofEntries(
Map.entry("husk", "zombie"),
Map.entry("giant", "zombie"),
@@ -46,7 +44,7 @@ public final class EntityModels {
/** The CEM model name for an entity type (boats/rafts share the boat hull). */
public static String cemModel(String typeKey) {
if (typeKey.endsWith("_boat") || typeKey.endsWith("_raft")) return "boat";
if (isBoat(typeKey)) return "boat";
return CEM_OVERRIDE.getOrDefault(typeKey, typeKey);
}
@@ -54,7 +52,7 @@ public final class EntityModels {
// or not the right default: variant-only mobs whose renderer default is a variant/"missingno", the dump's
// piglin_brute (= piglin), players/mannequins, and non-mob entities whose renderer texture isn't captured.
// Everything else comes from the dump (see CemBaker#resolveTexture).
private static final Map<String, String> TEX_OVERRIDE = Map.<String, String>ofEntries(
private static final Map<String, String> TEX_OVERRIDE = Map.ofEntries(
Map.entry("cow", "entity/cow/cow_temperate"),
Map.entry("player", "entity/player/wide/steve"),
Map.entry("ender_dragon", "entity/enderdragon/dragon"),
@@ -80,7 +78,7 @@ public final class EntityModels {
/** Ordered texture-path candidates; the baker uses the first that loads. */
public static List<ResourceLocation> textureCandidates(String typeKey, String variant) {
List<ResourceLocation> list = new ArrayList<>();
if (typeKey.endsWith("_boat") || typeKey.endsWith("_raft")) {
if (isBoat(typeKey)) {
String wood = typeKey.substring(0, typeKey.length() - 5);
boolean chest = wood.endsWith("_chest");
if (chest) wood = wood.substring(0, wood.length() - "_chest".length());
@@ -100,17 +98,15 @@ public final class EntityModels {
/** How many leading {@link #textureCandidates} entries are type/variant-specific (not generic guesses). */
public static int specificCandidateCount(String typeKey, String variant) {
if (typeKey.endsWith("_boat") || typeKey.endsWith("_raft")) return 1;
if (isBoat(typeKey)) return 1;
return (variant != null ? variantPaths(typeKey, variant).size() : 0)
+ (TEX_OVERRIDE.containsKey(typeKey) ? 1 : 0);
}
/**
* Variant-specific texture paths of the ENUM variants vanilla hardcodes in its renderers (modern pack
* naming is "entity/<folder>/<entity>_<variant>", with a handful of mismatches the small maps below
* normalise). Data-driven variants (cat, wolf, frog, cow, pig, chicken, zombie nautilus) are resolved from
* their {@code asset_id} in the vanilla dump instead. Returned paths are tried before the generic
* fallbacks, so an unknown variant still degrades to the base texture.
* Texture paths of the ENUM variants vanilla hardcodes in its renderers ("entity/<folder>/<entity>_<variant>",
* mismatches normalised by the maps below). Registry variants (cat, wolf, frog, cow, …) come from their
* {@code asset_id} in the dump instead. Tried before the generic fallbacks.
*/
private static List<String> variantPaths(String typeKey, String v) {
return switch (typeKey) {
@@ -135,6 +131,10 @@ public final class EntityModels {
};
}
private static boolean isBoat(String typeKey) {
return typeKey.endsWith("_boat") || typeKey.endsWith("_raft");
}
/** Vanilla tropical-fish patterns in id order: 0-5 use body shape A (small), 6-11 shape B (large). */
private static final List<String> TROPICAL_PATTERNS = List.of(
"kob",
@@ -4,16 +4,15 @@ 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;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.logging.Logger;
/**
* All baked entities (mobs + block-entities + decorations + objects) for one render. Provides the nearest hit
* along a ray. Every entity cube goes into one bounding volume hierarchy over the cubes' world AABBs (binned
* SAH, flat arrays), traversed front to back with the current best hit as the far bound, so a ray only tests
* the cubes near it instead of every entity — farms with hundreds of mobs/items cost about as much as a few.
* Near-ties (coplanar faces of different cubes) are decided by replaying the former linear scan over the few
* candidates, so images stay pixel-identical. Immutable after construction; the per-ray scratch is per thread
* → safe for the parallel tracer.
* All baked entities (mobs, block-entities, decorations, objects) of one render, as one bounding volume hierarchy
* over the cubes' world AABBs (binned SAH, flat arrays), traversed front to back with the best hit as far bound.
* Near-ties (coplanar faces of different cubes) are decided in bake order (see {@link #nearestHit}). Immutable after
* construction; the per-ray scratch is per thread.
*/
public final class EntityScene {
@@ -23,8 +22,6 @@ public final class EntityScene {
private static final int MAX_SAH_DEPTH = 40;
private static volatile boolean warned;
private final List<RenderedEntity> entities;
// Cubes in BVH leaf order, their bake-order rank (tie-break) and world AABBs (minX..maxZ, 6 per cube).
private final EntityCube[] cubes;
private final int[] cubeRank;
@@ -52,11 +49,11 @@ public final class EntityScene {
EntityIntersector.Hit candidate(int k) {
if (k == cand.length) {
int len = Math.max(8, k * 2);
EntityIntersector.Hit[] c = java.util.Arrays.copyOf(cand, len);
EntityIntersector.Hit[] c = Arrays.copyOf(cand, len);
for (int i = k; i < len; i++) c[i] = new EntityIntersector.Hit();
cand = c;
candCube = java.util.Arrays.copyOf(candCube, len);
order = java.util.Arrays.copyOf(order, len);
candCube = Arrays.copyOf(candCube, len);
order = Arrays.copyOf(order, len);
}
return cand[k];
}
@@ -73,17 +70,13 @@ public final class EntityScene {
DecorationBaker decoBaker,
List<ObjectState> objects,
ObjectBaker objectBaker) {
this.entities = new ArrayList<>(states.size() + blockEntities.size() + decorations.size() + objects.size());
addAll(states, baker);
addAll(blockEntities, beBaker);
addAll(decorations, decoBaker);
addAll(objects, objectBaker);
int n = 0;
for (RenderedEntity e : entities) n += e.cubes.size();
EntityCube[] all = new EntityCube[n];
int k = 0;
for (RenderedEntity e : entities) for (EntityCube c : e.cubes) all[k++] = c;
List<EntityCube> baked = new ArrayList<>();
addAll(states, baker, baked);
addAll(blockEntities, beBaker, baked);
addAll(decorations, decoBaker, baked);
addAll(objects, objectBaker, baked);
EntityCube[] all = baked.toArray(new EntityCube[0]);
int n = all.length;
cubes = new EntityCube[n];
cubeRank = new int[n];
@@ -91,28 +84,27 @@ public final class EntityScene {
maxDepth = build(all);
}
/** Bakes each state and keeps the ones with geometry. A null baker (path disabled) is skipped. */
private <S> void addAll(List<S> states, EntityBaker<S> baker) {
/** Bakes each state into {@code out} (in bake order = tie-break rank). A null baker (path disabled) is skipped. */
private static <S> void addAll(List<S> states, EntityBaker<S> baker, List<EntityCube> out) {
if (baker == null) return;
for (S s : states) {
RenderedEntity e;
try {
e = baker.bake(s);
} catch (RuntimeException ex) {
// One unexpected entity/asset must never fail the whole photo: skip just this one.
// One bad entity/asset must never fail the whole photo.
if (!warned) {
warned = true;
java.util.logging.Logger.getLogger("PixelPics")
.warning("PixelPics: skipped an entity that failed to bake: " + ex);
Logger.getLogger("PixelPics").warning("PixelPics: skipped an entity that failed to bake: " + ex);
}
continue;
}
if (e != null && !e.cubes.isEmpty()) entities.add(e);
if (e != null) out.addAll(e.cubes);
}
}
public boolean isEmpty() {
return entities.isEmpty();
return cubes.length == 0;
}
/** Nearest entity hit with {@code minT < t < maxT}, or null. */
@@ -164,34 +156,30 @@ public final class EntityScene {
int l = node + 1, r = nodeRight[node];
double tl = entry(nb, l, ox, oy, oz, ix, iy, iz, px, py, pz, t0, bound);
double trr = entry(nb, r, ox, oy, oz, ix, iy, iz, px, py, pz, t0, bound);
boolean hl = tl == tl, hr = trr == trr;
if (hl && hr) {
// Near child on top of the stack: visited first, so the far one is usually culled.
if (tl <= trr) {
stack[sp] = r;
stackT[sp++] = trr;
stack[sp] = l;
stackT[sp++] = tl;
} else {
stack[sp] = l;
stackT[sp++] = tl;
stack[sp] = r;
stackT[sp++] = trr;
}
} else if (hl) {
stack[sp] = l;
stackT[sp++] = tl;
} else if (hr) {
stack[sp] = r;
stackT[sp++] = trr;
// Near child on top of the stack: visited first, so the far one is usually culled. NaN = missed.
int near = l, far = r;
double tn = tl, tf = trr;
if (!(tl <= trr)) {
near = r;
far = l;
tn = trr;
tf = tl;
}
if (tf == tf) {
stack[sp] = far;
stackT[sp++] = tf;
}
if (tn == tn) {
stack[sp] = near;
stackT[sp++] = tn;
}
}
}
if (n == 0) return null;
// Pass 2: replay the former linear scan over the near candidates in bake order — a cube counts only if
// its AABB passes with the best t so far as far bound and it is strictly nearer. Coplanar faces of
// different cubes (equal t up to rounding) thus resolve exactly as before: pixel-identical images.
// Pass 2, the tie-break: the near candidates in bake order; a cube counts only if its AABB passes with the
// best t so far as far bound and it is strictly nearer. Coplanar faces of different cubes (equal t up to
// rounding) depend on this order, so it must not change.
int[] candCube = tr.candCube;
int[] order = tr.order;
int m = 0;
@@ -221,8 +209,7 @@ public final class EntityScene {
/**
* Slab test of box {@code i} of {@code boxes} (6 doubles per box) against the ray within {@code [t0, tMax]};
* returns the entry t, or NaN on a miss. Same comparisons as the former per-entity/per-cube AABB check
* (a box touching the interval at {@code tMax} still counts).
* returns the entry t, or NaN on a miss (a box touching the interval at {@code tMax} still counts).
*/
private static double entry(
double[] boxes,
@@ -352,7 +339,7 @@ public final class EntityScene {
}
if (mid < 0) {
// Leaf: cubes in bake order inside the leaf (ascending original index).
java.util.Arrays.sort(perm, lo, hi);
Arrays.sort(perm, lo, hi);
nodeStart[node] = filled;
nodeCount[node] = count;
for (int i = lo; i < hi; i++) {
@@ -410,7 +397,7 @@ public final class EntityScene {
for (int b = 0; b < SAH_BINS - 1; b++) {
grow(acc, binBox, b);
cnt += binCount[b];
leftArea[b] = area(acc);
leftArea[b] = area(acc, 0);
leftCount[b] = cnt;
}
acc[0] = acc[1] = acc[2] = Double.MAX_VALUE;
@@ -423,7 +410,7 @@ public final class EntityScene {
cnt += binCount[b];
int left = leftCount[b - 1];
if (left == 0 || cnt == 0) continue;
double cost = leftArea[b - 1] * left + area(acc) * cnt;
double cost = leftArea[b - 1] * left + area(acc, 0) * cnt;
if (cost < bestCost) {
bestCost = cost;
bestSplit = b;
@@ -459,7 +446,7 @@ public final class EntityScene {
private void sortByCentroid(int lo, int hi, int axis) {
Integer[] tmp = new Integer[hi - lo];
for (int i = lo; i < hi; i++) tmp[i - lo] = perm[i];
java.util.Arrays.sort(tmp, (a, b) -> Double.compare(centroid[a * 3 + axis], centroid[b * 3 + axis]));
Arrays.sort(tmp, (a, b) -> Double.compare(centroid[a * 3 + axis], centroid[b * 3 + axis]));
for (int i = lo; i < hi; i++) perm[i] = tmp[i - lo];
}
@@ -470,10 +457,6 @@ public final class EntityScene {
}
}
private static double area(double[] box) {
return area(box, 0);
}
private static double area(double[] box, int o) {
double x = box[o + 3] - box[o], y = box[o + 4] - box[o + 1], z = box[o + 5] - box[o + 2];
if (!(x >= 0) || !(y >= 0) || !(z >= 0)) return 0;
@@ -1,9 +1,8 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
/**
* Immutable snapshot of one entity captured on the main thread, sufficient to bake and pose it
* off-thread. Angles are in degrees (Minecraft convention).
*/
import java.util.Map;
/** Snapshot of one entity captured on the main thread, enough to bake it off-thread. Angles in degrees. */
public record EntityState(
String typeKey, // e.g. "cow", "zombie", "player"
double x,
@@ -16,8 +15,7 @@ public record EntityState(
boolean player,
String skinUrl,
boolean slim,
String variant, // variant key (e.g. "ashen", "warm", "tabby"; "<ns>:<key>" outside minecraft); for villagers
// the biome type, or null
String variant, // variant key ("ashen", "tabby"; "<ns>:<key>" outside minecraft; villagers: biome type)
int tint, // ARGB multiplier for tintable layers (sheep wool); 0 = none
double sizeScale, // extra model scale (slime/magma-cube size); 1.0 = default
String profession, // villager profession key (e.g. "farmer", "librarian", "none"), or null
@@ -26,11 +24,10 @@ public record EntityState(
boolean saddle, // horse/donkey/mule is saddled
boolean chest, // donkey/mule/llama is carrying a chest (pose flag "chest")
boolean arms, // armor stand shows its arms (pose flag "arms")
String bodyEquip, // BODY-slot equipment asset (horse armor material, llama carpet, harness, wolf armor,
// nautilus armor) or "trader_llama"; null = none
String bodyEquip, // BODY-slot equipment asset (horse armor, llama carpet, harness, …) or "trader_llama"
Equipment equipment, // worn humanoid armor (players, armor stands, zombies, …); null = none/not a wearer
boolean invisible, // entity is invisible -> render only its equipment (like vanilla), not the body
float pitch, // entity pitch (deg) — orients projectiles (arrows, tridents, skulls)
float pitch, // orients projectiles and pitched bodies
int bodyDye, // ARGB dye of dyeable BODY equipment (leather horse armor, wolf armor); 0 = none
int tint2, // second tint (tropical-fish pattern colour); 0 = none
boolean passenger, // riding something (vanilla sitting pose)
@@ -40,10 +37,9 @@ public record EntityState(
int collar, // ARGB dye of a tamed cat's/wolf's collar; 0 = no collar (untamed)
boolean powered, // energy-swirl layer: charged creeper, wither below half health
ItemSpec mouthItem, // item carried in the mouth (fox/dolphin main hand); null = none
String pose, // state pose flags, sorted and "+"-joined ("sitting", "lying+head_up", "sleeping", "in_water",
// …; the dump's STATE_POSES names, unknown ones are ignored per type); null = none (idle)
java.util.Map<String, float[]> partPoses, // continuous part poses by vanilla render-state field (armor stand
// "headPose"/"bodyPose"/"leftArmPose"/…: x/y/z degrees); null = defaults
String pose, // "+"-joined state pose/render flags (the dump's STATE_POSES names, e.g. "lying+head_up"); null =
// idle
Map<String, float[]> partPoses, // armor-stand part rotations by render-state field ("headPose", …), x/y/z deg
String shoulderLeft, // parrot variant on a player's left shoulder (e.g. "red"); null = none
String shoulderRight // parrot variant on the right shoulder; null = none
) {
@@ -3,9 +3,7 @@ package eu.mhsl.minecraft.pixelpics.render.entity;
import org.bukkit.block.data.BlockData;
/**
* An item to render (held, worn on the head, in an item display, dropped): its item key, the block data
* when the item places a block (block-model geometry), the block-entity state when that block is
* block-entity rendered (chests, heads, banners, …), and whether it is enchanted. Captured on the main
* thread; immutable afterwards.
* An item to render (held, worn, displayed, dropped): item key, the block it places (null if none), that block's
* block-entity state when it is block-entity rendered, and whether it is enchanted.
*/
public record ItemSpec(String id, BlockData block, BlockEntityState blockEntity, boolean glint) {}
@@ -1,16 +1,13 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
/**
* A single box of an entity model, in Bedrock model-pixel coordinates (16 px = 1 block).
* {@code origin} is the minimum corner, {@code uv} is the box-UV texture offset.
*/
/** One box of an entity model in model pixels: min corner, size, inflation and box-UV offset (or per-face UVs). */
public final class ModelCube {
public final double[] origin; // 3, min corner (px)
public final double[] size; // 3 (px)
public final double inflate; // px, expands the box on all sides (overlay layers)
public final double[] uv; // 2, box-UV offset (texels)
public final double[] origin;
public final double[] size;
public final double inflate; // grows the box on all sides (overlay layers)
public final double[] uv; // box-UV offset (texels)
public final boolean mirror;
/** Optional modern per-face UV, indexed by {@code Direction.ordinal()}: {u, v, w, h} texels (h/w may be negative for flips). Null = use box-UV. */
// Per-face {u, v, w, h} texels by Direction.ordinal() (negative w/h flip); null = box-UV.
public final double[][] faceUv;
public ModelCube(double[] origin, double[] size, double inflate, double[] uv, boolean mirror) {
@@ -7,18 +7,17 @@ import eu.mhsl.minecraft.pixelpics.render.entity.cem.ItemGeometry;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.SignTextRasterizer;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* Bakes {@link ObjectState}s (display entities, dropped items, falling blocks, primed TNT, minecart
* contents) into world-space {@link EntityCube}s: block content reuses the resolved BLOCK MODEL elements
* (any block, any version — straight from the resource pack), items use their block model or a flat
* sprite, text is rasterized with the pack's bitmap font (as for signs).
* Bakes {@link ObjectState}s (display entities, dropped items, falling blocks, primed TNT, minecart contents,
* leashes): blocks and items through {@link ItemGeometry}, text rasterized with the pack's bitmap font.
*/
public final class ObjectBaker implements EntityBaker<ObjectState> {
// Vanilla leash colours (LeashRenderer): brown, alternating with a darker shade per segment.
private static final int LEASH_LIGHT = 0xFF7F6650, LEASH_DARK = 0xFF59473A;
// Vanilla LeashRenderer colours, alternating per segment.
private static final Face[] LEASH_LIGHT = solidFaces(0xFF7F6650), LEASH_DARK = solidFaces(0xFF59473A);
private static final int LEASH_SEGMENTS = 24;
private static final double LEASH_HALF_WIDTH = 0.025;
@@ -35,8 +34,7 @@ public final class ObjectBaker implements EntityBaker<ObjectState> {
List<EntityCube> cubes = new ArrayList<>();
switch (s.kind()) {
case BLOCK -> {
// The block model plus what its block-entity renderer adds (chest/banner/… bodies, the lectern's
// book, the bell, the enchanting-table book): block-entity blocks draw no block-model geometry.
// Block model plus the block-entity renderer's parts (chest bodies, lectern book, bell, …).
items.bakeBlock(s.block(), s.toWorld(), cubes);
items.bakeBlockEntity(s.blockEntity(), s.toWorld(), cubes);
}
@@ -70,15 +68,14 @@ public final class ObjectBaker implements EntityBaker<ObjectState> {
for (int i = 1; i <= LEASH_SEGMENTS; i++) {
double t = (double) i / LEASH_SEGMENTS;
double[] cur = {p[0] + dx * t, p[1] + dy * (t * t + t) * 0.5, p[2] + dz * t};
int color = (i % 2 == 0) ? LEASH_LIGHT : LEASH_DARK;
EntityCube seg = segment(prev, cur, color);
EntityCube seg = segment(prev, cur, i % 2 == 0 ? LEASH_LIGHT : LEASH_DARK);
if (seg != null) cubes.add(seg);
prev = cur;
}
}
/** A thin square rope box from a to b. */
private static EntityCube segment(double[] a, double[] b, int argb) {
private static EntityCube segment(double[] a, double[] b, Face[] faces) {
double[] d = {b[0] - a[0], b[1] - a[1], b[2] - a[2]};
double len = Math.sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]);
if (len < 1e-6) return null;
@@ -89,11 +86,14 @@ public final class ObjectBaker implements EntityBaker<ObjectState> {
double[] v = cross(d, u);
Affine frame = new Affine(
new double[] {u[0], v[0], d[0], u[1], v[1], d[1], u[2], v[2], d[2]}, new double[] {a[0], a[1], a[2]});
int[][] solid = {{argb}};
Face[] f = new Face[6];
for (int i = 0; i < 6; i++) f[i] = new Face(solid, 0, 0, 1, 1, 0, -1);
double w = LEASH_HALF_WIDTH;
return new EntityCube(new double[] {-w, -w, 0}, new double[] {w, w, len + w}, f, frame);
return new EntityCube(new double[] {-w, -w, 0}, new double[] {w, w, len + w}, faces, frame);
}
private static Face[] solidFaces(int argb) {
Face[] f = new Face[6];
Arrays.fill(f, new Face(new int[][] {{argb}}, 0, 0, 1, 1, 0, -1));
return f;
}
private static double[] cross(double[] a, double[] b) {
@@ -110,21 +110,23 @@ public final class ObjectBaker implements EntityBaker<ObjectState> {
private void addText(ObjectState s, List<EntityCube> cubes) {
if (font == null || font.isEmpty()) return;
List<String> lines = wrap(s.lines(), Math.max(1, s.lineWidth()));
boolean blank = lines.stream().allMatch(String::isBlank);
int[][] text = blank ? null : SignTextRasterizer.rasterize(lines, font, s.textArgb(), 0, false);
int w = text == null ? 0 : text[0].length, h = text == null ? 0 : text.length;
if (text != null && s.shadow()) text = withShadow(text, s.textArgb());
// Text block: bottom edge on the origin, horizontally centred, front facing +Z.
double x0 = -w / 2.0, x1 = x0 + (text == null ? 0 : text[0].length);
double y1 = h, y0 = text == null ? 0 : h - text.length;
if (text != null) {
cubes.add(
new EntityCube(new double[] {x0, y0, 0.05}, new double[] {x1, y1, 0.05}, card(text), s.toWorld()));
int w = 0, h = 0;
if (!lines.stream().allMatch(String::isBlank)) {
int[][] text = SignTextRasterizer.rasterize(lines, font, s.textArgb(), 0, false);
w = text[0].length;
h = text.length;
if (s.shadow()) text = withShadow(text, s.textArgb());
// Bottom edge on the origin (the shadow hangs below), horizontally centred, front facing +Z.
double x0 = -w / 2.0;
cubes.add(new EntityCube(
new double[] {x0, h - text.length, 0.05},
new double[] {x0 + text[0].length, h, 0.05},
card(text),
s.toWorld()));
}
int bg = s.backgroundArgb();
// Translucent backgrounds (vanilla default: 25% black) keep their alpha; the tracer blends them.
if (ColorUtil.opaque(bg) && (w > 0 || !s.lines().isEmpty())) {
if (ColorUtil.opaque(bg) && !s.lines().isEmpty()) {
int[][] solid = {{bg}};
cubes.add(new EntityCube(
new double[] {-w / 2.0 - 1, -1, 0}, new double[] {w / 2.0 + 1, h, 0}, card(solid), s.toWorld()));
@@ -135,7 +137,7 @@ public final class ObjectBaker implements EntityBaker<ObjectState> {
* Faces of a planar (z-thickness 0) card: front (+Z) and back show the SAME texel at each point (the
* back is seen mirrored, as in vanilla), so a ray through a transparent texel misses both sides.
*/
private static Face[] card(int[][] tex) {
static Face[] card(int[][] tex) {
Face[] f = new Face[6];
f[Direction.SOUTH.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, -1);
f[Direction.NORTH.ordinal()] = new Face(tex, 1, 0, 0, 1, 0, -1);
@@ -171,8 +173,10 @@ public final class ObjectBaker implements EntityBaker<ObjectState> {
int h = text.length, w = text[0].length;
int[][] out = new int[h + 1][w + 1];
int shadow = ColorUtil.shade(argb, 0.25);
for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) if ((text[y][x] >>> 24) > 0) out[y + 1][x + 1] = shadow;
for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) if ((text[y][x] >>> 24) > 0) out[y][x] = text[y][x];
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++) if (ColorUtil.alpha(text[y][x]) > 0) out[y + 1][x + 1] = shadow;
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++) if (ColorUtil.alpha(text[y][x]) > 0) out[y][x] = text[y][x];
return out;
}
}
@@ -4,10 +4,9 @@ import java.util.List;
import org.bukkit.block.data.BlockData;
/**
* Snapshot of a non-mob object that renders block/item/text content instead of an entity model:
* block/item/text DISPLAY entities, dropped items, falling blocks, primed TNT and the block inside a
* minecart, and leash ropes. Captured on the main thread with its complete local→world transform already resolved
* (entity position, yaw/pitch or billboard facing, display transformation), so baking is pure geometry.
* Snapshot of a non-mob object that renders block/item/text content instead of an entity model: block/item/text
* display entities, dropped items, falling blocks, primed TNT, minecart blocks and leash ropes. The local→world
* transform is resolved at capture (position, yaw/pitch or billboard, display transformation).
*
* <p>Local frames: BLOCK = the block cube {@code [0,1]³}; ITEM = an item centred on the origin
* ({@code [-0.5,0.5]³}); TEXT = font pixels (x right, y up, text front facing +Z), already scaled.
@@ -19,7 +18,7 @@ public record ObjectState(
BlockEntityState blockEntity, // BLOCK: set when the block is block-entity rendered (chest, banner, …)
ItemSpec item, // ITEM: the item
String context, // ITEM: display context ("none", "fixed", "ground", "head", …)
boolean dropped, // ITEM: a dropped item entity (vanilla lifts it by 0.1 + 0.25·groundScaleY)
boolean dropped, // ITEM: a dropped item entity (lifted above the ground by the baker)
double[] leash, // LEASH: rope points {fromX,fromY,fromZ, toX,toY,toZ} (entity -> holder)
List<String> lines, // TEXT: explicit lines (wrapped by the baker to lineWidth)
int lineWidth, // TEXT: wrap width in font px
@@ -2,19 +2,18 @@ package eu.mhsl.minecraft.pixelpics.render.entity;
import java.util.List;
/** A baked entity: its world-space cubes and overall AABB (for broad-phase culling). */
/** A baked entity: its world-space cubes and their union AABB (framing in the test harnesses). */
public final class RenderedEntity {
public final List<EntityCube> cubes;
public final double[] aabbMin;
public final double[] aabbMax;
public RenderedEntity(List<EntityCube> cubes, double[] aabbMin, double[] aabbMax) {
private RenderedEntity(List<EntityCube> cubes, double[] aabbMin, double[] aabbMax) {
this.cubes = cubes;
this.aabbMin = aabbMin;
this.aabbMax = aabbMax;
}
/** Wraps baked world-space cubes, computing the overall broad-phase AABB from their per-cube AABBs. */
public static RenderedEntity of(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};
@@ -1,66 +1,55 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
/**
* In-place ARGB pixel-grid compositing shared by the entity/block-entity bakers: deep copy, tint and
* src-over alpha overlay. Grids are indexed {@code [y][x]}; all operations assume the ARGB layout used
* throughout the renderer.
*/
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
/** In-place ARGB pixel-grid ({@code [y][x]}) compositing shared by the entity and block-entity bakers. */
public final class TextureOps {
private TextureOps() {}
/** A row-by-row copy so callers can tint/overlay without mutating the cached source texture. */
/** A row-by-row copy, so callers can tint/overlay without mutating a cached source texture. */
public 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;
}
/** Multiplies every non-transparent texel by an ARGB tint (RGB channels only), in place. */
/** Multiplies the RGB of every non-transparent texel by {@code argb}, in place. */
public 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;
if (ColorUtil.alpha(row[x]) != 0) row[x] = ColorUtil.multiply(row[x], argb);
}
}
}
/** Standard src-over alpha composite of {@code src} onto {@code dst} (clipped to the overlap), in place. */
/** Src-over alpha composite of {@code src} onto {@code dst} (clipped to the overlap), in place. */
public 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;
int sa = ColorUtil.alpha(sp);
if (sa == 0) continue;
if (sa == 255) {
dst[y][x] = sp;
continue;
}
int dp = dst[y][x];
int da = (dp >>> 24) & 0xFF;
int da = ColorUtil.alpha(dp);
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;
int r = (ColorUtil.red(sp) * sa + ColorUtil.red(dp) * da * (255 - sa) / 255) / Math.max(1, outA);
int g = (ColorUtil.green(sp) * sa + ColorUtil.green(dp) * da * (255 - sa) / 255) / Math.max(1, outA);
int b = (ColorUtil.blue(sp) * sa + ColorUtil.blue(dp) * da * (255 - sa) / 255) / Math.max(1, outA);
dst[y][x] = ColorUtil.argb(outA, r, g, b);
}
}
}
/**
* Palette-swap recolouring used for armor trims: each (non-transparent) texel of {@code tex} is matched
* to its nearest entry in the grayscale source palette {@code from} (8-colour key, RGB distance) and
* replaced by the same-index colour from the material palette {@code to}, preserving alpha. The vanilla
* trim patterns are authored in those 8 key shades, so this reproduces the runtime palette swap.
* Armor-trim palette swap: each non-transparent texel is replaced by the {@code to} colour whose index is the
* nearest (RGB distance) {@code from} key shade, keeping its alpha. Vanilla trim patterns use exactly those keys.
*/
public static void paletteSwap(int[][] tex, int[] from, int[] to) {
int n = Math.min(from.length, to.length);
@@ -68,13 +57,14 @@ public final class TextureOps {
for (int[] row : tex) {
for (int x = 0; x < row.length; x++) {
int p = row[x];
int a = (p >>> 24) & 0xFF;
int a = ColorUtil.alpha(p);
if (a == 0) continue;
int pr = (p >> 16) & 0xFF, pg = (p >> 8) & 0xFF, pb = p & 0xFF;
int best = 0, bestD = Integer.MAX_VALUE;
for (int i = 0; i < n; i++) {
int c = from[i];
int dr = pr - ((c >> 16) & 0xFF), dg = pg - ((c >> 8) & 0xFF), db = pb - (c & 0xFF);
int dr = ColorUtil.red(p) - ColorUtil.red(c);
int dg = ColorUtil.green(p) - ColorUtil.green(c);
int db = ColorUtil.blue(p) - ColorUtil.blue(c);
int d = dr * dr + dg * dg + db * db;
if (d < bestD) {
bestD = d;
@@ -87,34 +77,27 @@ public final class TextureOps {
}
/**
* Static approximation of the (animated) enchantment glint: a single glint frame is tiled over the
* texture and additively blended — tinted by {@code glintColor} and scaled by {@code strength} and the
* frame's luminance — onto the texture's opaque texels only (so it sheens the armor, not the
* transparent background). Not animated; just a frozen purple shimmer.
* Static approximation of the animated enchantment glint: one glint frame tiled over the texture and added
* (tinted by {@code glintColor}, scaled by {@code strength}, frame alpha and luminance) to its opaque texels.
*/
public static void addGlint(int[][] tex, int[][] glint, int glintColor, double strength) {
if (glint == null || glint.length == 0 || glint[0].length == 0) return;
int gh = glint.length, gw = glint[0].length;
int cr = (glintColor >> 16) & 0xFF, cg = (glintColor >> 8) & 0xFF, cb = glintColor & 0xFF;
int cr = ColorUtil.red(glintColor), cg = ColorUtil.green(glintColor), cb = ColorUtil.blue(glintColor);
for (int y = 0; y < tex.length; y++) {
for (int x = 0; x < tex[y].length; x++) {
int p = tex[y][x];
int a = (p >>> 24) & 0xFF;
if (a == 0) continue; // only sheen actual armor pixels
int a = ColorUtil.alpha(p);
if (a == 0) continue;
int gp = glint[y % gh][x % gw];
int ga = (gp >>> 24) & 0xFF;
int gl = Math.max((gp >> 16) & 0xFF, Math.max((gp >> 8) & 0xFF, gp & 0xFF));
double inten = strength * (ga / 255.0) * (gl / 255.0);
int gl = Math.max(ColorUtil.red(gp), Math.max(ColorUtil.green(gp), ColorUtil.blue(gp)));
double inten = strength * (ColorUtil.alpha(gp) / 255.0) * (gl / 255.0);
if (inten <= 0) continue;
int r = clamp(((p >> 16) & 0xFF) + (int) (cr * inten));
int g = clamp(((p >> 8) & 0xFF) + (int) (cg * inten));
int b = clamp((p & 0xFF) + (int) (cb * inten));
tex[y][x] = (a << 24) | (r << 16) | (g << 8) | b;
int r = ColorUtil.clamp(ColorUtil.red(p) + (int) (cr * inten));
int g = ColorUtil.clamp(ColorUtil.green(p) + (int) (cg * inten));
int b = ColorUtil.clamp(ColorUtil.blue(p) + (int) (cb * inten));
tex[y][x] = ColorUtil.argb(a, r, g, b);
}
}
}
private static int clamp(int v) {
return v < 0 ? 0 : (Math.min(v, 255));
}
}
@@ -95,11 +95,11 @@ public final class ElementIntersector {
// Blockstate rotation: hit the model-space element with the inverse-rotated ray (same t: rigid
// transform), so faces and UVs stay bound to the model like vanilla's FaceBakery.
e = element.unrotated;
unrotate(v, ox, oy, oz, element.xSteps, element.ySteps, true);
Element.unrotate(v, ox, oy, oz, element.xSteps, element.ySteps, true);
eox = v[0];
eoy = v[1];
eoz = v[2];
unrotate(v, dx, dy, dz, element.xSteps, element.ySteps, false);
Element.unrotate(v, dx, dy, dz, element.xSteps, element.ySteps, false);
edx = v[0];
edy = v[1];
edz = v[2];
@@ -108,7 +108,7 @@ public final class ElementIntersector {
? slab(sc, eox, eoy, eoz, edx, edy, edz, e.from, e.to, null)
: intersectObb(sc, e, eox, eoy, eoz, edx, edy, edz);
if (hit && element.unrotated != null) {
rotate(v, sc.hitNx, sc.hitNy, sc.hitNz, element.xSteps, element.ySteps, false);
Element.rotate(v, sc.hitNx, sc.hitNy, sc.hitNz, element.xSteps, element.ySteps, false);
sc.hitNx = v[0];
sc.hitNy = v[1];
sc.hitNz = v[2];
@@ -250,7 +250,7 @@ public final class ElementIntersector {
double py = o[1] + d[1] * tEntry;
double pz = o[2] + d[2] * tEntry;
Direction dir = faceFor(axis, negFace);
Direction dir = Direction.of(axis, negFace);
setNormal(sc, obb, dir.nx, dir.ny, dir.nz);
double fracX = frac(px, from[0], to[0]);
double fracY = frac(py, from[1], to[1]);
@@ -274,26 +274,16 @@ public final class ElementIntersector {
double[] o = sc.o, d = sc.d;
double best = Double.POSITIVE_INFINITY;
Direction bestDir = null;
double bestSign = 0;
for (Direction dir : DIRECTIONS) {
int a = dir.nx != 0 ? 0 : dir.ny != 0 ? 1 : 2;
boolean neg = dir.nx + dir.ny + dir.nz < 0;
int b = (a + 1) % 3, c = (a + 2) % 3;
int flips = (from[b] > to[b] ? 1 : 0) + (from[c] > to[c] ? 1 : 0);
double sign = (neg ? -1 : 1) * (flips % 2 == 0 ? 1 : -1);
if (sign * d[a] >= 0) continue; // back face (or parallel)
double t = ((neg ? from[a] : to[a]) - o[a]) / d[a];
double t = invertedFaceT(dir, from, to, o, d, false);
if (!(t >= EPS) || t >= best) continue;
double pb = o[b] + d[b] * t, pc = o[c] + d[c] * t;
if (pb < Math.min(from[b], to[b]) - EPS || pb > Math.max(from[b], to[b]) + EPS) continue;
if (pc < Math.min(from[c], to[c]) - EPS || pc > Math.max(from[c], to[c]) + EPS) continue;
best = t;
bestDir = dir;
bestSign = sign;
}
if (bestDir == null) return false;
int a = bestDir.nx != 0 ? 0 : bestDir.ny != 0 ? 1 : 2;
setNormal(sc, obb, a == 0 ? bestSign : 0, a == 1 ? bestSign : 0, a == 2 ? bestSign : 0);
int a = bestDir.axis;
double sign = invertedFaceSign(bestDir, from, to, false);
setNormal(sc, obb, a == 0 ? sign : 0, a == 1 ? sign : 0, a == 2 ? sign : 0);
double fx = frac(o[0] + d[0] * best, from[0], to[0]);
double fy = frac(o[1] + d[1] * best, from[1], to[1]);
double fz = frac(o[2] + d[2] * best, from[2], to[2]);
@@ -304,6 +294,34 @@ public final class ElementIntersector {
return true;
}
/**
* Outward normal sign of face {@code dir} of an inverted box along its axis: each inverted OTHER axis flips the
* winding, as does a mirroring transform ({@code mirrored}).
*/
private static double invertedFaceSign(Direction dir, double[] from, double[] to, boolean mirrored) {
int b = (dir.axis + 1) % 3, c = (dir.axis + 2) % 3;
int flips = (from[b] > to[b] ? 1 : 0) + (from[c] > to[c] ? 1 : 0);
boolean neg = dir.nx + dir.ny + dir.nz < 0;
return (neg ? -1 : 1) * (flips % 2 == 0 ? 1 : -1) * (mirrored ? -1 : 1);
}
/**
* Ray parameter where the ray {@code o + t·d} meets face {@code dir} of an inverted box (see
* {@link #invertedBox}) from its visible side inside the face rectangle; NaN for a back face, a parallel ray or
* a miss. Shared with the entity intersector (inverted elements baked as items/displays).
*/
public static double invertedFaceT(
Direction dir, double[] from, double[] to, double[] o, double[] d, boolean mirrored) {
int a = dir.axis, b = (a + 1) % 3, c = (a + 2) % 3;
if (invertedFaceSign(dir, from, to, mirrored) * d[a] >= 0) return Double.NaN;
boolean neg = dir.nx + dir.ny + dir.nz < 0;
double t = ((neg ? from[a] : to[a]) - o[a]) / d[a];
double pb = o[b] + d[b] * t, pc = o[c] + d[c] * t;
if (pb < Math.min(from[b], to[b]) - EPS || pb > Math.max(from[b], to[b]) + EPS) return Double.NaN;
if (pc < Math.min(from[c], to[c]) - EPS || pc > Math.max(from[c], to[c]) + EPS) return Double.NaN;
return t;
}
/**
* The hit normal (local axis normal of the element's frame) in block space: rotated back for {@code obb}. The
* rescale is diagonal in the local frame, so an axis normal keeps its direction (n = R·n_local).
@@ -330,7 +348,7 @@ public final class ElementIntersector {
}
/** Position between {@code from} (0) and {@code to} (1), also when {@code from > to}; clamped to 0..1. */
private static double frac(double v, double from, double to) {
public static double frac(double v, double from, double to) {
double span = to - from;
if (Math.abs(span) < 1e-6) return 0;
double f = (v - from) / span;
@@ -360,14 +378,6 @@ public final class ElementIntersector {
};
}
private static Direction faceFor(int axis, boolean negFace) {
return switch (axis) {
case 0 -> negFace ? Direction.WEST : Direction.EAST;
case 1 -> negFace ? Direction.DOWN : Direction.UP;
default -> negFace ? Direction.NORTH : Direction.SOUTH;
};
}
/** Rotates (x, y, z) about axis 0/1/2 (x/y/z) by the angle with the given cos/sin into out. */
private static void rotate(double[] out, double x, double y, double z, int axis, double cos, double sin) {
switch (axis) {
@@ -388,29 +398,4 @@ public final class ElementIntersector {
}
}
}
/** {@link Element#rotate} into out (same arithmetic, without allocating). */
private static void rotate(double[] out, double x, double y, double z, int xSteps, int ySteps, boolean point) {
double c = point ? 0.5 : 0;
double dx = x - c, dy = y - c, dz = z - c;
for (int i = 0; i < xSteps; i++) {
double ny = dz, nz = -dy; // (dy, dz) -> (dz, -dy)
dy = ny;
dz = nz;
}
for (int i = 0; i < ySteps; i++) {
double nx = -dz, nz = dx; // (dx, dz) -> (-dz, dx)
dx = nx;
dz = nz;
}
out[0] = dx + c;
out[1] = dy + c;
out[2] = dz + c;
}
/** {@link Element#unrotate} into out (same arithmetic, without allocating). */
private static void unrotate(double[] out, double x, double y, double z, int xSteps, int ySteps, boolean point) {
rotate(out, x, y, z, 0, (4 - ySteps) % 4, point);
rotate(out, out[0], out[1], out[2], (4 - xSteps) % 4, 0, point);
}
}
@@ -212,7 +212,7 @@ public final class FluidRenderer {
int mask = 0;
if (model.elements.isEmpty()) return 0;
for (Direction d : Direction.values()) {
int axis = d.nx != 0 ? 0 : d.ny != 0 ? 1 : 2;
int axis = d.axis;
int a1 = axis == 0 ? 1 : 0, a2 = axis == 2 ? 1 : 2;
boolean positive = d.nx + d.ny + d.nz > 0;
boolean covered = true;
@@ -83,11 +83,11 @@ public final class BiomeClimate {
}
}
public static final int DEFAULT_WATER = 0x3F76E4;
private static final int DEFAULT_WATER = 0x3F76E4;
// Vanilla 26.3 EnvironmentAttributes defaults (no dimension type sets them).
public static final int DEFAULT_WATER_FOG_COLOR = 0x050533;
public static final double DEFAULT_WATER_FOG_START = -8;
public static final double DEFAULT_WATER_FOG_END = 96;
private static final int DEFAULT_WATER_FOG_COLOR = 0x050533;
private static final double DEFAULT_WATER_FOG_START = -8;
private static final double DEFAULT_WATER_FOG_END = 96;
public static final Climate DEFAULT = new Climate(0.8, 0.4, DEFAULT_WATER, NONE, NONE, NONE, null);
private static final String DATA_PATH = "data/minecraft/worldgen/biome/";
@@ -189,7 +189,7 @@ public final class BiomeClimate {
public static Climate forKey(String biomePath) {
Optional<Climate> loaded = LOADED.computeIfAbsent(biomePath, BiomeClimate::loadFromServerData);
return loaded.orElseGet(() -> FALLBACK.getOrDefault(biomePath, DEFAULT));
return loaded.isPresent() ? loaded.get() : FALLBACK.getOrDefault(biomePath, DEFAULT);
}
private static Optional<Climate> loadFromServerData(String biomePath) {
@@ -247,8 +247,8 @@ public final class BiomeClimate {
}
/** Colors are either "#rrggbb" strings or packed integers, depending on the data version. */
private static int color(JsonObject effects, String key, int fallback) {
JsonElement el = effects.get(key);
private static int color(JsonObject obj, String key, int fallback) {
JsonElement el = obj.get(key);
if (el == null || !el.isJsonPrimitive()) return fallback;
if (el.getAsJsonPrimitive().isNumber()) return el.getAsInt() & 0xFFFFFF;
String s = el.getAsString().trim();
@@ -6,10 +6,7 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.block.Biome;
/**
* Computes per-biome grass/foliage tint colors by sampling the resource pack's colormaps using the
* vanilla temperature/downfall formula, plus a per-biome water color. Results are cached per biome.
*/
/** Per-biome grass/foliage/water tints: explicit biome colours or the pack's colormaps, cached per biome. */
public final class BiomeTintProvider {
private final int[][] grassMap;
@@ -29,7 +26,7 @@ public final class BiomeTintProvider {
return cache.computeIfAbsent(keyOf(biome), this::compute);
}
/** The biome's climate/effects/attribute data (water fog etc.), see {@link BiomeClimate}. */
/** The biome's climate/effects/attribute data (water fog etc.). */
public BiomeClimate.Climate climate(Biome biome) {
return BiomeClimate.forKey(keyOf(biome));
}
@@ -44,18 +41,11 @@ public final class BiomeTintProvider {
private BiomeTint compute(String key) {
BiomeClimate.Climate climate = BiomeClimate.forKey(key);
// Explicit biome colors (biome "effects") win over the colormap, like in vanilla.
int grass = climate.grass() != BiomeClimate.NONE
? 0xFF000000 | climate.grass()
: sample(grassMap, climate.temperature(), climate.downfall(), 0xFF91BD59);
int foliage = climate.foliage() != BiomeClimate.NONE
? 0xFF000000 | climate.foliage()
: sample(foliageMap, climate.temperature(), climate.downfall(), 0xFF77AB2F);
int dry = climate.dryFoliage() != BiomeClimate.NONE
? 0xFF000000 | climate.dryFoliage()
: sample(dryFoliageMap, climate.temperature(), climate.downfall(), 0xFFA9A05B);
int grass = channel(climate.grass(), grassMap, climate, 0xFF91BD59);
int foliage = channel(climate.foliage(), foliageMap, climate, 0xFF77AB2F);
int dry = channel(climate.dryFoliage(), dryFoliageMap, climate, 0xFFA9A05B);
// Vanilla grass_color_modifier (applies to grass only).
// Vanilla grass_color_modifier (grass only).
if ("swamp".equals(climate.grassModifier())) {
// Vanilla picks 0x4C763C/0x6A7039 via position noise; use the dominant color.
grass = 0xFF6A7039;
@@ -65,11 +55,16 @@ public final class BiomeTintProvider {
return new BiomeTint(grass, foliage, dry, 0xFF000000 | climate.water());
}
/** An explicit biome colour wins over the colormap, like in vanilla. */
private static int channel(int explicit, int[][] colormap, BiomeClimate.Climate climate, int fallback) {
return explicit != BiomeClimate.NONE ? 0xFF000000 | explicit : sample(colormap, climate, fallback);
}
/** Vanilla colormap lookup: x = (1-temp)*255, y = (1-downfall*temp)*255. */
private int sample(int[][] colormap, double temperature, double downfall, int fallback) {
private static int sample(int[][] colormap, BiomeClimate.Climate climate, int fallback) {
if (colormap == null || colormap.length == 0) return fallback;
double temp = Math.clamp(temperature, 0, 1);
double down = Math.clamp(downfall, 0, 1) * temp;
double temp = Math.clamp(climate.temperature(), 0, 1);
double down = Math.clamp(climate.downfall(), 0, 1) * temp;
int x = (int) ((1.0 - temp) * 255.0);
int y = (int) ((1.0 - down) * 255.0);
int h = colormap.length;
@@ -1,11 +1,11 @@
package eu.mhsl.minecraft.pixelpics.render.tint;
import java.util.Locale;
import org.bukkit.Material;
import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.type.RedstoneWire;
/**
* Maps a tinted face (material + tintindex) to the concrete tint color, choosing between the
* biome-driven channels and a handful of vanilla constants.
*/
/** Maps a tinted face (material + tintindex) to its tint colour: a biome channel or a vanilla constant. */
public final class TintResolver {
private static final int BIRCH = 0xFF80A755;
@@ -13,51 +13,56 @@ public final class TintResolver {
private static final int LILY_PAD = 0xFF208030;
private static final int STEM = 0xFF60A017;
private enum Channel {
BIRCH,
SPRUCE,
FOLIAGE,
LILY_PAD,
WATER,
REDSTONE,
STEM,
GRASS
}
// Per Material.ordinal(), filled lazily (idempotent, so the benign race is harmless); called for every tinted hit.
private static final Channel[] CHANNELS = new Channel[Material.values().length];
private TintResolver() {}
/** Returns the ARGB tint to multiply with, or {@code -1} when the face should not be tinted. */
public static int resolve(BlockData data, int tintIndex, BiomeTint biomeTint) {
if (tintIndex < 0) return -1;
String name = data.getMaterial().name().toLowerCase();
if (name.equals("birch_leaves")) return BIRCH;
if (name.equals("spruce_leaves")) return SPRUCE;
if (name.endsWith("leaves") || name.equals("vine")) return biomeTint.foliage();
if (name.equals("lily_pad")) return LILY_PAD;
if (name.equals("water") || name.equals("water_cauldron") || name.equals("bubble_column")) {
return biomeTint.water();
}
if (name.equals("redstone_wire")) return redstone(data);
if (name.endsWith("stem")) return STEM;
// grass_block (top/overlay), short_grass, tall_grass, fern, large_fern, sugar_cane, ...
if (name.contains("grass")
|| name.equals("fern")
|| name.equals("large_fern")
|| name.equals("sugar_cane")
|| name.equals("potted_fern")) {
return biomeTint.grass();
}
// Default for unknown tinted faces: grass channel (the most common tintindex 0 use).
return biomeTint.grass();
Material material = data.getMaterial();
Channel channel = CHANNELS[material.ordinal()];
if (channel == null)
CHANNELS[material.ordinal()] = channel = channel(material.name().toLowerCase(Locale.ROOT));
return switch (channel) {
case BIRCH -> BIRCH;
case SPRUCE -> SPRUCE;
case FOLIAGE -> biomeTint.foliage();
case LILY_PAD -> LILY_PAD;
case WATER -> biomeTint.water();
case REDSTONE -> redstone(data);
case STEM -> STEM;
case GRASS -> biomeTint.grass();
};
}
private static Channel channel(String name) {
if (name.equals("birch_leaves")) return Channel.BIRCH;
if (name.equals("spruce_leaves")) return Channel.SPRUCE;
if (name.endsWith("leaves") || name.equals("vine")) return Channel.FOLIAGE;
if (name.equals("lily_pad")) return Channel.LILY_PAD;
if (name.equals("water") || name.equals("water_cauldron") || name.equals("bubble_column")) return Channel.WATER;
if (name.equals("redstone_wire")) return Channel.REDSTONE;
if (name.endsWith("stem")) return Channel.STEM;
// grass_block, short/tall grass, ferns, sugar cane, … and any other tinted face (tintindex 0 is mostly grass).
return Channel.GRASS;
}
/** Approximation of the redstone wire colour: red rising with the power level. */
private static int redstone(BlockData data) {
int power = 0;
String s = data.getAsString(false);
int idx = s.indexOf("power=");
if (idx >= 0) {
int end = idx + 6;
int e = end;
while (e < s.length() && Character.isDigit(s.charAt(e))) e++;
try {
power = Integer.parseInt(s.substring(end, e));
} catch (NumberFormatException ignored) {
}
}
int r = Math.min(255, 75 + power * 12);
return 0xFF000000 | (r << 16);
int power = data instanceof RedstoneWire wire ? wire.getPower() : 0;
return 0xFF000000 | (Math.min(255, 75 + power * 12) << 16);
}
}
@@ -3,9 +3,7 @@ package eu.mhsl.minecraft.pixelpics.render.util;
import org.bukkit.Color;
import org.bukkit.DyeColor;
/**
* Helpers for packed ARGB integer colors.
*/
/** Helpers for packed ARGB colours. */
public final class ColorUtil {
private ColorUtil() {}
@@ -37,11 +35,8 @@ public final class ColorUtil {
return argb(0xFF, c.getRed(), c.getGreen(), c.getBlue());
}
/**
* The vanilla sign-text colour for a dye (opaque ARGB), from Mojang's {@code DyeColor.getTextColor()}
* table (the firework/text colours, NOT the cloth colours). Null = black (the default sign ink).
*/
public static int signTextColor(DyeColor dye) {
/** Vanilla {@code DyeColor.getTextColor()} (not the cloth colour) as opaque ARGB; null = black (default ink). */
private static int signTextColor(DyeColor dye) {
int rgb =
switch (dye == null ? DyeColor.BLACK : dye) {
case WHITE -> 0xF9FFFE;
@@ -64,23 +59,16 @@ public final class ColorUtil {
return 0xFF000000 | rgb;
}
/**
* The fill colour for sign text: glowing text uses the full dye colour; non-glowing text is the dye
* colour darkened to 40% (matching vanilla {@code SignRenderer}, which is why normal ink looks dim).
*/
/** Sign text fill (vanilla SignRenderer): the dye's text colour, darkened to 40 % unless glowing. */
public static int signFillArgb(DyeColor dye, boolean glowing) {
int base = signTextColor(dye);
return glowing ? base : (0xFF000000 | (shade(base, 0.4) & 0xFFFFFF));
return glowing ? base : shade(base, 0.4);
}
/**
* The 8-directional outline colour drawn around glowing sign text (vanilla {@code getDarkColor}):
* the dye colour darkened to 40%, except glowing BLACK ink which gets a light cream outline so it
* stays readable.
*/
/** Outline of glowing sign text (vanilla {@code getDarkColor}): text colour at 40 %, black ink a light cream. */
public static int signOutlineArgb(DyeColor dye) {
if ((dye == null ? DyeColor.BLACK : dye) == DyeColor.BLACK) return 0xFFF0EBCC;
return 0xFF000000 | (shade(signTextColor(dye), 0.4) & 0xFFFFFF);
if (dye == null || dye == DyeColor.BLACK) return 0xFFF0EBCC;
return shade(signTextColor(dye), 0.4);
}
/** Multiplies the RGB channels of {@code base} by {@code tint} (per-channel, 0..255), keeping base alpha. */
@@ -100,7 +88,7 @@ public final class ColorUtil {
return alpha(argb) > ALPHA_CUTOUT;
}
/** Scales the RGB channels by {@code factor} (0..1), keeping alpha. Used for directional face shading. */
/** Scales the RGB channels by {@code factor}, keeping alpha. */
public static int shade(int argb, double factor) {
int a = alpha(argb);
int r = clamp((int) (red(argb) * factor));
@@ -116,14 +104,12 @@ public final class ColorUtil {
| clamp(blue(dst) + blue(src));
}
private static int clamp(int v) {
/** Clamps a channel value to 0..255. */
public static int clamp(int v) {
return v < 0 ? 0 : Math.min(v, 255);
}
/**
* Normalized weighted average of two RGB colors (alpha ignored, result opaque-RGB in the low 24 bits).
* Weights need not sum to 1; they are normalized by their total.
*/
/** Weighted average of two RGB colours (weights normalised by their sum); alpha ignored, result RGB only. */
public static int mix(int rgbA, int rgbB, double weightA, double weightB) {
double total = weightA + weightB;
int r = (int) ((red(rgbA) * weightA + red(rgbB) * weightB) / total);
@@ -132,8 +118,6 @@ public final class ColorUtil {
return (r << 16) | (g << 8) | b;
}
// --- Gamma-correct (linear-light) averaging ---
private static final float[] SRGB_TO_LINEAR = new float[256];
static {
@@ -147,7 +131,7 @@ public final class ColorUtil {
return SRGB_TO_LINEAR[channel & 0xFF];
}
public static int toSrgb(double linear) {
private static int toSrgb(double linear) {
double c = linear <= 0.0031308 ? linear * 12.92 : 1.055 * Math.pow(linear, 1 / 2.4) - 0.055;
return clamp((int) Math.round(c * 255.0));
}
@@ -1,9 +1,8 @@
package eu.mhsl.minecraft.pixelpics.render.util;
import org.bukkit.block.BlockFace;
import org.bukkit.util.Vector;
public class MathUtil {
public final class MathUtil {
private MathUtil() {}
@@ -11,8 +10,4 @@ public class MathUtil {
public static Vector reflectVector(Vector direction, Vector normal) {
return direction.clone().subtract(normal.clone().multiply(2 * direction.dot(normal)));
}
public static Vector toVector(BlockFace face) {
return new Vector(face.getModX(), face.getModY(), face.getModZ());
}
}