Compare commits

...

4 Commits

3393 changed files with 6160 additions and 28616 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/modules/PixelPic.main.iml" filepath="$PROJECT_DIR$/.idea/modules/PixelPic.main.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/modules/PixelPics.PixelPic.main.iml" filepath="$PROJECT_DIR$/.idea/modules/PixelPics.PixelPic.main.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/modules/eu.mhsl.minecraft.pixelpic.PixelPic.main.iml" filepath="$PROJECT_DIR$/.idea/modules/eu.mhsl.minecraft.pixelpic.PixelPic.main.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/modules/PixelPics.main.iml" filepath="$PROJECT_DIR$/.idea/modules/PixelPics.main.iml" />
</modules>
</component>
</project>
+1 -1
View File
@@ -18,7 +18,7 @@ repositories {
}
dependencies {
compileOnly("io.papermc.paper:paper-api:1.21.7-R0.1-SNAPSHOT")
compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT")
}
def targetJavaVersion = 21
+1 -1
View File
@@ -1 +1 @@
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
@@ -1,124 +1,91 @@
package eu.mhsl.minecraft.pixelpics;
import eu.mhsl.minecraft.pixelpics.assets.AssetReader;
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
import eu.mhsl.minecraft.pixelpics.assets.ResourcePack;
import eu.mhsl.minecraft.pixelpics.assets.ResourcePackLoader;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import eu.mhsl.minecraft.pixelpics.commands.PixelPicsCommand;
import eu.mhsl.minecraft.pixelpics.listeners.OnMapInitialize;
import eu.mhsl.minecraft.pixelpics.render.render.DefaultScreenRenderer;
import eu.mhsl.minecraft.pixelpics.render.render.Renderer;
import net.kyori.adventure.text.Component;
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
import java.io.*;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.Enumeration;
import java.io.File;
import java.util.Objects;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.Optional;
public final class Main extends JavaPlugin {
private static Main instance;
private Renderer screenRenderer;
public final NamespacedKey pictureIdFlag = Objects.requireNonNull(
NamespacedKey.fromString("imageId".toLowerCase(), this),
"Failed to create item-Flag Namespace"
);
private static Main instance;
private ResourcePack resourcePack;
private DefaultScreenRenderer screenRenderer;
public final NamespacedKey pictureIdFlag = new NamespacedKey(this, "imageid");
@Override
public void onEnable() {
instance = this;
extractJsonResources();
Objects.requireNonNull(Bukkit.getPluginCommand("pixelPic"))
.setExecutor(new PixelPicsCommand());
Bukkit.getPluginManager().registerEvents(new OnMapInitialize(), this);
Objects.requireNonNull(Bukkit.getPluginCommand("pixelPic")).setExecutor(new PixelPicsCommand());
Bukkit.getPluginCommand("test").setExecutor((sender, command, label, args) -> {
// Dialog dialog = Dialog.create(
// builder -> builder.empty()
// .base(
// DialogBase.builder(Component.text("Hello World")).build()
// )
// .type(DialogType.multiAction(
// List.of(
// ActionButton.builder(Component.text("Option 1")).action(DialogAction.staticAction(ClickEvent.callback(audience -> System.out.println("HIIIII")))).build(),
// ActionButton.builder(Component.text("Option 2")).action(DialogAction.customClick(Key.key("test"), null)).build(),
// ActionButton.builder(Component.text("Option 3")).action(DialogAction.commandTemplate("say hi")).build()
// ),
// ActionButton.builder(Component.text("Beenden")).build(),
// 3
// ))
//
// );
// sender.showDialog(dialog);
Material.getMaterial("acacia_button");
Bukkit.broadcast(Component.text(Material.STONE.getBlockTranslationKey().replace("block.minecraft.", "")));
if(!(sender instanceof Player))
throw new IllegalStateException("Dieser Command kann nur von einem Spieler ausgeführt werden!");
File blockDir = new File(getDataFolder(), "models/block");
for (File file : blockDir.listFiles()) {
String blockName = file.getName().substring(0, file.getName().lastIndexOf('.'));
Material material = Material.getMaterial(blockName.toUpperCase());
System.out.println(material);
if(material == null) {
System.out.println(blockName);
}
}
return true;
});
initRenderer();
}
public void extractJsonResources() {
String resourcePath = "models/block/";
File outputDir = new File(getDataFolder(), resourcePath);
if (outputDir.exists()) return;
outputDir.mkdirs();
private void initRenderer() {
File resourcePackDir = new File(getDataFolder(), "resourcepack");
if (!resourcePackDir.exists() && !resourcePackDir.mkdirs()) {
getLogger().warning("Could not create resource pack directory: " + resourcePackDir);
}
try {
URL jarUrl = getClass().getProtectionDomain().getCodeSource().getLocation();
File jarFile = new File(jarUrl.toURI());
Optional<ResourcePack> pack = ResourcePackLoader.load(resourcePackDir, getLogger());
if (pack.isEmpty()) {
getLogger().severe("No resource pack found in " + resourcePackDir.getPath()
+ " — place a vanilla resource pack (directory with assets/minecraft/... or a .zip) there. "
+ "/pixelPic is disabled until a pack is available.");
return;
}
try (JarFile jar = new JarFile(jarFile)) {
Enumeration<JarEntry> entries = jar.entries();
this.resourcePack = pack.get();
AssetReader reader = new AssetReader(resourcePack);
TextureCache textures = new TextureCache(resourcePack);
BlockModelRegistry registry = new BlockModelRegistry(reader, textures);
BiomeTintProvider tintProvider = new BiomeTintProvider(textures);
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
String entryName = entry.getName();
eu.mhsl.minecraft.pixelpics.render.entity.cem.CemModelLoader cemLoader =
new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemModelLoader();
try (java.io.InputStream in = getResource("cem/cem_template_models.json")) {
int n = in == null ? 0 : cemLoader.load(in, getLogger());
getLogger().info("Loaded " + n + " CEM entity models.");
} catch (Exception e) {
getLogger().severe("Failed to load CEM entity models: " + e.getMessage());
}
eu.mhsl.minecraft.pixelpics.assets.SkinCache skinCache = new eu.mhsl.minecraft.pixelpics.assets.SkinCache();
eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker entityBaker =
new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker(cemLoader, textures, skinCache);
eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker blockEntityBaker =
new eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker(cemLoader, textures, skinCache);
// Nur JSON-Dateien im gewünschten Ordner
if (entryName.startsWith(resourcePath) && entryName.endsWith(".json")) {
InputStream in = getResource(entryName);
if (in == null) continue;
this.screenRenderer = new DefaultScreenRenderer(registry, tintProvider, textures, entityBaker, blockEntityBaker, getLogger());
// Warm the map palette on the main thread so off-thread dithering never triggers its first init.
eu.mhsl.minecraft.pixelpics.utils.MapColorPalette.size();
getLogger().info("PixelPics renderer initialized with resource pack assets.");
}
File outFile = new File(getDataFolder(), entryName);
outFile.getParentFile().mkdirs(); // Ordnerstruktur sicherstellen
try (OutputStream out = new FileOutputStream(outFile)) {
byte[] buffer = new byte[1024];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
System.out.println("Extrahiert: " + entryName);
}
in.close();
}
}
}
} catch (IOException | URISyntaxException e) {
e.printStackTrace();
@Override
public void onDisable() {
if (resourcePack != null) {
resourcePack.close();
resourcePack = null;
}
}
public Renderer getScreenRenderer() {
if(this.screenRenderer == null) this.screenRenderer = new DefaultScreenRenderer();
/** The renderer, or {@code null} when no resource pack is available (degraded mode). */
public DefaultScreenRenderer getScreenRenderer() {
return this.screenRenderer;
}
@@ -0,0 +1,35 @@
package eu.mhsl.minecraft.pixelpics.assets;
/**
* Builds pack-relative asset paths from namespaced ids, following the vanilla layout
* {@code assets/<namespace>/<category>/<path>.<ext>}.
*/
public final class AssetPaths {
private AssetPaths() {}
/** {@code assets/<ns>/blockstates/<name>.json} for a plain block name (no {@code block/} prefix). */
public static String blockState(String blockName) {
return String.format("assets/%s/blockstates/%s.json", ResourceLocation.DEFAULT_NAMESPACE, blockName.toLowerCase());
}
/** {@code assets/<ns>/models/<path>.json}. The id path already contains e.g. {@code block/stone}. */
public static String model(ResourceLocation id) {
return String.format("assets/%s/models/%s.json", id.namespace(), id.path());
}
/** {@code assets/<ns>/textures/<path>.png}. The id path already contains e.g. {@code block/stone}. */
public static String texture(ResourceLocation id) {
return String.format("assets/%s/textures/%s.png", id.namespace(), id.path());
}
/** {@code assets/<ns>/textures/<path>.png.mcmeta} animation metadata, if present. */
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());
}
}
@@ -0,0 +1,45 @@
package eu.mhsl.minecraft.pixelpics.assets;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
/**
* Thin convenience layer over a {@link ResourcePack} for reading JSON assets.
*/
public final class AssetReader {
private final ResourcePack pack;
private final Gson gson = new Gson();
public AssetReader(ResourcePack pack) {
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 {
return Optional.ofNullable(gson.fromJson(new String(bytes, StandardCharsets.UTF_8), type));
} catch (Exception e) {
return Optional.empty();
}
});
}
public Optional<JsonObject> readJsonObject(String path) {
return pack.read(path).flatMap(bytes -> {
try {
return Optional.of(JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)).getAsJsonObject());
} catch (Exception e) {
return Optional.empty();
}
});
}
}
@@ -0,0 +1,185 @@
package eu.mhsl.minecraft.pixelpics.assets;
import eu.mhsl.minecraft.pixelpics.assets.model.AverageColor;
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Element;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel;
import org.bukkit.Material;
import org.bukkit.block.data.BlockData;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Top-level entry point that turns a {@link BlockData} into a baked {@link ResolvedModel}, combining
* blockstate resolution, model resolution and geometry baking. Results are cached per BlockData and
* the cache is safe for concurrent access by the parallel renderer.
*/
public final class BlockModelRegistry {
private final TextureCache textures;
private final BlockStateResolver blockStateResolver;
private final ModelResolver modelResolver;
private final ModelBaker baker;
private final Map<BlockData, ResolvedModel> cache = new ConcurrentHashMap<>();
private volatile ResolvedModel waterModel;
private volatile ResolvedModel lavaModel;
public BlockModelRegistry(AssetReader reader, TextureCache textures) {
this.textures = textures;
this.blockStateResolver = new BlockStateResolver(reader);
this.modelResolver = new ModelResolver(reader);
this.baker = new ModelBaker(textures);
}
public ResolvedModel get(BlockData data) {
return cache.computeIfAbsent(data, this::resolve);
}
private ResolvedModel resolve(BlockData data) {
Material material = data.getMaterial();
if (material == Material.WATER) return water();
if (material == Material.LAVA) return lava();
// Technical blocks that are invisible in the world (their textures only show held in hand).
if (INVISIBLE_MATERIALS.contains(material)) {
return new ResolvedModel(List.of(), 0, 0, 0, false, false);
}
List<Variant> variants = blockStateResolver.resolve(data);
List<Element> elements = new java.util.ArrayList<>();
long ar = 0, ag = 0, ab = 0;
int acount = 0;
FlatModel lastFlat = null;
for (Variant variant : variants) {
FlatModel flat = modelResolver.resolve(variant.model());
lastFlat = flat;
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++;
}
}
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);
}
int avg = fallbackColor(lastFlat);
// Block-entities (chests, signs, banners, beds, heads, …) use builtin/entity models with no
// geometry; their real shape is rendered separately through the entity scene. Return empty
// geometry so the ray passes through to that geometry instead of drawing a grey fallback cube.
if (isBlockEntityRendered(material)) {
return new ResolvedModel(List.of(), avg, 0, 0, false, false);
}
// No geometry: render a flat full cube using a fallback average color.
return new ResolvedModel(List.of(solidCube(avg)), avg, 0, 0, false, false);
}
/** Technical blocks that render nothing in the world (barrier/light/structure void are hand-only). */
private static final EnumSet<Material> INVISIBLE_MATERIALS = buildInvisibleMaterials();
private static EnumSet<Material> buildInvisibleMaterials() {
EnumSet<Material> set = EnumSet.noneOf(Material.class);
for (String n : new String[]{"BARRIER", "LIGHT", "STRUCTURE_VOID"}) {
try { set.add(Material.valueOf(n)); } catch (IllegalArgumentException ignored) { /* older/newer server */ }
}
return set;
}
/** Block materials whose shape is drawn by the block-entity renderer, not the block-model pipeline. */
private static final EnumSet<Material> BLOCK_ENTITY_MATERIALS = buildBlockEntityMaterials();
private static boolean isBlockEntityRendered(Material material) {
return BLOCK_ENTITY_MATERIALS.contains(material);
}
private static EnumSet<Material> buildBlockEntityMaterials() {
// Matched purely by name/identity (no Material#isBlock, which needs a live registry): the set is
// only ever queried with placed block materials, so any item-only matches are harmless.
EnumSet<Material> set = EnumSet.noneOf(Material.class);
for (Material m : Material.values()) {
String n = m.name();
boolean match =
n.endsWith("_SIGN") // signs: standing/wall/(wall_)hanging
|| n.endsWith("_BANNER") // banners: standing/wall
|| n.endsWith("_BED")
|| n.endsWith("SHULKER_BOX") // SHULKER_BOX + <color>_SHULKER_BOX
|| ((n.endsWith("_SKULL") || n.endsWith("_HEAD")) && m != Material.PISTON_HEAD)
|| m == Material.CHEST || m == Material.TRAPPED_CHEST || m == Material.ENDER_CHEST
|| m == Material.CONDUIT || m == Material.DECORATED_POT || m == Material.BELL;
if (match) set.add(m);
}
return set;
}
/** A full-block cube whose six faces sample a single solid color (1x1 texture). */
private Element solidCube(int color) {
int[][] tex = {{color}};
Face[] faces = new Face[6];
for (Direction d : Direction.values()) {
faces[d.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, -1);
}
return new Element(new double[]{0, 0, 0}, new double[]{1, 1, 1}, faces, null, -1, 0, false);
}
private int fallbackColor(FlatModel flat) {
if (flat != null && flat.textures() != null) {
String particle = flat.textures().get("particle");
if (particle != null && !particle.startsWith("#")) {
int[][] tex = textures.get(ResourceLocation.parse(particle)).orElse(null);
if (tex != null) return AverageColor.of(tex);
}
}
return 0xFF7F7F7F;
}
private ResolvedModel water() {
ResolvedModel m = waterModel;
if (m == null) {
m = liquid("block/water_still", 0, 0.60, 0.10);
waterModel = m;
}
return m;
}
private ResolvedModel lava() {
ResolvedModel m = lavaModel;
if (m == null) {
m = liquid("block/lava_still", -1, 0.15, 0.05);
lavaModel = m;
}
return m;
}
/** Builds a simple full-cube model for a liquid texture with the given tint/transparency/reflection. */
private ResolvedModel liquid(String texturePath, int tintIndex, double transparency, double reflection) {
int[][] tex = textures.get(ResourceLocation.parse(texturePath)).orElse(null);
if (tex == null) {
return new ResolvedModel(List.of(solidCube(0xFF3F76E4)), 0xFF3F76E4, transparency, reflection, true, true);
}
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);
}
Element cube = new Element(new double[]{0, 0, 0}, new double[]{1, 1, 1}, faces, null, -1, 0, false);
int avg = AverageColor.of(tex);
return new ResolvedModel(List.of(cube), avg, transparency, reflection, true, true);
}
}
@@ -0,0 +1,41 @@
package eu.mhsl.minecraft.pixelpics.assets;
import org.bukkit.block.data.BlockData;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Parses the property map and plain block name out of a {@link BlockData} string such as
* {@code minecraft:oak_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]}.
*/
public final class BlockStateProperties {
private BlockStateProperties() {}
/** The block name without namespace, e.g. {@code oak_stairs}. */
public static String blockName(BlockData data) {
String s = data.getAsString(false);
int bracket = s.indexOf('[');
String id = bracket < 0 ? s : s.substring(0, bracket);
int colon = id.indexOf(':');
return (colon < 0 ? id : id.substring(colon + 1)).trim();
}
/** The {@code prop -> value} map (empty when the block has no properties). */
public static Map<String, String> properties(BlockData data) {
Map<String, String> props = new LinkedHashMap<>();
String s = data.getAsString(false);
int open = s.indexOf('[');
int close = s.lastIndexOf(']');
if (open < 0 || close < 0 || close <= open) return props;
String body = s.substring(open + 1, close);
for (String pair : body.split(",")) {
int eq = pair.indexOf('=');
if (eq < 0) continue;
props.put(pair.substring(0, eq).trim(), pair.substring(eq + 1).trim());
}
return props;
}
}
@@ -0,0 +1,116 @@
package eu.mhsl.minecraft.pixelpics.assets;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import org.bukkit.block.data.BlockData;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Resolves a {@link BlockData} to the list of model variants vanilla would render, by reading the
* block's {@code blockstates/<name>.json} (either {@code variants} or {@code multipart}).
*/
public final class BlockStateResolver {
private final AssetReader reader;
public BlockStateResolver(AssetReader reader) {
this.reader = reader;
}
public List<Variant> resolve(BlockData data) {
String name = BlockStateProperties.blockName(data);
Map<String, String> props = BlockStateProperties.properties(data);
JsonObject root = reader.readJsonObject(AssetPaths.blockState(name)).orElse(null);
if (root == null) return List.of();
if (root.has("variants")) {
return resolveVariants(root.getAsJsonObject("variants"), props);
}
if (root.has("multipart")) {
return resolveMultipart(root.getAsJsonArray("multipart"), props);
}
return List.of();
}
private List<Variant> resolveVariants(JsonObject variants, Map<String, String> props) {
for (Map.Entry<String, JsonElement> entry : variants.entrySet()) {
if (variantKeyMatches(entry.getKey(), props)) {
return List.of(parseVariant(firstOf(entry.getValue())));
}
}
return List.of();
}
private List<Variant> resolveMultipart(JsonArray multipart, Map<String, String> props) {
List<Variant> result = new ArrayList<>();
for (JsonElement caseEl : multipart) {
JsonObject caseObj = caseEl.getAsJsonObject();
if (caseObj.has("when") && !whenMatches(caseObj.get("when"), props)) continue;
result.add(parseVariant(firstOf(caseObj.get("apply"))));
}
return result;
}
/** A variant key like {@code facing=east,half=bottom} matches when every pair holds for the block. */
private boolean variantKeyMatches(String key, Map<String, String> props) {
if (key.isEmpty()) return true;
for (String pair : key.split(",")) {
int eq = pair.indexOf('=');
if (eq < 0) continue;
String prop = pair.substring(0, eq).trim();
String value = pair.substring(eq + 1).trim();
if (!valueMatches(props.get(prop), value)) return false;
}
return true;
}
private boolean whenMatches(JsonElement when, Map<String, String> props) {
JsonObject obj = when.getAsJsonObject();
if (obj.has("OR")) {
for (JsonElement sub : obj.getAsJsonArray("OR")) {
if (whenMatches(sub, props)) return true;
}
return false;
}
if (obj.has("AND")) {
for (JsonElement sub : obj.getAsJsonArray("AND")) {
if (!whenMatches(sub, props)) return false;
}
return true;
}
// Implicit AND over property conditions.
for (Map.Entry<String, JsonElement> e : obj.entrySet()) {
if (!valueMatches(props.get(e.getKey()), e.getValue().getAsString())) return false;
}
return true;
}
/** A condition value may be an OR list like {@code "north|south"}. */
private boolean valueMatches(String actual, String expected) {
if (actual == null) return false;
for (String option : expected.split("\\|")) {
if (option.equals(actual)) return true;
}
return false;
}
private JsonObject firstOf(JsonElement element) {
if (element.isJsonArray()) {
return element.getAsJsonArray().get(0).getAsJsonObject();
}
return element.getAsJsonObject();
}
private Variant parseVariant(JsonObject obj) {
ResourceLocation model = ResourceLocation.parse(obj.get("model").getAsString());
int x = obj.has("x") ? obj.get("x").getAsInt() : 0;
int y = obj.has("y") ? obj.get("y").getAsInt() : 0;
boolean uvlock = obj.has("uvlock") && obj.get("uvlock").getAsBoolean();
return new Variant(model, x, y, uvlock);
}
}
@@ -0,0 +1,36 @@
package eu.mhsl.minecraft.pixelpics.assets;
import java.util.List;
import java.util.Optional;
/**
* Combines several packs into one; the first pack that contains an asset wins. Lets an admin drop
* both an unpacked directory and one or more {@code .zip} packs.
*/
public final class CompositeResourcePack implements ResourcePack {
private final List<ResourcePack> packs;
public CompositeResourcePack(List<ResourcePack> packs) {
this.packs = List.copyOf(packs);
}
@Override
public Optional<byte[]> read(String path) {
for (ResourcePack pack : packs) {
Optional<byte[]> result = pack.read(path);
if (result.isPresent()) return result;
}
return Optional.empty();
}
@Override
public boolean exists(String path) {
return packs.stream().anyMatch(pack -> pack.exists(path));
}
@Override
public void close() {
packs.forEach(ResourcePack::close);
}
}
@@ -0,0 +1,48 @@
package eu.mhsl.minecraft.pixelpics.assets;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
/**
* A {@link ResourcePack} backed by a directory on disk. The root typically contains the
* {@code assets/} folder.
*/
public final class DirectoryResourcePack implements ResourcePack {
private final Path root;
public DirectoryResourcePack(Path root) {
this.root = root.toAbsolutePath().normalize();
}
private Optional<Path> resolve(String path) {
Path resolved = root.resolve(path).normalize();
// Guard against path traversal outside the pack root.
if (!resolved.startsWith(root)) return Optional.empty();
return Optional.of(resolved);
}
@Override
public Optional<byte[]> read(String path) {
return resolve(path).flatMap(p -> {
if (!Files.isRegularFile(p)) return Optional.empty();
try {
return Optional.of(Files.readAllBytes(p));
} catch (IOException e) {
return Optional.empty();
}
});
}
@Override
public boolean exists(String path) {
return resolve(path).map(Files::isRegularFile).orElse(false);
}
@Override
public void close() {
// nothing to release
}
}
@@ -0,0 +1,17 @@
package eu.mhsl.minecraft.pixelpics.assets;
import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto;
import java.util.List;
import java.util.Map;
/**
* A model with its parent chain flattened: textures merged (child wins) and the nearest non-empty
* {@code elements} list selected (vanilla does not merge elements across parents).
*/
public record FlatModel(Map<String, String> textures, List<ModelFileDto.ElementDto> elements) {
public boolean hasElements() {
return elements != null && !elements.isEmpty();
}
}
@@ -0,0 +1,237 @@
package eu.mhsl.minecraft.pixelpics.assets;
import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto;
import eu.mhsl.minecraft.pixelpics.assets.model.AverageColor;
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Element;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Bakes a {@link FlatModel} plus a blockstate {@link Variant} into intersectable {@link Element}
* boxes. Texture variables are resolved against the model's merged texture map; default UVs follow
* the vanilla element extents; blockstate {@code x}/{@code y} rotations (90-degree steps) are baked
* into geometry and face directions, while a model element's own rotation is preserved for OBB
* intersection.
*/
public final class ModelBaker {
private final TextureCache textures;
public ModelBaker(TextureCache textures) {
this.textures = textures;
}
public record BakedGeometry(List<Element> elements, int averageColor, boolean hasGeometry) {}
public BakedGeometry bake(FlatModel model, Variant variant) {
if (!model.hasElements()) {
return new BakedGeometry(List.of(), 0, false);
}
int xSteps = ((variant.x() / 90) % 4 + 4) % 4;
int ySteps = ((variant.y() / 90) % 4 + 4) % 4;
List<Element> baked = new ArrayList<>();
long ar = 0, ag = 0, ab = 0, acount = 0;
for (ModelFileDto.ElementDto dto : model.elements()) {
if (dto.from == null || dto.to == null) continue;
double[] from = {dto.from[0] / 16.0, dto.from[1] / 16.0, dto.from[2] / 16.0};
double[] to = {dto.to[0] / 16.0, dto.to[1] / 16.0, dto.to[2] / 16.0};
// Build faces (pre variant rotation).
Face[] faces = new Face[6];
if (dto.faces != null) {
for (Map.Entry<String, ModelFileDto.FaceDto> e : dto.faces.entrySet()) {
Direction dir = Direction.fromName(e.getKey());
if (dir == null) continue;
Face face = buildFace(dir, e.getValue(), from, to, model.textures());
if (face != null) faces[dir.ordinal()] = face;
}
}
// Element's own rotation (origin in 0..1, axis index, radians).
double[] rotOrigin = null;
int rotAxis = -1;
double rotAngle = 0;
boolean rescale = false;
if (dto.rotation != null && dto.rotation.angle != 0 && dto.rotation.origin != null) {
rotOrigin = new double[]{
dto.rotation.origin[0] / 16.0, dto.rotation.origin[1] / 16.0, dto.rotation.origin[2] / 16.0};
rotAxis = axisIndex(dto.rotation.axis);
rotAngle = Math.toRadians(dto.rotation.angle);
rescale = dto.rotation.rescale;
}
// Apply blockstate variant rotation (90-degree steps) to box + faces + element rotation.
for (int i = 0; i < xSteps; i++) {
double[][] r = rotateBoxX(from, to);
from = r[0];
to = r[1];
faces = rotateFacesX(faces);
if (rotAxis >= 0) {
rotOrigin = rotatePointX(rotOrigin);
int[] na = rotateAxisX(rotAxis, rotAngle);
rotAxis = na[0];
rotAngle = na[1] == 0 ? rotAngle : -rotAngle;
}
}
for (int i = 0; i < ySteps; i++) {
double[][] r = rotateBoxY(from, to);
from = r[0];
to = r[1];
faces = rotateFacesY(faces);
if (rotAxis >= 0) {
rotOrigin = rotatePointY(rotOrigin);
int[] na = rotateAxisY(rotAxis, rotAngle);
rotAxis = na[0];
rotAngle = na[1] == 0 ? rotAngle : -rotAngle;
}
}
baked.add(new Element(from, to, faces, rotOrigin, rotAxis, rotAngle, rescale));
// 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++;
}
}
int avg = acount == 0 ? 0xFF7F7F7F
: 0xFF000000 | (((int) (ar / acount)) << 16) | (((int) (ag / acount)) << 8) | ((int) (ab / acount));
return new BakedGeometry(baked, avg, !baked.isEmpty());
}
private Face buildFace(Direction dir, ModelFileDto.FaceDto dto, double[] from, double[] to,
Map<String, String> textureVars) {
int[][] tex = resolveTexture(dto.texture, textureVars);
if (tex == null) return null;
double u1, v1, u2, v2;
if (dto.uv != null && dto.uv.length == 4) {
u1 = dto.uv[0] / 16.0;
v1 = dto.uv[1] / 16.0;
u2 = dto.uv[2] / 16.0;
v2 = dto.uv[3] / 16.0;
} else {
double[] d = defaultUv(dir, from, to);
u1 = d[0];
v1 = d[1];
u2 = d[2];
v2 = d[3];
}
int tint = dto.tintindex != null ? dto.tintindex : -1;
return new Face(tex, u1, v1, u2, v2, dto.rotation, tint);
}
/**
* Default UV (normalized 0..1) from the element extents, matching vanilla. Texture V is top-down,
* so for side faces v1 (texture top) corresponds to the element's high-Y edge: v = [1-to.y, 1-from.y].
*/
private double[] defaultUv(Direction dir, double[] f, double[] t) {
return switch (dir) {
case UP, DOWN -> new double[]{f[0], f[2], t[0], t[2]};
case NORTH, SOUTH -> new double[]{f[0], 1 - t[1], t[0], 1 - f[1]};
case WEST, EAST -> new double[]{f[2], 1 - t[1], t[2], 1 - f[1]};
};
}
private int[][] resolveTexture(String ref, Map<String, String> vars) {
if (ref == null) return null;
String current = ref;
int guard = 0;
while (current.startsWith("#") && guard++ < 16) {
current = vars.get(current.substring(1));
if (current == null) return null;
}
if (current.startsWith("#")) return null;
return textures.get(ResourceLocation.parse(current)).orElse(null);
}
private int axisIndex(String axis) {
if (axis == null) return -1;
return switch (axis.toLowerCase()) {
case "x" -> 0;
case "y" -> 1;
case "z" -> 2;
default -> -1;
};
}
// --- 90-degree box rotations around the block center (0.5, 0.5, 0.5) ---
private double[][] rotateBoxY(double[] from, double[] to) {
// (x,z) -> (z, 1-x): 90 deg clockwise viewed from above.
double[] c1 = rotatePointY(from);
double[] c2 = rotatePointY(to);
return minMax(c1, c2);
}
private double[][] rotateBoxX(double[] from, double[] to) {
double[] c1 = rotatePointX(from);
double[] c2 = rotatePointX(to);
return minMax(c1, c2);
}
private double[] rotatePointY(double[] p) {
double x = p[0] - 0.5, z = p[2] - 0.5;
return new double[]{0.5 + z, p[1], 0.5 - x};
}
private double[] rotatePointX(double[] p) {
double y = p[1] - 0.5, z = p[2] - 0.5;
return new double[]{p[0], 0.5 + z, 0.5 - y};
}
private double[][] minMax(double[] a, double[] b) {
double[] from = {Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.min(a[2], b[2])};
double[] to = {Math.max(a[0], b[0]), Math.max(a[1], b[1]), Math.max(a[2], b[2])};
return new double[][]{from, to};
}
private Face[] rotateFacesY(Face[] faces) {
Face[] out = new Face[6];
for (Direction d : Direction.values()) {
out[d.rotateY(1).ordinal()] = faces[d.ordinal()];
}
return out;
}
private Face[] rotateFacesX(Face[] faces) {
Face[] out = new Face[6];
for (Direction d : Direction.values()) {
out[d.rotateX(1).ordinal()] = faces[d.ordinal()];
}
return out;
}
// 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) {
// 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};
};
}
private int[] rotateAxisX(int axis, double angle) {
// 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};
};
}
}
@@ -0,0 +1,54 @@
package eu.mhsl.minecraft.pixelpics.assets;
import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Loads block models and flattens their parent chains into {@link FlatModel}s. Results are cached by
* model id.
*/
public final class ModelResolver {
private static final int MAX_DEPTH = 16;
private final AssetReader reader;
private final Map<ResourceLocation, FlatModel> cache = new ConcurrentHashMap<>();
public ModelResolver(AssetReader reader) {
this.reader = reader;
}
public FlatModel resolve(ResourceLocation modelId) {
FlatModel cached = cache.get(modelId);
if (cached != null) return cached;
FlatModel resolved = resolve(modelId, 0);
cache.put(modelId, resolved);
return resolved;
}
private FlatModel resolve(ResourceLocation modelId, int depth) {
ModelFileDto dto = reader.readJson(AssetPaths.model(modelId), ModelFileDto.class).orElse(null);
if (dto == null) {
return new FlatModel(new HashMap<>(), null);
}
Map<String, String> textures = new HashMap<>();
java.util.List<ModelFileDto.ElementDto> elements = dto.elements;
if (dto.parent != null && depth < MAX_DEPTH && !dto.parent.startsWith("builtin/")) {
FlatModel parent = resolve(ResourceLocation.parse(dto.parent), depth + 1);
textures.putAll(parent.textures());
if (elements == null || elements.isEmpty()) {
elements = parent.elements();
}
}
if (dto.textures != null) {
textures.putAll(dto.textures);
}
return new FlatModel(textures, elements);
}
}
@@ -0,0 +1,31 @@
package eu.mhsl.minecraft.pixelpics.assets;
/**
* A namespaced identifier as used throughout Minecraft assets, e.g. {@code minecraft:block/stone}.
* When no namespace is present the default {@code minecraft} is assumed.
*/
public record ResourceLocation(String namespace, String path) {
public static final String DEFAULT_NAMESPACE = "minecraft";
public ResourceLocation {
namespace = namespace.toLowerCase();
path = path.toLowerCase();
}
/**
* Parses a string like {@code minecraft:block/stone} or {@code block/stone}.
*/
public static ResourceLocation parse(String raw) {
int colon = raw.indexOf(':');
if (colon < 0) {
return new ResourceLocation(DEFAULT_NAMESPACE, raw);
}
return new ResourceLocation(raw.substring(0, colon), raw.substring(colon + 1));
}
@Override
public String toString() {
return namespace + ":" + path;
}
}
@@ -0,0 +1,27 @@
package eu.mhsl.minecraft.pixelpics.assets;
import java.io.Closeable;
import java.util.Optional;
/**
* A read-only source of Minecraft assets. Paths are pack-relative and always use {@code /} as a
* separator, e.g. {@code assets/minecraft/blockstates/oak_fence.json}.
*
* <p>Implementations must be safe for concurrent {@link #read} calls, since the renderer accesses
* assets from multiple threads.
*/
public interface ResourcePack extends Closeable {
/**
* Reads the raw bytes of an asset, or an empty optional if it does not exist.
*/
Optional<byte[]> read(String path);
/**
* Whether the given asset path exists in this pack.
*/
boolean exists(String path);
@Override
void close();
}
@@ -0,0 +1,79 @@
package eu.mhsl.minecraft.pixelpics.assets;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.logging.Logger;
import java.util.stream.Stream;
/**
* Builds a {@link ResourcePack} from the plugin's {@code resourcepack/} data folder. The folder may
* contain an unpacked pack (a directory with {@code assets/minecraft/...}) and/or one or more
* {@code .zip} packs. Returns an empty optional when nothing usable is found.
*/
public final class ResourcePackLoader {
/** The marker that identifies a valid pack root: {@code <root>/assets/minecraft}. */
private static final String MARKER = "assets/minecraft";
private ResourcePackLoader() {}
public static Optional<ResourcePack> load(File resourcePackDir, Logger logger) {
if (!resourcePackDir.isDirectory()) {
return Optional.empty();
}
List<ResourcePack> packs = new ArrayList<>();
// Directory packs: probe the folder itself and any direct sub-folder for the assets marker.
List<Path> dirCandidates = new ArrayList<>();
dirCandidates.add(resourcePackDir.toPath());
File[] children = resourcePackDir.listFiles(File::isDirectory);
if (children != null) {
for (File child : children) dirCandidates.add(child.toPath());
}
for (Path candidate : dirCandidates) {
if (Files.isDirectory(candidate.resolve(MARKER))) {
packs.add(new DirectoryResourcePack(candidate));
logger.info("Loaded resource pack directory: " + candidate);
}
}
// Zip packs anywhere under the resourcepack folder.
try (Stream<Path> walk = Files.walk(resourcePackDir.toPath())) {
List<Path> zips = walk
.filter(Files::isRegularFile)
.filter(p -> p.getFileName().toString().toLowerCase().endsWith(".zip"))
.toList();
for (Path zip : zips) {
try {
ZipResourcePack pack = new ZipResourcePack(zip);
if (pack.exists(MARKER + "/blockstates") || pack.exists("pack.mcmeta")
|| hasAnyBlockstate(pack)) {
packs.add(pack);
logger.info("Loaded resource pack zip: " + zip);
} else {
pack.close();
}
} catch (IOException e) {
logger.warning("Failed to open resource pack zip " + zip + ": " + e.getMessage());
}
}
} catch (IOException e) {
logger.warning("Failed to scan resource pack directory: " + e.getMessage());
}
if (packs.isEmpty()) return Optional.empty();
if (packs.size() == 1) return Optional.of(packs.getFirst());
return Optional.of(new CompositeResourcePack(packs));
}
private static boolean hasAnyBlockstate(ResourcePack pack) {
// Cheap sanity probe for a couple of guaranteed-present vanilla blockstates.
return pack.exists(AssetPaths.blockState("stone")) || pack.exists(AssetPaths.blockState("dirt"));
}
}
@@ -0,0 +1,76 @@
package eu.mhsl.minecraft.pixelpics.assets;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
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 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);
}
private int[][] download(String url) {
try {
URL u = URI.create(url).toURL();
URLConnection conn = u.openConnection();
conn.setConnectTimeout(5000);
conn.setReadTimeout(5000);
conn.setRequestProperty("User-Agent", "PixelPics");
BufferedImage img;
try (InputStream in = conn.getInputStream()) {
img = ImageIO.read(in);
}
if (img == null) return FAILED;
return toModern(img);
} catch (Exception e) {
return FAILED;
}
}
/** Reads the image to a 64x64 grid, converting the legacy 64x32 layout (mirrored arm/leg) if needed. */
private int[][] toModern(BufferedImage img) {
int w = img.getWidth();
int h = img.getHeight();
int[][] out = new int[64][64];
for (int y = 0; y < Math.min(h, 64); y++) {
for (int x = 0; x < Math.min(w, 64); x++) {
out[y][x] = img.getRGB(x, y);
}
}
if (h <= 32) {
// Legacy skin: copy the right arm/leg regions into the modern left arm/leg slots (mirrored).
copyMirrored(img, out, 44, 16, 36, 48); // right leg -> left leg (top/quads handled by mirror)
copyMirrored(img, out, 44, 16, 36, 52);
copyMirrored(img, out, 40, 16, 32, 48); // right arm -> left arm
}
return out;
}
/** Mirror-copies a 16x12 limb block (legacy) into a modern slot; coarse but visually adequate. */
private void copyMirrored(BufferedImage img, int[][] out, int srcX, int srcY, int dstX, int dstY) {
for (int y = 0; y < 12 && srcY + y < img.getHeight(); y++) {
for (int x = 0; x < 16 && srcX + x < img.getWidth(); x++) {
int px = img.getRGB(srcX + (15 - x), srcY + y);
int ox = dstX + x, oy = dstY + y;
if (ox < 64 && oy < 64) out[oy][ox] = px;
}
}
}
}
@@ -0,0 +1,64 @@
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
* UV origin is top-left); all orientation is handled by the UV math in the intersector.
*
* <p>Animated textures (e.g. {@code water_still.png}, a vertical strip of frames) are reduced to their
* first frame — but ONLY when an actual {@code .png.mcmeta} animation file is present. A tall sprite
* 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 {
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;
BufferedImage img;
try {
img = ImageIO.read(new ByteArrayInputStream(bytes.get()));
} catch (Exception e) {
return MISSING;
}
if (img == null) return MISSING;
int width = img.getWidth();
int height = img.getHeight();
// Reduce animated strips to the first frame — but only a REAL animation (has a .mcmeta); a tall
// sprite without one (e.g. a 64×128 entity texture) is a full texture, not a frame strip.
boolean animated = height > width && height % width == 0 && pack.exists(AssetPaths.textureMeta(id));
int frameHeight = animated ? width : height;
int[][] pixels = new int[frameHeight][width];
for (int y = 0; y < frameHeight; y++) {
for (int x = 0; x < width; x++) {
pixels[y][x] = img.getRGB(x, y);
}
}
return pixels;
}
}
@@ -0,0 +1,8 @@
package eu.mhsl.minecraft.pixelpics.assets;
/**
* A resolved blockstate variant: which model to use plus its {@code x}/{@code y} rotation (in
* degrees, multiples of 90) and {@code uvlock}.
*/
public record Variant(ResourceLocation model, int x, int y, boolean uvlock) {
}
@@ -0,0 +1,53 @@
package eu.mhsl.minecraft.pixelpics.assets;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
/**
* A {@link ResourcePack} backed by a {@code .zip} archive (e.g. a vanilla/custom resource pack).
*
* <p>{@link ZipFile} allows concurrent {@link ZipFile#getInputStream} calls, so reads are thread
* safe. The entry lookup map is built once at construction.
*/
public final class ZipResourcePack implements ResourcePack {
private final ZipFile zipFile;
private final Map<String, ZipEntry> entries = new HashMap<>();
public ZipResourcePack(Path zipPath) throws IOException {
this.zipFile = new ZipFile(zipPath.toFile());
zipFile.stream()
.filter(entry -> !entry.isDirectory())
.forEach(entry -> entries.put(entry.getName(), entry));
}
@Override
public Optional<byte[]> read(String path) {
ZipEntry entry = entries.get(path);
if (entry == null) return Optional.empty();
try (InputStream input = zipFile.getInputStream(entry)) {
return Optional.of(input.readAllBytes());
} catch (IOException e) {
return Optional.empty();
}
}
@Override
public boolean exists(String path) {
return entries.containsKey(path);
}
@Override
public void close() {
try {
zipFile.close();
} catch (IOException ignored) {
}
}
}
@@ -0,0 +1,36 @@
package eu.mhsl.minecraft.pixelpics.assets.dto;
import java.util.List;
import java.util.Map;
/**
* Gson-bound representation of a vanilla block model JSON
* ({@code assets/minecraft/models/block/*.json}).
*/
public class ModelFileDto {
public String parent;
public Map<String, String> textures;
public List<ElementDto> elements;
public static class ElementDto {
public double[] from; // 0..16
public double[] to; // 0..16
public RotationDto rotation; // optional
public Map<String, FaceDto> faces; // keys: down/up/north/south/west/east
}
public static class FaceDto {
public double[] uv; // optional, 0..16 (x1,y1,x2,y2)
public String texture; // e.g. "#side" or "minecraft:block/oak_planks"
public Integer tintindex;
public String cullface; // ignored by the renderer
public int rotation; // 0/90/180/270
}
public static class RotationDto {
public double[] origin; // 0..16
public String axis; // "x" | "y" | "z"
public double angle; // -45..45 in 22.5 steps
public boolean rescale;
}
}
@@ -0,0 +1,28 @@
package eu.mhsl.minecraft.pixelpics.assets.model;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
/**
* Computes the average opaque color of a texture, used as the 100% coverage fallback.
*/
public final class AverageColor {
private AverageColor() {}
/** Average ARGB over pixels with alpha &gt; 16; returns opaque gray when fully transparent/empty. */
public static int of(int[][] texture) {
long r = 0, g = 0, b = 0;
int count = 0;
for (int[] row : texture) {
for (int argb : row) {
if (ColorUtil.alpha(argb) <= 16) continue;
r += ColorUtil.red(argb);
g += ColorUtil.green(argb);
b += ColorUtil.blue(argb);
count++;
}
}
if (count == 0) return 0xFF7F7F7F;
return ColorUtil.argb(0xFF, (int) (r / count), (int) (g / count), (int) (b / count));
}
}
@@ -0,0 +1,66 @@
package eu.mhsl.minecraft.pixelpics.assets.model;
/**
* The six block face directions, with unit normals and 90-degree rotation helpers used when baking
* blockstate {@code x}/{@code y} rotations.
*/
public enum Direction {
DOWN(0, -1, 0),
UP(0, 1, 0),
NORTH(0, 0, -1),
SOUTH(0, 0, 1),
WEST(-1, 0, 0),
EAST(1, 0, 0);
public final int nx, ny, nz;
Direction(int nx, int ny, int nz) {
this.nx = nx;
this.ny = ny;
this.nz = nz;
}
public static Direction fromName(String name) {
return switch (name.toLowerCase()) {
case "down" -> DOWN;
case "up" -> UP;
case "north" -> NORTH;
case "south" -> SOUTH;
case "west" -> WEST;
case "east" -> EAST;
default -> null;
};
}
/** Rotate this direction by {@code steps * 90} degrees around the Y axis (clockwise from above). */
public Direction rotateY(int steps) {
steps = ((steps % 4) + 4) % 4;
Direction d = this;
for (int i = 0; i < steps; i++) {
d = switch (d) {
case NORTH -> EAST;
case EAST -> SOUTH;
case SOUTH -> WEST;
case WEST -> NORTH;
default -> d; // up/down unchanged
};
}
return d;
}
/** Rotate this direction by {@code steps * 90} degrees around the X axis. */
public Direction rotateX(int steps) {
steps = ((steps % 4) + 4) % 4;
Direction d = this;
for (int i = 0; i < steps; i++) {
d = switch (d) {
case UP -> NORTH;
case NORTH -> DOWN;
case DOWN -> SOUTH;
case SOUTH -> UP;
default -> d; // east/west unchanged
};
}
return d;
}
}
@@ -0,0 +1,36 @@
package eu.mhsl.minecraft.pixelpics.assets.model;
/**
* A single box of a baked block model. Coordinates are normalized to the 0..1 block cube. Faces are
* indexed by {@link Direction#ordinal()} and may be {@code null} when absent.
*
* <p>An optional element rotation (from the model JSON) is kept as origin/axis/angle so the
* intersector can treat the box as oriented (OBB) when {@code angle != 0}.
*/
public final class Element {
public final double[] from; // length 3, 0..1
public final double[] to; // length 3, 0..1
public final Face[] faces; // length 6, indexed by Direction.ordinal()
// Element rotation (0..1 origin), null/zero when axis-aligned.
public final double[] rotOrigin; // length 3, 0..1, may be null
public final int rotAxis; // 0=x,1=y,2=z, -1 when none
public final double rotAngleRad; // radians
public final boolean rescale;
public Element(double[] from, double[] to, Face[] faces,
double[] rotOrigin, int rotAxis, double rotAngleRad, boolean rescale) {
this.from = from;
this.to = to;
this.faces = faces;
this.rotOrigin = rotOrigin;
this.rotAxis = rotAxis;
this.rotAngleRad = rotAngleRad;
this.rescale = rescale;
}
public boolean isAxisAligned() {
return rotAxis < 0 || rotAngleRad == 0.0;
}
}
@@ -0,0 +1,51 @@
package eu.mhsl.minecraft.pixelpics.assets.model;
/**
* A textured face of an {@link Element}. UV coordinates are normalized to 0..1 in texture space with
* the origin at the top-left ({@code v} increasing downwards), matching the vanilla convention.
*/
public final class Face {
public final int[][] texture; // [y][x] ARGB, top-left origin
public final double u1, v1, u2, v2;
public final int rotation; // 0/90/180/270, applied to the sampled UV
public final int tintIndex; // -1 = no tint
public Face(int[][] texture, double u1, double v1, double u2, double v2, int rotation, int tintIndex) {
this.texture = texture;
this.u1 = u1;
this.v1 = v1;
this.u2 = u2;
this.v2 = v2;
this.rotation = rotation;
this.tintIndex = tintIndex;
}
/**
* Samples the ARGB pixel for a position within the face, where {@code (s, t)} are in 0..1 across
* the face's two in-plane axes (s = horizontal, t = vertical, top-left origin).
*/
public int sample(double s, double t) {
// Apply face rotation by rotating the (s,t) lookup.
double rs = s, rt = t;
switch (((rotation % 360) + 360) % 360) {
case 90 -> { rs = t; rt = 1.0 - s; }
case 180 -> { rs = 1.0 - s; rt = 1.0 - t; }
case 270 -> { rs = 1.0 - t; rt = s; }
default -> { /* 0 */ }
}
double u = u1 + (u2 - u1) * rs;
double v = v1 + (v2 - v1) * rt;
int h = texture.length;
if (h == 0) return 0;
int w = texture[0].length;
int px = (int) Math.floor(u * w);
int py = (int) Math.floor(v * h);
px = Math.max(0, Math.min(w - 1, px));
py = Math.max(0, Math.min(h - 1, py));
return texture[py][px];
}
}
@@ -0,0 +1,31 @@
package eu.mhsl.minecraft.pixelpics.assets.model;
import java.util.List;
/**
* A fully baked, intersectable block model: a list of {@link Element} boxes plus rendering hints.
*
* <p>{@code averageColor} is the 100% coverage fallback used for blocks without geometry
* (builtin/generated models, unresolved blocks) and as a backstop. {@code hasGeometry} is false when
* the model has no usable elements; the renderer then draws a flat shaded cube using
* {@code averageColor}.
*/
public final class ResolvedModel {
public final List<Element> elements;
public final int averageColor; // ARGB
public final double transparency; // 0..1
public final double reflection; // 0..1
public final boolean occluding;
public final boolean hasGeometry;
public ResolvedModel(List<Element> elements, int averageColor,
double transparency, double reflection, boolean occluding, boolean hasGeometry) {
this.elements = elements;
this.averageColor = averageColor;
this.transparency = transparency;
this.reflection = reflection;
this.occluding = occluding;
this.hasGeometry = hasGeometry;
}
}
@@ -1,8 +1,14 @@
package eu.mhsl.minecraft.pixelpics.commands;
import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer;
import eu.mhsl.minecraft.pixelpics.Main;
import eu.mhsl.minecraft.pixelpics.render.render.DefaultScreenRenderer;
import eu.mhsl.minecraft.pixelpics.render.render.RenderJob;
import eu.mhsl.minecraft.pixelpics.render.render.Resolution;
import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer;
import eu.mhsl.minecraft.pixelpics.utils.MapImageDither;
import eu.mhsl.minecraft.pixelpics.utils.MapManager;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.command.Command;
@@ -15,50 +21,128 @@ import org.bukkit.map.MapView;
import org.bukkit.persistence.PersistentDataType;
import org.jetbrains.annotations.NotNull;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.Set;
import java.util.UUID;
public class PixelPicsCommand implements CommandExecutor {
private static final int DEFAULT_CLEANUP_DAYS = 30;
@Override
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String @NotNull [] args) {
if(!(sender instanceof Player player))
throw new IllegalStateException("Dieser Command kann nur von einem Spieler ausgeführt werden!");
if(args.length > 0)
return false;
// render image
Resolution resolution = new Resolution(Resolution.Pixels._128P, Resolution.AspectRatio._1_1);
BufferedImage image = Main.getInstance().getScreenRenderer().render(player.getEyeLocation(), resolution);
// save image
UUID imageId = UUID.randomUUID();
File imageFolder = new File(Main.getInstance().getDataFolder(), "images");
try {
if(!imageFolder.exists() && !imageFolder.mkdirs())
throw new IOException("Failed to create folders for image output!");
ImageIO.write(image, "png", new File(imageFolder, String.format("%s.png", imageId)));
} catch (IOException e) {
throw new RuntimeException("Failed to save image to disk!");
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label,
@NotNull String @NotNull [] args) {
if (args.length >= 1 && args[0].equalsIgnoreCase("cleanup")) {
return cleanup(sender, args);
}
// image item
if (!(sender instanceof Player player)) {
sender.sendMessage(Component.text("Dieser Command kann nur von einem Spieler ausgeführt werden!",
NamedTextColor.RED));
return true;
}
if (args.length > 0) return false;
DefaultScreenRenderer renderer = Main.getInstance().getScreenRenderer();
if (renderer == null) {
player.sendMessage(Component.text("PixelPics ist nicht einsatzbereit: es wurde kein Resource-Pack geladen.",
NamedTextColor.RED));
return true;
}
Resolution resolution = new Resolution(Resolution.Pixels._128P, Resolution.AspectRatio._1_1);
// Capture the world snapshot on the main thread.
RenderJob job = renderer.prepare(player.getEyeLocation(), resolution, player.getUniqueId());
// Hand the map over immediately, showing blank "film"; it develops once the render is ready.
ImageMapRenderer mapRenderer = new ImageMapRenderer();
MapView mapView = Bukkit.createMap(player.getWorld());
int id = mapView.getId();
MapManager.attachView(mapView, mapRenderer);
ItemStack map = new ItemStack(Material.FILLED_MAP, 1);
MapMeta meta = (MapMeta) map.getItemMeta();
meta.getPersistentDataContainer().set(Main.getInstance().pictureIdFlag, PersistentDataType.STRING, imageId.toString());
// display image
MapView mapView = Bukkit.createMap(Bukkit.getWorlds().getFirst());
mapView.getRenderers().forEach(mapView::removeRenderer);
mapView.addRenderer(new ImageMapRenderer(image));
meta.getPersistentDataContainer().set(Main.getInstance().pictureIdFlag,
PersistentDataType.STRING, UUID.randomUUID().toString());
meta.setMapView(mapView);
map.setItemMeta(meta);
player.getInventory().addItem(map);
player.sendMessage(Component.text("📸 Aufnahme wird entwickelt …", NamedTextColor.GRAY));
// Trace + dither off-thread, then start the developing animation on the main thread.
Main plugin = Main.getInstance();
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
BufferedImage image;
byte[] indices;
try {
image = renderer.execute(job);
indices = MapImageDither.dither(image);
} catch (Exception e) {
plugin.getLogger().warning("Render failed: " + e.getMessage());
Bukkit.getScheduler().runTask(plugin, () ->
player.sendMessage(Component.text("Rendern fehlgeschlagen.", NamedTextColor.RED)));
return;
}
BufferedImage finalImage = image;
byte[] finalIndices = indices;
Bukkit.getScheduler().runTask(plugin, () -> {
MapManager.saveImage(finalImage, id);
MapManager.saveIndices(finalIndices, id);
mapRenderer.develop(finalIndices);
player.sendMessage(Component.text("✅ Aufnahme erstellt!", NamedTextColor.GREEN));
});
});
return true;
}
/** {@code /pixelPic cleanup [days]} dry-run; {@code /pixelPic cleanup confirm [days]} deletes orphans. */
private boolean cleanup(CommandSender sender, String[] args) {
if (!sender.hasPermission("pixelpic.admin") && !sender.isOp()) {
sender.sendMessage(Component.text("Dafür fehlt dir die Berechtigung (pixelpic.admin).", NamedTextColor.RED));
return true;
}
boolean confirm = false;
int days = DEFAULT_CLEANUP_DAYS;
for (int i = 1; i < args.length; i++) {
if (args[i].equalsIgnoreCase("confirm")) confirm = true;
else {
try {
days = Math.max(0, Integer.parseInt(args[i]));
} catch (NumberFormatException ignored) {
}
}
}
Set<Integer> inUse = MapManager.collectInUseMapIds();
long cutoff = System.currentTimeMillis() - days * 86_400_000L;
List<MapManager.StoredMap> candidates = MapManager.listStored().stream()
.filter(s -> !inUse.contains(s.id()) && s.lastModified() < cutoff)
.toList();
if (candidates.isEmpty()) {
sender.sendMessage(Component.text("Keine aufräumbaren Aufnahmen gefunden (älter als " + days
+ " Tage und nicht in Benutzung).", NamedTextColor.YELLOW));
return true;
}
if (!confirm) {
sender.sendMessage(Component.text(candidates.size() + " Aufnahme(n) könnten gelöscht werden "
+ "(älter als " + days + " Tage, nicht in geladenen Itemframes/Online-Inventaren).",
NamedTextColor.YELLOW));
sender.sendMessage(Component.text("Achtung: Maps in lange ungeladenen Bereichen werden hierbei nicht "
+ "erkannt. Zum Löschen: /pixelPic cleanup confirm " + days, NamedTextColor.GRAY));
return true;
}
int deleted = 0;
for (MapManager.StoredMap s : candidates) {
if (MapManager.delete(s.id())) deleted++;
}
sender.sendMessage(Component.text(deleted + " Aufnahme(n) gelöscht.", NamedTextColor.GREEN));
return true;
}
}
@@ -0,0 +1,32 @@
package eu.mhsl.minecraft.pixelpics.listeners;
import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer;
import eu.mhsl.minecraft.pixelpics.utils.MapImageDither;
import eu.mhsl.minecraft.pixelpics.utils.MapManager;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.server.MapInitializeEvent;
import java.awt.image.BufferedImage;
public class OnMapInitialize implements Listener {
@EventHandler
public void onMapInitialize(MapInitializeEvent event) {
int mapId = event.getMap().getId();
// Fast path: use the cached dithered indices (no re-quantization).
byte[] indices = MapManager.readIndices(mapId);
if (indices != null) {
MapManager.attachView(event.getMap(), new ImageMapRenderer(indices));
return;
}
// Fallback/migration: dither from the stored PNG once, then cache for next time.
BufferedImage image = MapManager.readImage(mapId);
if (image == null) return;
indices = MapImageDither.dither(image);
MapManager.saveIndices(indices, mapId);
MapManager.attachView(event.getMap(), new ImageMapRenderer(indices));
}
}
@@ -0,0 +1,98 @@
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.
*/
public final class Affine {
// row-major 3x3
public final double[] r;
public final double[] t;
public Affine(double[] r, double[] t) {
this.r = r;
this.t = t;
}
public static Affine identity() {
return new Affine(new double[]{1, 0, 0, 0, 1, 0, 0, 0, 1}, new double[]{0, 0, 0});
}
public static Affine translation(double x, double y, double z) {
return new Affine(new double[]{1, 0, 0, 0, 1, 0, 0, 0, 1}, new double[]{x, y, z});
}
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});
}
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});
}
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});
}
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});
}
/** this ∘ o (apply o first, then this). */
public Affine mul(Affine o) {
double[] a = this.r, b = o.r;
double[] nr = new double[9];
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
nr[i * 3 + j] = a[i * 3] * b[j] + a[i * 3 + 1] * b[3 + j] + a[i * 3 + 2] * b[6 + j];
}
}
double[] ot = o.t;
double[] nt = new double[]{
a[0] * ot[0] + a[1] * ot[1] + a[2] * ot[2] + this.t[0],
a[3] * ot[0] + a[4] * ot[1] + a[5] * ot[2] + this.t[1],
a[6] * ot[0] + a[7] * ot[1] + a[8] * ot[2] + this.t[2]
};
return new Affine(nr, nt);
}
public double[] apply(double x, double y, double z) {
return new double[]{
r[0] * x + r[1] * y + r[2] * z + t[0],
r[3] * x + r[4] * y + r[5] * z + t[1],
r[6] * x + r[7] * y + r[8] * z + t[2]
};
}
/** 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
};
}
/** General affine inverse (3x3 inverse + translation). */
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 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,
(f * g - d * i) * inv, (a * i - c * g) * inv, (c * d - a * f) * inv,
(d * h - e * g) * inv, (b * g - a * h) * inv, (a * e - b * d) * inv
};
double[] it = new double[]{
-(ir[0] * t[0] + ir[1] * t[1] + ir[2] * t[2]),
-(ir[3] * t[0] + ir[4] * t[1] + ir[5] * t[2]),
-(ir[6] * t[0] + ir[7] * t[1] + ir[8] * t[2])
};
return new Affine(ir, it);
}
}
@@ -0,0 +1,107 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import java.util.ArrayList;
import java.util.List;
/**
* Maps a {@link BlockEntityState} to its bundled CEM model name and ordered texture-path candidates.
* Block-entity geometry ships in the same CEM set as mobs (chest/sign/banner/…); textures live under
* {@code assets/minecraft/textures/entity/}. Mirrors the role {@link EntityModels} plays for mobs.
*/
public final class BlockEntityModels {
private BlockEntityModels() {}
/** The CEM ({@code .jem}) model name for a block-entity. */
public static String cemModel(BlockEntityState s) {
return switch (s.kind()) {
case CHEST, TRAPPED_CHEST, ENDER_CHEST -> switch (s.chestKind() == null ? BlockEntityState.ChestKind.SINGLE : s.chestKind()) {
case LEFT -> "chest_left";
case RIGHT -> "chest_right";
case SINGLE -> "chest";
};
case SIGN -> "sign";
case WALL_SIGN -> "wall_sign";
case HANGING_SIGN -> "hanging_sign";
case BANNER, WALL_BANNER -> "banner";
case BED -> "bed";
case SHULKER_BOX -> "shulker_box";
case CONDUIT -> "conduit";
case DECORATED_POT -> "decorated_pot";
case BELL -> "bell";
case HEAD, WALL_HEAD -> headModel(s.headType());
};
}
private static String headModel(String headType) {
if (headType == null) return "head";
return switch (headType) {
case "dragon" -> "head_dragon";
case "piglin" -> "head_piglin";
case "player" -> "head_player";
case "wither_skeleton" -> "wither_skull";
default -> "head"; // skeleton, zombie, creeper share the plain humanoid skull box
};
}
/** Ordered texture-path candidates; the baker uses the first that loads. */
public static List<ResourceLocation> textureCandidates(BlockEntityState s) {
List<String> paths = new ArrayList<>();
switch (s.kind()) {
case CHEST -> chestTextures(paths, s, "normal");
case TRAPPED_CHEST -> chestTextures(paths, s, "trapped");
case ENDER_CHEST -> paths.add("entity/chest/ender");
case SIGN, WALL_SIGN -> {
String wood = s.wood() == null ? "oak" : s.wood();
paths.add("entity/signs/" + wood);
paths.add("entity/sign"); // legacy single-texture fallback
}
case HANGING_SIGN -> {
String wood = s.wood() == null ? "oak" : s.wood();
paths.add("entity/signs/hanging/" + wood);
}
case BANNER, WALL_BANNER -> paths.add("entity/banner/banner_base"); // tinted by baseColorArgb
case BED -> {
String c = s.colorName() == null ? "red" : s.colorName();
paths.add("entity/bed/" + c);
}
case SHULKER_BOX -> {
if (s.colorName() != null) paths.add("entity/shulker/shulker_" + s.colorName());
paths.add("entity/shulker/shulker"); // uncoloured (purpur) default
}
case CONDUIT -> paths.add("entity/conduit/base");
case DECORATED_POT -> paths.add("entity/decorated_pot/decorated_pot_base");
case BELL -> paths.add("entity/bell/bell_body");
case HEAD, WALL_HEAD -> headTextures(paths, s.headType());
}
List<ResourceLocation> out = new ArrayList<>(paths.size());
for (String p : paths) out.add(ResourceLocation.parse(p));
return out;
}
private static void chestTextures(List<String> paths, BlockEntityState s, String base) {
BlockEntityState.ChestKind ck = s.chestKind() == null ? BlockEntityState.ChestKind.SINGLE : s.chestKind();
switch (ck) {
case LEFT -> paths.add("entity/chest/" + base + "_left");
case RIGHT -> paths.add("entity/chest/" + base + "_right");
case SINGLE -> paths.add("entity/chest/" + base);
}
paths.add("entity/chest/" + base); // fallback to the single texture
}
private static void headTextures(List<String> paths, String headType) {
if (headType == null) { paths.add("entity/skeleton/skeleton"); return; }
switch (headType) {
case "skeleton" -> paths.add("entity/skeleton/skeleton");
case "wither_skeleton" -> paths.add("entity/skeleton/wither_skeleton");
case "zombie" -> paths.add("entity/zombie/zombie");
case "creeper" -> paths.add("entity/creeper/creeper");
case "piglin" -> paths.add("entity/piglin/piglin");
case "dragon" -> paths.add("entity/enderdragon/dragon");
case "player" -> paths.add("entity/player/wide/steve"); // skin handled separately by the baker
default -> paths.add("entity/skeleton/skeleton");
}
}
}
@@ -0,0 +1,46 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
import java.util.List;
/**
* Immutable snapshot of one block-entity captured on the main thread, sufficient to bake and place it
* off-thread. Block-entities (chests, signs, banners, beds, heads, …) use vanilla {@code builtin/entity}
* block models with no geometry; their real shape lives in the bundled CEM models, so they are rendered
* through the same baking/intersection path as mobs. Bukkit-free so the baker needs no world access.
*
* <p>{@code facingDeg} is the world yaw the model should face (already converted from the block's
* facing/rotation, vanilla convention). Type-specific fields are null/0/empty when unused.
*/
public record BlockEntityState(
Kind kind,
int bx, int by, int bz,
float facingDeg,
ChestKind chestKind, // double-chest half (CHEST/TRAPPED_CHEST/ENDER_CHEST)
int baseColorArgb, // banner base tint (white texture); 0 = none
String colorName, // bed/shulker/banner colour variant ("red", "white", …); null = default
String wood, // sign/hanging-sign wood ("oak", "spruce", …); null = default
BedPart bedPart, // bed half
String headType, // "skeleton","wither_skeleton","zombie","creeper","piglin","dragon","player"
String skinUrl, // player-head owner skin URL; null otherwise
List<BannerPattern> patterns, // banner overlay patterns (may be empty)
List<String> sherds, // decorated-pot sherds: front/back/left/right item keys (may be empty)
BellAttach bellAttach // bell attachment; null when not a bell
) {
public enum Kind {
CHEST, TRAPPED_CHEST, ENDER_CHEST,
SIGN, WALL_SIGN, HANGING_SIGN,
BANNER, WALL_BANNER,
BED, SHULKER_BOX,
HEAD, WALL_HEAD,
CONDUIT, DECORATED_POT, BELL
}
public enum ChestKind { SINGLE, LEFT, RIGHT }
public enum BedPart { HEAD, FOOT }
public enum BellAttach { FLOOR, CEILING, SINGLE_WALL, DOUBLE_WALL }
/** One banner overlay layer: a pattern key (e.g. "stripe_top") and the ARGB colour it is dyed with. */
public record BannerPattern(String patternKey, int colorArgb) {}
}
@@ -0,0 +1,70 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
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.
*/
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.
*/
public static Face[] build(ModelCube cube, int[][] texture, int declaredW, int declaredH) {
int texW = texture.length > 0 ? texture[0].length : 64;
int texH = texture.length > 0 ? texture.length : 64;
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++) {
if (cube.faceUv[i] != null) faces[i] = face(cube.faceUv[i], texture, nW, nH);
}
return faces;
}
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.
double[] east = {u, v + dz, dz, dy};
double[] west = {u + dz + dx, v + dz, dz, dy};
double[] north = {u + dz, v + dz, dx, dy};
double[] south = {u + 2 * dz + dx, v + dz, dx, dy};
double[] up = {u + dz + dx, v + dz, -dx, -dz};
double[] down = {u + dz + 2 * dx, v, -dx, dz};
if (cube.mirror) {
for (double[] f : new double[][]{east, west, up, down, south, north}) { f[0] += f[2]; f[2] = -f[2]; }
double[] tmp = east; east = west; west = tmp; // mirror swaps the left/right faces
}
Face[] faces = new Face[6];
faces[Direction.EAST.ordinal()] = face(east, texture, nW, nH);
faces[Direction.WEST.ordinal()] = face(west, texture, nW, nH);
faces[Direction.NORTH.ordinal()] = face(north, texture, nW, nH);
faces[Direction.SOUTH.ordinal()] = face(south, texture, nW, nH);
faces[Direction.UP.ordinal()] = face(up, texture, nW, nH);
faces[Direction.DOWN.ordinal()] = face(down, texture, nW, nH);
return faces;
}
private static Face face(double[] rect, int[][] texture, int texW, int texH) {
double u1 = rect[0] / texW;
double v1 = rect[1] / texH;
double u2 = (rect[0] + rect[2]) / texW;
double v2 = (rect[1] + rect[3]) / texH;
return new Face(texture, u1, v1, u2, v2, 0, -1);
}
}
@@ -0,0 +1,168 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import java.util.ArrayList;
import java.util.List;
/**
* Bakes flat wall decorations (paintings, item frames) into world-space {@link EntityCube}s. These are
* not CEM models but simple textured quads, so the geometry is built directly from the captured world
* bounding box and the front-facing direction. Added to the {@link EntityScene} like any other entity.
*/
public final class DecorationBaker {
private static final double MIN_THICKNESS = 0.0625; // 1px, so the slab test never degenerates
private final TextureCache textures;
public DecorationBaker(TextureCache textures) {
this.textures = textures;
}
public RenderedEntity bake(DecorationState s) {
return s.kind() == DecorationState.Kind.PAINTING ? bakePainting(s) : bakeItemFrame(s);
}
private RenderedEntity bakePainting(DecorationState s) {
int[][] art = textures.get(ResourceLocation.parse("painting/" + s.paintingArt())).orElse(null);
if (art == null) return null;
int[][] back = textures.get(ResourceLocation.parse("painting/back")).orElse(art);
double[] from = {s.minX(), s.minY(), s.minZ()};
double[] to = {s.maxX(), s.maxY(), s.maxZ()};
thicken(from, to, s.facing());
Face[] faces = new Face[6];
Direction front = direction(s.facing());
faces[front.ordinal()] = frontFace(art, s.facing());
faces[opposite(front).ordinal()] = new Face(back, 0, 0, 1, 1, 0, -1);
EntityCube cube = new EntityCube(from, to, faces, Affine.identity());
return scene(List.of(cube));
}
/**
* Item frames render as the vanilla geometry: a 12x12 birch border, a recessed 10x10 leather back
* panel ({@code block/item_frame}), and the held item as a centred 8x8 sprite. Built in a canonical
* local frame (front toward +Z, centred on the block face) and oriented to the wall by an affine.
*/
private RenderedEntity bakeItemFrame(DecorationState s) {
String frameTex = s.glow() ? "block/glow_item_frame" : "block/item_frame";
int[][] leather = textures.get(ResourceLocation.parse(frameTex)).orElse(null);
int[][] wood = textures.get(ResourceLocation.parse("block/birch_planks")).orElse(leather);
if (leather == null) return null;
Affine toWorld = Affine.translation(faceCenterX(s), faceCenterY(s), faceCenterZ(s)).mul(facingRotation(s.facing()));
int si = Direction.SOUTH.ordinal();
List<EntityCube> cubes = new ArrayList<>(3);
// Wood border 12x12 (behind), leather back 10x10 (1px proud), item 8x8 (in front). Front = local +Z.
if (wood != null) {
Face[] f = new Face[6];
f[si] = new Face(wood, 1, 0, 0, 1, 0, -1); // tileable; flip matches the others
cubes.add(new EntityCube(px(-6, -6, 0), px(6, 6, 1), f, toWorld));
}
Face[] back = new Face[6];
back[si] = new Face(leather, 13.0 / 16, 3.0 / 16, 3.0 / 16, 13.0 / 16, 0, -1); // centre 10x10, flipped
cubes.add(new EntityCube(px(-5, -5, 1), px(5, 5, 2), back, toWorld));
if (s.itemId() != null) {
int[][] item = resolveItem(s.itemId());
if (item != null) {
int rot = ((Math.round(s.itemRotationDeg() / 90f) * 90) % 360 + 360) % 360;
Face[] f = new Face[6];
f[si] = new Face(item, 1, 0, 0, 1, rot, -1); // full sprite, flipped to read upright
cubes.add(new EntityCube(px(-4, -4, 2), px(4, 4, 3), f, toWorld));
}
}
return scene(cubes);
}
/** A local-space corner in model pixels (1/16 block); z is the outward (front) offset from the wall. */
private static double[] px(double x, double y, double z) {
return new double[]{x / 16.0, y / 16.0, z / 16.0};
}
/** Item sprite: generated items live under item/, block items fall back to block/. */
private int[][] resolveItem(String id) {
int[][] t = textures.get(ResourceLocation.parse("item/" + id)).orElse(null);
if (t != null) return t;
return textures.get(ResourceLocation.parse("block/" + id)).orElse(null);
}
// Frame placement: in-plane centre from the bbox, depth at the outward wall surface.
private static double faceCenterX(DecorationState s) {
return s.facing().axis() == 0 ? (s.facing().sign() > 0 ? s.maxX() : s.minX()) : (s.minX() + s.maxX()) / 2;
}
private static double faceCenterY(DecorationState s) {
return s.facing().axis() == 1 ? (s.facing().sign() > 0 ? s.maxY() : s.minY()) : (s.minY() + s.maxY()) / 2;
}
private static double faceCenterZ(DecorationState s) {
return s.facing().axis() == 2 ? (s.facing().sign() > 0 ? s.maxZ() : s.minZ()) : (s.minZ() + s.maxZ()) / 2;
}
/** Maps the canonical local frame (front +Z, up +Y) onto the wall facing the given direction. */
private static Affine facingRotation(DecorationState.Facing f) {
return switch (f) {
case SOUTH -> Affine.identity();
case NORTH -> Affine.rotY(Math.PI);
case EAST -> Affine.rotY(Math.PI / 2);
case WEST -> Affine.rotY(-Math.PI / 2);
case UP -> Affine.rotX(-Math.PI / 2);
case DOWN -> Affine.rotX(Math.PI / 2);
};
}
/** Front face with the per-facing horizontal flip so the texture reads upright and unmirrored. */
private Face frontFace(int[][] tex, DecorationState.Facing facing) {
boolean flipH = facing == DecorationState.Facing.SOUTH || facing == DecorationState.Facing.WEST;
double u1 = flipH ? 1 : 0, u2 = flipH ? 0 : 1;
return new Face(tex, u1, 0, u2, 1, 0, -1);
}
private void thicken(double[] from, double[] to, DecorationState.Facing facing) {
int a = facing.axis();
if (to[a] - from[a] >= MIN_THICKNESS) return;
if (facing.sign() > 0) from[a] = to[a] - MIN_THICKNESS;
else to[a] = from[a] + MIN_THICKNESS;
}
private static Direction direction(DecorationState.Facing f) {
return switch (f) {
case NORTH -> Direction.NORTH;
case SOUTH -> Direction.SOUTH;
case EAST -> Direction.EAST;
case WEST -> Direction.WEST;
case UP -> Direction.UP;
case DOWN -> Direction.DOWN;
};
}
private static Direction opposite(Direction d) {
return switch (d) {
case NORTH -> Direction.SOUTH;
case SOUTH -> Direction.NORTH;
case EAST -> Direction.WEST;
case WEST -> Direction.EAST;
case UP -> Direction.DOWN;
case DOWN -> Direction.UP;
};
}
private static RenderedEntity scene(List<EntityCube> cubes) {
double[] min = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE};
double[] max = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE};
for (EntityCube c : cubes) {
for (int a = 0; a < 3; a++) {
if (c.aabbMin[a] < min[a]) min[a] = c.aabbMin[a];
if (c.aabbMax[a] > max[a]) max[a] = c.aabbMax[a];
}
}
return new RenderedEntity(cubes, min, max);
}
}
@@ -0,0 +1,41 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
/**
* Immutable snapshot of a flat wall decoration (painting or item frame). These are Bukkit
* {@code Hanging} entities, not block-entities, but render as flat textured quads rather than CEM
* models, so they have their own state/baker. The world-space bounding box is captured directly (it
* already encodes vanilla's awkward multi-block painting offset), avoiding placement math.
*/
public record DecorationState(
Kind kind,
double minX, double minY, double minZ,
double maxX, double maxY, double maxZ,
Facing facing, // direction the front faces (away from the wall)
String paintingArt, // painting art asset key (texture painting/<art>); null for frames
String itemId, // item-frame contents material key (e.g. "diamond"); null if empty
int itemRotationDeg, // item-frame content rotation (0/45/…/315)
boolean glow // glow item frame
) {
public enum Kind { PAINTING, ITEM_FRAME }
public enum Facing {
NORTH, SOUTH, EAST, WEST, UP, DOWN;
/** The axis this face's normal lies on: 0=x, 1=y, 2=z. */
public int axis() {
return switch (this) {
case EAST, WEST -> 0;
case UP, DOWN -> 1;
case NORTH, SOUTH -> 2;
};
}
/** +1 or -1 along {@link #axis()}. */
public int sign() {
return switch (this) {
case SOUTH, UP, EAST -> 1;
case NORTH, DOWN, WEST -> -1;
};
}
}
}
@@ -0,0 +1,36 @@
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.
*/
public final class EntityCube {
public final double[] from; // local min (px, inflated)
public final double[] to; // local max
public final Face[] faces; // by Direction.ordinal()
public final Affine toWorld;
public final Affine toLocal; // inverse
public final double[] aabbMin = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE};
public final double[] aabbMax = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE};
public EntityCube(double[] from, double[] to, Face[] faces, Affine toWorld) {
this.from = from;
this.to = to;
this.faces = faces;
this.toWorld = toWorld;
this.toLocal = toWorld.inverse();
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];
}
}
}
}
@@ -0,0 +1,88 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.render.raytrace.FaceHit;
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). The returned {@code t} is a world-space distance.
*/
public final class EntityIntersector {
private static final double EPS = 1e-7;
private static final int ALPHA_THRESHOLD = 16;
private EntityIntersector() {}
public static FaceHit intersect(EntityCube cube, double ox, double oy, double oz,
double dx, double dy, double dz) {
double[] o = cube.toLocal.apply(ox, oy, oz);
double[] d = cube.toLocal.applyLinear(dx, dy, dz);
double tmin = Double.NEGATIVE_INFINITY, tmax = Double.POSITIVE_INFINITY;
int axis = -1;
boolean neg = false;
for (int a = 0; a < 3; a++) {
if (Math.abs(d[a]) < EPS) {
if (o[a] < cube.from[a] - EPS || o[a] > cube.to[a] + EPS) return null;
continue;
}
double inv = 1.0 / d[a];
double t1 = (cube.from[a] - o[a]) * inv;
double t2 = (cube.to[a] - o[a]) * inv;
boolean n = true;
if (t1 > t2) {
double tmp = t1; t1 = t2; t2 = tmp;
n = false;
}
if (t1 > tmin) { tmin = t1; axis = a; neg = n; }
if (t2 < tmax) tmax = t2;
if (tmin > tmax) return null;
}
if (axis < 0) return null;
double t = tmin;
if (t < EPS) { t = tmax; if (t < EPS) return null; }
double px = o[0] + d[0] * t, py = o[1] + d[1] * t, pz = o[2] + d[2] * t;
Direction dir = switch (axis) {
case 0 -> neg ? Direction.WEST : Direction.EAST;
case 1 -> neg ? Direction.DOWN : Direction.UP;
default -> neg ? Direction.NORTH : Direction.SOUTH;
};
Face face = cube.faces[dir.ordinal()];
if (face == null) return null;
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]);
// (s,t) = Blockbench/Java box-UV (lerp_x, lerp_y) for this face (see BoxUv). Front/right faces
// run their horizontal axis opposite to back/left (they're viewed from the other side).
double s, tt;
switch (dir) {
case UP -> { s = fx; tt = fz; }
case DOWN -> { s = fx; tt = 1 - fz; }
case NORTH -> { s = 1 - fx; tt = 1 - fy; }
case SOUTH -> { s = fx; tt = 1 - fy; }
case EAST -> { s = 1 - fz; tt = 1 - fy; }
default -> { s = fz; tt = 1 - fy; } // WEST
}
int color = face.sample(s, tt);
if (ColorUtil.alpha(color) <= ALPHA_THRESHOLD) return null;
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();
return new FaceHit(t, world, normal, color, -1);
}
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);
}
}
@@ -0,0 +1,168 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import java.util.ArrayList;
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.
*/
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).
private static final Map<String, String> CEM_OVERRIDE = Map.ofEntries(
Map.entry("husk", "zombie"),
Map.entry("giant", "zombie"),
Map.entry("mooshroom", "cow"),
Map.entry("ocelot", "cat"),
Map.entry("cave_spider", "spider"),
Map.entry("elder_guardian", "guardian"),
Map.entry("glow_squid", "squid"),
Map.entry("mule", "donkey"),
Map.entry("skeleton_horse", "horse"),
Map.entry("zombie_horse", "horse"),
Map.entry("trader_llama", "llama"),
Map.entry("stray", "skeleton"),
Map.entry("wither_skeleton", "skeleton"),
Map.entry("zoglin", "hoglin"),
Map.entry("piglin_brute", "piglin"),
Map.entry("zombified_piglin", "piglin"),
Map.entry("evoker", "illager"),
Map.entry("vindicator", "illager"),
Map.entry("illusioner", "illager"),
Map.entry("wandering_trader", "villager"),
Map.entry("ender_dragon", "dragon"),
Map.entry("mannequin", "player"),
Map.entry("camel_husk", "camel"),
Map.entry("rabbit", "rabbit_21.11"),
Map.entry("pufferfish", "puffer_fish_big"),
Map.entry("tropical_fish", "tropical_fish_a")
);
/** 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";
return CEM_OVERRIDE.getOrDefault(typeKey, typeKey);
}
// Type key -> texture path override (where the first derived candidate is wrong).
private static final Map<String, String> TEX_OVERRIDE = Map.ofEntries(
Map.entry("cow", "entity/cow/cow_temperate"),
Map.entry("mooshroom", "entity/cow/mooshroom_red"),
Map.entry("zombie", "entity/zombie/zombie"),
Map.entry("husk", "entity/zombie/husk"),
Map.entry("drowned", "entity/zombie/drowned"),
Map.entry("zombified_piglin", "entity/piglin/zombified_piglin"),
Map.entry("skeleton", "entity/skeleton/skeleton"),
Map.entry("stray", "entity/skeleton/stray"),
Map.entry("wither_skeleton", "entity/skeleton/wither_skeleton"),
Map.entry("creeper", "entity/creeper/creeper"),
Map.entry("spider", "entity/spider/spider"),
Map.entry("enderman", "entity/enderman/enderman"),
Map.entry("player", "entity/player/wide/steve"),
// Textures whose folder/name doesn't follow the "entity/<key>/<key>" pattern.
Map.entry("iron_golem", "entity/iron_golem/iron_golem"),
Map.entry("polar_bear", "entity/bear/polarbear"),
Map.entry("ender_dragon", "entity/enderdragon/dragon"),
Map.entry("magma_cube", "entity/slime/magmacube"),
Map.entry("tropical_fish", "entity/fish/tropical_a"),
Map.entry("bogged", "entity/skeleton/bogged"),
Map.entry("donkey", "entity/horse/donkey"),
Map.entry("mule", "entity/horse/mule"),
Map.entry("skeleton_horse", "entity/horse/horse_skeleton"),
Map.entry("zombie_horse", "entity/horse/horse_zombie"),
Map.entry("trader_llama", "entity/llama/llama_creamy"),
Map.entry("cave_spider", "entity/spider/cave_spider"),
Map.entry("guardian", "entity/guardian/guardian"),
Map.entry("elder_guardian", "entity/guardian/guardian_elder"),
Map.entry("piglin_brute", "entity/piglin/piglin_brute"),
Map.entry("zoglin", "entity/hoglin/zoglin"),
Map.entry("illusioner", "entity/illager/illusioner"),
Map.entry("giant", "entity/zombie/zombie"),
// Illagers share one texture folder; none follow the entity/<key>/<key> pattern.
Map.entry("pillager", "entity/illager/pillager"),
Map.entry("vindicator", "entity/illager/vindicator"),
Map.entry("evoker", "entity/illager/evoker"),
Map.entry("ravager", "entity/illager/ravager"),
Map.entry("vex", "entity/illager/vex"),
// Fish share entity/fish/; squids share entity/squid/.
Map.entry("cod", "entity/fish/cod"),
Map.entry("salmon", "entity/fish/salmon"),
Map.entry("pufferfish", "entity/fish/pufferfish"),
Map.entry("glow_squid", "entity/squid/glow_squid"),
// Variant-only textures with no plain base file — pick a sensible default variant.
Map.entry("cat", "entity/cat/cat_tabby"),
Map.entry("ocelot", "entity/cat/ocelot"), // ocelot texture lives in the cat folder now
Map.entry("axolotl", "entity/axolotl/axolotl_wild"),
Map.entry("parrot", "entity/parrot/parrot_red_blue"),
Map.entry("turtle", "entity/turtle/turtle"),
Map.entry("wind_charge", "entity/projectiles/wind_charge"),
Map.entry("camel_husk", "entity/camel/camel_husk"),
Map.entry("armor_stand", "entity/armorstand/armorstand"), // texture folder is "armorstand"
Map.entry("happy_ghast", "entity/ghast/happy_ghast"),
Map.entry("parched", "entity/skeleton/parched"), // husk-style skeleton, texture in skeleton/
Map.entry("zombie_nautilus_coral", "entity/nautilus/zombie_nautilus_coral"),
Map.entry("mannequin", "entity/player/wide/steve")
);
/** 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")) {
String wood = typeKey.substring(0, typeKey.length() - "_boat".length());
if (wood.endsWith("_chest")) wood = wood.substring(0, wood.length() - "_chest".length());
list.add(ResourceLocation.parse("entity/boat/" + wood));
return list;
}
if (variant != null) {
for (String p : variantPaths(typeKey, variant)) list.add(ResourceLocation.parse(p));
}
String override = TEX_OVERRIDE.get(typeKey);
if (override != null) list.add(ResourceLocation.parse(override));
list.add(ResourceLocation.parse("entity/" + typeKey + "/temperate_" + typeKey)); // legacy 1.21 default
list.add(ResourceLocation.parse("entity/" + typeKey + "/" + typeKey));
list.add(ResourceLocation.parse("entity/" + typeKey));
return list;
}
/**
* Variant-specific texture paths (modern pack naming is "entity/<folder>/<entity>_<variant>", with a
* handful of mismatches the small maps below normalise). Returned paths are tried before the generic
* fallbacks, so an unknown variant still degrades to the base texture.
*/
private static List<String> variantPaths(String typeKey, String v) {
switch (typeKey) {
case "cat": return List.of("entity/cat/cat_" + v);
case "axolotl": return List.of("entity/axolotl/axolotl_" + v);
case "wolf": return List.of("entity/wolf/wolf_" + v, "entity/wolf/wolf");
case "horse": return List.of("entity/horse/horse_" + HORSE_COLOR.getOrDefault(v, v));
case "llama": return List.of("entity/llama/llama_" + v);
case "cow": return List.of("entity/cow/cow_" + v);
case "pig": return List.of("entity/pig/pig_" + v);
case "chicken": return List.of("entity/chicken/chicken_" + v);
case "frog": return List.of("entity/frog/frog_" + v);
case "panda": return List.of(v.equals("normal") ? "entity/panda/panda" : "entity/panda/panda_" + v);
case "fox": return List.of(v.equals("snow") ? "entity/fox/fox_snow" : "entity/fox/fox");
case "parrot": return List.of("entity/parrot/parrot_" + PARROT_COLOR.getOrDefault(v, v));
case "rabbit": return List.of("entity/rabbit/rabbit_" + RABBIT_TYPE.getOrDefault(v, v));
case "mooshroom": return List.of("entity/cow/mooshroom_" + v);
case "shulker": return List.of("entity/shulker/shulker_" + v);
// villager/zombie_villager: type/<biome> and profession are transparent OVERLAYS (clothing
// only); the opaque base body is entity/<folder>/<folder> — handled by the generic candidates.
default: return List.of();
}
}
private static final Map<String, String> HORSE_COLOR = Map.of("dark_brown", "darkbrown");
private static final Map<String, String> PARROT_COLOR = Map.of(
"red", "red_blue", "cyan", "yellow_blue", "gray", "grey");
private static final Map<String, String> RABBIT_TYPE = Map.of(
"black_and_white", "white_splotched", "salt_and_pepper", "salt", "the_killer_bunny", "caerbannog");
}
@@ -0,0 +1,88 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker;
import eu.mhsl.minecraft.pixelpics.render.raytrace.FaceHit;
import java.util.ArrayList;
import java.util.List;
/**
* All baked entities (mobs + block-entities) for one render. Provides the nearest hit along a ray,
* using per-entity and per-cube AABB broad-phase culling. Block-entities are baked into the same list
* and tested identically, so the raytracer needs no special case. Immutable after construction → safe
* for the parallel tracer.
*/
public final class EntityScene {
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) {
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() {
return entities.isEmpty();
}
/** Nearest entity hit with {@code t < maxT}, or null. */
public FaceHit nearestHit(double ox, double oy, double oz, double dx, double dy, double dz, double maxT) {
FaceHit best = null;
double bestT = maxT;
for (RenderedEntity e : entities) {
if (!rayAabb(e.aabbMin, e.aabbMax, ox, oy, oz, dx, dy, dz, bestT)) continue;
for (EntityCube cube : e.cubes) {
if (!rayAabb(cube.aabbMin, cube.aabbMax, ox, oy, oz, dx, dy, dz, bestT)) continue;
FaceHit hit = EntityIntersector.intersect(cube, ox, oy, oz, dx, dy, dz);
if (hit != null && hit.t() > EPS && hit.t() < bestT) {
best = hit;
bestT = hit.t();
}
}
}
return best;
}
private static boolean rayAabb(double[] min, double[] max, double ox, double oy, double oz,
double dx, double dy, double dz, double maxT) {
double tmin = 0, tmax = maxT;
double[] o = {ox, oy, oz}, d = {dx, dy, dz};
for (int a = 0; a < 3; a++) {
if (Math.abs(d[a]) < EPS) {
if (o[a] < min[a] || o[a] > max[a]) return false;
} else {
double inv = 1.0 / d[a];
double t1 = (min[a] - o[a]) * inv;
double t2 = (max[a] - o[a]) * inv;
if (t1 > t2) { double tmp = t1; t1 = t2; t2 = tmp; }
if (t1 > tmin) tmin = t1;
if (t2 < tmax) tmax = t2;
if (tmin > tmax) return false;
}
}
return true;
}
}
@@ -0,0 +1,22 @@
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).
*/
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,
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
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);
}
}
@@ -0,0 +1,38 @@
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.
*/
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 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,
double[] rotation, double[] pivot, 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;
}
}
@@ -0,0 +1,16 @@
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). */
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) {
this.cubes = cubes;
this.aabbMin = aabbMin;
this.aabbMax = aabbMax;
}
}
@@ -0,0 +1,236 @@
package eu.mhsl.minecraft.pixelpics.render.entity.cem;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import eu.mhsl.minecraft.pixelpics.assets.SkinCache;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityModels;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* Bakes a {@link BlockEntityState} into world-space cubes using the bundled CEM models, reusing
* {@link CemGeometry}. Unlike mobs, block-entities are not dropped to the ground by their model bounds;
* each type is placed by an affine that reproduces the vanilla BlockEntityRenderer: the block cell
* centre, a yaw from the block's facing/rotation, and a per-type local offset/scale.
*
* <p>The CEM block-entity models are authored centred on X/Z with their base near Y=0 (after the px→
* block scale), so the placement is {@code T(cell centre) · rotY(yaw) · T(localOffset)} and most types
* only need defaults. Wall-mounted and scaled types (signs, wall heads, beds) override via {@link Place}.
*/
public final class BlockEntityBaker {
private static final double SIGN_SCALE = 0.6666667; // vanilla SignRenderer model scale
private final CemModelLoader models;
private final TextureCache textures;
private final SkinCache skins;
public BlockEntityBaker(CemModelLoader models, TextureCache textures, SkinCache skins) {
this.models = models;
this.textures = textures;
this.skins = skins;
}
/** Returns the baked block-entity, or null when it has no model/texture (then nothing renders). */
public RenderedEntity bake(BlockEntityState s) {
List<Layer> layers = layers(s);
if (layers.isEmpty()) return null;
// Head model depends on the texture aspect (skeleton 64x32 vs zombie/player 64x64), so resolve it
// from the chosen texture rather than statically.
CemModelLoader.CemModel model = models.get(modelName(s, layers.get(0).tex));
if (model == null) return null;
Place p = place(s);
Affine pre = Affine.scale(p.scale / 16.0);
Affine placement = Affine.translation(s.bx() + 0.5, s.by(), s.bz() + 0.5)
.mul(Affine.rotY(Math.toRadians(p.yaw)))
.mul(Affine.translation(p.lx, p.ly, p.lz));
List<EntityCube> cubes = new ArrayList<>();
for (Layer layer : layers) {
for (CemGeometry.Baked b : CemGeometry.bakeModel(model, layer.tex, pre, layer.hidden, layer.texW, layer.texH, layer.boxUv)) {
cubes.add(new EntityCube(b.from(), b.to(), b.faces(), placement.mul(b.world())));
}
}
return cubes.isEmpty() ? null : CemGeometry.finish(cubes);
}
/** The CEM model name; for heads it depends on the texture's aspect (64x32 vs square 64x64). */
private String modelName(BlockEntityState s, int[][] tex) {
if (s.kind() != BlockEntityState.Kind.HEAD && s.kind() != BlockEntityState.Kind.WALL_HEAD) {
return BlockEntityModels.cemModel(s);
}
String t = s.headType();
if ("dragon".equals(t)) return "head_dragon";
if ("piglin".equals(t)) return "head_piglin";
// Square textures (zombie, player) carry a hat overlay → use the 64x64 player-head model; the
// 2:1 skull textures (skeleton, wither_skeleton, creeper) use the 64x32 skull model.
boolean square = tex.length > 0 && tex.length == tex[0].length;
return square ? "head_player" : "head";
}
/** A single bake pass: one texture applied to the parts not in {@code hidden}, with optional UV size
* and a flag forcing box-UV (for standalone part textures not matching the model's per-face UV). */
private record Layer(int[][] tex, Set<String> hidden, int texW, int texH, boolean boxUv) {
Layer(int[][] tex, Set<String> hidden) { this(tex, hidden, 0, 0, false); }
}
/** Some types paint different parts with different textures (pot sherds, conduit cage/heart). */
private List<Layer> layers(BlockEntityState s) {
if (s.kind() == BlockEntityState.Kind.DECORATED_POT) {
return potLayers(s);
}
if (s.kind() == BlockEntityState.Kind.CONDUIT) {
return conduitLayers();
}
int[][] tex = resolveTexture(s);
return tex == null ? List.of() : List.of(new Layer(tex, hiddenParts(s)));
}
private static final Set<String> CONDUIT_PARTS = Set.of("eye", "cage", "base", "wind");
/** The conduit's cage and inner heart use separate textures; the eye/wind (active state) are skipped. */
private List<Layer> conduitLayers() {
int[][] cage = textures.get(ResourceLocation.parse("entity/conduit/cage")).orElse(null);
int[][] base = textures.get(ResourceLocation.parse("entity/conduit/base")).orElse(null);
// Each part's texture is authored at its own native size (32x16) with a box-UV layout, so force
// box-UV and normalise by the texture's own size (the model's per-face UV assumes a combined sheet).
List<Layer> layers = new ArrayList<>(2);
if (cage != null) layers.add(new Layer(cage, onlyPart("cage", CONDUIT_PARTS), cage[0].length, cage.length, true));
if (base != null) layers.add(new Layer(base, onlyPart("base", CONDUIT_PARTS), base[0].length, base.length, true));
return layers;
}
/** Hidden set that leaves only {@code keep} visible out of {@code all}. */
private static Set<String> onlyPart(String keep, Set<String> all) {
Set<String> hidden = new java.util.HashSet<>(all);
hidden.remove(keep);
return hidden;
}
private static final Set<String> POT_PARTS = Set.of("neck", "top", "bottom", "front", "back", "left", "right");
private static final String[] POT_FACES = {"front", "left", "right", "back"}; // matches sherd capture order
private List<Layer> potLayers(BlockEntityState s) {
int[][] base = textures.get(ResourceLocation.parse("entity/decorated_pot/decorated_pot_base")).orElse(null);
int[][] side = textures.get(ResourceLocation.parse("entity/decorated_pot/decorated_pot_side")).orElse(null);
if (base == null) return List.of();
List<Layer> layers = new ArrayList<>();
// Structure (rim/neck/foot) comes from the combined base texture; the four sides are NOT in it.
layers.add(new Layer(base, new java.util.HashSet<>(java.util.List.of("front", "back", "left", "right"))));
// Each side: its sherd pattern if set, else the plain brick side. The model's per-face UV maps the
// centre of the 16x16 texture onto the face (centred, edges intact).
for (int i = 0; i < POT_FACES.length; i++) {
int[][] tex = side;
if (i < s.sherds().size()) {
String sherd = s.sherds().get(i);
if (sherd != null && sherd.endsWith("_pottery_sherd")) {
int[][] pat = textures.get(ResourceLocation.parse(
"entity/decorated_pot/" + sherd.replace("_pottery_sherd", "_pottery_pattern"))).orElse(null);
if (pat != null) tex = pat;
}
}
if (tex == null) continue;
layers.add(new Layer(tex, onlyPart(POT_FACES[i], POT_PARTS)));
}
return layers;
}
// --- placement parameters per type ---
/** Local placement: applied yaw (deg), model scale, and a local-frame offset (blocks). */
private record Place(double yaw, double scale, double lx, double ly, double lz) {}
private Place place(BlockEntityState s) {
double yaw = 180 - s.facingDeg(); // model default faces north; rotate by the block's facing
return switch (s.kind()) {
case SIGN -> new Place(yaw, SIGN_SCALE, 0, 0, 0);
case WALL_SIGN -> new Place(yaw, SIGN_SCALE, 0, -0.3125, 0.4375); // drop to mid-block, push to wall
case HANGING_SIGN -> new Place(yaw, 1.0, 0, 0, 0);
case WALL_HEAD -> new Place(yaw, 1.0, 0, 0.25, 0.25); // mid-height, against the wall
case WALL_BANNER -> new Place(yaw, 1.0, 0, -0.16, 0.4375);
default -> new Place(yaw, 1.0, 0, 0, 0);
};
}
/** Parts to omit (the unused bed half / its legs, the conduit's open-state shell). */
private Set<String> hiddenParts(BlockEntityState s) {
return switch (s.kind()) {
case BED -> s.bedPart() == BlockEntityState.BedPart.HEAD
? Set.of("foot", "leg3", "leg4")
: Set.of("head", "leg1", "leg2");
case CONDUIT -> Set.of("eye", "wind");
default -> Set.of();
};
}
// --- texture resolution ---
private int[][] resolveTexture(BlockEntityState s) {
// Player heads use the owner's skin when available.
if (s.skinUrl() != null && (s.kind() == BlockEntityState.Kind.HEAD || s.kind() == BlockEntityState.Kind.WALL_HEAD)) {
int[][] skin = skins.get(s.skinUrl()).orElse(null);
if (skin != null) return skin;
}
boolean isBanner = s.kind() == BlockEntityState.Kind.BANNER || s.kind() == BlockEntityState.Kind.WALL_BANNER;
for (ResourceLocation rl : BlockEntityModels.textureCandidates(s)) {
int[][] t = textures.get(rl).orElse(null);
if (t == null) continue;
if (isBanner) return bakeBanner(t, s);
return t;
}
return null;
}
/**
* Composites a banner texture: tint the white {@code banner_base} cloth with the base colour, then
* alpha-overlay each pattern mask ({@code entity/banner/<key>}) dyed with its own colour, in order.
*/
private int[][] bakeBanner(int[][] base, BlockEntityState s) {
int[][] out = deepCopy(base);
if (s.baseColorArgb() != 0) CemGeometry.tint(out, s.baseColorArgb());
for (BlockEntityState.BannerPattern pat : s.patterns()) {
int[][] mask = textures.get(ResourceLocation.parse("entity/banner/" + pat.patternKey())).orElse(null);
if (mask == null) continue;
int[][] dyed = deepCopy(mask);
CemGeometry.tint(dyed, pat.colorArgb());
overlay(out, dyed);
}
return out;
}
/** Standard src-over alpha composite of {@code src} onto {@code dst} (same dimensions), in place. */
private static void overlay(int[][] dst, int[][] src) {
int h = Math.min(dst.length, src.length);
for (int y = 0; y < h; y++) {
int w = Math.min(dst[y].length, src[y].length);
for (int x = 0; x < w; x++) {
int sp = src[y][x];
int sa = (sp >>> 24) & 0xFF;
if (sa == 0) continue;
if (sa == 255) { dst[y][x] = sp; continue; }
int dp = dst[y][x];
int da = (dp >>> 24) & 0xFF;
int outA = sa + da * (255 - sa) / 255;
int sr = (sp >> 16) & 0xFF, sg = (sp >> 8) & 0xFF, sb = sp & 0xFF;
int dr = (dp >> 16) & 0xFF, dg = (dp >> 8) & 0xFF, db = dp & 0xFF;
int r = (sr * sa + dr * da * (255 - sa) / 255) / Math.max(1, outA);
int g = (sg * sa + dg * da * (255 - sa) / 255) / Math.max(1, outA);
int b = (sb * sa + db * da * (255 - sa) / 255) / Math.max(1, outA);
dst[y][x] = (outA << 24) | (r << 16) | (g << 8) | b;
}
}
}
private static int[][] deepCopy(int[][] src) {
int[][] out = new int[src.length][];
for (int y = 0; y < src.length; y++) out[y] = src[y].clone();
return out;
}
}
@@ -0,0 +1,125 @@
package eu.mhsl.minecraft.pixelpics.render.entity.cem;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import eu.mhsl.minecraft.pixelpics.assets.SkinCache;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import eu.mhsl.minecraft.pixelpics.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.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 java.util.ArrayList;
import java.util.List;
/**
* Bakes an {@link EntityState} into world-space cubes using a vanilla Java {@link CemModelLoader.CemModel}
* (OptiFine-CEM format). These models are already correctly posed (standing), so no animation/lay-down
* logic is needed. The CEM model space (px, invertAxis "xy") is mapped to the world by an inner X/Y flip
* + 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 {
// 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(
"armadillo", java.util.Set.of("cube"), // the rolled-up ball
"illager", java.util.Set.of("left_arm", "right_arm")
);
private final CemModelLoader models;
private final TextureCache textures;
private final SkinCache skins;
public CemBaker(CemModelLoader models, TextureCache textures, SkinCache skins) {
this.models = models;
this.textures = textures;
this.skins = skins;
}
public RenderedEntity bake(EntityState s) {
int[][] tex = resolveTexture(s);
CemModelLoader.CemModel model = models.get(EntityModels.cemModel(s.typeKey()));
if (model == null || tex == null) return fallbackBox(s, tex);
double sc = (s.baby() ? 0.5 : 1.0) * s.sizeScale();
// CEM model px -> entity-local blocks. Identity orientation (no axis flip) preserves ALL part
// 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());
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());
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")) {
double[] org = {-8, 2, -6}, size = {2, 12, 12};
ModelCube mc = new ModelCube(org, size, 0, new double[]{0, 28}, true, new double[]{0,0,0}, new double[]{0,0,0});
Face[] faces = BoxUv.build(mc, tex, model.texW(), model.texH());
baked.add(new CemGeometry.Baked(org, new double[]{org[0]+size[0], org[1]+size[1], org[2]+size[2]}, faces, pre));
}
if (baked.isEmpty()) return fallbackBox(s, tex);
double minY = Double.MAX_VALUE;
for (CemGeometry.Baked b : baked) minY = Math.min(minY, b.minWorldY());
Affine place = Affine.translation(s.x(), s.y(), s.z())
.mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw())))
.mul(Affine.translation(0, -minY, 0));
List<EntityCube> cubes = new ArrayList<>(baked.size());
for (CemGeometry.Baked b : baked) cubes.add(new EntityCube(b.from(), b.to(), b.faces(), place.mul(b.world())));
return CemGeometry.finish(cubes);
}
// --- texture resolution (player skin, dyed sheep wool, variant candidates) ---
private int[][] resolveTexture(EntityState s) {
if (s.player()) {
int[][] skin = skins.get(s.skinUrl()).orElse(null);
if (skin != null) return skin;
int[][] def = textures.get(ResourceLocation.parse(
s.slim() ? "entity/player/slim/steve" : "entity/player/wide/steve")).orElse(null);
if (def != null) return def;
}
for (ResourceLocation rl : EntityModels.textureCandidates(s.typeKey(), s.variant())) {
int[][] t = textures.get(rl).orElse(null);
if (t != null) return t;
}
return null;
}
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});
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);
}
private static int[][] flat(int argb) {
int[][] t = new int[1][1];
t[0][0] = argb;
return t;
}
}
@@ -0,0 +1,132 @@
package eu.mhsl.minecraft.pixelpics.render.entity.cem;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
import eu.mhsl.minecraft.pixelpics.render.entity.BoxUv;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
import eu.mhsl.minecraft.pixelpics.render.entity.ModelCube;
import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* Shared, stateless CEM geometry baking used by both {@link CemBaker} (mobs) and the block-entity
* baker. It turns a {@link CemModelLoader.CemModel} into a list of {@link Baked} boxes (local cube +
* its model→pre-space transform), faithfully reproducing the OptiFine/Blockbench CEM transform. The
* placement into the world (yaw, ground-snap, block cell) is the caller's responsibility.
*/
final class CemGeometry {
private CemGeometry() {}
/** A baked box: local min/max corner, its six faces and the transform mapping it into pre-space. */
record Baked(double[] from, double[] to, Face[] faces, Affine world) {
double minWorldY() {
double m = Double.MAX_VALUE;
for (int i = 0; i < 8; i++) {
double x = (i & 1) == 0 ? from[0] : to[0];
double y = (i & 2) == 0 ? from[1] : to[1];
double z = (i & 4) == 0 ? from[2] : to[2];
m = Math.min(m, world.apply(x, y, z)[1]);
}
return m;
}
}
/** Bake all parts of a model with the given pre-transform; parts in {@code hidden} are skipped. */
static List<Baked> bakeModel(CemModelLoader.CemModel model, int[][] tex, Affine pre, Set<String> hidden) {
return bakeModel(model, tex, pre, hidden, 0, 0);
}
/**
* As {@link #bakeModel(CemModelLoader.CemModel, int[][], Affine, Set)} but with an explicit texture
* size for UV normalisation (use when the applied texture's size differs from the model's declared
* {@code textureSize} in a non-proportional way, e.g. a 16x16 sherd on a 32x32-authored pot face).
* {@code texW}/{@code texH} of 0 fall back to the model's declared size.
*/
static List<Baked> bakeModel(CemModelLoader.CemModel model, int[][] tex, Affine pre, Set<String> hidden,
int texW, int texH) {
return bakeModel(model, tex, pre, hidden, texW, texH, false);
}
/**
* As above, but {@code ignoreFaceUv} forces box-UV even when the model declares per-face UV — used when
* applying a standalone texture (e.g. the conduit cage's own {@code cage.png}) whose layout is box-UV,
* not the combined-sheet layout the model's per-face UV assumes.
*/
static List<Baked> bakeModel(CemModelLoader.CemModel model, int[][] tex, Affine pre, Set<String> hidden,
int texW, int texH, boolean ignoreFaceUv) {
int nw = texW > 0 ? texW : model.texW();
int nh = texH > 0 ? texH : model.texH();
List<Baked> out = new ArrayList<>();
for (CemModelLoader.CemPart p : model.parts()) {
double[] o = {-p.translate()[0], -p.translate()[1], -p.translate()[2]};
bakePart(p, pre, o, 0, hidden, nw, nh, tex, ignoreFaceUv, out);
}
return out;
}
/**
* Faithful OptiFine/Blockbench CEM transform: each part is a group whose rotation pivots around its
* origin {@code O} (top-level: {@code -translate}; submodel: {@code translate}, accumulated with the
* parent origin from the 2nd nesting level on). Top-level boxes are absolute; nested boxes are offset
* by their group origin. The group transform is {@code parent · T(O) · R · T(-O)}.
*/
private static void bakePart(CemModelLoader.CemPart part, Affine parentWorld, double[] o, int depth,
Set<String> hidden, int texW, int texH, int[][] tex, boolean ignoreFaceUv, List<Baked> out) {
if (hidden.contains(part.name())) return;
Affine world = parentWorld
.mul(Affine.translation(o[0], o[1], o[2]))
.mul(Affine.rotZ(Math.toRadians(part.rotate()[2])))
.mul(Affine.rotY(Math.toRadians(part.rotate()[1])))
.mul(Affine.rotX(Math.toRadians(part.rotate()[0])))
.mul(Affine.translation(-o[0], -o[1], -o[2]));
double ox = depth > 0 ? o[0] : 0, oy = depth > 0 ? o[1] : 0, oz = depth > 0 ? o[2] : 0;
for (CemModelLoader.CemBox b : part.boxes()) {
double inf = b.inflate();
double[] org = {b.origin()[0] + ox, b.origin()[1] + oy, b.origin()[2] + oz};
double[] from = {org[0] - inf, org[1] - inf, org[2] - inf};
double[] to = {org[0] + b.size()[0] + inf, org[1] + b.size()[1] + inf, org[2] + b.size()[2] + inf};
double[][] faceUv = ignoreFaceUv ? null : b.faceUv();
ModelCube mc = new ModelCube(org, b.size(), inf, b.uv(), b.mirror(), new double[]{0, 0, 0}, new double[]{0, 0, 0}, faceUv);
Face[] faces = BoxUv.build(mc, tex, texW, texH);
out.add(new Baked(from, to, faces, world));
}
for (CemModelLoader.CemPart child : part.children()) {
double[] t = child.translate();
// submodel origin = its translate, accumulated with this group's origin from the 2nd level on.
double[] co = depth >= 1 ? new double[]{t[0] + o[0], t[1] + o[1], t[2] + o[2]} : new double[]{t[0], t[1], t[2]};
bakePart(child, world, co, depth + 1, hidden, texW, texH, tex, ignoreFaceUv, out);
}
}
/** Multiplies every non-transparent texel by an ARGB tint (in place). */
static void tint(int[][] tex, int argb) {
int tr = (argb >> 16) & 0xFF, tg = (argb >> 8) & 0xFF, tb = argb & 0xFF;
for (int[] row : tex) {
for (int x = 0; x < row.length; x++) {
int p = row[x];
int a = (p >>> 24) & 0xFF;
if (a == 0) continue;
int r = ((p >> 16) & 0xFF) * tr / 255, g = ((p >> 8) & 0xFF) * tg / 255, b = (p & 0xFF) * tb / 255;
row[x] = (a << 24) | (r << 16) | (g << 8) | b;
}
}
}
/** Wraps baked world-space cubes into a {@link RenderedEntity}, computing the overall AABB. */
static RenderedEntity finish(List<EntityCube> cubes) {
double[] min = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE};
double[] max = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE};
for (EntityCube c : cubes) {
for (int a = 0; a < 3; a++) {
if (c.aabbMin[a] < min[a]) min[a] = c.aabbMin[a];
if (c.aabbMax[a] > max[a]) max[a] = c.aabbMax[a];
}
}
return new RenderedEntity(cubes, min, max);
}
}
@@ -0,0 +1,126 @@
package eu.mhsl.minecraft.pixelpics.render.entity.cem;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;
/**
* Loads OptiFine-CEM ({@code .jem}) entity models from the bundled {@code cem_template_models.json}
* (CEM Template Loader data) into baked-ready {@link CemModel}s. These are the vanilla Java entity
* models, already in the correct standing pose — no Bedrock geometry / animation needed.
*
* <p>Format per part: {@code coordinates} are ABSOLUTE model pixels, {@code translate} is the rotation
* pivot (negated), {@code rotate} is degrees. {@code invertAxis "xy"} is handled by the baker's flips.
*/
public final class CemModelLoader {
/**
* A box: absolute min corner + size (px), inflate, box-UV offset (texels), horizontal texture mirror,
* and optional per-face UV ({@code faceUv}, indexed by {@link eu.mhsl.minecraft.pixelpics.assets.model.Direction}
* ordinal, each {@code {x, y, w, h}} texels; null = use box-UV).
*/
public record CemBox(double[] origin, double[] size, double inflate, double[] uv, boolean mirror, double[][] faceUv) {}
/** A model part: its (raw) translate, rotation (deg), boxes and nested submodels. The rotation pivot
* is {@code -(sum of translates from the root to this part)} — accumulated by the baker. */
public record CemPart(String name, double[] translate, double[] rotate, List<CemBox> boxes, List<CemPart> children) {}
/** A whole model: declared texture size and its top-level parts. */
public record CemModel(int texW, int texH, List<CemPart> parts) {}
private final Map<String, CemModel> models = new HashMap<>();
public CemModel get(String name) {
return models.get(name);
}
public int size() {
return models.size();
}
/** Parse the CEM template-models JSON stream. Returns the number of models loaded. */
public int load(InputStream in, Logger logger) {
JsonObject root = JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8)).getAsJsonObject();
JsonObject modelsObj = root.getAsJsonObject("models");
for (Map.Entry<String, JsonElement> e : modelsObj.entrySet()) {
try {
JsonObject entry = e.getValue().getAsJsonObject();
if (!entry.has("model")) continue;
JsonObject model = JsonParser.parseString(entry.get("model").getAsString()).getAsJsonObject();
int tw = model.getAsJsonArray("textureSize").get(0).getAsInt();
int th = model.getAsJsonArray("textureSize").get(1).getAsInt();
List<CemPart> parts = new ArrayList<>();
for (JsonElement pe : model.getAsJsonArray("models")) parts.add(parsePart(pe.getAsJsonObject()));
models.put(e.getKey(), new CemModel(tw, th, parts));
} catch (Exception ex) {
if (logger != null) logger.warning("Failed to parse CEM model " + e.getKey() + ": " + ex.getMessage());
}
}
return models.size();
}
private CemPart parsePart(JsonObject p) {
double[] translate = arr3(p, "translate");
double[] rotate = arr3(p, "rotate");
boolean partMirror = mirrorsU(p); // mirrorTexture "u" — applies to all of the part's boxes
List<CemBox> boxes = new ArrayList<>();
if (p.has("boxes")) {
for (JsonElement be : p.getAsJsonArray("boxes")) {
JsonObject b = be.getAsJsonObject();
if (!b.has("coordinates")) continue;
JsonArray c = b.getAsJsonArray("coordinates");
double[] origin = {c.get(0).getAsDouble(), c.get(1).getAsDouble(), c.get(2).getAsDouble()};
double[] size = {c.get(3).getAsDouble(), c.get(4).getAsDouble(), c.get(5).getAsDouble()};
double inflate = b.has("sizeAdd") ? b.get("sizeAdd").getAsDouble() : 0;
double[] uv = b.has("textureOffset")
? new double[]{b.getAsJsonArray("textureOffset").get(0).getAsDouble(), b.getAsJsonArray("textureOffset").get(1).getAsDouble()}
: new double[]{0, 0};
boxes.add(new CemBox(origin, size, inflate, uv, partMirror || mirrorsU(b), parseFaceUv(b)));
}
}
List<CemPart> children = new ArrayList<>();
if (p.has("submodels")) for (JsonElement se : p.getAsJsonArray("submodels")) children.add(parsePart(se.getAsJsonObject()));
if (p.has("submodel")) children.add(parsePart(p.getAsJsonObject("submodel")));
String name = p.has("part") ? p.get("part").getAsString() : (p.has("id") ? p.get("id").getAsString() : "");
return new CemPart(name, translate, rotate, boxes, children);
}
// CEM per-face UV keys ordered by Direction ordinal (DOWN, UP, NORTH, SOUTH, WEST, EAST).
private static final String[] FACE_UV_KEYS = {"uvDown", "uvUp", "uvNorth", "uvSouth", "uvWest", "uvEast"};
/** Parses per-face UV ({@code uvNorth} etc., each {@code [u1,v1,u2,v2]}) into {@code {x,y,w,h}}, or null. */
private static double[][] parseFaceUv(JsonObject b) {
boolean any = false;
for (String k : FACE_UV_KEYS) if (b.has(k)) { any = true; break; }
if (!any) return null;
double[][] faces = new double[6][];
for (int i = 0; i < FACE_UV_KEYS.length; i++) {
if (!b.has(FACE_UV_KEYS[i])) continue;
JsonArray a = b.getAsJsonArray(FACE_UV_KEYS[i]);
double u1 = a.get(0).getAsDouble(), v1 = a.get(1).getAsDouble();
double u2 = a.get(2).getAsDouble(), v2 = a.get(3).getAsDouble();
faces[i] = new double[]{u1, v1, u2 - u1, v2 - v1};
}
return faces;
}
private static boolean mirrorsU(JsonObject o) {
return o.has("mirrorTexture") && o.get("mirrorTexture").getAsString().contains("u");
}
private static double[] arr3(JsonObject o, String key) {
if (!o.has(key) || !o.get(key).isJsonArray()) return new double[]{0, 0, 0};
JsonArray a = o.getAsJsonArray(key);
return new double[]{a.get(0).getAsDouble(), a.get(1).getAsDouble(), a.get(2).getAsDouble()};
}
}
@@ -1,108 +0,0 @@
package eu.mhsl.minecraft.pixelpics.render.model;
import com.google.common.base.Preconditions;
import eu.mhsl.minecraft.pixelpics.render.model.MultiModel.MultiModelBuilder;
import eu.mhsl.minecraft.pixelpics.render.model.CrossModel.CrossModelBuilder;
import eu.mhsl.minecraft.pixelpics.render.model.StaticModel.StaticModelBuilder;
import eu.mhsl.minecraft.pixelpics.render.model.OctahedronModel.OctahedronModelBuilder;
import eu.mhsl.minecraft.pixelpics.render.model.SphereModel.SphereModelBuilder;
public abstract class AbstractModel implements Model {
final int textureSize;
final int[][] texture;
private final double transparencyFactor;
private final double reflectionFactor;
private final boolean occluding;
AbstractModel(int[][] texture, double transparencyFactor, double reflectionFactor,
boolean occluding) {
Preconditions.checkNotNull(texture);
Preconditions.checkArgument(texture.length > 0, "texture cannot be empty");
Preconditions.checkArgument(texture.length == texture[0].length, "texture must be a square array");
this.textureSize = texture.length;
this.texture = texture;
this.transparencyFactor = transparencyFactor;
this.reflectionFactor = reflectionFactor;
this.occluding = occluding;
}
@Override
public double getTransparencyFactor() {
return transparencyFactor;
}
@Override
public double getReflectionFactor() {
return reflectionFactor;
}
@Override
public boolean isOccluding() {
return occluding;
}
public static abstract class Builder {
final int[][] texture;
double transparencyFactor;
double reflectionFactor;
boolean occluding;
Builder(int[][] texture) {
this.texture = texture;
this.transparencyFactor = 0;
this.reflectionFactor = 0;
this.occluding = false;
}
public static SimpleModel.SimpleModelBuilder createSimple(int[][] texture) {
return new SimpleModel.SimpleModelBuilder(texture);
}
public static MultiModelBuilder createMulti(int[][] topTexture, int[][] sideTexture,
int[][] bottomTexture) {
return new MultiModelBuilder(topTexture, sideTexture, bottomTexture);
}
public static StaticModelBuilder createStatic(int color) {
return new StaticModelBuilder(color);
}
public static CrossModelBuilder createCross(int[][] texture) {
return new CrossModelBuilder(texture);
}
public static SphereModelBuilder createSphere(int[][] texture) {
return new SphereModelBuilder(texture);
}
public static OctahedronModelBuilder createOctahedron(int[][] texture) {
return new OctahedronModelBuilder(texture);
}
public Builder transparency(double transparencyFactor) {
this.transparencyFactor = transparencyFactor;
return this;
}
public Builder reflection(double reflectionFactor) {
this.reflectionFactor = reflectionFactor;
return this;
}
public Builder occlusion() {
this.occluding = true;
return this;
}
public abstract Model build();
}
}
@@ -1,91 +0,0 @@
package eu.mhsl.minecraft.pixelpics.render.model;
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
import org.bukkit.block.Block;
import org.bukkit.util.Vector;
public class CrossModel extends AbstractModel {
private static final Vector NORMAL_ONE = new Vector(1, 0, 1).normalize();
private static final Vector NORMAL_TWO = new Vector(-1, 0, 1).normalize();
private static final Vector POINT_ONE = new Vector(1, 0, 0);
private static final Vector POINT_TWO = new Vector(1, 0, 1);
private CrossModel(int[][] texture, double transparencyFactor, double reflectionFactor,
boolean occluding) {
super(texture, transparencyFactor, reflectionFactor, occluding);
}
@Override
public Intersection intersect(Block block, Intersection currentIntersection) {
Vector linePoint = currentIntersection.getPoint();
Vector lineDirection = currentIntersection.getDirection();
Vector blockPoint = block.getLocation().toVector();
Vector planePoint = block.getLocation().add(0.5, 0, 0.5).toVector();
double distance = Double.POSITIVE_INFINITY;
int color = 0;
Vector target = null;
Vector intersectionOne = MathUtil.getLinePlaneIntersection(linePoint, lineDirection, planePoint, NORMAL_ONE,
true);
if (intersectionOne != null) {
intersectionOne.subtract(blockPoint);
if (isInsideBlock(intersectionOne)) {
color = getColor(intersectionOne, POINT_ONE);
distance = linePoint.distanceSquared(intersectionOne.add(blockPoint));
target = intersectionOne;
}
}
Vector intersectionTwo = MathUtil.getLinePlaneIntersection(linePoint, lineDirection, planePoint, NORMAL_TWO,
true);
if (intersectionTwo != null) {
intersectionTwo.subtract(blockPoint);
if (isInsideBlock(intersectionTwo)) {
int colorTwo = getColor(intersectionTwo, POINT_TWO);
double distanceTwo = linePoint.distanceSquared(intersectionTwo.add(blockPoint));
if ((distanceTwo < distance && (colorTwo >> 24) != 0) || (color >> 24) == 0) {
target = intersectionTwo;
color = colorTwo;
}
}
}
if (target == null) {
target = linePoint;
}
return Intersection.of(currentIntersection.getNormal(), target, lineDirection, color);
}
private boolean isInsideBlock(Vector vec) {
return vec.getX() >= 0 && vec.getZ() < 1 && vec.getY() >= 0 && vec.getY() < 1 && vec.getZ() >= 0
&& vec.getZ() < 1;
}
private int getColor(Vector vec, Vector base) {
double xOffset = Math.sqrt(Math.pow(vec.getX() - base.getX(), 2) + Math.pow(vec.getZ() - base.getZ(), 2));
double yOffset = vec.getY();
int pixelY = (int) Math.floor(yOffset * textureSize);
int pixelX = (int) Math.floor(xOffset / Math.sqrt(2) * textureSize);
return texture[pixelY][pixelX];
}
public static class CrossModelBuilder extends Builder {
CrossModelBuilder(int[][] texture) {
super(texture);
}
@Override
public CrossModel build() {
return new CrossModel(texture, transparencyFactor, reflectionFactor, occluding);
}
}
}
@@ -1,15 +0,0 @@
package eu.mhsl.minecraft.pixelpics.render.model;
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
import org.bukkit.block.Block;
public interface Model {
Intersection intersect(Block block, Intersection currentIntersection);
double getTransparencyFactor();
double getReflectionFactor();
boolean isOccluding();
}
@@ -1,61 +0,0 @@
package eu.mhsl.minecraft.pixelpics.render.model;
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
import org.bukkit.block.Block;
import org.bukkit.util.Vector;
public class MultiModel extends SimpleModel {
private final int[][] topTexture;
private final int[][] bottomTexture;
private MultiModel(int[][] topTexture, int[][] sideTexture, int[][] bottomTexture,
double transparencyFactor, double reflectionFactor, boolean occluding) {
super(sideTexture, transparencyFactor, reflectionFactor, occluding);
this.topTexture = topTexture;
this.bottomTexture = bottomTexture;
}
@Override
public Intersection intersect(Block block, Intersection currentIntersection) {
if (!currentIntersection.getNormal().equals(UP) && !currentIntersection.getNormal().equals(DOWN)) {
return super.intersect(block, currentIntersection);
}
Vector normal = currentIntersection.getNormal();
Vector point = currentIntersection.getPoint();
Vector direction = currentIntersection.getDirection();
double yOffset = point.getX() - (int) point.getX();
double xOffset = point.getZ() - (int) point.getZ();
int pixelY = (int) Math.floor((yOffset < 0 ? yOffset + 1 : yOffset) * textureSize);
int pixelX = (int) Math.floor((xOffset < 0 ? xOffset + 1 : xOffset) * textureSize);
if (normal.equals(UP)) {
return Intersection.of(normal, point, direction, topTexture[pixelY][pixelX]);
} else {
return Intersection.of(normal, point, direction, bottomTexture[pixelY][pixelX]);
}
}
public static class MultiModelBuilder extends SimpleModelBuilder {
private final int[][] topTexture;
private final int[][] bottomTexture;
MultiModelBuilder(int[][] topTexture, int[][] sideTexture, int[][] bottomTexture) {
super(sideTexture);
this.topTexture = topTexture;
this.bottomTexture = bottomTexture;
}
@Override
public MultiModel build() {
return new MultiModel(topTexture, texture, bottomTexture, transparencyFactor,
reflectionFactor, occluding);
}
}
}
@@ -1,100 +0,0 @@
package eu.mhsl.minecraft.pixelpics.render.model;
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
import org.bukkit.block.Block;
import org.bukkit.util.Vector;
public class OctahedronModel extends AbstractModel {
private static final double RADIUS = 0.5;
private static final Vector[] NORMALS = new Vector[]{new Vector(-1, -1, -1), new Vector(-1, -1, 1),
new Vector(-1, 1, -1), new Vector(-1, 1, 1), new Vector(1, -1, -1), new Vector(1, -1, 1),
new Vector(1, 1, -1), new Vector(1, 1, 1)};
private OctahedronModel(int[][] texture, double transparencyFactor, double reflectionFactor,
boolean occluding) {
super(texture, transparencyFactor, reflectionFactor, occluding);
}
@Override
public Intersection intersect(Block block, Intersection currentIntersection) {
Vector linePoint = currentIntersection.getPoint();
Vector lineDirection = currentIntersection.getDirection();
Vector blockPoint = block.getLocation().toVector();
Vector centerPoint = blockPoint.clone().add(new Vector(0.5, 0.5, 0.5));
Vector lastIntersection = null;
double lastDistance = Double.POSITIVE_INFINITY;
for (int i = 0; i < 8; i++) {
Vector planePoint = new Vector(i < 4 ? -0.5 : 0.5, 0, 0).add(centerPoint);
Vector planeNormal = NORMALS[i];
Vector intersection = MathUtil.getLinePlaneIntersection(linePoint, lineDirection, planePoint, planeNormal,
false);
if (intersection == null) {
continue;
}
if (!isInsideBlock(blockPoint, planeNormal, intersection)) {
continue;
}
double distance = intersection.distance(linePoint);
if (distance < lastDistance) {
lastIntersection = intersection;
lastDistance = distance;
}
}
if (lastIntersection == null) {
return currentIntersection;
}
double dist = linePoint.distance(centerPoint);
double minDist = dist - RADIUS;
double maxDist = dist + RADIUS;
double factor = (lastDistance - minDist) / (maxDist - minDist);
double yOffset = lastIntersection.getX() - (int) lastIntersection.getX();
double xOffset = lastIntersection.getZ() - (int) lastIntersection.getZ();
int pixelY = (int) Math.floor((yOffset < 0 ? yOffset + 1 : yOffset) * textureSize);
int pixelX = (int) Math.floor((xOffset < 0 ? xOffset + 1 : xOffset) * textureSize);
return Intersection.of(currentIntersection.getNormal(), lastIntersection, lineDirection,
0xFF000000 | MathUtil.weightedColorSum(texture[pixelY][pixelX], 0, 1 - factor, factor));
}
private boolean isInsideBlock(Vector blockPoint, Vector planeNormal, Vector intersection) {
intersection = intersection.clone().subtract(blockPoint);
if (intersection.getX() < 0 || intersection.getX() >= 1 || intersection.getY() < 0 || intersection.getY() >= 1
|| intersection.getZ() < 0 || intersection.getZ() >= 1) {
return false;
}
boolean posX = planeNormal.getX() >= 0;
boolean posY = planeNormal.getY() >= 0;
boolean posZ = planeNormal.getZ() >= 0;
boolean blockX = intersection.getX() >= 0.5;
boolean blockY = intersection.getY() >= 0.5;
boolean blockZ = intersection.getZ() >= 0.5;
return posX == blockX && posY == blockY && posZ == blockZ;
}
public static class OctahedronModelBuilder extends Builder {
OctahedronModelBuilder(int[][] texture) {
super(texture);
}
@Override
public Model build() {
return new OctahedronModel(texture, transparencyFactor, reflectionFactor, occluding);
}
}
}
@@ -1,58 +0,0 @@
package eu.mhsl.minecraft.pixelpics.render.model;
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
import org.bukkit.block.Block;
import org.bukkit.util.Vector;
public class SimpleModel extends AbstractModel {
static final Vector UP = new Vector(0, 1, 0);
static final Vector DOWN = new Vector(0, -1, 0);
private static final Vector NORTH = new Vector(0, 0, -1);
private static final Vector SOUTH = new Vector(0, 0, 1);
private static final Vector EAST = new Vector(1, 0, 0);
private static final Vector WEST = new Vector(-1, 0, 0);
SimpleModel(int[][] texture, double transparencyFactor, double reflectionFactor,
boolean occluding) {
super(texture, transparencyFactor, reflectionFactor, occluding);
}
@Override
public Intersection intersect(Block block, Intersection currentIntersection) {
double yOffset;
double xOffset;
Vector normal = currentIntersection.getNormal();
Vector point = currentIntersection.getPoint();
Vector direction = currentIntersection.getDirection();
if (normal.equals(NORTH) || normal.equals(SOUTH)) {
yOffset = point.getY() - (int) point.getY();
xOffset = point.getX() - (int) point.getX();
} else if (normal.equals(EAST) || normal.equals(WEST)) {
yOffset = point.getY() - (int) point.getY();
xOffset = point.getZ() - (int) point.getZ();
} else {
yOffset = point.getX() - (int) point.getX();
xOffset = point.getZ() - (int) point.getZ();
}
int pixelY = (int) Math.floor((yOffset < 0 ? yOffset + 1 : yOffset) * textureSize);
int pixelX = (int) Math.floor((xOffset < 0 ? xOffset + 1 : xOffset) * textureSize);
return Intersection.of(normal, point, direction, texture[pixelY][pixelX]);
}
public static class SimpleModelBuilder extends Builder {
protected SimpleModelBuilder(int[][] texture) {
super(texture);
}
@Override
public Model build() {
return new SimpleModel(texture, transparencyFactor, reflectionFactor, occluding);
}
}
}
@@ -1,128 +0,0 @@
package eu.mhsl.minecraft.pixelpics.render.model;
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
import org.bukkit.Location;
import org.bukkit.block.Block;
import org.bukkit.util.Vector;
public class SphereModel extends AbstractModel {
private final double radius;
private final Vector offset;
private SphereModel(int[][] texture, double transparencyFactor, double reflectionFactor,
boolean occluding, double radius, Vector offset) {
super(texture, transparencyFactor, reflectionFactor, occluding);
this.radius = radius;
this.offset = offset;
}
@Override
public Intersection intersect(Block block, Intersection currentIntersection) {
Vector linePoint = currentIntersection.getPoint();
Vector lineDirection = currentIntersection.getDirection();
Vector blockPoint = block.getLocation().toVector();
Vector centerPoint = block.getLocation().add(0.5, 0.5, 0.5).add(offset).toVector();
double a = lineDirection.dot(lineDirection);
double b = 2 * (linePoint.dot(lineDirection) - centerPoint.dot(lineDirection));
double c = linePoint.dot(linePoint) - 2 * centerPoint.dot(linePoint) + centerPoint.dot(centerPoint)
- Math.pow(radius, 2);
double delta = Math.pow(b, 2) - 4 * a * c;
if (delta < 0) {
return Intersection.of(currentIntersection.getNormal(), linePoint, lineDirection);
}
double dist = linePoint.distance(centerPoint);
double minDist = dist - radius;
double maxDist = dist + radius;
if (delta == 0) {
double t = -b / (2 * a);
Vector intersection = lineDirection.clone().add(lineDirection.clone().multiply(t));
if (!isInsideBlock(blockPoint, intersection)) {
return currentIntersection;
}
double currentDist = intersection.distance(linePoint);
double factor = (currentDist - minDist) / (maxDist - minDist);
Vector normal = intersection.clone().subtract(centerPoint).normalize();
return Intersection.of(normal, intersection, lineDirection, getColor(centerPoint, intersection, factor));
}
double deltaSqrt = Math.sqrt(delta);
double tOne = (-b + deltaSqrt) / (2 * a);
double tTwo = (-b - deltaSqrt) / (2 * a);
Vector intersectionOne = linePoint.clone().add(lineDirection.clone().multiply(tOne));
Vector intersectionTwo = linePoint.clone().add(lineDirection.clone().multiply(tTwo));
boolean first = intersectionOne.distanceSquared(linePoint) < intersectionTwo.distanceSquared(linePoint);
double currentDist = (first ? intersectionOne : intersectionTwo).distance(linePoint);
double factor = (currentDist - minDist) / (maxDist - minDist);
if (first && isInsideBlock(blockPoint, intersectionOne)) {
Vector normal = intersectionOne.clone().subtract(centerPoint).normalize();
return Intersection.of(normal, intersectionOne, lineDirection,
getColor(centerPoint, intersectionOne, factor));
} else if (isInsideBlock(blockPoint, intersectionTwo)) {
Vector normal = intersectionTwo.clone().subtract(centerPoint).normalize();
return Intersection.of(normal, intersectionTwo, lineDirection,
getColor(centerPoint, intersectionTwo, factor));
} else {
return currentIntersection;
}
}
private int getColor(Vector base, Vector intersection, double factor) {
Location loc = base.toLocation(null);
loc.setDirection(intersection.clone().subtract(base).normalize());
double perimeter = Math.round(2 * Math.PI * radius);
double yawDiv = 360 / perimeter;
double pitchDiv = 180 / perimeter;
int pixelX = (int) ((loc.getYaw() % yawDiv) / (yawDiv / textureSize));
int pixelY = (int) (((loc.getPitch() + 90) % pitchDiv) / (pitchDiv / textureSize));
return 0xFF000000 | MathUtil.weightedColorSum(texture[pixelY][pixelX], 0, 1 - factor, factor);
}
private boolean isInsideBlock(Vector blockPoint, Vector intersection) {
intersection = intersection.clone().subtract(blockPoint);
return intersection.getX() >= 0 && intersection.getX() < 1 && intersection.getY() >= 0
&& intersection.getY() < 1 && intersection.getZ() >= 0 && intersection.getZ() < 1;
}
public static class SphereModelBuilder extends Builder {
private double radius;
private Vector offset;
SphereModelBuilder(int[][] texture) {
super(texture);
this.radius = 0.5;
this.offset = new Vector();
}
public SphereModelBuilder radius(double radius) {
this.radius = radius;
return this;
}
public SphereModelBuilder offset(Vector offset) {
this.offset = offset.clone();
return this;
}
@Override
public Model build() {
return new SphereModel(texture, transparencyFactor, reflectionFactor, occluding, radius,
offset);
}
}
}
@@ -1,57 +0,0 @@
package eu.mhsl.minecraft.pixelpics.render.model;
import eu.mhsl.minecraft.pixelpics.render.model.AbstractModel.Builder;
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
import org.bukkit.block.Block;
public class StaticModel implements Model {
private final int color;
private final double transparencyFactor;
private final double reflectionFactor;
private final boolean occluding;
private StaticModel(int color, double transparencyFactor, double reflectionFactor, boolean occluding) {
this.color = color;
this.transparencyFactor = transparencyFactor;
this.reflectionFactor = reflectionFactor;
this.occluding = occluding;
}
@Override
public Intersection intersect(Block block, Intersection currentIntersection) {
return Intersection.of(currentIntersection.getNormal(), currentIntersection.getPoint(),
currentIntersection.getDirection(), color);
}
@Override
public double getTransparencyFactor() {
return transparencyFactor;
}
@Override
public double getReflectionFactor() {
return reflectionFactor;
}
@Override
public boolean isOccluding() {
return occluding;
}
public static class StaticModelBuilder extends Builder {
private final int color;
StaticModelBuilder(int color) {
super(new int[1][1]);
this.color = color;
}
@Override
public StaticModel build() {
return new StaticModel(color, transparencyFactor, reflectionFactor, occluding);
}
}
}
@@ -1,149 +0,0 @@
package eu.mhsl.minecraft.pixelpics.render.raytrace;
import eu.mhsl.minecraft.pixelpics.render.model.Model;
import eu.mhsl.minecraft.pixelpics.render.registry.AdvancedModelRegistry;
import eu.mhsl.minecraft.pixelpics.render.util.BlockRaytracer;
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.util.Vector;
public class AdvancedRaytracer implements Raytracer {
private final int maxDistance;
private final int reflectionDepth;
private final AdvancedModelRegistry textureRegistry;
private Block reflectedBlock;
public AdvancedRaytracer() {
this(300, 10);
}
public AdvancedRaytracer(int maxDistance, int reflectionDepth) {
this.maxDistance = maxDistance;
this.reflectionDepth = reflectionDepth;
this.textureRegistry = new AdvancedModelRegistry();
this.textureRegistry.initialize();
this.reflectedBlock = null;
}
@Override
public int trace(World world, Vector point, Vector direction) {
return trace(world, point, direction, reflectionDepth);
}
private int trace(World world, Vector point, Vector direction, int reflectionDepth) {
Location loc = point.toLocation(world);
loc.setDirection(direction);
BlockRaytracer iterator = new BlockRaytracer(loc);
int baseColor = Color.fromRGB(65, 89, 252).asRGB();
Vector finalIntersection = null;
int reflectionColor = 0;
double reflectionFactor = 0;
boolean reflected = false;
Vector transparencyStart = null;
int transparencyColor = 0;
double transparencyFactor = 0;
Material occlusionMaterial = null;
BlockData occlusionData = null;
for (int i = 0; i < maxDistance; i++) {
if (!iterator.hasNext()) break;
Block block = iterator.next();
if (reflectedBlock != null && reflectedBlock.equals(block)) continue;
reflectedBlock = null;
Material material = block.getType();
if (material == Material.AIR) {
occlusionMaterial = null;
occlusionData = null;
continue;
}
Model textureModel = textureRegistry.getModel(block.getType(), block.getBlockData(), block.getTemperature(), block.getHumidity());
Intersection currentIntersection = Intersection.of(
MathUtil.toVector(iterator.getIntersectionFace()),
i == 0 ? point : iterator.getIntersectionPoint(),
direction
);
Intersection newIntersection = textureModel.intersect(block, currentIntersection);
if (newIntersection == null) continue;
int color = newIntersection.getColor();
if (!reflected && textureModel.getReflectionFactor() > 0 && reflectionDepth > 0 && (color >> 24) != 0) {
reflectedBlock = block;
reflectionColor = trace(
world,
newIntersection.getPoint(),
MathUtil.reflectVector(
point,
direction,
newIntersection.getPoint(),
newIntersection.getNormal()
),
reflectionDepth - 1
);
reflectionFactor = textureModel.getReflectionFactor();
reflected = true;
}
if (transparencyStart == null && textureModel.getTransparencyFactor() > 0) {
transparencyStart = newIntersection.getPoint();
transparencyColor = newIntersection.getColor();
transparencyFactor = textureModel.getTransparencyFactor();
}
if (textureModel.isOccluding()) {
BlockData data = block.getBlockData();
if (material == occlusionMaterial && data.equals(occlusionData)) continue;
occlusionMaterial = material;
occlusionData = data;
} else {
occlusionMaterial = null;
occlusionData = null;
}
if (transparencyStart != null && textureModel.getTransparencyFactor() > 0) continue;
if ((color >> 24) == 0) continue;
baseColor = color;
finalIntersection = newIntersection.getPoint();
break;
}
if (transparencyStart != null) {
baseColor = MathUtil.weightedColorSum(
baseColor,
transparencyColor,
transparencyFactor,
(1
- transparencyFactor)
* (1 + transparencyStart.distance(finalIntersection == null ? transparencyStart : finalIntersection)
/ 5.0));
}
if (reflected) {
baseColor = MathUtil.weightedColorSum(
baseColor,
reflectionColor,
1 - reflectionFactor,
reflectionFactor
);
}
return baseColor & 0xFFFFFF;
}
}
@@ -1,152 +0,0 @@
package eu.mhsl.minecraft.pixelpics.render.raytrace;
import eu.mhsl.minecraft.pixelpics.render.model.Model;
import eu.mhsl.minecraft.pixelpics.render.registry.AdvancedModelRegistry;
import eu.mhsl.minecraft.pixelpics.render.registry.ModelRegistry;
import eu.mhsl.minecraft.pixelpics.render.util.BlockRaytracer;
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Biome;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.util.Vector;
public class DefaultRaytracer implements Raytracer {
private final int maxDistance;
private final int reflectionDepth;
private final ModelRegistry textureRegistry;
private Block reflectedBlock;
public DefaultRaytracer() {
this(300, 10);
}
public DefaultRaytracer(int maxDistance, int reflectionDepth) {
this.maxDistance = maxDistance;
this.reflectionDepth = reflectionDepth;
this.textureRegistry = new AdvancedModelRegistry();
this.textureRegistry.initialize();
this.reflectedBlock = null;
}
@Override
public int trace(World world, Vector point, Vector direction) {
return trace(world, point, direction, reflectionDepth);
}
private int trace(World world, Vector point, Vector direction, int reflectionDepth) {
Location loc = point.toLocation(world);
loc.setDirection(direction);
BlockRaytracer iterator = new BlockRaytracer(loc);
int baseColor = Color.fromRGB(65, 89, 252).asRGB();
Vector finalIntersection = null;
int reflectionColor = 0;
double reflectionFactor = 0;
boolean reflected = false;
Vector transparencyStart = null;
int transparencyColor = 0;
double transparencyFactor = 0;
Material occlusionMaterial = null;
BlockData occlusionData = null;
for (int i = 0; i < maxDistance; i++) {
if (!iterator.hasNext()) break;
Block block = iterator.next();
if (reflectedBlock != null && reflectedBlock.equals(block)) continue;
reflectedBlock = null;
Material material = block.getType();
if (material == Material.AIR) {
occlusionMaterial = null;
occlusionData = null;
continue;
}
Biome biome = block.getBiome();
Model textureModel = textureRegistry.getModel(block);
Intersection currentIntersection = Intersection.of(
MathUtil.toVector(iterator.getIntersectionFace()),
i == 0 ? point : iterator.getIntersectionPoint(),
direction
);
Intersection newIntersection = textureModel.intersect(block, currentIntersection);
if (newIntersection == null) continue;
int color = newIntersection.getColor();
if (!reflected && textureModel.getReflectionFactor() > 0 && reflectionDepth > 0 && (color >> 24) != 0) {
reflectedBlock = block;
reflectionColor = trace(
world,
newIntersection.getPoint(),
MathUtil.reflectVector(
point,
direction,
newIntersection.getPoint(),
newIntersection.getNormal()
),
reflectionDepth - 1
);
reflectionFactor = textureModel.getReflectionFactor();
reflected = true;
}
if (transparencyStart == null && textureModel.getTransparencyFactor() > 0) {
transparencyStart = newIntersection.getPoint();
transparencyColor = newIntersection.getColor();
transparencyFactor = textureModel.getTransparencyFactor();
}
if (textureModel.isOccluding()) {
BlockData data = block.getBlockData();
if (material == occlusionMaterial && data.equals(occlusionData)) continue;
occlusionMaterial = material;
occlusionData = data;
} else {
occlusionMaterial = null;
occlusionData = null;
}
if (transparencyStart != null && textureModel.getTransparencyFactor() > 0) continue;
if ((color >> 24) == 0) continue;
baseColor = color;
finalIntersection = newIntersection.getPoint();
break;
}
if (transparencyStart != null) {
baseColor = MathUtil.weightedColorSum(
baseColor,
transparencyColor,
transparencyFactor,
(1
- transparencyFactor)
* (1 + transparencyStart.distance(finalIntersection == null ? transparencyStart : finalIntersection)
/ 5.0));
}
if (reflected) {
baseColor = MathUtil.weightedColorSum(
baseColor,
reflectionColor,
1 - reflectionFactor,
reflectionFactor
);
}
return baseColor & 0xFFFFFF;
}
}
@@ -0,0 +1,173 @@
package eu.mhsl.minecraft.pixelpics.render.raytrace;
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Element;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import org.bukkit.util.Vector;
import java.util.ArrayList;
import java.util.List;
/**
* Intersects a ray (in block coordinates) with a {@link ResolvedModel}'s element boxes and returns
* the nearest opaque face hit. Axis-aligned boxes use a slab test; rotated elements are handled as
* oriented boxes by transforming the ray into the element's local frame. Fully transparent texels
* (alpha &le; threshold) are treated as holes so the ray passes through (cutout for plants, glass).
*/
public final class ElementIntersector {
private static final double EPS = 1e-7;
private static final int ALPHA_THRESHOLD = 16;
private ElementIntersector() {}
/**
* @param ox,oy,oz ray origin in block-local coordinates (world origin minus block min corner)
* @param dx,dy,dz ray direction (need not be normalized)
* @param bx,by,bz block min corner in world coordinates (for reconstructing the world hit point)
*/
public static FaceHit intersect(ResolvedModel model,
double ox, double oy, double oz,
double dx, double dy, double dz,
int bx, int by, int bz) {
List<Candidate> candidates = new ArrayList<>(model.elements.size());
for (int i = 0; i < model.elements.size(); i++) {
Element element = model.elements.get(i);
Candidate c = element.isAxisAligned()
? intersectAabb(element, ox, oy, oz, dx, dy, dz)
: intersectObb(element, ox, oy, oz, dx, dy, dz);
if (c != null) candidates.add(new Candidate(c.element, c.t, c.dir, c.s, c.t2, c.normal, i));
}
if (candidates.isEmpty()) return null;
// Sort by depth; for coplanar faces (equal t) render later elements first, matching vanilla's
// draw order so overlays (e.g. the tinted grass side overlay) sit on top of the base face.
candidates.sort((a, b) -> {
if (Math.abs(a.t - b.t) > 1e-4) return Double.compare(a.t, b.t);
return Integer.compare(b.order, a.order);
});
for (Candidate c : candidates) {
Face face = c.element.faces[c.dir.ordinal()];
if (face == null) continue;
int color = face.sample(c.s, c.t2);
if (ColorUtil.alpha(color) <= ALPHA_THRESHOLD) continue;
Vector world = new Vector(bx + ox + dx * c.t, by + oy + dy * c.t, bz + oz + dz * c.t);
Vector normal = new Vector(c.normal[0], c.normal[1], c.normal[2]);
return new FaceHit(c.t, world, normal, color, face.tintIndex);
}
return null;
}
private record Candidate(Element element, double t, Direction dir, double s, double t2, double[] normal, int order) {}
private static Candidate intersectAabb(Element e, double ox, double oy, double oz,
double dx, double dy, double dz) {
return slab(e, ox, oy, oz, dx, dy, dz, e.from, e.to, null);
}
/** Rotated element: transform the ray into the element's local (unrotated) frame, then slab-test. */
private static Candidate intersectObb(Element e, double ox, double oy, double oz,
double dx, double dy, double dz) {
double[] o = rotate(ox - e.rotOrigin[0], oy - e.rotOrigin[1], oz - e.rotOrigin[2], e.rotAxis, -e.rotAngleRad);
o[0] += e.rotOrigin[0];
o[1] += e.rotOrigin[1];
o[2] += e.rotOrigin[2];
double[] d = rotate(dx, dy, dz, e.rotAxis, -e.rotAngleRad);
return slab(e, o[0], o[1], o[2], d[0], d[1], d[2], e.from, e.to, e);
}
private static Candidate slab(Element e, double ox, double oy, double oz,
double dx, double dy, double dz,
double[] from, double[] to, Element obb) {
double tmin = Double.NEGATIVE_INFINITY;
double tmax = Double.POSITIVE_INFINITY;
int axis = -1;
boolean negFace = false; // entered through the low-coordinate face
double[] o = {ox, oy, oz};
double[] d = {dx, dy, dz};
for (int a = 0; a < 3; a++) {
if (Math.abs(d[a]) < EPS) {
if (o[a] < from[a] - EPS || o[a] > to[a] + EPS) return null;
continue;
}
double inv = 1.0 / d[a];
double t1 = (from[a] - o[a]) * inv;
double t2 = (to[a] - o[a]) * inv;
boolean neg = true;
if (t1 > t2) {
double tmp = t1; t1 = t2; t2 = tmp;
neg = false;
}
if (t1 > tmin) {
tmin = t1;
axis = a;
negFace = neg;
}
if (t2 < tmax) tmax = t2;
if (tmin > tmax) return null;
}
if (axis < 0) return null;
double tEntry = tmin;
if (tEntry < EPS) {
// origin inside the box (e.g. camera within a block): use exit point instead
tEntry = tmax;
if (tEntry < EPS) return null;
}
double px = o[0] + d[0] * tEntry;
double py = o[1] + d[1] * tEntry;
double pz = o[2] + d[2] * tEntry;
Direction dir = faceFor(axis, negFace);
double[] normal = {dir.nx, dir.ny, dir.nz};
if (obb != null) {
// rotate the normal back into block space
normal = rotate(normal[0], normal[1], normal[2], obb.rotAxis, obb.rotAngleRad);
}
double fracX = frac(px, from[0], to[0]);
double fracY = frac(py, from[1], to[1]);
double fracZ = frac(pz, from[2], to[2]);
double s, t;
switch (dir) {
// Texture V is top-down (0 = texture top). For side faces the texture top is the block
// top (high Y), so t = 1 - fracY.
case UP, DOWN -> { s = fracX; t = fracZ; }
case NORTH, SOUTH -> { s = fracX; t = 1 - fracY; }
default -> { s = fracZ; t = 1 - fracY; } // WEST, EAST
}
return new Candidate(e, tEntry, dir, s, t, normal, 0);
}
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;
};
}
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);
}
/** Rotate (x,y,z) around the given axis (0=x,1=y,2=z) by angle radians. */
private static double[] rotate(double x, double y, double z, int axis, double angle) {
double cos = Math.cos(angle);
double sin = Math.sin(angle);
return switch (axis) {
case 0 -> new double[]{x, y * cos - z * sin, y * sin + z * cos};
case 1 -> new double[]{x * cos + z * sin, y, -x * sin + z * cos};
default -> new double[]{x * cos - y * sin, x * sin + y * cos, z};
};
}
}
@@ -0,0 +1,10 @@
package eu.mhsl.minecraft.pixelpics.render.raytrace;
import org.bukkit.util.Vector;
/**
* The result of intersecting a ray with a block's geometry: the world-space hit point and normal,
* the sampled ARGB color (before shading/tinting) and the face's tint index ({@code -1} = none).
*/
public record FaceHit(double t, Vector point, Vector normal, int color, int tintIndex) {
}
@@ -1,9 +0,0 @@
package eu.mhsl.minecraft.pixelpics.render.raytrace;
import org.bukkit.World;
import org.bukkit.util.Vector;
public interface Raytracer {
int trace(World world, Vector point, Vector direction);
}
@@ -0,0 +1,286 @@
package eu.mhsl.minecraft.pixelpics.render.raytrace;
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityScene;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyRenderer;
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTint;
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
import eu.mhsl.minecraft.pixelpics.render.tint.TintResolver;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
import org.bukkit.Material;
import org.bukkit.block.Biome;
import org.bukkit.block.data.BlockData;
import org.bukkit.util.Vector;
/**
* Traces a single ray against a {@link WorldSnapshot}, sampling block models via the
* {@link ElementIntersector} and applying biome tint, directional face shading, transparency and
* reflection. Contains no Bukkit world access, so it is safe to invoke from worker threads.
*/
public final class SnapshotRaytracer {
private static final int BIOME_BLEND_RADIUS = 2;
// Distance fog (atmospheric perspective).
private static final double FOG_START = 128;
private static final double FOG_END = 256;
private static final double FOG_MAX = 0.75;
// Vanilla-style ambient occlusion brightness per occlusion level (0=most occluded .. 3=open). Kept subtle.
private static final double[] AO_BRIGHTNESS = {0.55, 0.70, 0.85, 1.0};
private final BlockModelRegistry registry;
private final BiomeTintProvider tintProvider;
private final SkyRenderer skyRenderer;
private final double maxDistance;
private final int reflectionDepth;
private final int maxSteps;
private final java.util.Map<Long, BiomeTint> tintCache = new java.util.concurrent.ConcurrentHashMap<>();
public SnapshotRaytracer(BlockModelRegistry registry, BiomeTintProvider tintProvider,
SkyRenderer skyRenderer, double maxDistance, int reflectionDepth) {
this.registry = registry;
this.tintProvider = tintProvider;
this.skyRenderer = skyRenderer;
this.maxDistance = maxDistance;
this.reflectionDepth = reflectionDepth;
this.maxSteps = (int) (maxDistance * 3) + 3;
}
public int trace(WorldSnapshot snapshot, Vector origin, Vector direction, SkyContext sky, EntityScene scene) {
return trace(snapshot, origin, direction, sky, scene, reflectionDepth);
}
private int trace(WorldSnapshot snapshot, Vector origin, Vector direction, SkyContext sky, EntityScene scene, int depth) {
double ox = origin.getX(), oy = origin.getY(), oz = origin.getZ();
double dx = direction.getX(), dy = direction.getY(), dz = direction.getZ();
VoxelDDA dda = new VoxelDDA(ox, oy, oz, dx, dy, dz);
int skyColor = skyRenderer.colorFor(direction, origin, sky);
int baseColor = skyColor;
Vector finalPoint = null;
int reflectionColor = 0;
double reflectionFactor = 0;
boolean reflected = false;
Vector transparencyStart = null;
int transparencyColor = 0;
double transparencyFactor = 0;
BlockData occlusion = null;
for (int i = 0; i < maxSteps && dda.tCurrent <= maxDistance; i++) {
int bx = dda.x, by = dda.y, bz = dda.z;
BlockData data = snapshot.getBlockData(bx, by, bz);
if (data.getMaterial() == Material.AIR) {
occlusion = null;
dda.advance();
continue;
}
ResolvedModel model = registry.get(data);
FaceHit hit = ElementIntersector.intersect(model, ox - bx, oy - by, oz - bz, dx, dy, dz, bx, by, bz);
if (hit == null) {
occlusion = null;
dda.advance();
continue;
}
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 reflectStart = hit.point().clone().add(hit.normal().clone().multiply(1e-3));
reflectionColor = trace(snapshot, reflectStart, reflectDir, sky, scene, depth - 1);
reflectionFactor = model.reflection;
reflected = true;
}
if (transparencyStart == null && model.transparency > 0) {
transparencyStart = hit.point();
transparencyColor = color;
transparencyFactor = model.transparency;
}
if (model.occluding) {
if (data.equals(occlusion)) {
dda.advance();
continue;
}
occlusion = data;
} else {
occlusion = null;
}
if (transparencyStart != null && model.transparency > 0) {
dda.advance();
continue;
}
baseColor = color;
finalPoint = hit.point();
break;
}
// Entities: if one is closer than the opaque block/sky, it becomes the surface.
if (scene != null && !scene.isEmpty()) {
double blockDist = finalPoint != null ? origin.distance(finalPoint) : maxDistance;
FaceHit eh = scene.nearestHit(ox, oy, oz, dx, dy, dz, blockDist);
if (eh != null) {
baseColor = ColorUtil.shade(eh.color(), shadeFactor(eh.normal()));
finalPoint = eh.point();
reflected = false;
if (transparencyStart != null && origin.distance(transparencyStart) >= eh.t()) {
transparencyStart = null;
}
}
}
if (transparencyStart != null) {
baseColor = MathUtil.weightedColorSum(
baseColor,
transparencyColor,
transparencyFactor,
(1 - transparencyFactor)
* (1 + transparencyStart.distance(finalPoint == null ? transparencyStart : finalPoint) / 5.0));
}
if (reflected) {
baseColor = MathUtil.weightedColorSum(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);
}
return baseColor & 0xFFFFFF;
}
private int shadeAndTint(FaceHit hit, BlockData data, WorldSnapshot snapshot, int bx, int by, int bz) {
int color = hit.color();
if (hit.tintIndex() >= 0) {
BiomeTint tint = blendedTint(snapshot, bx, by, bz);
if (tint != null) {
int tintColor = TintResolver.resolve(data, hit.tintIndex(), tint);
if (tintColor != -1) color = ColorUtil.multiply(color, tintColor);
}
}
double light = shadeFactor(hit.normal()) * ambientOcclusion(hit, snapshot, bx, by, bz);
return ColorUtil.shade(color, light);
}
private double fogFactor(double distance) {
if (distance <= FOG_START) return 0;
double f = (distance - FOG_START) / (FOG_END - FOG_START);
return Math.clamp(f, 0, FOG_MAX);
}
/**
* Vanilla-style smooth ambient occlusion: darkens face corners by how many of the three blocks
* touching that corner (in the layer just outside the face) are solid, bilinearly interpolated
* across the face. Only applied to axis-aligned faces.
*/
private double ambientOcclusion(FaceHit hit, WorldSnapshot snapshot, int bx, int by, int bz) {
double nx = hit.normal().getX(), ny = hit.normal().getY(), nz = hit.normal().getZ();
double ax = Math.abs(nx), ay = Math.abs(ny), az = Math.abs(nz);
if (Math.max(ax, Math.max(ay, az)) < 0.99) return 1.0; // skip rotated/diagonal faces
double lx = hit.point().getX() - bx;
double ly = hit.point().getY() - by;
double lz = hit.point().getZ() - bz;
// Offset to the layer just outside the face, plus the two in-plane unit axes and face coords.
int ofx = (int) Math.round(nx), ofy = (int) Math.round(ny), ofz = (int) Math.round(nz);
int ux, uy, uz, vx, vy, vz;
double su, sv;
if (ay > 0.5) { // up/down
ux = 1; uy = 0; uz = 0; vx = 0; vy = 0; vz = 1; su = lx; sv = lz;
} else if (ax > 0.5) { // east/west
ux = 0; uy = 0; uz = 1; vx = 0; vy = 1; vz = 0; su = lz; sv = ly;
} else { // north/south
ux = 1; uy = 0; uz = 0; vx = 0; vy = 1; vz = 0; su = lx; sv = ly;
}
double b00 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, -1, -1);
double b10 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, +1, -1);
double b01 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, -1, +1);
double b11 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, +1, +1);
double top = b00 + (b10 - b00) * su;
double bottom = b01 + (b11 - b01) * su;
return top + (bottom - top) * sv;
}
private double aoCorner(WorldSnapshot snapshot, int bx, int by, int bz,
int ofx, int ofy, int ofz, int ux, int uy, int uz, int vx, int vy, int vz,
int du, int dv) {
boolean side1 = solid(snapshot, bx + ofx + du * ux, by + ofy + du * uy, bz + ofz + du * uz);
boolean side2 = solid(snapshot, bx + ofx + dv * vx, by + ofy + dv * vy, bz + ofz + dv * vz);
boolean corner = solid(snapshot,
bx + ofx + du * ux + dv * vx, by + ofy + du * uy + dv * vy, bz + ofz + du * uz + dv * vz);
int level = (side1 && side2) ? 0 : 3 - (side1 ? 1 : 0) - (side2 ? 1 : 0) - (corner ? 1 : 0);
return AO_BRIGHTNESS[Math.clamp(level, 0, 3)];
}
private boolean solid(WorldSnapshot snapshot, int x, int y, int z) {
Material m = snapshot.getBlockData(x, y, z).getMaterial();
return m != Material.AIR && m.isOccluding();
}
/**
* Biome-blended tint: averages the per-biome tint over a {@code (2r+1)x(2r+1)} neighbourhood in
* X/Z (vanilla biome blend radius, default 2), giving smooth grass/foliage gradients across biome
* borders instead of hard edges. Cached per column.
*/
private BiomeTint blendedTint(WorldSnapshot snapshot, int bx, int by, int bz) {
long key = (((long) bx) & 0xFFFFFFFFL) | (((long) bz) << 32);
BiomeTint cached = tintCache.get(key);
if (cached != null) return cached;
long[] g = new long[3], f = new long[3], d = new long[3], w = new long[3];
int n = 0;
for (int dx = -BIOME_BLEND_RADIUS; dx <= BIOME_BLEND_RADIUS; dx++) {
for (int dz = -BIOME_BLEND_RADIUS; dz <= BIOME_BLEND_RADIUS; dz++) {
Biome biome = snapshot.getBiome(bx + dx, by, bz + dz);
if (biome == null) continue;
BiomeTint t = tintProvider.forBiome(biome);
accumulate(g, t.grass());
accumulate(f, t.foliage());
accumulate(d, t.dryFoliage());
accumulate(w, t.water());
n++;
}
}
if (n == 0) return null;
BiomeTint result = new BiomeTint(average(g, n), average(f, n), average(d, n), average(w, n));
tintCache.put(key, result);
return result;
}
private static void accumulate(long[] acc, int argb) {
acc[0] += (argb >> 16) & 0xFF;
acc[1] += (argb >> 8) & 0xFF;
acc[2] += argb & 0xFF;
}
private static int average(long[] acc, int n) {
return 0xFF000000 | (((int) (acc[0] / n)) << 16) | (((int) (acc[1] / n)) << 8) | ((int) (acc[2] / n));
}
/** Vanilla-style directional shading: top 1.0, north/south 0.8, east/west 0.6, bottom 0.5. */
private double shadeFactor(Vector normal) {
double ax = Math.abs(normal.getX());
double ay = Math.abs(normal.getY());
double az = Math.abs(normal.getZ());
if (ay >= ax && ay >= az) return normal.getY() >= 0 ? 1.0 : 0.5;
if (az >= ax) return 0.8;
return 0.6;
}
}
@@ -0,0 +1,65 @@
package eu.mhsl.minecraft.pixelpics.render.raytrace;
/**
* Amanatides-Woo voxel traversal: walks the integer block grid a ray passes through, in order,
* without any Bukkit world access (safe to run off the main thread).
*/
public final class VoxelDDA {
public int x, y, z;
public double tCurrent; // ray parameter at which the current voxel was entered
private final int stepX, stepY, stepZ;
private final double tDeltaX, tDeltaY, tDeltaZ;
private double tMaxX, tMaxY, tMaxZ;
public VoxelDDA(double ox, double oy, double oz, double dx, double dy, double dz) {
this.x = (int) Math.floor(ox);
this.y = (int) Math.floor(oy);
this.z = (int) Math.floor(oz);
this.tCurrent = 0;
this.stepX = dx > 0 ? 1 : (dx < 0 ? -1 : 0);
this.stepY = dy > 0 ? 1 : (dy < 0 ? -1 : 0);
this.stepZ = dz > 0 ? 1 : (dz < 0 ? -1 : 0);
this.tDeltaX = dx == 0 ? Double.POSITIVE_INFINITY : Math.abs(1.0 / dx);
this.tDeltaY = dy == 0 ? Double.POSITIVE_INFINITY : Math.abs(1.0 / dy);
this.tDeltaZ = dz == 0 ? Double.POSITIVE_INFINITY : Math.abs(1.0 / dz);
this.tMaxX = boundary(ox, dx, x, stepX);
this.tMaxY = boundary(oy, dy, y, stepY);
this.tMaxZ = boundary(oz, dz, z, stepZ);
}
private static double boundary(double origin, double dir, int voxel, int step) {
if (dir == 0) return Double.POSITIVE_INFINITY;
double next = step > 0 ? (voxel + 1) : voxel;
return (next - origin) / dir;
}
/** Advance to the next voxel along the ray, updating {@link #tCurrent}. */
public void advance() {
if (tMaxX < tMaxY) {
if (tMaxX < tMaxZ) {
x += stepX;
tCurrent = tMaxX;
tMaxX += tDeltaX;
} else {
z += stepZ;
tCurrent = tMaxZ;
tMaxZ += tDeltaZ;
}
} else {
if (tMaxY < tMaxZ) {
y += stepY;
tCurrent = tMaxY;
tMaxY += tDeltaY;
} else {
z += stepZ;
tCurrent = tMaxZ;
tMaxZ += tDeltaZ;
}
}
}
}
@@ -1,157 +0,0 @@
package eu.mhsl.minecraft.pixelpics.render.registry;
import com.google.gson.Gson;
import eu.mhsl.minecraft.pixelpics.Main;
import eu.mhsl.minecraft.pixelpics.render.model.AbstractModel;
import eu.mhsl.minecraft.pixelpics.render.model.Model;
import org.bukkit.Color;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.*;
import java.net.URL;
import java.util.*;
import static eu.mhsl.minecraft.pixelpics.render.registry.DefaultModelRegistry.TEXTURE_SIZE;
public class AdvancedModelRegistry implements ModelRegistry {
private final Gson gson = new Gson();
private final Map<Material, Map<BlockData, Model>> modelMap = new HashMap<>();
private final Set<String> tintedBlocks = Set.of("grass", "grass_block", "leaves", "oak_leaves", "water", "vine", "sugar_cane");
public record BlockInfo(String parent, BlockTextures textures){}
public record BlockTextures(
String texture,
String bottom,
String top,
String all,
String particle,
String end,
String side,
String cross,
String rail,
String overlay
){}
@Override
public void initialize() {
System.out.println(modelMap);
File blockDir = new File(Main.getInstance().getDataFolder(), "models/block");
for (File file : Objects.requireNonNull(blockDir.listFiles())) {
addModelFromFile(file);
}
try {
registerModel(Material.LAVA, AbstractModel.Builder.createSimple(getTextureArray("lava_still"))
.transparency(0.15).reflection(0.05).occlusion().build());
registerModel(Material.WATER, AbstractModel.Builder.createSimple(getTextureArray("water_still"))
.transparency(0.60).reflection(0.1).occlusion().build());
} catch (Exception ignored) { }
}
@Override
public Model getModel(Block block) {
return ModelRegistry.super.getModel(block);
}
@Override
public Model getModel(Material material, BlockData blockData) {
return getModel(material, blockData, 0.8, 0.4);
}
public Model getModel(Material material, BlockData blockData, double temperature, double humidity) {
return modelMap.computeIfAbsent(material, key -> new HashMap<>()).getOrDefault(blockData,
blockData == null ? getDefaultModel()
: modelMap.get(material).getOrDefault(null, getDefaultModel()));
}
@Override
public Model getDefaultModel() {
return AbstractModel.Builder.createStatic(Color.PURPLE.asRGB()).build();
}
private void registerModel(Material material, Model blockModel) {
modelMap.computeIfAbsent(material, key -> new HashMap<>())
.put(null, blockModel);
}
private void addModelFromFile(File file) {
String blockName = file.getName().substring(0, file.getName().lastIndexOf('.'));
Material material = Material.getMaterial(blockName.toUpperCase());
if(material == null) return;
Model model = getModelFromFile(file);
if(model == null) return;
registerModel(material, model);
}
private Model getModelFromFile(File file) {
try (Reader reader = new FileReader(file)) {
BlockInfo blockInfo = gson.fromJson(reader, BlockInfo.class);
if(blockInfo.textures.all != null) {
return AbstractModel.Builder.createSimple(
getTextureArray(blockInfo.textures.all.substring(blockInfo.textures.all.lastIndexOf('/') + 1))
).build();
}
if(blockInfo.textures.cross != null) {
return AbstractModel.Builder.createCross(
getTextureArray(blockInfo.textures.cross.substring(blockInfo.textures.cross.lastIndexOf('/') + 1))
).build();
}
if(blockInfo.textures.side != null && blockInfo.textures.bottom != null && blockInfo.textures.top != null) {
return AbstractModel.Builder.createMulti(
getTextureArray(blockInfo.textures.top.substring(blockInfo.textures.top.lastIndexOf('/') + 1)),
getTextureArray(blockInfo.textures.side.substring(blockInfo.textures.side.lastIndexOf('/') + 1)),
getTextureArray(blockInfo.textures.bottom.substring(blockInfo.textures.bottom.lastIndexOf('/') + 1))
).build();
}
if(blockInfo.textures.side != null && blockInfo.textures.end != null) {
return AbstractModel.Builder.createMulti(
getTextureArray(blockInfo.textures.end.substring(blockInfo.textures.end.lastIndexOf('/') + 1)),
getTextureArray(blockInfo.textures.side.substring(blockInfo.textures.side.lastIndexOf('/') + 1)),
getTextureArray(blockInfo.textures.end.substring(blockInfo.textures.end.lastIndexOf('/') + 1))
).build();
}
} catch (Exception e) {
System.out.println(e.getMessage());
}
return null;
}
private int[][] getTextureArray(String textureName) {
int[][] texture = new int[TEXTURE_SIZE][TEXTURE_SIZE];
BufferedImage img;
URL url = this.getClass().getClassLoader().getResource(String.format("textures/block/%s.png", textureName));
if (url == null) {
throw new RuntimeException("Block Texture Resource not found.");
}
try (InputStream input = url.openConnection().getInputStream()) {
img = ImageIO.read(input);
} catch (IOException e) {
throw new RuntimeException(e);
}
for (int pixelY = 0; pixelY < TEXTURE_SIZE; pixelY++) {
for (int pixelX = 0; pixelX < TEXTURE_SIZE; pixelX++) {
texture[TEXTURE_SIZE - 1 - pixelY][TEXTURE_SIZE - 1 - pixelX] = img.getRGB(pixelX, pixelY);
}
}
return texture;
}
private int tintPixel(int baseColor, int tintColor) {
int a = (baseColor >> 24) & 0xFF;
int r = ((baseColor >> 16) & 0xFF) * ((tintColor >> 16) & 0xFF) / 255;
int g = ((baseColor >> 8) & 0xFF) * ((tintColor >> 8) & 0xFF) / 255;
int b = (baseColor & 0xFF) * (tintColor & 0xFF) / 255;
return (a << 24) | (r << 16) | (g << 8) | b;
}
}
@@ -1,169 +0,0 @@
package eu.mhsl.minecraft.pixelpics.render.registry;
import eu.mhsl.minecraft.pixelpics.render.model.AbstractModel.Builder;
import eu.mhsl.minecraft.pixelpics.render.model.Model;
import org.bukkit.Color;
import org.bukkit.Material;
import org.bukkit.block.data.BlockData;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.HashMap;
import java.util.Map;
public class DefaultModelRegistry implements ModelRegistry {
private static final String IMAGE_RESOURCE = "terrain.png";
static final int TEXTURE_SIZE = 16;
private final Map<Material, Map<BlockData, Model>> modelMap;
private BufferedImage textures;
public DefaultModelRegistry() {
this.modelMap = new HashMap<>();
}
@Override
public void initialize() {
URL url = this.getClass().getClassLoader().getResource(IMAGE_RESOURCE);
if (url == null) {
throw new RuntimeException("Default resource \"terrain.png\" is missing");
}
try (InputStream input = url.openConnection().getInputStream()) {
this.textures = ImageIO.read(input);
} catch (IOException e) {
throw new RuntimeException(e);
}
registerModel(Material.GRASS_BLOCK, Builder.createMulti(textureIndex(0, 0), textureIndex(0, 3), textureIndex(0, 2)).build());
registerModel(Material.STONE, Builder.createSimple(textureIndex(0, 1)).build());
registerModel(Material.DIRT, Builder.createSimple(textureIndex(0, 2)).build());
registerModel(Material.OAK_PLANKS, Builder.createSimple(textureIndex(0, 4)).build());
registerModel(Material.SPRUCE_PLANKS,
Builder.createSimple(textureIndex(0, 4)).build());
registerModel(Material.BIRCH_PLANKS,
Builder.createSimple(textureIndex(0, 4)).build());
registerModel(Material.JUNGLE_PLANKS,
Builder.createSimple(textureIndex(0, 4)).build());
registerModel(Material.ACACIA_PLANKS,
Builder.createSimple(textureIndex(0, 4)).build());
registerModel(Material.DARK_OAK_PLANKS,
Builder.createSimple(textureIndex(0, 4)).build());
registerModel(Material.BRICK, Builder.createSimple(textureIndex(0, 7)).build());
registerModel(Material.TNT, Builder.createMulti(textureIndex(0, 9),
textureIndex(0, 8), textureIndex(0, 10)).build());
registerModel(Material.WATER, Builder.createStatic(0xFF000000 | Color.fromRGB(0, 5, 60).asRGB())
.transparency(0.60).reflection(0.1).occlusion().build());
registerModel(Material.DIAMOND_BLOCK,
Builder.createSimple(textureIndex(3, 3)).reflection(0.75).build());
registerModel(Material.POPPY, Builder.createCross(textureIndex(0, 12)).build());
registerModel(Material.DANDELION, Builder.createCross(textureIndex(0, 13)).build());
registerModel(Material.OAK_SAPLING,
Builder.createCross(textureIndex(0, 15)).build());
registerModel(Material.SPRUCE_SAPLING,
Builder.createCross(textureIndex(0, 15)).build());
registerModel(Material.BIRCH_SAPLING,
Builder.createCross(textureIndex(0, 15)).build());
registerModel(Material.JUNGLE_SAPLING,
Builder.createCross(textureIndex(0, 15)).build());
registerModel(Material.ACACIA_SAPLING,
Builder.createCross(textureIndex(0, 15)).build());
registerModel(Material.DARK_OAK_SAPLING,
Builder.createCross(textureIndex(0, 15)).build());
registerModel(Material.COBBLESTONE,
Builder.createSimple(textureIndex(1, 0)).build());
registerModel(Material.BEDROCK, Builder.createSimple(textureIndex(1, 1)).build());
registerModel(Material.SAND, Builder.createSimple(textureIndex(1, 2)).build());
registerModel(Material.GRAVEL, Builder.createSimple(textureIndex(1, 3)).build());
registerModel(Material.OAK_LOG, Builder.createMulti(textureIndex(1, 5),
textureIndex(1, 4), textureIndex(1, 5)).build());
registerModel(Material.SPRUCE_LOG, Builder.createMulti(textureIndex(1, 5),
textureIndex(1, 4), textureIndex(1, 5)).build());
registerModel(Material.BIRCH_LOG, Builder.createMulti(textureIndex(1, 5),
textureIndex(1, 4), textureIndex(1, 5)).build());
registerModel(Material.JUNGLE_LOG, Builder.createMulti(textureIndex(1, 5),
textureIndex(1, 4), textureIndex(1, 5)).build());
registerModel(Material.ACACIA_LOG, Builder.createMulti(textureIndex(1, 5),
textureIndex(1, 4), textureIndex(1, 5)).build());
registerModel(Material.DARK_OAK_LOG, Builder.createMulti(textureIndex(1, 5),
textureIndex(1, 4), textureIndex(1, 5)).build());
registerModel(Material.OAK_WOOD, Builder.createSimple(textureIndex(1, 4)).build());
registerModel(Material.SPRUCE_WOOD,
Builder.createSimple(textureIndex(1, 4)).build());
registerModel(Material.BIRCH_WOOD, Builder.createSimple(textureIndex(1, 4)).build());
registerModel(Material.JUNGLE_WOOD,
Builder.createSimple(textureIndex(1, 4)).build());
registerModel(Material.ACACIA_WOOD,
Builder.createSimple(textureIndex(1, 4)).build());
registerModel(Material.DARK_OAK_WOOD,
Builder.createSimple(textureIndex(1, 4)).build());
registerModel(Material.OAK_LEAVES, Builder.createSimple(textureIndex(1, 6)).build());
registerModel(Material.SPRUCE_LEAVES,
Builder.createSimple(textureIndex(1, 6)).build());
registerModel(Material.BIRCH_LEAVES,
Builder.createSimple(textureIndex(1, 6)).build());
registerModel(Material.JUNGLE_LEAVES,
Builder.createSimple(textureIndex(1, 6)).build());
registerModel(Material.ACACIA_LEAVES,
Builder.createSimple(textureIndex(1, 6)).build());
registerModel(Material.DARK_OAK_LEAVES,
Builder.createSimple(textureIndex(1, 6)).build());
registerModel(Material.IRON_BLOCK,
Builder.createMulti(textureIndex(1, 7),
textureIndex(2, 7), textureIndex(3, 7)).build());
registerModel(Material.GOLD_BLOCK, Builder.createMulti(textureIndex(1, 8),
textureIndex(2, 8), textureIndex(3, 8)).build());
registerModel(Material.RED_MUSHROOM,
Builder.createCross(textureIndex(1, 12)).build());
registerModel(Material.BROWN_MUSHROOM,
Builder.createCross(textureIndex(1, 13)).build());
registerModel(Material.LAVA, Builder.createSimple(textureIndex(2, 14))
.transparency(0.15).reflection(0.05).occlusion().build());
registerModel(Material.GOLD_ORE, Builder.createSimple(textureIndex(2, 0)).build());
registerModel(Material.IRON_ORE, Builder.createSimple(textureIndex(2, 1)).build());
registerModel(Material.COAL_ORE, Builder.createSimple(textureIndex(2, 2)).build());
registerModel(Material.GLASS,
Builder.createSimple(textureIndex(3, 1)).occlusion().build());
registerModel(Material.SHORT_GRASS, Builder.createCross(textureIndex(5, 6)).build());
registerModel(Material.SUGAR_CANE, Builder.createCross(textureIndex(5, 5)).build());
}
@Override
public Model getModel(Material material, BlockData blockData) {
return modelMap.computeIfAbsent(material, key -> new HashMap<>()).getOrDefault(blockData,
blockData == null ? getDefaultModel()
: modelMap.get(material).getOrDefault(null, getDefaultModel()));
}
@Override
public Model getDefaultModel() {
return Builder.createStatic(Color.PURPLE.asRGB()).build();
}
private void registerModel(Material material, Model blockModel) {
modelMap.computeIfAbsent(material, key -> new HashMap<>())
.put(null, blockModel);
}
private int[][] textureIndex(int verticalIndex, int horizontalIndex) {
int[][] texture = new int[TEXTURE_SIZE][TEXTURE_SIZE];
int offsetY = verticalIndex * TEXTURE_SIZE + (TEXTURE_SIZE - 1);
int offsetX = horizontalIndex * TEXTURE_SIZE;
for (int pixelY = 0; pixelY < TEXTURE_SIZE; pixelY++) {
for (int pixelX = 0; pixelX < TEXTURE_SIZE; pixelX++) {
texture[pixelY][pixelX] = textures.getRGB(offsetX + pixelX, offsetY - pixelY);
}
}
return texture;
}
}
@@ -1,23 +0,0 @@
package eu.mhsl.minecraft.pixelpics.render.registry;
import eu.mhsl.minecraft.pixelpics.render.model.Model;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
public interface ModelRegistry {
void initialize();
default Model getModel(Block block) {
return getModel(block.getType(), block.getBlockData());
}
default Model getModel(Material material) {
return getModel(material, null);
}
Model getModel(Material material, BlockData blockData);
Model getDefaultModel();
}
@@ -1,52 +1,132 @@
package eu.mhsl.minecraft.pixelpics.render.render;
import eu.mhsl.minecraft.pixelpics.render.raytrace.DefaultRaytracer;
import eu.mhsl.minecraft.pixelpics.render.raytrace.Raytracer;
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityScene;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.raytrace.SnapshotRaytracer;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyRenderer;
import eu.mhsl.minecraft.pixelpics.render.snapshot.BlockEntitySnapshotBuilder;
import eu.mhsl.minecraft.pixelpics.render.snapshot.DecorationSnapshotBuilder;
import eu.mhsl.minecraft.pixelpics.render.snapshot.EntitySnapshotBuilder;
import eu.mhsl.minecraft.pixelpics.render.snapshot.SnapshotBuilder;
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.util.Vector;
import java.util.UUID;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Logger;
import java.util.stream.IntStream;
/**
* Renders the scene by capturing a world snapshot on the main thread ({@link #prepare}) and then
* tracing one ray per pixel in parallel against that snapshot ({@link #execute}).
*/
public class DefaultScreenRenderer implements Renderer {
private static final double FOV_YAW_DEG = 53;
private static final double FOV_PITCH_DEG = 23;
private static final double FOV_YAW_RAD = Math.toRadians(FOV_YAW_DEG);
private static final double FOV_PITCH_RAD = Math.toRadians(FOV_PITCH_DEG);
/** Horizontal half field-of-view; the vertical half is derived from the output aspect ratio. */
private static final double H_FOV_HALF_RAD = Math.toRadians(35);
private static final Vector BASE_VEC = new Vector(1, 0, 0);
private final Raytracer raytracer;
private static final double MAX_DISTANCE = 256;
private static final int REFLECTION_DEPTH = 4;
public DefaultScreenRenderer() {
this.raytracer = new DefaultRaytracer();
/** Supersampling factor: SSAA x SSAA rays per output pixel, downsampled gamma-correctly. */
private static final int SSAA = 3;
private final SnapshotRaytracer raytracer;
private final CemBaker entityBaker;
private final BlockEntityBaker blockEntityBaker;
private final DecorationBaker decorationBaker;
private final Logger logger;
public DefaultScreenRenderer(BlockModelRegistry registry, BiomeTintProvider tintProvider,
TextureCache textures, CemBaker entityBaker,
BlockEntityBaker blockEntityBaker, Logger logger) {
SkyRenderer skyRenderer = new SkyRenderer(textures);
this.raytracer = new SnapshotRaytracer(registry, tintProvider, skyRenderer, MAX_DISTANCE, REFLECTION_DEPTH);
this.entityBaker = entityBaker;
this.blockEntityBaker = blockEntityBaker;
this.decorationBaker = new DecorationBaker(textures);
this.logger = logger;
}
/** Convenience: prepare and execute in one call (must run on the main thread). */
@Override
public BufferedImage render(Location eyeLocation, Resolution resolution) {
int width = resolution.getWidth();
int height = resolution.getHeight();
return execute(prepare(eyeLocation, resolution, null));
}
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
int[] imageData = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
/** Builds the (supersampled) ray map and captures world + entities. MUST run on the main thread. */
public RenderJob prepare(Location eyeLocation, Resolution resolution, UUID shooter) {
int superW = resolution.getWidth() * SSAA;
int superH = resolution.getHeight() * SSAA;
List<Vector> rayMap = buildRayMap(eyeLocation, superW, superH);
WorldSnapshot snapshot = SnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE, logger);
List<EntityState> entities = EntitySnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE, shooter);
List<BlockEntityState> blockEntities = BlockEntitySnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE);
List<DecorationState> decorations = DecorationSnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE);
World world = eyeLocation.getWorld();
Vector linePoint = eyeLocation.toVector();
List<Vector> rayMap = buildRayMap(eyeLocation, resolution);
for (int i = 0; i < rayMap.size(); i++) {
imageData[i] = raytracer.trace(world, linePoint, rayMap.get(i));
}
long dayTime = world.getTime();
long fullTime = world.getFullTime();
int moonPhase = (int) (fullTime / 24000L % 8L);
SkyContext sky = new SkyContext(dayTime, moonPhase, fullTime);
return new RenderJob(snapshot, rayMap, eyeLocation.toVector(),
resolution.getWidth(), resolution.getHeight(), sky, entities, blockEntities, decorations);
}
/** Traces every (super)ray in parallel, then downsamples gamma-correctly. Safe off the main thread. */
public BufferedImage execute(RenderJob job) {
int finalW = job.width();
int finalH = job.height();
int superW = finalW * SSAA;
List<Vector> rayMap = job.rayMap();
WorldSnapshot snapshot = job.snapshot();
Vector origin = job.origin();
SkyContext sky = job.sky();
EntityScene scene = new EntityScene(job.entities(), entityBaker, job.blockEntities(), blockEntityBaker,
job.decorations(), decorationBaker);
int[] superBuf = new int[rayMap.size()];
IntStream.range(0, rayMap.size()).parallel().forEach(i ->
superBuf[i] = raytracer.trace(snapshot, origin, rayMap.get(i), sky, scene));
BufferedImage image = new BufferedImage(finalW, finalH, BufferedImage.TYPE_INT_RGB);
int[] imageData = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
IntStream.range(0, finalH).parallel().forEach(fy -> {
int[] block = new int[SSAA * SSAA];
for (int fx = 0; fx < finalW; fx++) {
int n = 0;
for (int sy = 0; sy < SSAA; sy++) {
int srcRow = (fy * SSAA + sy) * superW + fx * SSAA;
for (int sx = 0; sx < SSAA; sx++) {
block[n++] = superBuf[srcRow + sx];
}
}
imageData[fy * finalW + fx] = ColorUtil.averageLinear(block, 0, n);
}
});
return image;
}
private List<Vector> buildRayMap(Location eyeLocation, Resolution resolution) {
private List<Vector> buildRayMap(Location eyeLocation, int width, int height) {
Vector lineDirection = eyeLocation.getDirection();
double x = lineDirection.getX();
@@ -56,20 +136,21 @@ public class DefaultScreenRenderer implements Renderer {
double angleYaw = Math.atan2(z, x);
double anglePitch = Math.atan2(y, Math.sqrt(x * x + z * z));
Vector lowerLeftCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, -FOV_YAW_RAD, -FOV_PITCH_RAD, angleYaw, anglePitch);
Vector upperLeftCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, -FOV_YAW_RAD, FOV_PITCH_RAD, angleYaw, anglePitch);
Vector lowerRightCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, FOV_YAW_RAD, -FOV_PITCH_RAD, angleYaw, anglePitch);
Vector upperRightCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, FOV_YAW_RAD, FOV_PITCH_RAD, angleYaw, anglePitch);
// Derive the vertical half-FOV from the horizontal one so square output is not distorted.
double yawHalf = H_FOV_HALF_RAD;
double pitchHalf = Math.atan(Math.tan(yawHalf) * ((double) height / width));
Vector lowerLeftCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, -yawHalf, -pitchHalf, angleYaw, anglePitch);
Vector upperLeftCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, -yawHalf, pitchHalf, angleYaw, anglePitch);
Vector lowerRightCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, yawHalf, -pitchHalf, angleYaw, anglePitch);
Vector upperRightCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, yawHalf, pitchHalf, angleYaw, anglePitch);
int width = resolution.getWidth();
int height = resolution.getHeight();
List<Vector> rayMap = new ArrayList<>(width * height);
Vector leftFraction = upperLeftCorner.clone().subtract(lowerLeftCorner).multiply(1.0 / (height - 1));
Vector rightFraction = upperRightCorner.clone().subtract(lowerRightCorner).multiply(1.0 / (height - 1));
for (int pitch = 0; pitch < height; pitch++) {
Vector leftPitch = upperLeftCorner.clone().subtract(leftFraction.clone().multiply(pitch));
Vector rightPitch = upperRightCorner.clone().subtract(rightFraction.clone().multiply(pitch));
Vector yawFraction = rightPitch.clone().subtract(leftPitch).multiply(1.0 / (width - 1));
@@ -0,0 +1,33 @@
package eu.mhsl.minecraft.pixelpics.render.render;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
import org.bukkit.util.Vector;
import java.util.List;
/**
* A prepared render: the world snapshot (captured on the main thread) plus the ray map, camera origin,
* the sky context (time of day / moon phase) and the captured entity, block-entity and decoration
* (painting / item frame) states. {@link DefaultScreenRenderer#execute} can run this off the main thread.
*/
public record RenderJob(WorldSnapshot snapshot, List<Vector> rayMap, Vector origin,
int width, int height, SkyContext sky, List<EntityState> entities,
List<BlockEntityState> blockEntities, List<DecorationState> decorations) {
/** Backwards-compatible constructor (no block-entities/decorations), used by the standalone harness. */
public RenderJob(WorldSnapshot snapshot, List<Vector> rayMap, Vector origin,
int width, int height, SkyContext sky, List<EntityState> entities) {
this(snapshot, rayMap, origin, width, height, sky, entities, List.of(), List.of());
}
/** Convenience for callers that supply entities + block-entities but no decorations. */
public RenderJob(WorldSnapshot snapshot, List<Vector> rayMap, Vector origin,
int width, int height, SkyContext sky, List<EntityState> entities,
List<BlockEntityState> blockEntities) {
this(snapshot, rayMap, origin, width, height, sky, entities, blockEntities, List.of());
}
}
@@ -0,0 +1,9 @@
package eu.mhsl.minecraft.pixelpics.render.sky;
/**
* Per-render sky state captured on the main thread: the world time of day (0..24000), the moon phase
* (0..7) and the absolute world time (for continuous cloud drift). Immutable so it can be read from
* worker threads.
*/
public record SkyContext(long dayTime, int moonPhase, long fullTime) {
}
@@ -0,0 +1,271 @@
package eu.mhsl.minecraft.pixelpics.render.sky;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import org.bukkit.util.Vector;
/**
* Computes a time-of-day dependent sky color for rays that escape the world: a day/night gradient
* with twilight glow, the sun and moon (with phase), stars at night and a procedural cloud layer.
* All inputs are immutable ({@link SkyContext} + captured textures), so it is thread safe.
*/
public final class SkyRenderer {
private static final double TICKS_PER_DAY = 24000.0;
private static final double CLOUD_HEIGHT = 192.0;
private static final double CLOUD_CELL = 12.0; // world blocks per cloud texel
private static final double CLOUD_SPEED = 0.03; // blocks per tick, drift along +X
private static final double SUN_HALF = 0.085; // angular half-size (radians)
private static final double MOON_HALF = 0.075;
// Gradient endpoints (RGB).
private static final int DAY_ZENITH = rgb(86, 138, 252);
private static final int DAY_HORIZON = rgb(170, 205, 255);
private static final int NIGHT_ZENITH = rgb(2, 3, 12);
private static final int NIGHT_HORIZON = rgb(10, 14, 40);
private static final int SUNSET_ORANGE = rgb(255, 150, 70);
private static final int SUNSET_RED = rgb(205, 70, 60);
private static final int TWI_PURPLE = rgb(80, 42, 92);
private final int[][] sunTexture;
private final int[][] moonTexture;
private final int[][] cloudTexture;
public SkyRenderer(TextureCache textures) {
this.sunTexture = textures.get(ResourceLocation.parse("environment/sun")).orElse(null);
this.moonTexture = textures.get(ResourceLocation.parse("environment/moon_phases")).orElse(null);
this.cloudTexture = textures.get(ResourceLocation.parse("environment/clouds")).orElse(null);
}
public int colorFor(Vector direction, Vector origin, SkyContext ctx) {
double dx = direction.getX(), dy = direction.getY(), dz = direction.getZ();
double len = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (len < 1e-9) return DAY_ZENITH;
dx /= len; dy /= len; dz /= len;
// Sun/moon position, derived exactly from Minecraft's sky transforms:
// celestialAngle ca = getTimeOfDay(dayTime); the sun is rotated by ca*360deg about the X axis
// (after a -90deg Y rotation), giving sunDir = (-sin(2*pi*ca), cos(2*pi*ca), 0) in world space.
double ca = celestialAngle(ctx.dayTime());
double ang = ca * 2 * Math.PI;
double sunX = -Math.sin(ang), sunY = Math.cos(ang);
double dayFactor = smoothstep(-0.20, 0.25, sunY);
// Base vertical gradient, blended day<->night.
double up = clamp01(dy);
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);
if (twilight > 0) {
double az = clamp01(dx * Math.signum(sunX) * 0.5 + 0.5); // 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);
int twiColor = lerp(lerp(TWI_PURPLE, grad, 0.55), grad, az); // cooler away from the sun
color = lerp(color, twiColor, twilight * 0.85);
}
// Stars: at night, faded out by daylight and twilight.
if (dy > 0) {
double visibility = (1 - dayFactor) * (1 - twilight);
if (visibility > 0.05) {
double star = starField(dx, dy, dz);
if (star > 0) {
int s = (int) (star * 255 * visibility);
color = add(color, s, s, s);
}
}
}
// Warm bloom halo around the sun near the horizon.
if (sunY > -0.20) {
double cosSun = dx * sunX + dy * sunY;
if (cosSun > 0) {
double bloom = Math.pow(clamp01(cosSun), 16) * clamp01(sunY + 0.3);
color = lerp(color, rgb(255, 235, 190), bloom * 0.7);
}
}
// Sun disc (soft glowing disc, texture used only as a shape mask).
if (sunY > -0.15) {
color = overlayDisc(color, dx, dy, dz, sunX, sunY, 0, SUN_HALF, sunTexture, rgb(255, 244, 214), -1);
}
// Moon disc (phase shape from the texture's alpha).
if (-sunY > -0.15) {
color = overlayDisc(color, dx, dy, dz, -sunX, -sunY, 0, MOON_HALF, moonTexture, rgb(228, 228, 238), ctx.moonPhase());
}
// Cloud layer: the ray crosses the cloud plane at y = CLOUD_HEIGHT; the world hit point is
// mapped to a clouds.png texel exactly as vanilla does (see clouds()). Horizontal drift uses
// the world time (fullTime * CLOUD_SPEED along +X).
if (dy > 0.02 && origin.getY() < CLOUD_HEIGHT) {
double t = (CLOUD_HEIGHT - origin.getY()) / dy;
double cx = origin.getX() + dx * t + ctx.fullTime() * CLOUD_SPEED;
double cz = origin.getZ() + dz * t;
double coverage = clouds(cx, cz);
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)
color = lerp(color, cloudColor, coverage * fade);
}
}
return color & 0xFFFFFF;
}
/** Draws a sun/moon disc, sampling a texture when available (moonPhase &ge; 0 picks the phase tile). */
private int overlayDisc(int base, double dx, double dy, double dz,
double cx, double cy, double cz, double half, int[][] texture, int solid, int moonPhase) {
double cos = dx * cx + dy * cy + dz * cz;
if (cos <= 0) return base;
double sinHalf = Math.sin(half);
// Local disc coordinates: project the direction onto the plane around the body axis.
// right = normalize(body x worldUp); discUp = right x body
double crx = cz, cry = 0, crz = -cx;
double crl = Math.sqrt(crx * crx + crz * crz);
if (crl < 1e-6) { crx = 1; cry = 0; crz = 0; crl = 1; }
crx /= crl; cry /= crl; crz /= crl;
// discUp = right cross body
double ux = cry * cz - crz * cy;
double uy = crz * cx - crx * cz;
double uz = crx * cy - cry * cx;
double u = dx * crx + dy * cry + dz * crz;
double v = dx * ux + dy * uy + dz * uz;
// The sun and moon are flat SQUARE billboards in Minecraft, not round discs.
double m = Math.max(Math.abs(u), Math.abs(v)) / sinHalf; // 0 center .. 1 square edge
if (m > 1) return base;
double su = u / sinHalf * 0.5 + 0.5;
double sv = v / sinHalf * 0.5 + 0.5;
// The texture is used only as a shape/phase mask; the body color is always `solid` so the
// texture's black transparent texels never bleed in as a dark rim.
double alpha;
if (moonPhase >= 0 && texture != null && texture.length > 0) {
alpha = bodyAlpha(texture, su, sv, moonPhase) > 80 ? 1.0 : 0.0; // phase shape
} else {
alpha = 1 - smoothstep(0.92, 1.0, m); // solid square, faint edge softening
}
if (alpha <= 0.02) return base;
return lerp(base, solid, alpha);
}
/** Alpha of the body texture at the disc coordinate; moonPhase&ge;0 selects a tile in the 4x2 grid. */
private int bodyAlpha(int[][] texture, double su, double sv, int moonPhase) {
int h = texture.length;
int w = texture[0].length;
double u = su, v = 1 - sv; // texture v is top-down
int col = moonPhase % 4;
int row = (moonPhase / 4) % 2;
u = (col + u) / 4.0;
v = (row + v) / 2.0;
int px = clamp((int) (u * w), 0, w - 1);
int py = clamp((int) (v * h), 0, h - 1);
return ColorUtil.alpha(texture[py][px]);
}
/** Sparse pseudo-random star field keyed on the quantized direction. */
private double starField(double dx, double dy, double dz) {
int gx = (int) Math.floor(dx * 320);
int gy = (int) Math.floor(dy * 320);
int gz = (int) Math.floor(dz * 320);
int h = hash(gx, gy, gz);
if ((h & 0x1FF) != 0) return 0; // ~1/512 cells contain a star
return 0.5 + ((h >>> 9) & 0xFF) / 510.0;
}
/**
* Exact vanilla cloud coverage. Minecraft tiles {@code clouds.png} (256x256) over the world with
* each texel covering a {@link #CLOUD_CELL} (=12) block square, so the pattern repeats every
* 256*12 = 3072 blocks. A world position maps to texel
* {@code (col = floorMod(floor(x/12), 256), row = floorMod(floor(z/12), 256))} with the texture's
* U axis along world X and V axis along world Z; a texel is a cloud where its alpha &gt; 0. This
* reproduces the blocky cloud shapes and their world alignment exactly. Falls back to value noise
* only when the texture is missing from the pack.
*/
private double clouds(double x, double z) {
if (cloudTexture != null && cloudTexture.length > 0) {
int w = cloudTexture[0].length;
int h = cloudTexture.length;
int tx = Math.floorMod((int) Math.floor(x / CLOUD_CELL), w);
int tz = Math.floorMod((int) Math.floor(z / CLOUD_CELL), h);
int alpha = ColorUtil.alpha(cloudTexture[tz][tx]);
return alpha > 16 ? 0.85 : 0.0;
}
double scale = 0.012;
double n = valueNoise(x * scale, z * scale) * 0.6
+ valueNoise(x * scale * 2.3, z * scale * 2.3) * 0.4;
return smoothstep(0.52, 0.72, n) * 0.8;
}
private double valueNoise(double x, double z) {
int x0 = (int) Math.floor(x), z0 = (int) Math.floor(z);
double fx = x - x0, fz = z - z0;
double sx = fx * fx * (3 - 2 * fx);
double sz = fz * fz * (3 - 2 * fz);
double n00 = rand(x0, z0), n10 = rand(x0 + 1, z0);
double n01 = rand(x0, z0 + 1), n11 = rand(x0 + 1, z0 + 1);
double nx0 = n00 + (n10 - n00) * sx;
double nx1 = n01 + (n11 - n01) * sx;
return nx0 + (nx1 - nx0) * sz;
}
private double rand(int x, int z) {
return (hash(x, z, 0) & 0xFFFF) / 65535.0;
}
private int hash(int x, int y, int z) {
int h = x * 374761393 + y * 668265263 + z * 2147483647;
h = (h ^ (h >>> 13)) * 1274126177;
return h ^ (h >>> 16);
}
// --- small color/math helpers ---
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);
}
private static int add(int c, int r, int g, int b) {
int cr = Math.min(255, ((c >> 16) & 0xFF) + r);
int cg = Math.min(255, ((c >> 8) & 0xFF) + g);
int cb = Math.min(255, (c & 0xFF) + b);
return rgb(cr, cg, cb);
}
/** Minecraft's {@code Level.getTimeOfDay}: the celestial angle as a fraction [0,1). */
private static double celestialAngle(long dayTime) {
double d = frac(dayTime / TICKS_PER_DAY - 0.25);
double e = 0.5 - Math.cos(d * Math.PI) / 2.0;
return (d * 2.0 + e) / 3.0;
}
private static double frac(double v) {
return v - Math.floor(v);
}
private static double smoothstep(double edge0, double edge1, double x) {
double t = clamp01((x - edge0) / (edge1 - edge0));
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); }
}
@@ -0,0 +1,327 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.BannerPattern;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.BedPart;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.BellAttach;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.ChestKind;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.Kind;
import org.bukkit.Color;
import org.bukkit.DyeColor;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Banner;
import org.bukkit.block.BlockFace;
import org.bukkit.block.BlockState;
import org.bukkit.block.DecoratedPot;
import org.bukkit.block.Skull;
import org.bukkit.block.banner.Pattern;
import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.Directional;
import org.bukkit.block.data.Rotatable;
import org.bukkit.util.Vector;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
/**
* Captures block-entities (chests, signs, banners, beds, heads, …) near the view frustum into immutable
* {@link BlockEntityState}s. MUST run on the main thread (live {@link BlockState} access). Reads each
* loaded chunk's tile-entities rather than scanning every block, then keeps only those inside the
* camera's bounding box.
*/
public final class BlockEntitySnapshotBuilder {
private BlockEntitySnapshotBuilder() {}
public static List<BlockEntityState> build(Location eye, List<Vector> rayMap, double maxDistance) {
World world = eye.getWorld();
Vector o = eye.toVector();
double minX = o.getX(), minY = o.getY(), minZ = o.getZ();
double maxX = o.getX(), maxY = o.getY(), maxZ = o.getZ();
for (Vector ray : rayMap) {
minX = Math.min(minX, o.getX() + ray.getX() * maxDistance);
maxX = Math.max(maxX, o.getX() + ray.getX() * maxDistance);
minY = Math.min(minY, o.getY() + ray.getY() * maxDistance);
maxY = Math.max(maxY, o.getY() + ray.getY() * maxDistance);
minZ = Math.min(minZ, o.getZ() + ray.getZ() * maxDistance);
maxZ = Math.max(maxZ, o.getZ() + ray.getZ() * maxDistance);
}
// 1-block margin so block-entities straddling the frustum edge are still captured.
int bMinX = (int) Math.floor(minX) - 1, bMaxX = (int) Math.ceil(maxX) + 1;
int bMinY = (int) Math.floor(minY) - 1, bMaxY = (int) Math.ceil(maxY) + 1;
int bMinZ = (int) Math.floor(minZ) - 1, bMaxZ = (int) Math.ceil(maxZ) + 1;
int minCX = bMinX >> 4, maxCX = bMaxX >> 4, minCZ = bMinZ >> 4, maxCZ = bMaxZ >> 4;
List<BlockEntityState> out = new ArrayList<>();
for (int cx = minCX; cx <= maxCX; cx++) {
for (int cz = minCZ; cz <= maxCZ; cz++) {
if (!world.isChunkLoaded(cx, cz)) continue;
for (BlockState ts : world.getChunkAt(cx, cz).getTileEntities(false)) {
int bx = ts.getX(), by = ts.getY(), bz = ts.getZ();
if (bx < bMinX || bx > bMaxX || by < bMinY || by > bMaxY || bz < bMinZ || bz > bMaxZ) continue;
try {
BlockEntityState s = classify(ts);
if (s != null) out.add(s);
} catch (Throwable ignored) {
// Unsupported on this server version / odd state — skip this one.
}
}
}
}
return out;
}
private static BlockEntityState classify(BlockState ts) {
Material mat = ts.getType();
String n = mat.name();
BlockData data = ts.getBlockData();
int bx = ts.getX(), by = ts.getY(), bz = ts.getZ();
// --- chests ---
if (mat == Material.CHEST || mat == Material.TRAPPED_CHEST || mat == Material.ENDER_CHEST) {
Kind kind = mat == Material.TRAPPED_CHEST ? Kind.TRAPPED_CHEST
: mat == Material.ENDER_CHEST ? Kind.ENDER_CHEST : Kind.CHEST;
ChestKind ck = ChestKind.SINGLE;
if (data instanceof org.bukkit.block.data.type.Chest cd) {
ck = switch (cd.getType()) {
case LEFT -> ChestKind.LEFT;
case RIGHT -> ChestKind.RIGHT;
case SINGLE -> ChestKind.SINGLE;
};
}
return base(kind, bx, by, bz, facingYaw(data)).chestKind(ck).build();
}
// --- beds ---
if (data instanceof org.bukkit.block.data.type.Bed bed) {
BedPart part = bed.getPart() == org.bukkit.block.data.type.Bed.Part.HEAD ? BedPart.HEAD : BedPart.FOOT;
return base(Kind.BED, bx, by, bz, faceToYaw(bed.getFacing()))
.bedPart(part).colorName(stripColor(n, "_BED")).build();
}
// --- shulker boxes ---
if (n.endsWith("SHULKER_BOX")) {
String color = n.equals("SHULKER_BOX") ? null : stripColor(n, "_SHULKER_BOX");
return base(Kind.SHULKER_BOX, bx, by, bz, facingYaw(data)).colorName(color).build();
}
// --- banners ---
if (n.endsWith("_BANNER")) {
boolean wall = n.endsWith("_WALL_BANNER");
float yaw = wall ? facingYaw(data) : rotationYaw(data);
Builder b = base(wall ? Kind.WALL_BANNER : Kind.BANNER, bx, by, bz, yaw);
if (ts instanceof Banner banner) {
DyeColor base = banner.getBaseColor();
b.baseColorArgb(dyeArgb(base));
List<BannerPattern> pats = new ArrayList<>();
for (Pattern p : banner.getPatterns()) {
String key = p.getPattern().key().value();
pats.add(new BannerPattern(key, dyeArgb(p.getColor())));
}
b.patterns(pats);
}
return b.build();
}
// --- signs ---
if (n.endsWith("_SIGN")) {
String wood = signWood(n);
if (n.endsWith("_WALL_SIGN")) {
return base(Kind.WALL_SIGN, bx, by, bz, facingYaw(data)).wood(wood).build();
}
if (n.endsWith("_HANGING_SIGN")) {
boolean wall = n.endsWith("_WALL_HANGING_SIGN");
float yaw = wall ? facingYaw(data) : rotationYaw(data);
return base(Kind.HANGING_SIGN, bx, by, bz, yaw).wood(wood).build();
}
return base(Kind.SIGN, bx, by, bz, rotationYaw(data)).wood(wood).build();
}
// --- heads / skulls ---
if (n.endsWith("_SKULL") || n.endsWith("_HEAD")) {
boolean wall = n.contains("_WALL_");
String headType = headType(n);
float yaw = wall ? facingYaw(data) : rotationYaw(data);
Builder b = base(wall ? Kind.WALL_HEAD : Kind.HEAD, bx, by, bz, yaw).headType(headType);
if ("player".equals(headType) && ts instanceof Skull skull) {
b.skinUrl(skinUrl(skull));
}
return b.build();
}
// --- conduit ---
if (mat == Material.CONDUIT) {
return base(Kind.CONDUIT, bx, by, bz, 0).build();
}
// --- decorated pot ---
if (mat == Material.DECORATED_POT) {
Builder b = base(Kind.DECORATED_POT, bx, by, bz, facingYaw(data));
if (ts instanceof DecoratedPot pot) b.sherds(sherds(pot));
return b.build();
}
// --- bell ---
if (mat == Material.BELL) {
BellAttach attach = BellAttach.FLOOR;
if (data instanceof org.bukkit.block.data.type.Bell bd) {
attach = switch (bd.getAttachment()) {
case FLOOR -> BellAttach.FLOOR;
case CEILING -> BellAttach.CEILING;
case SINGLE_WALL -> BellAttach.SINGLE_WALL;
case DOUBLE_WALL -> BellAttach.DOUBLE_WALL;
};
}
return base(Kind.BELL, bx, by, bz, facingYaw(data)).bellAttach(attach).build();
}
return null;
}
// --- facing / rotation helpers ---
private static float facingYaw(BlockData data) {
if (data instanceof Directional d) return faceToYaw(d.getFacing());
if (data instanceof Rotatable r) return faceToYaw(r.getRotation());
return 0;
}
private static float rotationYaw(BlockData data) {
if (data instanceof Rotatable r) return faceToYaw(r.getRotation());
if (data instanceof Directional d) return faceToYaw(d.getFacing());
return 0;
}
/** Yaw in degrees for a block face, 0 = south increasing clockwise (vanilla rotation convention). */
private static float faceToYaw(BlockFace face) {
Float y = FACE_YAW.get(face);
return y == null ? 0 : y;
}
private static final Map<BlockFace, Float> FACE_YAW = buildFaceYaw();
private static Map<BlockFace, Float> buildFaceYaw() {
Map<BlockFace, Float> m = new EnumMap<>(BlockFace.class);
m.put(BlockFace.SOUTH, 0f);
m.put(BlockFace.SOUTH_SOUTH_WEST, 22.5f);
m.put(BlockFace.SOUTH_WEST, 45f);
m.put(BlockFace.WEST_SOUTH_WEST, 67.5f);
m.put(BlockFace.WEST, 90f);
m.put(BlockFace.WEST_NORTH_WEST, 112.5f);
m.put(BlockFace.NORTH_WEST, 135f);
m.put(BlockFace.NORTH_NORTH_WEST, 157.5f);
m.put(BlockFace.NORTH, 180f);
m.put(BlockFace.NORTH_NORTH_EAST, 202.5f);
m.put(BlockFace.NORTH_EAST, 225f);
m.put(BlockFace.EAST_NORTH_EAST, 247.5f);
m.put(BlockFace.EAST, 270f);
m.put(BlockFace.EAST_SOUTH_EAST, 292.5f);
m.put(BlockFace.SOUTH_EAST, 315f);
m.put(BlockFace.SOUTH_SOUTH_EAST, 337.5f);
return m;
}
// --- data extraction helpers ---
private static String stripColor(String name, String suffix) {
return name.substring(0, name.length() - suffix.length()).toLowerCase(java.util.Locale.ROOT);
}
private static String signWood(String name) {
String s = name;
for (String suf : new String[]{"_WALL_HANGING_SIGN", "_HANGING_SIGN", "_WALL_SIGN", "_SIGN"}) {
if (s.endsWith(suf)) { s = s.substring(0, s.length() - suf.length()); break; }
}
return s.toLowerCase(java.util.Locale.ROOT);
}
private static String headType(String name) {
String s = name.replace("_WALL_", "_");
return switch (s) {
case "PLAYER_HEAD" -> "player";
case "ZOMBIE_HEAD" -> "zombie";
case "CREEPER_HEAD" -> "creeper";
case "DRAGON_HEAD" -> "dragon";
case "PIGLIN_HEAD" -> "piglin";
case "SKELETON_SKULL" -> "skeleton";
case "WITHER_SKELETON_SKULL" -> "wither_skeleton";
default -> "skeleton";
};
}
private static List<String> sherds(DecoratedPot pot) {
// Order: front, left, right, back — matches the CEM decorated_pot face parts.
List<String> out = new ArrayList<>(4);
for (DecoratedPot.Side side : new DecoratedPot.Side[]{
DecoratedPot.Side.FRONT, DecoratedPot.Side.LEFT, DecoratedPot.Side.RIGHT, DecoratedPot.Side.BACK}) {
Material m = pot.getSherd(side);
out.add(m.name().toLowerCase(java.util.Locale.ROOT));
}
return out;
}
private static String skinUrl(Skull skull) {
try {
org.bukkit.profile.PlayerProfile profile = skull.getOwnerProfile();
if (profile != null && profile.getTextures().getSkin() != null) {
return profile.getTextures().getSkin().toString();
}
} catch (Throwable ignored) {
}
return null;
}
/** Opaque ARGB for a dye colour. */
private static int dyeArgb(DyeColor dye) {
if (dye == null) return 0xFFFFFFFF;
Color c = dye.getColor();
return 0xFF000000 | (c.getRed() << 16) | (c.getGreen() << 8) | c.getBlue();
}
// --- small fluent builder to keep the 15-field record construction readable ---
private static Builder base(Kind kind, int bx, int by, int bz, float yaw) {
return new Builder(kind, bx, by, bz, yaw);
}
private static final class Builder {
private final Kind kind;
private final int bx, by, bz;
private final float yaw;
private ChestKind chestKind;
private int baseColorArgb;
private String colorName;
private String wood;
private BedPart bedPart;
private String headType;
private String skinUrl;
private List<BannerPattern> patterns = List.of();
private List<String> sherds = List.of();
private BellAttach bellAttach;
Builder(Kind kind, int bx, int by, int bz, float yaw) {
this.kind = kind; this.bx = bx; this.by = by; this.bz = bz; this.yaw = yaw;
}
Builder chestKind(ChestKind v) { this.chestKind = v; return this; }
Builder baseColorArgb(int v) { this.baseColorArgb = v; return this; }
Builder colorName(String v) { this.colorName = v; return this; }
Builder wood(String v) { this.wood = v; return this; }
Builder bedPart(BedPart v) { this.bedPart = v; return this; }
Builder headType(String v) { this.headType = v; return this; }
Builder skinUrl(String v) { this.skinUrl = v; return this; }
Builder patterns(List<BannerPattern> v) { this.patterns = v; return this; }
Builder sherds(List<String> v) { this.sherds = v; return this; }
Builder bellAttach(BellAttach v) { this.bellAttach = v; return this; }
BlockEntityState build() {
return new BlockEntityState(kind, bx, by, bz, yaw, chestKind, baseColorArgb, colorName, wood,
bedPart, headType, skinUrl, patterns, sherds, bellAttach);
}
}
}
@@ -0,0 +1,90 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState;
import org.bukkit.Location;
import org.bukkit.block.BlockFace;
import org.bukkit.entity.Entity;
import org.bukkit.entity.ItemFrame;
import org.bukkit.entity.Painting;
import org.bukkit.inventory.ItemStack;
import org.bukkit.util.BoundingBox;
import org.bukkit.util.Vector;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
/**
* Captures flat wall decorations (paintings + item frames) near the view frustum into immutable
* {@link DecorationState}s. MUST run on the main thread. Uses each entity's world bounding box directly,
* which already encodes vanilla's painting placement offsets.
*/
public final class DecorationSnapshotBuilder {
private DecorationSnapshotBuilder() {}
public static List<DecorationState> build(Location eye, List<Vector> rayMap, double maxDistance) {
Vector o = eye.toVector();
double minX = o.getX(), minY = o.getY(), minZ = o.getZ();
double maxX = o.getX(), maxY = o.getY(), maxZ = o.getZ();
for (Vector ray : rayMap) {
minX = Math.min(minX, o.getX() + ray.getX() * maxDistance);
maxX = Math.max(maxX, o.getX() + ray.getX() * maxDistance);
minY = Math.min(minY, o.getY() + ray.getY() * maxDistance);
maxY = Math.max(maxY, o.getY() + ray.getY() * maxDistance);
minZ = Math.min(minZ, o.getZ() + ray.getZ() * maxDistance);
maxZ = Math.max(maxZ, o.getZ() + ray.getZ() * maxDistance);
}
Location center = new Location(eye.getWorld(), (minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2);
double hx = (maxX - minX) / 2 + 2, hy = (maxY - minY) / 2 + 2, hz = (maxZ - minZ) / 2 + 2;
Collection<Entity> nearby = eye.getWorld().getNearbyEntities(center, hx, hy, hz);
List<DecorationState> out = new ArrayList<>();
for (Entity e : nearby) {
try {
DecorationState s = toState(e);
if (s != null) out.add(s);
} catch (Throwable ignored) {
}
}
return out;
}
private static DecorationState toState(Entity e) {
if (e instanceof Painting painting) {
BoundingBox bb = e.getBoundingBox();
String art = painting.getArt().assetId().value();
return new DecorationState(DecorationState.Kind.PAINTING,
bb.getMinX(), bb.getMinY(), bb.getMinZ(), bb.getMaxX(), bb.getMaxY(), bb.getMaxZ(),
facing(e.getFacing()), art, null, 0, false);
}
if (e instanceof ItemFrame frame) {
BoundingBox bb = e.getBoundingBox();
boolean glow = e instanceof org.bukkit.entity.GlowItemFrame
|| e.getType().getKey().getKey().equals("glow_item_frame");
String itemId = itemId(frame.getItem());
int rot = frame.getRotation().ordinal() * 45;
return new DecorationState(DecorationState.Kind.ITEM_FRAME,
bb.getMinX(), bb.getMinY(), bb.getMinZ(), bb.getMaxX(), bb.getMaxY(), bb.getMaxZ(),
facing(e.getFacing()), null, itemId, rot, glow);
}
return null;
}
/** The framed item's material key (e.g. "diamond"); the baker resolves item/ or block/ textures from it. */
private static String itemId(ItemStack item) {
if (item == null || item.getType().isAir()) return null;
return item.getType().getKey().getKey();
}
private static DecorationState.Facing facing(BlockFace face) {
return switch (face) {
case NORTH -> DecorationState.Facing.NORTH;
case SOUTH -> DecorationState.Facing.SOUTH;
case EAST -> DecorationState.Facing.EAST;
case WEST -> DecorationState.Facing.WEST;
case UP -> DecorationState.Facing.UP;
default -> DecorationState.Facing.DOWN;
};
}
}
@@ -0,0 +1,197 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import org.bukkit.Location;
import org.bukkit.entity.Ageable;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.util.Vector;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
/**
* Captures entities near the view frustum into immutable {@link EntityState}s. MUST run on the main
* thread (live entity access). The camera entity is skipped.
*/
public final class EntitySnapshotBuilder {
private EntitySnapshotBuilder() {}
// Technical / non-mob entity types that have no meaningful geometry; rendering them would only
// produce stray fallback boxes. Markers, displays, item frames, paintings, projectiles, drops, etc.
private static final java.util.Set<String> NON_RENDERABLE = java.util.Set.of(
"area_effect_cloud", "marker", "interaction",
"item_frame", "glow_item_frame", "painting",
"block_display", "item_display", "text_display",
"fishing_bobber", "lightning_bolt", "eye_of_ender",
"experience_orb", "experience_bottle", "egg", "snowball",
"potion", "ender_pearl", "tnt", "falling_block", "item"
);
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);
List<EntityState> states = new ArrayList<>();
for (Entity e : nearby) {
if (shooter != null && e.getUniqueId().equals(shooter)) continue;
EntityState s = toState(e);
if (s != null) states.add(s);
}
return states;
}
private static EntityState toState(Entity e) {
Location loc = e.getLocation();
// Skip non-renderable technical entities.
String type = e.getType().getKey().getKey();
// Boats now have a bundled geometry.boat; rafts use a different hull we don't ship yet — skip those.
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);
boolean player = e instanceof Player;
String skinUrl = null;
boolean slim = false;
if (player) {
String[] skin = resolveSkin((Player) e);
skinUrl = skin[0];
slim = "slim".equals(skin[1]);
}
String variant = null;
int tint = 0;
double sizeScale = 1.0;
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());
} else if (e instanceof org.bukkit.entity.Cat c) {
variant = keyOf(c.getCatType());
} else if (e instanceof org.bukkit.entity.Wolf w) {
variant = keyOf(w.getVariant());
} else if (e instanceof org.bukkit.entity.Axolotl a) {
variant = keyOf(a.getVariant());
} else if (e instanceof org.bukkit.entity.Parrot p) {
variant = keyOf(p.getVariant());
} else if (e instanceof org.bukkit.entity.Rabbit r) {
variant = keyOf(r.getRabbitType());
} else if (e instanceof org.bukkit.entity.Horse h) {
variant = keyOf(h.getColor());
} else if (e instanceof org.bukkit.entity.Llama l) {
variant = keyOf(l.getColor());
} else if (e instanceof org.bukkit.entity.Fox f) {
variant = keyOf(f.getFoxType());
} else if (e instanceof org.bukkit.entity.MushroomCow mc) {
variant = keyOf(mc.getVariant());
} else if (e instanceof org.bukkit.entity.Panda pa) {
variant = keyOf(pa.getMainGene());
} else if (e instanceof org.bukkit.entity.Frog fr) {
variant = keyOf(fr.getVariant());
} else if (e instanceof org.bukkit.entity.Shulker s) {
variant = s.getColor() == null ? null : keyOf(s.getColor());
} else if (e instanceof org.bukkit.entity.ZombieVillager zv) {
variant = keyOf(zv.getVillagerType());
} else if (e instanceof org.bukkit.entity.Villager vi) {
variant = keyOf(vi.getVillagerType());
} else if (e instanceof org.bukkit.entity.Cow co) {
variant = keyOf(co.getVariant());
} else if (e instanceof org.bukkit.entity.Pig pg) {
variant = keyOf(pg.getVariant());
} else if (e instanceof org.bukkit.entity.Chicken ch) {
variant = keyOf(ch.getVariant());
}
} catch (Throwable ignored) {
// Unsupported on this server version — fall back to the base texture.
}
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);
}
/** Registry/Keyed values yield their key path; plain enums yield their lower-case name. */
private static String keyOf(Object o) {
if (o == null) return null;
if (o instanceof org.bukkit.Keyed k) return k.getKey().getKey();
if (o instanceof Enum<?> en) return en.name().toLowerCase(java.util.Locale.ROOT);
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 {
for (com.destroystokyo.paper.profile.ProfileProperty prop : player.getPlayerProfile().getProperties()) {
if (!prop.getName().equals("textures")) continue;
String json = new String(java.util.Base64.getDecoder().decode(prop.getValue()),
java.nio.charset.StandardCharsets.UTF_8);
com.google.gson.JsonObject root = com.google.gson.JsonParser.parseString(json).getAsJsonObject();
com.google.gson.JsonObject skin = root.getAsJsonObject("textures").getAsJsonObject("SKIN");
String url = skin.get("url").getAsString();
String model = null;
if (skin.has("metadata") && skin.getAsJsonObject("metadata").has("model")) {
model = skin.getAsJsonObject("metadata").get("model").getAsString();
}
return new String[]{url, model};
}
} catch (Exception ignored) {
}
return new String[]{null, null};
}
private static double safeWidth(Entity e) {
try {
return e.getWidth();
} catch (Throwable t) {
return e.getBoundingBox().getWidthX();
}
}
private static double safeHeight(Entity e) {
try {
return e.getHeight();
} catch (Throwable t) {
return e.getBoundingBox().getHeight();
}
}
}
@@ -0,0 +1,78 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import org.bukkit.ChunkSnapshot;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.util.Vector;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;
/**
* Captures the world region covered by the camera frustum into a {@link WorldSnapshot}.
*
* <p>MUST be called on the main server thread: it reads live chunks. Only already-loaded chunks are
* captured (no forced generation), so the call is cheap and rays into unloaded areas hit sky.
*/
public final class SnapshotBuilder {
/** Safety cap on captured chunks to avoid pathological memory/latency. */
private static final int MAX_CHUNKS = 4096;
private 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);
}
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 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;
Map<Long, ChunkSnapshot> chunks = new HashMap<>();
int captured = 0;
int skipped = 0;
for (int cx = minCX; cx <= maxCX; cx++) {
for (int cz = minCZ; cz <= maxCZ; cz++) {
if (captured >= MAX_CHUNKS) {
skipped++;
continue;
}
if (!world.isChunkLoaded(cx, cz)) {
skipped++;
continue;
}
ChunkSnapshot cs = world.getChunkAt(cx, cz).getChunkSnapshot(false, true, false);
chunks.put(WorldSnapshot.chunkKey(cx, cz), cs);
captured++;
}
}
if (skipped > 0) {
logger.fine(String.format("Snapshot captured %d chunks, skipped %d (unloaded or over cap)", captured, skipped));
}
return new WorldSnapshot(chunks, clampedMinY, clampedMaxY, Material.AIR.createBlockData());
}
}
@@ -0,0 +1,47 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import org.bukkit.ChunkSnapshot;
import org.bukkit.block.Biome;
import org.bukkit.block.data.BlockData;
import java.util.Map;
/**
* An immutable, thread-safe view of a bounded region of the world, backed by {@link ChunkSnapshot}s.
* Block/biome lookups outside the captured region return air/null so rays simply terminate there.
*/
public final class WorldSnapshot {
private final Map<Long, ChunkSnapshot> chunks;
private final int minY;
private final int maxY; // exclusive
private final BlockData air;
public WorldSnapshot(Map<Long, ChunkSnapshot> chunks, int minY, int maxY, BlockData air) {
this.chunks = chunks;
this.minY = minY;
this.maxY = maxY;
this.air = air;
}
public static long chunkKey(int chunkX, int chunkZ) {
return ((long) chunkX << 32) ^ (chunkZ & 0xFFFFFFFFL);
}
public BlockData getBlockData(int x, int y, int z) {
if (y < minY || y >= maxY) return air;
ChunkSnapshot cs = chunks.get(chunkKey(x >> 4, z >> 4));
if (cs == null) return air;
return cs.getBlockData(x & 15, y, z & 15);
}
public Biome getBiome(int x, int y, int z) {
if (y < minY || y >= maxY) return null;
ChunkSnapshot cs = chunks.get(chunkKey(x >> 4, z >> 4));
if (cs == null) return null;
return cs.getBiome(x & 15, y, z & 15);
}
public int minY() { return minY; }
public int maxY() { return maxY; }
}
@@ -0,0 +1,95 @@
package eu.mhsl.minecraft.pixelpics.render.tint;
import java.util.HashMap;
import java.util.Map;
/**
* Hardcoded vanilla temperature/downfall and water color per biome. Used to drive the colormap
* lookup, since Paper does not expose the client-side per-block climate reliably. Unknown biomes
* fall back to a plains-like default.
*/
public final class BiomeClimate {
public record Climate(double temperature, double downfall, int water) {}
public static final int DEFAULT_WATER = 0x3F76E4;
public static final Climate DEFAULT = new Climate(0.8, 0.4, DEFAULT_WATER);
private static final Map<String, Climate> TABLE = new HashMap<>();
private static void put(String key, double t, double d) { TABLE.put(key, new Climate(t, d, DEFAULT_WATER)); }
private static void put(String key, double t, double d, int water) { TABLE.put(key, new Climate(t, d, water)); }
static {
put("plains", 0.8, 0.4);
put("sunflower_plains", 0.8, 0.4);
put("snowy_plains", 0.0, 0.5);
put("ice_spikes", 0.0, 0.5);
put("desert", 2.0, 0.0);
put("swamp", 0.8, 0.9, 0x617B64);
put("mangrove_swamp", 0.8, 0.9, 0x3A7A6A);
put("forest", 0.7, 0.8);
put("flower_forest", 0.7, 0.8);
put("birch_forest", 0.6, 0.6);
put("old_growth_birch_forest", 0.6, 0.6);
put("dark_forest", 0.7, 0.8);
put("old_growth_pine_taiga", 0.3, 0.8);
put("old_growth_spruce_taiga", 0.25, 0.8);
put("taiga", 0.25, 0.8);
put("snowy_taiga", -0.5, 0.4);
put("savanna", 2.0, 0.0);
put("savanna_plateau", 2.0, 0.0);
put("windswept_hills", 0.2, 0.3);
put("windswept_gravelly_hills", 0.2, 0.3);
put("windswept_forest", 0.2, 0.3);
put("windswept_savanna", 2.0, 0.0);
put("jungle", 0.95, 0.9);
put("sparse_jungle", 0.95, 0.8);
put("bamboo_jungle", 0.95, 0.9);
put("badlands", 2.0, 0.0);
put("eroded_badlands", 2.0, 0.0);
put("wooded_badlands", 2.0, 0.0);
put("meadow", 0.5, 0.8);
put("cherry_grove", 0.5, 0.8, 0x5DB7DD);
put("grove", -0.2, 0.8);
put("snowy_slopes", -0.3, 0.9);
put("frozen_peaks", -0.7, 0.9);
put("jagged_peaks", -0.7, 0.9);
put("stony_peaks", 1.0, 0.3);
put("river", 0.5, 0.5);
put("frozen_river", 0.0, 0.5, 0x3938C9);
put("beach", 0.8, 0.4);
put("snowy_beach", 0.05, 0.3, 0x3D57D6);
put("stony_shore", 0.2, 0.3);
put("warm_ocean", 0.5, 0.5, 0x43D5EE);
put("lukewarm_ocean", 0.5, 0.5, 0x45ADF2);
put("deep_lukewarm_ocean", 0.5, 0.5, 0x45ADF2);
put("ocean", 0.5, 0.5);
put("deep_ocean", 0.5, 0.5);
put("cold_ocean", 0.5, 0.5, 0x3D57D6);
put("deep_cold_ocean", 0.5, 0.5, 0x3D57D6);
put("frozen_ocean", 0.0, 0.5, 0x3938C9);
put("deep_frozen_ocean", 0.5, 0.5, 0x3938C9);
put("mushroom_fields", 0.9, 1.0);
put("dripstone_caves", 0.8, 0.4);
put("lush_caves", 0.5, 0.5);
put("deep_dark", 0.8, 0.4);
put("nether_wastes", 2.0, 0.0, 0x905957);
put("soul_sand_valley", 2.0, 0.0, 0x905957);
put("crimson_forest", 2.0, 0.0, 0x905957);
put("warped_forest", 2.0, 0.0, 0x905957);
put("basalt_deltas", 2.0, 0.0, 0x3F76E4);
put("the_end", 0.5, 0.5, 0x62529E);
put("end_highlands", 0.5, 0.5, 0x62529E);
put("end_midlands", 0.5, 0.5, 0x62529E);
put("small_end_islands", 0.5, 0.5, 0x62529E);
put("end_barrens", 0.5, 0.5, 0x62529E);
put("the_void", 0.5, 0.5);
}
private BiomeClimate() {}
public static Climate forKey(String biomePath) {
return TABLE.getOrDefault(biomePath, DEFAULT);
}
}
@@ -0,0 +1,7 @@
package eu.mhsl.minecraft.pixelpics.render.tint;
/**
* The biome-dependent tint colors (RGB) for the colormap-driven channels.
*/
public record BiomeTint(int grass, int foliage, int dryFoliage, int water) {
}
@@ -0,0 +1,82 @@
package eu.mhsl.minecraft.pixelpics.render.tint;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import org.bukkit.block.Biome;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 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.
*/
public final class BiomeTintProvider {
private final int[][] grassMap;
private final int[][] foliageMap;
private final int[][] dryFoliageMap;
private final Map<String, BiomeTint> cache = new ConcurrentHashMap<>();
public BiomeTintProvider(TextureCache textures) {
this.grassMap = textures.get(ResourceLocation.parse("colormap/grass")).orElse(null);
this.foliageMap = textures.get(ResourceLocation.parse("colormap/foliage")).orElse(null);
this.dryFoliageMap = textures.get(ResourceLocation.parse("colormap/dry_foliage")).orElse(null);
}
public BiomeTint forBiome(Biome biome) {
return cache.computeIfAbsent(keyOf(biome), this::compute);
}
private String keyOf(Biome biome) {
try {
return biome.getKey().getKey();
} catch (Throwable t) {
return "plains";
}
}
private BiomeTint compute(String key) {
BiomeClimate.Climate climate = BiomeClimate.forKey(key);
int grass = sample(grassMap, climate.temperature(), climate.downfall(), 0xFF91BD59);
int foliage = sample(foliageMap, climate.temperature(), climate.downfall(), 0xFF77AB2F);
int dry = sample(dryFoliageMap, climate.temperature(), climate.downfall(), 0xFFA9A05B);
// Vanilla per-biome grass/foliage color overrides and modifiers that the colormap alone misses.
switch (key) {
case "swamp", "mangrove_swamp" -> {
grass = 0xFF6A7039;
foliage = 0xFF6A7039;
}
case "badlands", "eroded_badlands", "wooded_badlands" -> {
grass = 0xFF90814D;
foliage = 0xFF9E814D;
}
case "dark_forest" -> {
// DARK_FOREST modifier: ((color & 0xFEFEFE) + 0x28340A) >> 1
grass = 0xFF000000 | (((grass & 0xFEFEFE) + 0x28340A) >> 1);
foliage = 0xFF000000 | (((foliage & 0xFEFEFE) + 0x28340A) >> 1);
}
default -> { }
}
return new BiomeTint(grass, foliage, dry, 0xFF000000 | climate.water());
}
/** 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;
int x = (int) ((1.0 - temp) * 255.0);
int y = (int) ((1.0 - down) * 255.0);
int h = colormap.length;
int w = colormap[0].length;
x = Math.max(0, Math.min(w - 1, x));
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);
}
}
@@ -0,0 +1,60 @@
package eu.mhsl.minecraft.pixelpics.render.tint;
import org.bukkit.block.data.BlockData;
/**
* Maps a tinted face (material + tintindex) to the concrete tint color, choosing between the
* biome-driven channels and a handful of vanilla constants.
*/
public final class TintResolver {
private static final int BIRCH = 0xFF80A755;
private static final int SPRUCE = 0xFF619961;
private static final int LILY_PAD = 0xFF208030;
private static final int STEM = 0xFF60A017;
private 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();
}
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);
}
}
@@ -1,51 +0,0 @@
package eu.mhsl.minecraft.pixelpics.render.util;
import org.bukkit.Location;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.util.BlockIterator;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull;
public class BlockRaytracer extends BlockIterator {
private final Vector position;
private final Vector direction;
private Block lastBlock;
private BlockFace currentFace;
public BlockRaytracer(Location loc) {
super(loc);
this.position = loc.toVector();
this.direction = loc.getDirection();
}
public BlockFace getIntersectionFace() {
if (currentFace == null) {
throw new IllegalStateException("Called before next()");
}
return currentFace;
}
public Vector getIntersectionPoint() {
BlockFace lastFace = getIntersectionFace();
Vector planeNormal = new Vector(lastFace.getModX(), lastFace.getModY(), lastFace.getModZ());
Vector planePoint = lastBlock.getLocation()
.add(0.5, 0.5, 0.5)
.toVector()
.add(planeNormal.clone().multiply(0.5));
return MathUtil.getLinePlaneIntersection(position, direction, planePoint, planeNormal, true);
}
@Override
public @NotNull Block next() {
Block currentBlock = super.next();
currentFace = lastBlock == null ? BlockFace.SELF : currentBlock.getFace(lastBlock);
return (lastBlock = currentBlock);
}
}
@@ -0,0 +1,71 @@
package eu.mhsl.minecraft.pixelpics.render.util;
/**
* Helpers for packed ARGB integer colors.
*/
public final class ColorUtil {
private ColorUtil() {}
public static int alpha(int argb) { return (argb >> 24) & 0xFF; }
public static int red(int argb) { return (argb >> 16) & 0xFF; }
public static int green(int argb) { return (argb >> 8) & 0xFF; }
public static int blue(int argb) { return argb & 0xFF; }
public static int argb(int a, int r, int g, int b) {
return (a << 24) | (r << 16) | (g << 8) | b;
}
/** 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);
int r = red(base) * red(tint) / 255;
int g = green(base) * green(tint) / 255;
int b = blue(base) * blue(tint) / 255;
return argb(a, r, g, b);
}
/** Scales the RGB channels by {@code factor} (0..1), keeping alpha. Used for directional face shading. */
public static int shade(int argb, double factor) {
int a = alpha(argb);
int r = clamp((int) (red(argb) * factor));
int g = clamp((int) (green(argb) * factor));
int b = clamp((int) (blue(argb) * factor));
return argb(a, r, g, b);
}
private static int clamp(int v) {
return v < 0 ? 0 : Math.min(v, 255);
}
// --- Gamma-correct (linear-light) averaging ---
private static final float[] SRGB_TO_LINEAR = new float[256];
static {
for (int i = 0; i < 256; i++) {
double c = i / 255.0;
SRGB_TO_LINEAR[i] = (float) (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
}
}
public static float toLinear(int channel) {
return SRGB_TO_LINEAR[channel & 0xFF];
}
public 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));
}
/** Averages a set of RGB colors in linear light and returns the sRGB result (opaque). */
public static int averageLinear(int[] colors, int from, int count) {
double r = 0, g = 0, b = 0;
for (int i = 0; i < count; i++) {
int c = colors[from + i];
r += toLinear((c >> 16) & 0xFF);
g += toLinear((c >> 8) & 0xFF);
b += toLinear(c & 0xFF);
}
return argb(0xFF, toSrgb(r / count), toSrgb(g / count), toSrgb(b / count));
}
}
@@ -1,42 +0,0 @@
package eu.mhsl.minecraft.pixelpics.render.util;
import org.bukkit.util.Vector;
public final class Intersection {
private final Vector normal;
private final Vector point;
private final Vector direction;
private final int color;
private Intersection(Vector normal, Vector point, Vector direction, int color) {
this.normal = normal;
this.point = point;
this.direction = direction;
this.color = color;
}
public Vector getNormal() {
return normal;
}
public Vector getPoint() {
return point;
}
public Vector getDirection() {
return direction;
}
public int getColor() {
return color;
}
public static Intersection of(Vector normal, Vector point, Vector direction) {
return of(normal, point, direction, 0);
}
public static Intersection of(Vector normal, Vector point, Vector direction, int color) {
return new Intersection(normal, point, direction, color);
}
}
@@ -8,39 +8,114 @@ import org.jetbrains.annotations.NotNull;
import java.awt.image.BufferedImage;
/**
* Draws precomputed map palette indices onto the canvas. Supports a Polaroid-style "developing"
* animation: the picture dissolves in over {@link #DEVELOP_MILLIS} once the render is ready, like an
* old instant camera. Persisted maps are drawn instantly without animation.
*
* <p>{@code render()} is invoked by Bukkit each tick for viewing players, which drives the animation;
* progress is based on wall-clock time so it is correct even if the player looks away and back.
*/
public class ImageMapRenderer extends MapRenderer {
public static final int MAP_SIZE = 128;
private final BufferedImage image;
private boolean alreadyRendered = false;
public static final int MAP_SIZE = MapImageDither.SIZE;
private static final long DEVELOP_MILLIS = 3000;
private static byte filmIndex = 0;
private static boolean filmResolved = false;
private volatile byte[] indices; // MAP_SIZE*MAP_SIZE, null while still rendering
private final boolean animate;
private volatile long developStart = 0; // 0 until indices are available
private boolean finished = false;
private boolean blankDrawn = false;
/** Persisted/instant map: draw the full picture immediately, no animation. */
public ImageMapRenderer(byte[] indices) {
this.indices = indices;
this.animate = false;
}
/** Fresh capture from a rendered image: dithers immediately, then develops. */
public ImageMapRenderer(BufferedImage image) {
this(image, 0, 0);
this(MapImageDither.dither(image));
}
public ImageMapRenderer(BufferedImage image, int x, int y) {
this.image = this.recalculateInput(image, x, y);
/** Pending capture: shows blank film until {@link #develop} supplies the picture. */
public ImageMapRenderer() {
this.indices = null;
this.animate = true;
}
private BufferedImage recalculateInput(BufferedImage input, int x, int y) {
if (x * MAP_SIZE > input.getWidth() || y * MAP_SIZE > input.getHeight())
throw new RuntimeException(String.format("Input image mus match a multiple of x and y with %d", MAP_SIZE));
public byte[] getIndices() {
return indices;
}
int x1 = (int) (double) (x * MAP_SIZE);
int y1 = (int) (double) (y * MAP_SIZE);
int x2 = (int) (double) Math.min(input.getWidth(), ((x + 1) * MAP_SIZE));
int y2 = (int) (double) Math.min(input.getHeight(), ((y + 1) * MAP_SIZE));
if (x2 - x1 <= 0 || y2 - y1 <= 0)
throw new RuntimeException("Invalid Image dimensions!");
return input.getSubimage(x1, y1, x2 - x1, y2 - y1);
/** Supplies the finished picture and starts the developing animation. */
public void develop(byte[] indices) {
this.indices = indices;
this.developStart = System.currentTimeMillis();
this.finished = false;
}
@Override
public void render(@NotNull MapView map, @NotNull MapCanvas canvas, @NotNull Player player) {
if(this.alreadyRendered) return;
canvas.drawImage(0, 0, this.image);
this.alreadyRendered = true;
if (finished) return;
byte[] data = this.indices;
// Still rendering: show a blank "film" once.
if (data == null) {
if (!blankDrawn) {
fill(canvas, film());
blankDrawn = true;
}
return;
}
if (!animate) {
blit(canvas, data, 1.0);
finished = true;
return;
}
double progress = (System.currentTimeMillis() - developStart) / (double) DEVELOP_MILLIS;
blit(canvas, data, progress);
if (progress >= 1.0) finished = true;
}
/** Draws revealed pixels at the given progress; unrevealed pixels stay film-colored. */
private void blit(MapCanvas canvas, byte[] data, double progress) {
byte film = film();
for (int y = 0; y < MAP_SIZE; y++) {
for (int x = 0; x < MAP_SIZE; x++) {
byte value = (progress >= 1.0 || revealThreshold(x, y) <= progress)
? data[y * MAP_SIZE + x] : film;
canvas.setPixel(x, y, value);
}
}
}
@SuppressWarnings("deprecation")
private void fill(MapCanvas canvas, byte value) {
for (int y = 0; y < MAP_SIZE; y++) {
for (int x = 0; x < MAP_SIZE; x++) canvas.setPixel(x, y, value);
}
}
/** Per-pixel reveal time in [0,1]: mostly noise plus a gentle top-to-bottom sweep for an organic develop. */
private double revealThreshold(int x, int y) {
int h = (x * 374761393 + y * 668265263);
h = (h ^ (h >>> 13)) * 1274126177;
double noise = ((h >>> 8) & 0xFFFF) / 65535.0;
double sweep = y / (double) (MAP_SIZE - 1);
return Math.min(0.999, noise * 0.7 + sweep * 0.3);
}
private static byte film() {
if (!filmResolved) {
filmIndex = MapColorPalette.mapIndex(MapColorPalette.nearestPos(206, 202, 190));
filmResolved = true;
}
return filmIndex;
}
}
@@ -0,0 +1,102 @@
package eu.mhsl.minecraft.pixelpics.utils;
import org.bukkit.map.MapPalette;
import java.awt.Color;
import java.util.ArrayList;
import java.util.List;
/**
* The set of usable Minecraft map colors, with nearest-color matching in CIELAB (perceptual) space.
* Built once from {@link MapPalette}. Matching in Lab rather than RGB keeps hues correct (greens stay
* green instead of collapsing to gray).
*/
public final class MapColorPalette {
private static volatile boolean initialized = false;
private static byte[] indices;
private static int[] rgb;
private static double[][] lab;
private MapColorPalette() {}
private static synchronized void ensure() {
if (initialized) return;
List<Byte> idx = new ArrayList<>();
List<Integer> rgbs = new ArrayList<>();
List<double[]> labs = new ArrayList<>();
for (int i = 0; i < 256; i++) {
Color c;
try {
c = MapPalette.getColor((byte) i);
} catch (Throwable t) {
continue;
}
if (c == null || 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()));
}
indices = new byte[idx.size()];
rgb = new int[idx.size()];
lab = new double[idx.size()][];
for (int i = 0; i < idx.size(); i++) {
indices[i] = idx.get(i);
rgb[i] = rgbs.get(i);
lab[i] = labs.get(i);
}
initialized = true;
}
public static int size() {
ensure();
return indices.length;
}
/** Index into the internal arrays of the perceptually nearest palette color. */
public static int nearestPos(int r, int g, int b) {
ensure();
double[] target = rgbToLab(r, g, b);
double best = Double.MAX_VALUE;
int bestPos = 0;
for (int i = 0; i < lab.length; i++) {
double[] l = lab[i];
double dl = target[0] - l[0], da = target[1] - l[1], db = target[2] - l[2];
double d = dl * dl + da * da + db * db;
if (d < best) {
best = d;
bestPos = i;
}
}
return bestPos;
}
public static byte mapIndex(int pos) {
return indices[pos];
}
public static int mapRgb(int pos) {
return rgb[pos];
}
private static double[] rgbToLab(int r, int g, int b) {
double rl = pivotSrgb(r), gl = pivotSrgb(g), bl = pivotSrgb(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;
double z = rl * 0.0193 + gl * 0.1192 + bl * 0.9505;
double fx = pivotXyz(x / 0.95047);
double fy = pivotXyz(y);
double fz = pivotXyz(z / 1.08883);
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);
}
}
@@ -0,0 +1,73 @@
package eu.mhsl.minecraft.pixelpics.utils;
import java.awt.image.BufferedImage;
/**
* Quantizes a rendered image to Minecraft map color indices using dampened FloydSteinberg error
* diffusion with perceptual (CIELAB) nearest-color matching. The result is a {@code 128*128} byte
* array of palette indices, ready to blit onto a {@link org.bukkit.map.MapCanvas}.
*
* <p>This is the expensive step; computing it once and caching the byte array lets map re-rendering
* after a restart be a cheap copy instead of a full re-quantization.
*/
public final class MapImageDither {
public static final int SIZE = 128;
/** Fraction of quantization error diffused. Low values keep gradients smooth without visible noise. */
private static final float DITHER_STRENGTH = 0.35f;
private MapImageDither() {}
/** Returns a {@code SIZE*SIZE} array of map palette indices (row-major, y*SIZE + x). */
public static byte[] dither(BufferedImage image) {
int w = Math.min(SIZE, image.getWidth());
int h = Math.min(SIZE, image.getHeight());
byte[] out = new byte[SIZE * SIZE];
float[][] buf = new float[h][w * 3];
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
int argb = image.getRGB(x, y);
buf[y][x * 3] = (argb >> 16) & 0xFF;
buf[y][x * 3 + 1] = (argb >> 8) & 0xFF;
buf[y][x * 3 + 2] = argb & 0xFF;
}
}
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
int i = x * 3;
int r = clamp(buf[y][i]);
int g = clamp(buf[y][i + 1]);
int b = clamp(buf[y][i + 2]);
int pos = MapColorPalette.nearestPos(r, g, b);
out[y * SIZE + x] = MapColorPalette.mapIndex(pos);
int chosen = MapColorPalette.mapRgb(pos);
float er = (r - ((chosen >> 16) & 0xFF)) * DITHER_STRENGTH;
float eg = (g - ((chosen >> 8) & 0xFF)) * DITHER_STRENGTH;
float eb = (b - (chosen & 0xFF)) * DITHER_STRENGTH;
spread(buf, y, x + 1, w, h, er, eg, eb, 7f / 16f);
spread(buf, y + 1, x - 1, w, h, er, eg, eb, 3f / 16f);
spread(buf, y + 1, x, w, h, er, eg, eb, 5f / 16f);
spread(buf, y + 1, x + 1, w, h, er, eg, eb, 1f / 16f);
}
}
return out;
}
private static void spread(float[][] buf, int y, int x, int w, int h, float er, float eg, float eb, float f) {
if (x < 0 || x >= w || y < 0 || y >= h) return;
int i = x * 3;
buf[y][i] += er * f;
buf[y][i + 1] += eg * f;
buf[y][i + 2] += eb * f;
}
private static int clamp(float v) {
return v < 0 ? 0 : (v > 255 ? 255 : Math.round(v));
}
}
@@ -0,0 +1,173 @@
package eu.mhsl.minecraft.pixelpics.utils;
import eu.mhsl.minecraft.pixelpics.Main;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.entity.ItemFrame;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.MapMeta;
import org.bukkit.map.MapView;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Persists rendered images (PNG, source of truth) and their dithered map-color indices (cache) to
* disk, (re)attaches the renderer to a {@link MapView}, and supports orphan cleanup. State is
* filesystem-backed so screenshots survive a restart.
*/
public class MapManager {
private static final Pattern FILE_ID = Pattern.compile("mapId_(\\d+)\\.(png|mcmap)");
private static final int INDEX_BYTES = ImageMapRenderer.MAP_SIZE * ImageMapRenderer.MAP_SIZE;
private MapManager() {}
private static File imageFolder() {
return new File(Main.getInstance().getDataFolder(), "images");
}
public static void attachView(MapView mapView, ImageMapRenderer renderer) {
mapView.getRenderers().clear();
mapView.setScale(MapView.Scale.FARTHEST);
mapView.addRenderer(renderer);
mapView.setTrackingPosition(false);
}
public static File getImagePath(int mapId) {
return new File(imageFolder(), String.format("mapId_%d.png", mapId));
}
public static File getIndexPath(int mapId) {
return new File(imageFolder(), String.format("mapId_%d.mcmap", mapId));
}
private static boolean ensureFolder() {
File folder = imageFolder();
if (!folder.exists() && !folder.mkdirs()) {
Main.getInstance().getLogger().warning("Failed to create image output folder: " + folder);
return false;
}
return true;
}
public static void saveImage(BufferedImage image, int id) {
if (!ensureFolder()) return;
try {
ImageIO.write(image, "png", getImagePath(id));
} catch (IOException e) {
Main.getInstance().getLogger().warning("Failed to save map image " + id + ": " + e.getMessage());
}
}
public static void saveIndices(byte[] indices, int id) {
if (indices == null || indices.length != INDEX_BYTES || !ensureFolder()) return;
try {
Files.write(getIndexPath(id).toPath(), indices);
} catch (IOException e) {
Main.getInstance().getLogger().warning("Failed to save map index cache " + id + ": " + e.getMessage());
}
}
/** Reads the cached dithered indices, or {@code null} if absent/invalid. */
public static byte[] readIndices(int id) {
File path = getIndexPath(id);
if (!path.isFile()) return null;
try {
byte[] data = Files.readAllBytes(path.toPath());
return data.length == INDEX_BYTES ? data : null;
} catch (IOException e) {
return null;
}
}
/** Reads the stored image, or {@code null} if it does not exist or cannot be read. */
public static BufferedImage readImage(int id) {
File path = getImagePath(id);
if (!path.isFile()) return null;
try {
return ImageIO.read(path);
} catch (IOException e) {
Main.getInstance().getLogger().warning("Failed to read map image " + id + ": " + e.getMessage());
return null;
}
}
public static boolean isManaged(int id) {
return getIndexPath(id).isFile() || getImagePath(id).isFile();
}
// --- Cleanup support ---
/** A map id stored on disk together with the newest of its files' last-modified time. */
public record StoredMap(int id, long lastModified) {}
/** All map ids that have files on disk. */
public static Set<StoredMap> listStored() {
Set<StoredMap> result = new HashSet<>();
File folder = imageFolder();
File[] files = folder.listFiles();
if (files == null) return result;
Set<Integer> seen = new HashSet<>();
for (File f : files) {
Matcher m = FILE_ID.matcher(f.getName());
if (!m.matches()) continue;
int id = Integer.parseInt(m.group(1));
if (!seen.add(id)) continue;
long lm = Math.max(lastModified(getImagePath(id)), lastModified(getIndexPath(id)));
result.add(new StoredMap(id, lm));
}
return result;
}
private static long lastModified(File f) {
return f.isFile() ? f.lastModified() : 0L;
}
/** Map ids referenced by currently-loaded item frames and online players (inventory + ender chest). */
public static Set<Integer> collectInUseMapIds() {
Set<Integer> inUse = new HashSet<>();
for (Player p : Bukkit.getOnlinePlayers()) {
addFromContents(inUse, p.getInventory().getContents());
addFromContents(inUse, p.getEnderChest().getContents());
addFromItem(inUse, p.getItemOnCursor());
}
for (World w : Bukkit.getWorlds()) {
for (ItemFrame frame : w.getEntitiesByClass(ItemFrame.class)) {
addFromItem(inUse, frame.getItem());
}
}
return inUse;
}
private static void addFromContents(Set<Integer> set, ItemStack[] contents) {
if (contents == null) return;
for (ItemStack item : contents) addFromItem(set, item);
}
private static void addFromItem(Set<Integer> set, ItemStack item) {
if (item == null || item.getType() != Material.FILLED_MAP || !item.hasItemMeta()) return;
if (item.getItemMeta() instanceof MapMeta meta && meta.hasMapView() && meta.getMapView() != null) {
set.add(meta.getMapView().getId());
}
}
/** Deletes both files for a map id. Returns true if anything was deleted. */
public static boolean delete(int id) {
boolean deleted = false;
File png = getImagePath(id);
File idx = getIndexPath(id);
if (png.isFile()) deleted |= png.delete();
if (idx.isFile()) deleted |= idx.delete();
return deleted;
}
}
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

