Compare commits

...

2 Commits

32 changed files with 509 additions and 414 deletions
@@ -27,9 +27,4 @@ public final class AssetPaths {
public static String textureMeta(ResourceLocation id) {
return texture(id) + ".mcmeta";
}
/** {@code assets/minecraft/textures/colormap/<name>.png}. */
public static String colormap(String name) {
return String.format("assets/%s/textures/colormap/%s.png", ResourceLocation.DEFAULT_NAMESPACE, name.toLowerCase());
}
}
@@ -19,10 +19,6 @@ public final class AssetReader {
this.pack = pack;
}
public ResourcePack pack() {
return pack;
}
public <T> Optional<T> readJson(String path, Class<T> type) {
return pack.read(path).flatMap(bytes -> {
try {
@@ -52,8 +52,7 @@ public final class BlockModelRegistry {
List<Variant> variants = blockStateResolver.resolve(data);
List<Element> elements = new java.util.ArrayList<>();
long ar = 0, ag = 0, ab = 0;
int acount = 0;
AverageColor.Accumulator avgColor = new AverageColor.Accumulator();
FlatModel lastFlat = null;
for (Variant variant : variants) {
@@ -62,17 +61,12 @@ public final class BlockModelRegistry {
ModelBaker.BakedGeometry baked = baker.bake(flat, variant);
elements.addAll(baked.elements());
if (baked.hasGeometry()) {
int c = baked.averageColor();
ar += (c >> 16) & 0xFF;
ag += (c >> 8) & 0xFF;
ab += c & 0xFF;
acount++;
avgColor.add(baked.averageColor());
}
}
if (!elements.isEmpty()) {
int avg = 0xFF000000 | (((int) (ar / acount)) << 16) | (((int) (ag / acount)) << 8) | ((int) (ab / acount));
return new ResolvedModel(elements, avg, 0, 0, false, true);
return new ResolvedModel(elements, avgColor.average(0xFF7F7F7F), 0, 0, false, true);
}
int avg = fallbackColor(lastFlat);
@@ -172,11 +166,7 @@ public final class BlockModelRegistry {
}
Face[] faces = new Face[6];
for (Direction d : Direction.values()) {
double[] uv = switch (d) {
case UP, DOWN -> new double[]{0, 0, 1, 1};
default -> new double[]{0, 0, 1, 1};
};
faces[d.ordinal()] = new Face(tex, uv[0], uv[1], uv[2], uv[3], 0, tintIndex);
faces[d.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, tintIndex);
}
Element cube = new Element(new double[]{0, 0, 0}, new double[]{1, 1, 1}, faces, null, -1, 0, false);
int avg = AverageColor.of(tex);
@@ -0,0 +1,30 @@
package eu.mhsl.minecraft.pixelpics.assets;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* Thread-safe cache of ARGB pixel grids keyed by {@code K}. Subclasses implement {@link #load} to
* produce the grid (or {@code null} on failure); a failure is cached as a sentinel so it is not
* retried, and {@link #get} returns empty for it. The grid is indexed {@code [y][x]}, top-left origin.
*/
public abstract class ImageCache<K> {
private static final int[][] MISSING = new int[0][0];
private final Map<K, int[][]> cache = new ConcurrentHashMap<>();
/** Loads the pixel grid for {@code key}, or returns {@code null} if it cannot be loaded. */
protected abstract int[][] load(K key);
public Optional<int[][]> get(K key) {
int[][] result = cache.computeIfAbsent(key, this::loadOrSentinel);
return result == MISSING ? Optional.empty() : Optional.of(result);
}
private int[][] loadOrSentinel(K key) {
int[][] loaded = load(key);
return loaded == null ? MISSING : loaded;
}
}
@@ -36,7 +36,7 @@ public final class ModelBaker {
int ySteps = ((variant.y() / 90) % 4 + 4) % 4;
List<Element> baked = new ArrayList<>();
long ar = 0, ag = 0, ab = 0, acount = 0;
AverageColor.Accumulator avgColor = new AverageColor.Accumulator();
for (ModelFileDto.ElementDto dto : model.elements()) {
if (dto.from == null || dto.to == null) continue;
@@ -76,9 +76,9 @@ public final class ModelBaker {
faces = rotateFacesX(faces);
if (rotAxis >= 0) {
rotOrigin = rotatePointX(rotOrigin);
int[] na = rotateAxisX(rotAxis, rotAngle);
rotAxis = na[0];
rotAngle = na[1] == 0 ? rotAngle : -rotAngle;
AxisRotation na = rotateAxisX(rotAxis);
rotAxis = na.axis();
if (na.flip()) rotAngle = -rotAngle;
}
}
for (int i = 0; i < ySteps; i++) {
@@ -88,9 +88,9 @@ public final class ModelBaker {
faces = rotateFacesY(faces);
if (rotAxis >= 0) {
rotOrigin = rotatePointY(rotOrigin);
int[] na = rotateAxisY(rotAxis, rotAngle);
rotAxis = na[0];
rotAngle = na[1] == 0 ? rotAngle : -rotAngle;
AxisRotation na = rotateAxisY(rotAxis);
rotAxis = na.axis();
if (na.flip()) rotAngle = -rotAngle;
}
}
@@ -99,17 +99,11 @@ public final class ModelBaker {
// Accumulate average color from the element's face textures.
for (Face f : faces) {
if (f == null) continue;
int c = AverageColor.of(f.texture);
ar += (c >> 16) & 0xFF;
ag += (c >> 8) & 0xFF;
ab += c & 0xFF;
acount++;
avgColor.add(AverageColor.of(f.texture));
}
}
int avg = acount == 0 ? 0xFF7F7F7F
: 0xFF000000 | (((int) (ar / acount)) << 16) | (((int) (ag / acount)) << 8) | ((int) (ab / acount));
return new BakedGeometry(baked, avg, !baked.isEmpty());
return new BakedGeometry(baked, avgColor.average(0xFF7F7F7F), !baked.isEmpty());
}
private Face buildFace(Direction dir, ModelFileDto.FaceDto dto, double[] from, double[] to,
@@ -216,22 +210,24 @@ public final class ModelBaker {
}
// Rotating an element's own rotation axis under a 90-degree block rotation.
// Returns {newAxisIndex, flipFlag(0/1)}; flip indicates the angle sign should invert.
private int[] rotateAxisY(int axis, double angle) {
// {@code flip} indicates the angle sign should invert.
private record AxisRotation(int axis, boolean flip) {}
private AxisRotation rotateAxisY(int axis) {
// Y rotation maps x<->z; the y axis is unchanged.
return switch (axis) {
case 0 -> new int[]{2, 1}; // x -> z
case 2 -> new int[]{0, 0}; // z -> x
default -> new int[]{axis, 0};
case 0 -> new AxisRotation(2, true); // x -> z
case 2 -> new AxisRotation(0, false); // z -> x
default -> new AxisRotation(axis, false);
};
}
private int[] rotateAxisX(int axis, double angle) {
private AxisRotation rotateAxisX(int axis) {
// X rotation maps y<->z; the x axis is unchanged.
return switch (axis) {
case 1 -> new int[]{2, 1}; // y -> z
case 2 -> new int[]{1, 0}; // z -> y
default -> new int[]{axis, 0};
case 1 -> new AxisRotation(2, true); // y -> z
case 2 -> new AxisRotation(1, false); // z -> y
default -> new AxisRotation(axis, false);
};
}
}
@@ -6,27 +6,23 @@ import java.io.InputStream;
import java.net.URI;
import java.net.URL;
import java.net.URLConnection;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* Downloads and caches player skin textures (by URL) as ARGB pixel grids. Legacy 64x32 skins are
* converted to the modern 64x64 layout (so the model's overlay/second-layer bones map correctly).
* Downloads happen off the main thread (from the entity baking step) and are cached.
*/
public final class SkinCache {
private final Map<String, int[][]> cache = new ConcurrentHashMap<>();
private static final int[][] FAILED = new int[0][0];
public final class SkinCache extends ImageCache<String> {
@Override
public Optional<int[][]> get(String url) {
if (url == null || url.isEmpty()) return Optional.empty();
int[][] result = cache.computeIfAbsent(url, this::download);
return result == FAILED ? Optional.empty() : Optional.of(result);
return super.get(url);
}
private int[][] download(String url) {
@Override
protected int[][] load(String url) {
try {
URL u = URI.create(url).toURL();
URLConnection conn = u.openConnection();
@@ -37,10 +33,10 @@ public final class SkinCache {
try (InputStream in = conn.getInputStream()) {
img = ImageIO.read(in);
}
if (img == null) return FAILED;
if (img == null) return null;
return toModern(img);
} catch (Exception e) {
return FAILED;
return null;
}
}
@@ -3,9 +3,6 @@ package eu.mhsl.minecraft.pixelpics.assets;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* Loads and caches block textures as raw ARGB pixel grids. Textures are stored unflipped (vanilla
@@ -16,35 +13,25 @@ import java.util.concurrent.ConcurrentHashMap;
* without one (e.g. a 64×128 entity texture like the witch/strider) is a real texture, not an animation,
* and must be loaded in full.
*/
public final class TextureCache {
public final class TextureCache extends ImageCache<ResourceLocation> {
private final ResourcePack pack;
private final Map<ResourceLocation, int[][]> cache = new ConcurrentHashMap<>();
private static final int[][] MISSING = new int[0][0];
public TextureCache(ResourcePack pack) {
this.pack = pack;
}
/**
* Returns the texture pixels for the given id, or empty if it cannot be loaded.
* The grid is indexed {@code [y][x]} with {@code [0][0]} at the top-left.
*/
public Optional<int[][]> get(ResourceLocation textureId) {
int[][] result = cache.computeIfAbsent(textureId, this::load);
return result == MISSING ? Optional.empty() : Optional.of(result);
}
private int[][] load(ResourceLocation id) {
Optional<byte[]> bytes = pack.read(AssetPaths.texture(id));
if (bytes.isEmpty()) return MISSING;
@Override
protected int[][] load(ResourceLocation id) {
var bytes = pack.read(AssetPaths.texture(id));
if (bytes.isEmpty()) return null;
BufferedImage img;
try {
img = ImageIO.read(new ByteArrayInputStream(bytes.get()));
} catch (Exception e) {
return MISSING;
return null;
}
if (img == null) return MISSING;
if (img == null) return null;
int width = img.getWidth();
int height = img.getHeight();
@@ -25,4 +25,23 @@ public final class AverageColor {
if (count == 0) return 0xFF7F7F7F;
return ColorUtil.argb(0xFF, (int) (r / count), (int) (g / count), (int) (b / count));
}
/** Mutable accumulator that averages a set of already-opaque ARGB colors (e.g. per-face/per-variant). */
public static final class Accumulator {
private long r, g, b;
private int count;
public void add(int argb) {
r += ColorUtil.red(argb);
g += ColorUtil.green(argb);
b += ColorUtil.blue(argb);
count++;
}
/** Opaque average of the added colors, or {@code fallback} if none were added. */
public int average(int fallback) {
if (count == 0) return fallback;
return ColorUtil.argb(0xFF, (int) (r / count), (int) (g / count), (int) (b / count));
}
}
}
@@ -13,7 +13,7 @@ import java.util.List;
* 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 {
public final class DecorationBaker implements EntityBaker<DecorationState> {
private static final double MIN_THICKNESS = 0.0625; // 1px, so the slab test never degenerates
@@ -23,6 +23,7 @@ public final class DecorationBaker {
this.textures = textures;
}
@Override
public RenderedEntity bake(DecorationState s) {
return s.kind() == DecorationState.Kind.PAINTING ? bakePainting(s) : bakeItemFrame(s);
}
@@ -41,7 +42,7 @@ public final class DecorationBaker {
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));
return RenderedEntity.of(List.of(cube));
}
/**
@@ -78,7 +79,7 @@ public final class DecorationBaker {
cubes.add(new EntityCube(px(-4, -4, 2), px(4, 4, 3), f, toWorld));
}
}
return scene(cubes);
return RenderedEntity.of(cubes);
}
/** A local-space corner in model pixels (1/16 block); z is the outward (front) offset from the wall. */
@@ -153,16 +154,4 @@ public final class DecorationBaker {
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,10 @@
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.
*/
public interface EntityBaker<S> {
RenderedEntity bake(S state);
}
@@ -18,30 +18,22 @@ public final class EntityScene {
private static final double EPS = 1e-7;
private final List<RenderedEntity> entities;
public EntityScene(List<EntityState> states, CemBaker baker) {
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) {
addAll(states, baker);
addAll(blockEntities, beBaker);
addAll(decorations, decoBaker);
}
/** 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) {
if (baker == null) return;
for (S 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() {
@@ -7,16 +7,17 @@ package eu.mhsl.minecraft.pixelpics.render.entity;
public record EntityState(
String typeKey, // e.g. "cow", "zombie", "player"
double x, double y, double z,
float bodyYaw, float headYaw, float pitch,
double vx, double vy, double vz,
float bodyYaw,
boolean baby,
double width, double height,
boolean player, String skinUrl, boolean slim,
String variant, // texture-selecting variant key (e.g. "ashen", "warm", "tabby"), or null
String variant, // texture-selecting variant key (e.g. "ashen", "warm", "tabby"); for villagers the biome type, or null
int tint, // ARGB multiplier for tintable layers (sheep wool); 0 = none
double sizeScale // extra model scale (slime/magma-cube size); 1.0 = default
) {
public double horizontalSpeed() {
return Math.sqrt(vx * vx + vz * vz);
}
}
double sizeScale, // extra model scale (slime/magma-cube size); 1.0 = default
String profession, // villager profession key (e.g. "farmer", "librarian", "none"), or null
int villagerLevel, // villager profession level 1-5 (badge tier); 0 = none/unknown
String markings, // horse coat markings style (e.g. "white", "whitefield", "whitedots", "blackdots"), or null
boolean saddle, // horse/donkey/mule is saddled
boolean chest, // donkey/mule/llama is carrying a chest
String bodyEquip // horse armor material (iron/gold/diamond/leather) OR llama carpet colour OR "trader_llama"; null = none
) {}
@@ -10,29 +10,19 @@ public final class ModelCube {
public final double inflate; // px, expands the box on all sides (overlay layers)
public final double[] uv; // 2, box-UV offset (texels)
public final boolean mirror;
public final double[] rotation; // 3 (degrees), per-cube rotation around pivot
public final double[] pivot; // 3 (px), per-cube rotation pivot
/** Optional modern per-face UV, indexed by {@link Direction#ordinal()}: {u, v, w, h} texels (h/w may be negative for flips). Null = use box-UV. */
public final double[][] faceUv;
public ModelCube(double[] origin, double[] size, double inflate, double[] uv, boolean mirror,
double[] rotation, double[] pivot) {
this(origin, size, inflate, uv, mirror, rotation, pivot, null);
public ModelCube(double[] origin, double[] size, double inflate, double[] uv, boolean mirror) {
this(origin, size, inflate, uv, mirror, null);
}
public ModelCube(double[] origin, double[] size, double inflate, double[] uv, boolean mirror,
double[] rotation, double[] pivot, double[][] faceUv) {
public ModelCube(double[] origin, double[] size, double inflate, double[] uv, boolean mirror, double[][] faceUv) {
this.origin = origin;
this.size = size;
this.inflate = inflate;
this.uv = uv;
this.mirror = mirror;
this.rotation = rotation;
this.pivot = pivot;
this.faceUv = faceUv;
}
public boolean hasRotation() {
return rotation[0] != 0 || rotation[1] != 0 || rotation[2] != 0;
}
}
@@ -13,4 +13,17 @@ public final class RenderedEntity {
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};
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,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.
*/
public final class TextureOps {
private TextureOps() {}
/** A row-by-row copy so callers can tint/overlay without mutating the 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. */
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;
}
}
}
/** Standard 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;
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;
}
}
}
}
@@ -6,8 +6,10 @@ 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.EntityBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity;
import eu.mhsl.minecraft.pixelpics.render.entity.TextureOps;
import java.util.ArrayList;
import java.util.List;
@@ -23,7 +25,7 @@ import java.util.Set;
* 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 {
public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
private static final double SIGN_SCALE = 0.6666667; // vanilla SignRenderer model scale
@@ -38,6 +40,7 @@ public final class BlockEntityBaker {
}
/** Returns the baked block-entity, or null when it has no model/texture (then nothing renders). */
@Override
public RenderedEntity bake(BlockEntityState s) {
List<Layer> layers = layers(s);
if (layers.isEmpty()) return null;
@@ -58,7 +61,7 @@ public final class BlockEntityBaker {
cubes.add(new EntityCube(b.from(), b.to(), b.faces(), placement.mul(b.world())));
}
}
return cubes.isEmpty() ? null : CemGeometry.finish(cubes);
return cubes.isEmpty() ? null : RenderedEntity.of(cubes);
}
/** The CEM model name; for heads it depends on the texture's aspect (64x32 vs square 64x64). */
@@ -193,44 +196,15 @@ public final class BlockEntityBaker {
* 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());
int[][] out = TextureOps.deepCopy(base);
if (s.baseColorArgb() != 0) TextureOps.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);
int[][] dyed = TextureOps.deepCopy(mask);
TextureOps.tint(dyed, pat.colorArgb());
TextureOps.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;
}
}
@@ -6,11 +6,13 @@ import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
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.EntityBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityModels;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.ModelCube;
import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity;
import eu.mhsl.minecraft.pixelpics.render.entity.TextureOps;
import java.util.ArrayList;
import java.util.List;
@@ -22,7 +24,7 @@ import java.util.List;
* + px→block scale + an outer Y-flip (upright); the model is then dropped onto the ground and placed at
* the entity's position/yaw. Calibrated against fox/pig/cow.
*/
public final class CemBaker {
public final class CemBaker implements EntityBaker<EntityState> {
// Parts representing an alternate state (rolled-up, sleeping, …) that must not render in the idle pose.
private static final java.util.Map<String, java.util.Set<String>> HIDDEN_PARTS = java.util.Map.of(
@@ -40,9 +42,19 @@ public final class CemBaker {
this.skins = skins;
}
@Override
public RenderedEntity bake(EntityState s) {
int[][] tex = resolveTexture(s);
CemModelLoader.CemModel model = models.get(EntityModels.cemModel(s.typeKey()));
String cem = EntityModels.cemModel(s.typeKey());
// Same-UV overlays are composited straight into the base texture (no extra geometry -> no ray Z-fighting).
if (s.typeKey().equals("villager") || s.typeKey().equals("zombie_villager")) {
tex = compositeVillager(s, tex);
} else if (cem.equals("horse")) {
tex = compositeHorse(s, tex); // coat markings + horse armor
} else if (cem.equals("llama")) {
tex = compositeLlama(s, tex); // dyed/trader carpet decor
}
CemModelLoader.CemModel model = models.get(cem);
if (model == null || tex == null) return fallbackBox(s, tex);
double sc = (s.baby() ? 0.5 : 1.0) * s.sizeScale();
@@ -50,27 +62,33 @@ public final class CemBaker {
// rotations and handedness; only px->block scaling is applied.
Affine pre = Affine.scale(sc / 16.0);
java.util.Set<String> hidden = HIDDEN_PARTS.getOrDefault(EntityModels.cemModel(s.typeKey()), java.util.Set.of());
java.util.Set<String> hidden = new java.util.HashSet<>(HIDDEN_PARTS.getOrDefault(cem, java.util.Set.of()));
// Donkeys/llamas carry the chest boxes inside the base model; hide them unless a chest is equipped.
if (!s.chest()) {
if (cem.equals("donkey")) { hidden.add("left_chest"); hidden.add("right_chest"); }
else if (cem.equals("llama")) { hidden.add("chest_left"); hidden.add("chest_right"); }
}
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");
int[][] woolTex = textures.get(ResourceLocation.parse("entity/sheep/sheep_wool")).orElse(null);
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) CemGeometry.tint(t, s.tint());
int[][] t = TextureOps.deepCopy(woolTex);
if (s.tint() != 0) TextureOps.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
// left face is transparent in the texture → a see-through hole on the left. Add the mirrored left panel.
if (EntityModels.cemModel(s.typeKey()).equals("guardian")) {
if (cem.equals("guardian")) {
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});
ModelCube mc = new ModelCube(org, size, 0, new double[]{0, 28}, true);
Face[] faces = BoxUv.build(mc, tex, model.texW(), model.texH());
baked.add(new CemGeometry.Baked(org, new double[]{org[0]+size[0], org[1]+size[1], org[2]+size[2]}, faces, pre));
}
// Saddle: an extra inflated layer from the *_saddle CEM model, showing only its saddle-specific parts.
if (s.saddle()) addSaddleLayer(s, cem, model, pre, baked);
if (baked.isEmpty()) return fallbackBox(s, tex);
double minY = Double.MAX_VALUE;
@@ -82,7 +100,7 @@ public final class CemBaker {
List<EntityCube> cubes = new ArrayList<>(baked.size());
for (CemGeometry.Baked b : baked) cubes.add(new EntityCube(b.from(), b.to(), b.faces(), place.mul(b.world())));
return CemGeometry.finish(cubes);
return RenderedEntity.of(cubes);
}
// --- texture resolution (player skin, dyed sheep wool, variant candidates) ---
@@ -101,20 +119,79 @@ public final class CemBaker {
return null;
}
// --- villager texture compositing (biome type + profession + level badge over the base body) ---
// Profession-level (1-5) -> badge texture, like Mojang's VillagerProfessionLayer.
private static final String[] LEVEL_BADGE = {"stone", "iron", "gold", "emerald", "diamond"};
private int[][] compositeVillager(EntityState s, int[][] base) {
if (base == null) return null;
String folder = s.typeKey(); // "villager" or "zombie_villager"
int[][] out = TextureOps.deepCopy(base);
// Biome-type clothing overlay (always, if known).
if (s.variant() != null) overlayIfPresent(out, "entity/" + folder + "/type/" + s.variant());
String prof = s.profession();
if (prof != null && !prof.equals("none")) {
overlayIfPresent(out, "entity/" + folder + "/profession/" + prof);
// Level badge: only for real professions (not the work-less nitwit) and a known level.
if (!prof.equals("nitwit") && s.villagerLevel() >= 1) {
int lvl = Math.min(5, s.villagerLevel());
overlayIfPresent(out, "entity/" + folder + "/profession_level/" + LEVEL_BADGE[lvl - 1]);
}
}
return out;
}
/** Alpha-composite an overlay texture onto {@code dst} in place, if it exists and matches dst's size. */
private void overlayIfPresent(int[][] dst, String path) {
int[][] o = textures.get(ResourceLocation.parse(path)).orElse(null);
if (o == null || o.length != dst.length || o[0].length != dst[0].length) return; // missing or HD-mismatch
TextureOps.overlay(dst, o);
}
// --- horse / llama equipment compositing (same UV layout as the base model) ---
private int[][] compositeHorse(EntityState s, int[][] base) {
if (base == null || (s.markings() == null && s.bodyEquip() == null)) return base;
int[][] out = TextureOps.deepCopy(base);
if (s.markings() != null) overlayIfPresent(out, "entity/horse/horse_markings_" + s.markings());
if (s.bodyEquip() != null) overlayIfPresent(out, "entity/equipment/horse_body/" + s.bodyEquip());
return out;
}
private int[][] compositeLlama(EntityState s, int[][] base) {
if (base == null || s.bodyEquip() == null) return base;
int[][] out = TextureOps.deepCopy(base);
overlayIfPresent(out, "entity/equipment/llama_body/" + s.bodyEquip());
return out;
}
/** Bake the saddle as a separate inflated layer; only the saddle-specific parts (those not in the base model). */
private void addSaddleLayer(EntityState s, String cem, CemModelLoader.CemModel base, Affine pre, List<CemGeometry.Baked> baked) {
String saddleModel = cem.equals("donkey") ? "donkey_saddle" : (cem.equals("horse") ? "horse_saddle" : null);
if (saddleModel == null) return; // llamas and other mounts have no saddle model
CemModelLoader.CemModel sm = models.get(saddleModel);
if (sm == null) return;
int[][] saddleTex = textures.get(ResourceLocation.parse("entity/equipment/" + s.typeKey() + "_saddle/saddle")).orElse(null);
if (saddleTex == null) return;
// Show only the saddle-specific parts: hide every part the base body model also defines.
java.util.Set<String> hideBase = new java.util.HashSet<>();
for (CemModelLoader.CemPart p : base.parts()) hideBase.add(p.name());
baked.addAll(CemGeometry.bakeModel(sm, saddleTex, pre, hideBase));
}
private RenderedEntity fallbackBox(EntityState s, int[][] tex) {
double w = Math.max(0.3, s.width()) * 16 * s.sizeScale(), h = Math.max(0.3, s.height()) * 16 * s.sizeScale();
double[] from = {-w / 2, 0, -w / 2};
double[] to = {w / 2, h, w / 2};
int[][] t = tex != null ? tex : flat(0xFF8C8C8C);
ModelCube box = new ModelCube(new double[]{-w/2, 0, -w/2}, new double[]{w, h, w}, 0,
new double[]{0, 0}, false, new double[]{0, 0, 0}, new double[]{0, 0, 0});
new double[]{0, 0}, false);
Face[] faces = BoxUv.build(box, t, Math.max(64, (int) (2 * (w + w))), Math.max(64, (int) (2 * (w + h))));
Affine place = Affine.translation(s.x(), s.y(), s.z())
.mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw())))
.mul(Affine.scale(1.0 / 16.0));
List<EntityCube> cubes = new ArrayList<>();
cubes.add(new EntityCube(from, to, faces, place));
return CemGeometry.finish(cubes);
return RenderedEntity.of(cubes);
}
private static int[][] flat(int argb) {
@@ -3,9 +3,7 @@ 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;
@@ -37,24 +35,15 @@ final class CemGeometry {
/** 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);
return bakeModel(model, tex, pre, hidden, 0, 0, false);
}
/**
* 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.
* Bake all parts with an explicit texture size for UV normalisation ({@code texW}/{@code texH} of 0
* fall back to the model's declared size; use a real size when the applied texture differs from the
* model's declared {@code textureSize}, e.g. a 16x16 sherd on a 32x32-authored pot face). When
* {@code ignoreFaceUv} is set, box-UV is forced even if the model declares per-face UV — used for a
* standalone texture (e.g. the conduit cage) whose layout is box-UV, not the combined-sheet layout.
*/
static List<Baked> bakeModel(CemModelLoader.CemModel model, int[][] tex, Affine pre, Set<String> hidden,
int texW, int texH, boolean ignoreFaceUv) {
@@ -91,7 +80,7 @@ final class CemGeometry {
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);
ModelCube mc = new ModelCube(org, b.size(), inf, b.uv(), b.mirror(), faceUv);
Face[] faces = BoxUv.build(mc, tex, texW, texH);
out.add(new Baked(from, to, faces, world));
}
@@ -103,30 +92,4 @@ final class CemGeometry {
}
}
/** 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);
}
}
@@ -95,7 +95,7 @@ public final class SnapshotRaytracer {
int color = shadeAndTint(hit, data, snapshot, bx, by, bz);
if (!reflected && model.reflection > 0 && depth > 0) {
Vector reflectDir = MathUtil.reflectVector(origin, direction, hit.point(), hit.normal());
Vector reflectDir = MathUtil.reflectVector(direction, hit.normal());
Vector reflectStart = hit.point().clone().add(hit.normal().clone().multiply(1e-3));
reflectionColor = trace(snapshot, reflectStart, reflectDir, sky, scene, depth - 1);
reflectionFactor = model.reflection;
@@ -143,7 +143,7 @@ public final class SnapshotRaytracer {
}
if (transparencyStart != null) {
baseColor = MathUtil.weightedColorSum(
baseColor = ColorUtil.mix(
baseColor,
transparencyColor,
transparencyFactor,
@@ -151,13 +151,13 @@ public final class SnapshotRaytracer {
* (1 + transparencyStart.distance(finalPoint == null ? transparencyStart : finalPoint) / 5.0));
}
if (reflected) {
baseColor = MathUtil.weightedColorSum(baseColor, reflectionColor, 1 - reflectionFactor, reflectionFactor);
baseColor = ColorUtil.mix(baseColor, reflectionColor, 1 - reflectionFactor, reflectionFactor);
}
// Distance fog (atmospheric perspective): fade distant geometry toward the sky color.
if (finalPoint != null) {
double fog = fogFactor(origin.distance(finalPoint));
if (fog > 0) baseColor = MathUtil.weightedColorSum(baseColor, skyColor, 1 - fog, fog);
if (fog > 0) baseColor = ColorUtil.mix(baseColor, skyColor, 1 - fog, fog);
}
return baseColor & 0xFFFFFF;
@@ -2,19 +2,15 @@ package eu.mhsl.minecraft.pixelpics.render.render;
import com.google.common.base.Preconditions;
import java.util.*;
public final class Resolution {
private final int width;
private final int height;
public Resolution(Pixels pixels, AspectRatio aspectRatio) {
Preconditions.checkNotNull(pixels);
Preconditions.checkNotNull(aspectRatio);
this.height = pixels.height;
this.width = (int) Math.round(pixels.height * aspectRatio.ratio);
this(
(int) Math.round(Preconditions.checkNotNull(pixels).height * Preconditions.checkNotNull(aspectRatio).ratio),
pixels.height);
}
public Resolution(int width, int height) {
@@ -34,29 +30,25 @@ public final class Resolution {
}
public enum Pixels {
_128P(128, "128p"),
_256P(256, "256p");
_128P(128),
_256P(256);
private final int height;
private final List<String> aliases;
Pixels(int height, String... aliases) {
Pixels(int height) {
this.height = height;
this.aliases = Collections.unmodifiableList(Arrays.asList(aliases));
}
}
public enum AspectRatio {
_1_1(1, "1:1"),
_2_1(2, "2:1"),
_3_2(3 / 2.0, "3:2");
_1_1(1),
_2_1(2),
_3_2(3 / 2.0);
private final double ratio;
private final List<String> aliases;
AspectRatio(double ratio, String... aliases) {
AspectRatio(double ratio) {
this.ratio = ratio;
this.aliases = Collections.unmodifiableList(Arrays.asList(aliases));
}
}
}
}
@@ -54,16 +54,16 @@ public final class SkyRenderer {
double dayFactor = smoothstep(-0.20, 0.25, sunY);
// Base vertical gradient, blended day<->night.
double up = clamp01(dy);
double up = Math.clamp(dy, 0, 1);
int dayColor = lerp(DAY_HORIZON, DAY_ZENITH, up);
int nightColor = lerp(NIGHT_HORIZON, NIGHT_ZENITH, up);
int color = lerp(nightColor, dayColor, dayFactor);
// Sunrise/sunset: a full-sky warm wash (orange at the horizon -> red -> purple at the zenith),
// strongest while the sun is near the horizon and warmer toward its azimuth. Matches vanilla.
double twilight = clamp01(1 - Math.abs(sunY) / 0.45);
double twilight = Math.clamp(1 - Math.abs(sunY) / 0.45, 0, 1);
if (twilight > 0) {
double az = clamp01(dx * Math.signum(sunX) * 0.5 + 0.5); // 1 toward sun .. 0 away
double az = Math.clamp(dx * Math.signum(sunX) * 0.5 + 0.5, 0, 1); // 1 toward sun .. 0 away
int grad = up < 0.40
? lerp(SUNSET_ORANGE, SUNSET_RED, up / 0.40)
: lerp(SUNSET_RED, TWI_PURPLE, (up - 0.40) / 0.60);
@@ -87,7 +87,7 @@ public final class SkyRenderer {
if (sunY > -0.20) {
double cosSun = dx * sunX + dy * sunY;
if (cosSun > 0) {
double bloom = Math.pow(clamp01(cosSun), 16) * clamp01(sunY + 0.3);
double bloom = Math.pow(Math.clamp(cosSun, 0, 1), 16) * Math.clamp(sunY + 0.3, 0, 1);
color = lerp(color, rgb(255, 235, 190), bloom * 0.7);
}
}
@@ -112,7 +112,7 @@ public final class SkyRenderer {
if (coverage > 0) {
int cloudColor = lerp(rgb(45, 48, 60), rgb(236, 240, 248), dayFactor);
if (twilight > 0) cloudColor = lerp(cloudColor, rgb(150, 95, 85), twilight * 0.45);
double fade = clamp01((dy - 0.02) * 4); // fade out near the horizon (single-plane sampling)
double fade = Math.clamp((dy - 0.02) * 4, 0, 1); // fade out near the horizon (single-plane sampling)
color = lerp(color, cloudColor, coverage * fade);
}
}
@@ -233,13 +233,8 @@ public final class SkyRenderer {
private static int rgb(int r, int g, int b) { return (r << 16) | (g << 8) | b; }
private static int lerp(int a, int b, double t) {
t = clamp01(t);
int ar = (a >> 16) & 0xFF, ag = (a >> 8) & 0xFF, ab = a & 0xFF;
int br = (b >> 16) & 0xFF, bg = (b >> 8) & 0xFF, bb = b & 0xFF;
int r = (int) (ar + (br - ar) * t);
int g = (int) (ag + (bg - ag) * t);
int bl = (int) (ab + (bb - ab) * t);
return rgb(r, g, bl);
t = Math.clamp(t, 0, 1);
return ColorUtil.mix(a, b, 1 - t, t);
}
private static int add(int c, int r, int g, int b) {
@@ -261,11 +256,9 @@ public final class SkyRenderer {
}
private static double smoothstep(double edge0, double edge1, double x) {
double t = clamp01((x - edge0) / (edge1 - edge0));
double t = Math.clamp((x - edge0) / (edge1 - edge0), 0, 1);
return t * t * (3 - 2 * t);
}
private static double clamp01(double v) { return v < 0 ? 0 : Math.min(v, 1); }
private static int clamp(int v, int lo, int hi) { return v < lo ? lo : Math.min(v, hi); }
}
@@ -6,7 +6,7 @@ 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 eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import org.bukkit.DyeColor;
import org.bukkit.Location;
import org.bukkit.Material;
@@ -39,21 +39,11 @@ public final class 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);
}
FrustumBounds bounds = FrustumBounds.of(eye.toVector(), rayMap, 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 bMinX = (int) Math.floor(bounds.minX) - 1, bMaxX = (int) Math.ceil(bounds.maxX) + 1;
int bMinY = (int) Math.floor(bounds.minY) - 1, bMaxY = (int) Math.ceil(bounds.maxY) + 1;
int bMinZ = (int) Math.floor(bounds.minZ) - 1, bMaxZ = (int) Math.ceil(bounds.maxZ) + 1;
int minCX = bMinX >> 4, maxCX = bMaxX >> 4, minCZ = bMinZ >> 4, maxCZ = bMaxZ >> 4;
@@ -117,11 +107,10 @@ public final class BlockEntitySnapshotBuilder {
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));
b.baseColorArgb(ColorUtil.dyeArgb(base, 0xFFFFFFFF));
List<BannerPattern> pats = new ArrayList<>();
for (Pattern p : banner.getPatterns()) {
String key = p.getPattern().key().value();
pats.add(new BannerPattern(key, dyeArgb(p.getColor())));
pats.add(new BannerPattern(patternKey(p), ColorUtil.dyeArgb(p.getColor(), 0xFFFFFFFF)));
}
b.patterns(pats);
}
@@ -185,16 +174,17 @@ public final class BlockEntitySnapshotBuilder {
// --- facing / rotation helpers ---
/** Yaw preferring the block's {@link Directional} facing (wall-mounted block-entities). */
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;
}
/** Yaw preferring the block's {@link Rotatable} rotation (free-standing block-entities). */
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;
return facingYaw(data);
}
/** Yaw in degrees for a block face, 0 = south increasing clockwise (vanilla rotation convention). */
@@ -276,11 +266,11 @@ public final class BlockEntitySnapshotBuilder {
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();
/** The banner pattern's path key (e.g. "stripe_bottom"); every PatternType key accessor is marked
* for removal in this API with no stable replacement, so the warning is suppressed here. */
@SuppressWarnings("removal")
private static String patternKey(Pattern p) {
return p.getPattern().getKey().getKey();
}
// --- small fluent builder to keep the 15-field record construction readable ---
@@ -24,21 +24,8 @@ 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);
FrustumBounds bounds = FrustumBounds.of(eye.toVector(), rayMap, maxDistance);
Collection<Entity> nearby = bounds.nearbyEntities(eye.getWorld(), 2);
List<DecorationState> out = new ArrayList<>();
for (Entity e : nearby) {
try {
@@ -1,6 +1,7 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import org.bukkit.Location;
import org.bukkit.entity.Ageable;
import org.bukkit.entity.Entity;
@@ -33,21 +34,8 @@ public final class EntitySnapshotBuilder {
);
public static List<EntityState> build(Location eye, List<Vector> rayMap, double maxDistance, UUID shooter) {
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);
FrustumBounds bounds = FrustumBounds.of(eye.toVector(), rayMap, maxDistance);
Collection<Entity> nearby = bounds.nearbyEntities(eye.getWorld(), 2);
List<EntityState> states = new ArrayList<>();
for (Entity e : nearby) {
if (shooter != null && e.getUniqueId().equals(shooter)) continue;
@@ -65,21 +53,15 @@ public final class EntitySnapshotBuilder {
if (NON_RENDERABLE.contains(type) || type.endsWith("_raft")) return null;
float bodyYaw = loc.getYaw();
float headYaw = loc.getYaw();
float pitch = loc.getPitch();
if (e instanceof LivingEntity le) {
bodyYaw = le.getBodyYaw();
Location eyeLoc = le.getEyeLocation();
headYaw = eyeLoc.getYaw();
pitch = eyeLoc.getPitch();
}
boolean baby = (e instanceof Ageable a && !a.isAdult())
|| (e instanceof org.bukkit.entity.Zombie z && z.isBaby());
Vector v = e.getVelocity();
double width = safeWidth(e);
double height = safeHeight(e);
double width = safeDim(e::getWidth, () -> e.getBoundingBox().getWidthX());
double height = safeDim(e::getHeight, () -> e.getBoundingBox().getHeight());
boolean player = e instanceof Player;
String skinUrl = null;
@@ -93,12 +75,18 @@ public final class EntitySnapshotBuilder {
String variant = null;
int tint = 0;
double sizeScale = 1.0;
String profession = null;
int villagerLevel = 0;
String markings = null;
boolean saddle = false;
boolean chest = false;
String bodyEquip = null;
try {
// Slime & magma cube (MagmaCube extends Slime) scale their model by size (1/2/4).
if (e instanceof org.bukkit.entity.Slime sl) sizeScale = sl.getSize();
// MushroomCow extends Cow, ZombieVillager does not extend Villager — order matters.
if (e instanceof org.bukkit.entity.Sheep sh) {
tint = dyeArgb(sh.getColor());
tint = ColorUtil.dyeArgb(sh.getColor(), 0);
} else if (e instanceof org.bukkit.entity.Cat c) {
variant = keyOf(c.getCatType());
} else if (e instanceof org.bukkit.entity.Wolf w) {
@@ -111,8 +99,21 @@ public final class EntitySnapshotBuilder {
variant = keyOf(r.getRabbitType());
} else if (e instanceof org.bukkit.entity.Horse h) {
variant = keyOf(h.getColor());
markings = markingsKey(h.getStyle());
saddle = isSaddled(h);
bodyEquip = horseArmorKey(h);
} else if (e instanceof org.bukkit.entity.Llama l) {
variant = keyOf(l.getColor());
chest = l.isCarryingChest();
// Trader llamas wear a fixed decor; normal llamas carry a dyed carpet in the decor slot.
bodyEquip = (e instanceof org.bukkit.entity.TraderLlama) ? "trader_llama" : carpetKey(l);
} else if (e instanceof org.bukkit.entity.ChestedHorse ch) {
// Donkey & mule (llama already handled above).
chest = ch.isCarryingChest();
saddle = isSaddled(ch);
} else if (e instanceof org.bukkit.entity.AbstractHorse ah) {
// Skeleton/zombie horse: only saddle (no colour/markings/armor variants).
saddle = isSaddled(ah);
} else if (e instanceof org.bukkit.entity.Fox f) {
variant = keyOf(f.getFoxType());
} else if (e instanceof org.bukkit.entity.MushroomCow mc) {
@@ -125,8 +126,12 @@ public final class EntitySnapshotBuilder {
variant = s.getColor() == null ? null : keyOf(s.getColor());
} else if (e instanceof org.bukkit.entity.ZombieVillager zv) {
variant = keyOf(zv.getVillagerType());
profession = keyOf(zv.getVillagerProfession());
// ZombieVillager exposes no level via Bukkit -> no profession-level badge (matches vanilla).
} else if (e instanceof org.bukkit.entity.Villager vi) {
variant = keyOf(vi.getVillagerType());
profession = keyOf(vi.getProfession());
villagerLevel = vi.getVillagerLevel();
} else if (e instanceof org.bukkit.entity.Cow co) {
variant = keyOf(co.getVariant());
} else if (e instanceof org.bukkit.entity.Pig pg) {
@@ -139,8 +144,37 @@ public final class EntitySnapshotBuilder {
}
return new EntityState(type, loc.getX(), loc.getY(), loc.getZ(),
bodyYaw, headYaw, pitch, v.getX(), v.getY(), v.getZ(), baby, width, height,
player, skinUrl, slim, variant, tint, sizeScale);
bodyYaw, baby, width, height,
player, skinUrl, slim, variant, tint, sizeScale, profession, villagerLevel,
markings, saddle, chest, bodyEquip);
}
/** Horse coat markings overlay key (vanilla texture suffix); null for the plain NONE style. */
private static String markingsKey(org.bukkit.entity.Horse.Style style) {
if (style == null || style == org.bukkit.entity.Horse.Style.NONE) return null;
return style.name().toLowerCase(java.util.Locale.ROOT).replace("_", ""); // WHITE_DOTS -> whitedots
}
/** Horse armor material -> equipment/horse_body texture key (golden uses the "gold" file); null if none. */
private static String horseArmorKey(org.bukkit.entity.Horse h) {
org.bukkit.inventory.ItemStack a = h.getInventory().getArmor();
if (a == null || a.getType().isAir()) return null;
String k = a.getType().getKey().getKey().replace("_horse_armor", "");
return k.equals("golden") ? "gold" : k;
}
/** Llama carpet decor -> equipment/llama_body colour key; null if none. */
private static String carpetKey(org.bukkit.entity.Llama l) {
org.bukkit.inventory.ItemStack d = l.getInventory().getDecor();
if (d == null || d.getType().isAir()) return null;
String k = d.getType().getKey().getKey();
return k.endsWith("_carpet") ? k.substring(0, k.length() - "_carpet".length()) : null;
}
/** Whether a horse-like mount carries a saddle in its dedicated saddle slot. */
private static boolean isSaddled(org.bukkit.entity.AbstractHorse h) {
org.bukkit.inventory.ItemStack st = h.getInventory().getSaddle();
return st != null && !st.getType().isAir();
}
/** Registry/Keyed values yield their key path; plain enums yield their lower-case name. */
@@ -151,13 +185,6 @@ public final class EntitySnapshotBuilder {
return o.toString().toLowerCase(java.util.Locale.ROOT);
}
/** ARGB wool-tint multiplier for a dye colour (opaque); never returns 0 so it stays "set". */
private static int dyeArgb(org.bukkit.DyeColor dye) {
if (dye == null) return 0;
org.bukkit.Color c = dye.getColor();
return 0xFF000000 | (c.getRed() << 16) | (c.getGreen() << 8) | c.getBlue();
}
/** Returns {skinUrl, model} from the player's profile texture property, or {null, null}. */
private static String[] resolveSkin(Player player) {
try {
@@ -179,19 +206,12 @@ public final class EntitySnapshotBuilder {
return new String[]{null, null};
}
private static double safeWidth(Entity e) {
/** Reads a dimension via {@code primary}, falling back to {@code fallback} on any version mismatch. */
private static double safeDim(java.util.function.DoubleSupplier primary, java.util.function.DoubleSupplier fallback) {
try {
return e.getWidth();
return primary.getAsDouble();
} catch (Throwable t) {
return e.getBoundingBox().getWidthX();
}
}
private static double safeHeight(Entity e) {
try {
return e.getHeight();
} catch (Throwable t) {
return e.getBoundingBox().getHeight();
return fallback.getAsDouble();
}
}
}
@@ -0,0 +1,48 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Entity;
import org.bukkit.util.Vector;
import java.util.Collection;
import java.util.List;
/**
* Axis-aligned world-space bounds of the camera frustum: the min/max corner of the camera origin
* together with every ray endpoint at {@code maxDistance}. Shared by all snapshot builders to size
* their chunk/entity queries identically.
*/
final class FrustumBounds {
final double minX, minY, minZ;
final double maxX, maxY, maxZ;
private FrustumBounds(double minX, double minY, double minZ, double maxX, double maxY, double maxZ) {
this.minX = minX; this.minY = minY; this.minZ = minZ;
this.maxX = maxX; this.maxY = maxY; this.maxZ = maxZ;
}
static FrustumBounds of(Vector origin, List<Vector> rayMap, double maxDistance) {
double minX = origin.getX(), minY = origin.getY(), minZ = origin.getZ();
double maxX = origin.getX(), maxY = origin.getY(), maxZ = origin.getZ();
for (Vector ray : rayMap) {
double fx = origin.getX() + ray.getX() * maxDistance;
double fy = origin.getY() + ray.getY() * maxDistance;
double fz = origin.getZ() + ray.getZ() * maxDistance;
minX = Math.min(minX, fx); maxX = Math.max(maxX, fx);
minY = Math.min(minY, fy); maxY = Math.max(maxY, fy);
minZ = Math.min(minZ, fz); maxZ = Math.max(maxZ, fz);
}
return new FrustumBounds(minX, minY, minZ, maxX, maxY, maxZ);
}
/** All entities whose centre falls within these bounds expanded by {@code margin} on every side. */
Collection<Entity> nearbyEntities(World world, double margin) {
Location center = new Location(world, (minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2);
double hx = (maxX - minX) / 2 + margin;
double hy = (maxY - minY) / 2 + margin;
double hz = (maxZ - minZ) / 2 + margin;
return world.getNearbyEntities(center, hx, hy, hz);
}
}
@@ -26,29 +26,17 @@ public final class SnapshotBuilder {
public static WorldSnapshot build(Location eye, List<Vector> rayMap, double maxDistance, Logger logger) {
World world = eye.getWorld();
Vector origin = eye.toVector();
double minX = origin.getX(), minY = origin.getY(), minZ = origin.getZ();
double maxX = origin.getX(), maxY = origin.getY(), maxZ = origin.getZ();
for (Vector ray : rayMap) {
double fx = origin.getX() + ray.getX() * maxDistance;
double fy = origin.getY() + ray.getY() * maxDistance;
double fz = origin.getZ() + ray.getZ() * maxDistance;
minX = Math.min(minX, fx); maxX = Math.max(maxX, fx);
minY = Math.min(minY, fy); maxY = Math.max(maxY, fy);
minZ = Math.min(minZ, fz); maxZ = Math.max(maxZ, fz);
}
FrustumBounds bounds = FrustumBounds.of(eye.toVector(), rayMap, maxDistance);
int worldMinY = world.getMinHeight();
int worldMaxY = world.getMaxHeight();
int clampedMinY = Math.max(worldMinY, (int) Math.floor(minY) - 1);
int clampedMaxY = Math.min(worldMaxY, (int) Math.ceil(maxY) + 1);
int clampedMinY = Math.max(worldMinY, (int) Math.floor(bounds.minY) - 1);
int clampedMaxY = Math.min(worldMaxY, (int) Math.ceil(bounds.maxY) + 1);
int minCX = (int) Math.floor(minX) >> 4;
int maxCX = (int) Math.floor(maxX) >> 4;
int minCZ = (int) Math.floor(minZ) >> 4;
int maxCZ = (int) Math.floor(maxZ) >> 4;
int minCX = (int) Math.floor(bounds.minX) >> 4;
int maxCX = (int) Math.floor(bounds.maxX) >> 4;
int minCZ = (int) Math.floor(bounds.minZ) >> 4;
int maxCZ = (int) Math.floor(bounds.maxZ) >> 4;
Map<Long, ChunkSnapshot> chunks = new HashMap<>();
int captured = 0;
@@ -65,8 +65,8 @@ public final class BiomeTintProvider {
/** Vanilla colormap lookup: x = (1-temp)*255, y = (1-downfall*temp)*255. */
private int sample(int[][] colormap, double temperature, double downfall, int fallback) {
if (colormap == null || colormap.length == 0) return fallback;
double temp = clamp01(temperature);
double down = clamp01(downfall) * temp;
double temp = Math.clamp(temperature, 0, 1);
double down = Math.clamp(downfall, 0, 1) * temp;
int x = (int) ((1.0 - temp) * 255.0);
int y = (int) ((1.0 - down) * 255.0);
int h = colormap.length;
@@ -75,8 +75,4 @@ public final class BiomeTintProvider {
y = Math.max(0, Math.min(h - 1, y));
return 0xFF000000 | (colormap[y][x] & 0xFFFFFF);
}
private double clamp01(double v) {
return v < 0 ? 0 : Math.min(v, 1);
}
}
@@ -16,6 +16,13 @@ public final class ColorUtil {
return (a << 24) | (r << 16) | (g << 8) | b;
}
/** Opaque ARGB for a Bukkit dye colour, or {@code fallback} when {@code dye} is null. */
public static int dyeArgb(org.bukkit.DyeColor dye, int fallback) {
if (dye == null) return fallback;
org.bukkit.Color c = dye.getColor();
return argb(0xFF, c.getRed(), c.getGreen(), c.getBlue());
}
/** Multiplies the RGB channels of {@code base} by {@code tint} (per-channel, 0..255), keeping base alpha. */
public static int multiply(int base, int tint) {
int a = alpha(base);
@@ -38,6 +45,18 @@ public final class ColorUtil {
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.
*/
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);
int g = (int) ((green(rgbA) * weightA + green(rgbB) * weightB) / total);
int b = (int) ((blue(rgbA) * weightA + blue(rgbB) * weightB) / total);
return (r << 16) | (g << 8) | b;
}
// --- Gamma-correct (linear-light) averaging ---
private static final float[] SRGB_TO_LINEAR = new float[256];
@@ -1,6 +1,5 @@
package eu.mhsl.minecraft.pixelpics.render.util;
import org.bukkit.Color;
import org.bukkit.block.BlockFace;
import org.bukkit.util.Vector;
@@ -8,7 +7,7 @@ public class MathUtil {
private MathUtil() {}
public static Vector yawPitchRotation(Vector base, double angleYaw, double anglePitch) {
private static Vector yawPitchRotation(Vector base, double angleYaw, double anglePitch) {
double oldX = base.getX();
double oldY = base.getY();
double oldZ = base.getZ();
@@ -30,39 +29,12 @@ public class MathUtil {
return yawPitchRotation(yawPitchRotation(base, firstYaw, firstPitch), secondYaw, secondPitch);
}
public static Vector reflectVector(Vector linePoint, Vector lineDirection, Vector planePoint, Vector planeNormal) {
return lineDirection.clone().subtract(planeNormal.clone().multiply(2 * lineDirection.dot(planeNormal)));
/** Reflects {@code direction} across the plane with the given (unit) {@code normal}. */
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());
}
public static int weightedColorSum(int rgbOne, int rgbTwo, double weightOne, double weightTwo) {
Color colorOne = Color.fromRGB(rgbOne & 0xFFFFFF);
Color colorTwo = Color.fromRGB(rgbTwo & 0xFFFFFF);
double total = weightOne + weightTwo;
int newRed = (int) ((colorOne.getRed() * weightOne + colorTwo.getRed() * weightTwo) / total);
int newGreen = (int) ((colorOne.getGreen() * weightOne + colorTwo.getGreen() * weightTwo) / total);
int newBlue = (int) ((colorOne.getBlue() * weightOne + colorTwo.getBlue() * weightTwo) / total);
return Color.fromRGB(newRed, newGreen, newBlue).asRGB();
}
public static Vector getLinePlaneIntersection(Vector linePoint, Vector lineDirection, Vector planePoint,
Vector planeNormal, boolean allowBackwards) {
double d = planePoint.dot(planeNormal);
double t = (d - planeNormal.dot(linePoint)) / planeNormal.dot(lineDirection);
if (t < 0 && !allowBackwards) {
return null;
}
double x = linePoint.getX() + lineDirection.getX() * t;
double y = linePoint.getY() + lineDirection.getY() * t;
double z = linePoint.getZ() + lineDirection.getZ() * t;
return new Vector(x, y, z);
}
}
@@ -1,5 +1,6 @@
package eu.mhsl.minecraft.pixelpics.utils;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import org.bukkit.map.MapPalette;
import java.awt.Color;
@@ -20,6 +21,9 @@ public final class MapColorPalette {
private MapColorPalette() {}
// MapPalette.getColor(byte) is deprecated for removal with no replacement for enumerating the
// index->colour palette; suppress until the API offers an alternative.
@SuppressWarnings("removal")
private static synchronized void ensure() {
if (initialized) return;
List<Byte> idx = new ArrayList<>();
@@ -32,7 +36,7 @@ public final class MapColorPalette {
} catch (Throwable t) {
continue;
}
if (c == null || c.getAlpha() < 255) continue; // skip transparent slots
if (c.getAlpha() < 255) continue; // skip transparent slots
idx.add((byte) i);
rgbs.add((c.getRed() << 16) | (c.getGreen() << 8) | c.getBlue());
labs.add(rgbToLab(c.getRed(), c.getGreen(), c.getBlue()));
@@ -80,7 +84,7 @@ public final class MapColorPalette {
}
private static double[] rgbToLab(int r, int g, int b) {
double rl = pivotSrgb(r), gl = pivotSrgb(g), bl = pivotSrgb(b);
double rl = ColorUtil.toLinear(r), gl = ColorUtil.toLinear(g), bl = ColorUtil.toLinear(b);
// sRGB -> XYZ (D65)
double x = rl * 0.4124 + gl * 0.3576 + bl * 0.1805;
double y = rl * 0.2126 + gl * 0.7152 + bl * 0.0722;
@@ -91,11 +95,6 @@ public final class MapColorPalette {
return new double[]{116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)};
}
private static double pivotSrgb(int c) {
double v = c / 255.0;
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}
private static double pivotXyz(double t) {
return t > 0.008856 ? Math.cbrt(t) : (7.787 * t + 16.0 / 116.0);
}
+1 -1
View File
@@ -54,7 +54,7 @@ public class BlockEntityTestRender {
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) -> {
new Class<?>[]{BlockData.class}, (p, m, a) -> {
switch (m.getName()) {
case "getMaterial": return Material.AIR;
case "equals": return p == a[0];
+22 -4
View File
@@ -48,7 +48,24 @@ public class EntityTestRender {
Map.entry("parrot", "red"), Map.entry("rabbit", "brown"), Map.entry("horse", "white"),
Map.entry("llama", "creamy"), Map.entry("trader_llama", "creamy"), Map.entry("fox", "red"),
Map.entry("mooshroom", "red"), Map.entry("frog", "temperate"), Map.entry("panda", "normal"),
Map.entry("cow", "temperate"), Map.entry("pig", "temperate"), Map.entry("chicken", "temperate")
Map.entry("cow", "temperate"), Map.entry("pig", "temperate"), Map.entry("chicken", "temperate"),
Map.entry("villager", "taiga"), Map.entry("zombie_villager", "swamp")
);
// Villager profession / level for the standalone render (biome type comes from VAR above).
static final Map<String, String> PROF = Map.ofEntries(
Map.entry("villager", "librarian"), Map.entry("zombie_villager", "farmer")
);
static final Map<String, Integer> LVL = Map.ofEntries(
Map.entry("villager", 5)
);
// Horse/llama/donkey equipment for the standalone render.
static final Map<String, String> MARK = Map.of("horse", "blackdots"); // coat markings
static final java.util.Set<String> SADDLE = java.util.Set.of("horse", "donkey", "mule");
static final java.util.Set<String> CHEST = java.util.Set.of("llama", "donkey");
static final Map<String, String> EQUIP = Map.ofEntries( // armor / carpet
Map.entry("horse", "diamond"), Map.entry("llama", "red"), Map.entry("trader_llama", "trader_llama")
);
public static void main(String[] args) throws Exception {
@@ -67,7 +84,7 @@ public class EntityTestRender {
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) -> {
new Class<?>[]{BlockData.class}, (p, m, a) -> {
switch (m.getName()) {
case "getMaterial": return Material.AIR;
case "equals": return p == a[0];
@@ -125,8 +142,9 @@ public class EntityTestRender {
static BufferedImage renderEntity(DefaultScreenRenderer renderer, eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker baker, WorldSnapshot world,
SkyContext sky, String key, float yaw) {
boolean isPlayer = key.equals("player");
EntityState s = new EntityState(key, 0, 0, 0, yaw, yaw, 0, 0, 0, 0, false, 0.8, 1.0,
isPlayer, null, false, VAR.get(key), 0, 1.0);
EntityState s = new EntityState(key, 0, 0, 0, yaw, false, 0.8, 1.0,
isPlayer, null, false, VAR.get(key), 0, 1.0, PROF.get(key), LVL.getOrDefault(key, 0),
MARK.get(key), SADDLE.contains(key), CHEST.contains(key), EQUIP.get(key));
RenderedEntity re = baker.bake(s);
double cx = (re.aabbMin[0] + re.aabbMax[0]) / 2;
double cy = (re.aabbMin[1] + re.aabbMax[1]) / 2;