@@ -1,6 +0,0 @@
{
"parent": "minecraft:block/button",
"textures": {
"texture": "minecraft:block/acacia_planks"
}
}
@@ -1,6 +0,0 @@
{
"parent": "minecraft:block/button_inventory",
"textures": {
"texture": "minecraft:block/acacia_planks"
}
}
@@ -1,6 +0,0 @@
{
"parent": "minecraft:block/button_pressed",
"textures": {
"texture": "minecraft:block/acacia_planks"
}
}
@@ -1,7 +0,0 @@
{
"parent": "minecraft:block/door_bottom_left",
"textures": {
"bottom": "minecraft:block/acacia_door_bottom",
"top": "minecraft:block/acacia_door_top"
}
}
@@ -1,7 +0,0 @@
{
"parent": "minecraft:block/door_bottom_left_open",
"textures": {
"bottom": "minecraft:block/acacia_door_bottom",
"top": "minecraft:block/acacia_door_top"
}
}
@@ -1,7 +0,0 @@
{
"parent": "minecraft:block/door_bottom_right",
"textures": {
"bottom": "minecraft:block/acacia_door_bottom",
"top": "minecraft:block/acacia_door_top"
}
}
@@ -1,7 +0,0 @@
{
"parent": "minecraft:block/door_bottom_right_open",
"textures": {
"bottom": "minecraft:block/acacia_door_bottom",
"top": "minecraft:block/acacia_door_top"
}
}
@@ -1,7 +0,0 @@
{
"parent": "minecraft:block/door_top_left",
"textures": {
"bottom": "minecraft:block/acacia_door_bottom",
"top": "minecraft:block/acacia_door_top"
}
}
@@ -1,7 +0,0 @@
{
"parent": "minecraft:block/door_top_left_open",
"textures": {
"bottom": "minecraft:block/acacia_door_bottom",
"top": "minecraft:block/acacia_door_top"
}
}
@@ -1,7 +0,0 @@
{
"parent": "minecraft:block/door_top_right",
"textures": {
"bottom": "minecraft:block/acacia_door_bottom",
"top": "minecraft:block/acacia_door_top"
}
}
@@ -1,7 +0,0 @@
{
"parent": "minecraft:block/door_top_right_open",
"textures": {
"bottom": "minecraft:block/acacia_door_bottom",
"top": "minecraft:block/acacia_door_top"
}
}
@@ -1,6 +0,0 @@
{
"parent": "minecraft:block/template_fence_gate",
"textures": {
"texture": "minecraft:block/acacia_planks"
}
}
@@ -1,6 +0,0 @@
{
"parent": "minecraft:block/template_fence_gate_open",
"textures": {
"texture": "minecraft:block/acacia_planks"
}
}

Some files were not shown because too many files have changed in this diff Show More