Compare commits

...
31 Commits
Author SHA1 Message Date
MineTec 37fa1d8f56 bind block textures to the model under blockstate rotations (vanilla UVs)
- face UV orientation now follows vanilla BlockElement.uvsByFace: north/
  east/down faces were sampled mirrored. Default UVs adjusted to match.
  Block and entity faces share one mapping (ElementIntersector.faceUv),
  so the separate blockUv mode of the entity intersector is gone.
- x/y blockstate rotations without uvlock: elements keep their model-space
  copy and are intersected with the inverse-rotated ray, so textures turn
  with the model exactly like vanilla (pistons facing down showed their
  side textures in different directions per face). Replaces the earlier
  up/down-only UV rotation. Display entities use the same model-space path.

Edge-case test plugin: piston/observer/dispenser scenes.
2026-11-04 13:36:15 +01:00
MineTec 73e521131b parse 26.x texture objects in block models (redstone, glass, panes)
Since 26.x a model texture can be an object {"sprite": ..., "force_translucent": ...}
instead of a string. The block-model DTO only accepted strings, so those
models failed to load and rendered as the grey fallback cube: redstone
wire and every glass block and pane (110 models). Accept both forms in
ModelResolver (shared with ItemGeometry).

Edge-case test plugin: redstone/glass scene.
2026-11-04 13:28:10 +01:00
MineTec 9e59435e13 edge-case test plugin: fence and door in the rotated-block scene 2026-11-04 13:24:48 +01:00
MineTec a99acad80c fix blockstate y-rotation direction and rotate top/bottom UVs without uvlock
Blockstate "y" rotations turned the geometry counter-clockwise while the
faces were re-assigned clockwise (vanilla). Every east/west variant of an
asymmetric block ended up mirrored on the wrong side of its cell: shelf
mushrooms and ladders floated away from their support, stairs faced
backwards. Rotate clockwise like vanilla, adjust element-rotation axes,
and turn up/down face textures with the model unless uvlock is set.

Edge-case test plugin: shelf mushroom (side + top view) and rotated-block
scenes.
2026-11-04 13:20:40 +01:00
MineTec 32afe08f03 render entities from vanilla client models, add items, displays and leashes
- dump entity models straight from the unobfuscated client jar
  (tools/VanillaModelDump.java, tools/update-vanilla-models.sh): models posed
  by vanilla setupAnim, baby/variant/layer models, renderer transforms,
  default textures, pose variants (sitting, holding items, illager arms)
  and all layer definitions; the old CEM template is only a fallback now
- fix adult zombies captured as babies, zero-thickness planes (wings,
  fins) and back faces in the entity intersector
- held items, head items (pumpkins, skulls), shields, elytra, saddles and
  body equipment for every mount, armor following the pose
- block/item/text display entities, dropped items, falling blocks, primed
  TNT, minecart contents, block entities inside displays, leash ropes
- item models resolved from the 26.x item definitions with display
  transforms; generated items extruded like vanilla
- bundle the vanilla font definitions as fallback so sign/text rendering
  works with packs that lack font/*.json
- one failing entity no longer aborts the whole photo
- edge-case test plugin (tools/edgecase-plugin) and offline harnesses
2026-11-04 13:11:39 +01:00
MineTec 5904814e69 reduce harsh dithering: constrained error diffusion in Lab space
Colors the map palette cannot represent (light sky blue, sunset tones) were
approximated by mixing far-apart palette colors, producing a high-contrast
checkerboard in the sky and stray speckles elsewhere. Diffuse the error in
CIELAB and only allow palette colors nearly as close to the original as the
best match, so the dither blends between neighbouring colors only.
2026-11-04 11:29:32 +01:00
MineTec 2d84a35cc4 update to Paper 26.3: load biome colors from vanilla data, fix dappled forest grass
Biome grass/foliage/dry-foliage/water colors and grass color modifiers are now
read from the server's built-in worldgen biome data at runtime, with a 26.3
snapshot as fallback. Fixes missing biomes (dappled_forest, pale_garden,
sulfur_caves) and several outdated values. Replace deprecated ExactChoice
constructors.
2026-11-04 11:19:08 +01:00
MineTec 8dd295e5d6 update to Paper 26.2, Java 25 and Gradle 9.6.1 2026-10-08 23:33:14 +02:00
MineTec 6d60b4f74b improve crafting and camera behavior: enforce single-item results, add exact crafting choices, refine item metadata, and update textures 2026-07-23 22:04:45 +02:00
MineTec 7063d27fb4 add Spotless plugin: enforce code formatting 2026-06-21 18:32:46 +02:00
MineTec dce7804fed refactor: simplify rendering logic, remove redundant cases, and replace bounds checks with Math.clamp 2026-06-21 18:09:11 +02:00
MineTec 1d3040610e refactor: replace fully qualified class names with imports and simplify code readability across rendering modules 2026-06-21 17:56:32 +02:00
MineTec f83ccdc7ff add RenderManager: improve CPU control with global render queue and parallel trace pooling 2026-06-21 17:49:57 +02:00
MineTec 220cda1deb add survival mode support: camera, film system, and dynamic crafting recipes for photos 2026-06-21 17:30:24 +02:00
MineTec fed94f97d1 add sign text rendering: support font loading, text rasterization, and color adjustments for signs 2026-06-21 16:15:57 +02:00
MineTec 5330948dbd enhance entity rendering: add support for villager levels, horse armor, llama decor, and saddle/chest states 2026-06-21 15:32:53 +02:00
MineTec 094aa463c5 refactor and simplify rendering logic; remove redundant code and consolidate utility methods 2026-06-21 14:42:10 +02:00
MineTec c5d5eae1c1 refactor block entity handling and add support for invisible materials 2026-06-21 14:22:10 +02:00
MineTec f1844a9dd9 wire up rendering + build config 2026-06-07 18:57:53 +02:00
MineTec 211c7e8479 resource-pack render engine 2026-06-07 18:57:40 +02:00
MineTec 18c5fc4ffc remove bundled vanilla assets 2026-06-07 18:57:13 +02:00
MineTec 3f0814639d cleanup 2025-08-03 11:13:47 +02:00
MineTec bf7e52138d updated project, configured gradle 2025-07-12 12:15:26 +02:00
MineTec 683cf8d80c fixed merge of texture support 2025-07-12 10:09:20 +02:00
MineTec 0a0075ce05 Merge branch 'develop-textures' into develop
# Conflicts:
#	.idea/modules.xml
#	src/main/java/eu/mhsl/minecraft/pixelpics/Main.java
#	src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/AdvancedRaytracer.java
#	src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/DefaultRaytracer.java
#	src/main/java/eu/mhsl/minecraft/pixelpics/render/registry/AdvancedModelRegistry.java
#	src/main/resources/plugin.yml
2025-07-12 10:05:38 +02:00
Pupsi 2d4f820f04 added advanced texture support 2025-03-22 17:28:43 +01:00
MineTec 838eafcf56 renamed project from PixelPic to PixelPics matching name scheme of PixelBlocks 2025-03-21 19:27:58 +01:00
Pupsi ab97315910 added advanced model registry 2025-03-21 19:22:15 +01:00
Pupsi c5c21d89b6 added texture files and fixed short grass model 2025-03-19 18:03:43 +01:00
MineTec ddbe59a5ac cleanup and introducing plugin structure 2025-03-19 17:40:18 +01:00
Pupsi 0103a654c3 basic image generation based on WinX64/bukkit-screenshot 2025-03-18 21:34:22 +01:00
116 changed files with 12735 additions and 25 deletions

No files matched your search

+2
View File
@@ -168,3 +168,5 @@ gradle-app.setting
*.hprof *.hprof
# End of https://www.toptal.com/developers/gitignore/api/java,intellij,gradle # End of https://www.toptal.com/developers/gitignore/api/java,intellij,gradle
/local.gradle
+2
View File
@@ -3,6 +3,8 @@
<component name="ProjectModuleManager"> <component name="ProjectModuleManager">
<modules> <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/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/PixelPics.main.iml" filepath="$PROJECT_DIR$/.idea/modules/PixelPics.main.iml" />
</modules> </modules>
</component> </component>
</project> </project>
+4
View File
@@ -11,4 +11,8 @@
</configuration> </configuration>
</facet> </facet>
</component> </component>
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module> </module>
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<module version="4">
<component name="FacetManager">
<facet type="minecraft" name="Minecraft">
<configuration>
<autoDetectTypes>
<platformType>PAPER</platformType>
<platformType>ADVENTURE</platformType>
</autoDetectTypes>
<projectReimportVersion>1</projectReimportVersion>
</configuration>
</facet>
</component>
</module>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<module version="4">
<component name="FacetManager">
<facet type="minecraft" name="Minecraft">
<configuration>
<autoDetectTypes>
<platformType>PAPER</platformType>
<platformType>ADVENTURE</platformType>
</autoDetectTypes>
<projectReimportVersion>1</projectReimportVersion>
</configuration>
</facet>
</component>
</module>
+16 -3
View File
@@ -1,8 +1,9 @@
plugins { plugins {
id 'java' id 'java'
id 'com.diffplug.spotless' version '7.0.2'
} }
group = 'eu.mhsl.minecraft.pixelpic' group = 'eu.mhsl.minecraft.pixelpics'
version = '1.0-SNAPSHOT' version = '1.0-SNAPSHOT'
repositories { repositories {
@@ -18,10 +19,10 @@ repositories {
} }
dependencies { dependencies {
compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT") compileOnly("io.papermc.paper:paper-api:26.3.build.240-stable")
} }
def targetJavaVersion = 21 def targetJavaVersion = 25
java { java {
def javaVersion = JavaVersion.toVersion(targetJavaVersion) def javaVersion = JavaVersion.toVersion(targetJavaVersion)
sourceCompatibility = javaVersion sourceCompatibility = javaVersion
@@ -47,3 +48,15 @@ processResources {
expand props expand props
} }
} }
spotless {
java {
target 'src/main/java/**/*.java'
palantirJavaFormat()
removeUnusedImports()
}
}
if (file("local.gradle").exists()) {
apply from: "local.gradle"
}
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
Vendored Executable
+249
View File
@@ -0,0 +1,249 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+1 -1
View File
@@ -1 +1 @@
rootProject.name = 'PixelPic' rootProject.name = 'PixelPics'
@@ -1,17 +0,0 @@
package eu.mhsl.minecraft.pixelpic;
import org.bukkit.plugin.java.JavaPlugin;
public final class Main extends JavaPlugin {
@Override
public void onEnable() {
// Plugin startup logic
}
@Override
public void onDisable() {
// Plugin shutdown logic
}
}
@@ -0,0 +1,169 @@
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.SkinCache;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import eu.mhsl.minecraft.pixelpics.assets.font.BitmapFont;
import eu.mhsl.minecraft.pixelpics.assets.font.FontLoader;
import eu.mhsl.minecraft.pixelpics.commands.PixelPicsCommand;
import eu.mhsl.minecraft.pixelpics.listeners.OnMapInitialize;
import eu.mhsl.minecraft.pixelpics.render.RenderManager;
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.cem.CemModelLoader;
import eu.mhsl.minecraft.pixelpics.render.render.DefaultScreenRenderer;
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
import eu.mhsl.minecraft.pixelpics.survival.CameraListener;
import eu.mhsl.minecraft.pixelpics.survival.CraftingListener;
import eu.mhsl.minecraft.pixelpics.survival.JoinListener;
import eu.mhsl.minecraft.pixelpics.survival.SurvivalRecipes;
import eu.mhsl.minecraft.pixelpics.utils.MapColorPalette;
import java.io.File;
import java.io.InputStream;
import java.util.Objects;
import java.util.Optional;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.plugin.java.JavaPlugin;
public final class Main extends JavaPlugin {
private static Main instance;
private ResourcePack resourcePack;
private DefaultScreenRenderer screenRenderer;
private RenderManager renderManager;
public final NamespacedKey pictureIdFlag = new NamespacedKey(this, "imageid");
/** Marks a {@code PLAYER_HEAD} as a camera (BYTE 1). */
public final NamespacedKey cameraMarker = new NamespacedKey(this, "camera");
/** Loaded film count stored on a camera (INTEGER, 0..{@code CameraItems.MAX_FILM}). */
public final NamespacedKey filmCountKey = new NamespacedKey(this, "filmcount");
/** Marks a {@code PLAYER_HEAD} as a film roll (BYTE 1). */
public final NamespacedKey filmMarker = new NamespacedKey(this, "film");
@Override
public void onEnable() {
instance = this;
saveDefaultConfig();
initRenderManager();
Bukkit.getPluginManager().registerEvents(new OnMapInitialize(), this);
Objects.requireNonNull(Bukkit.getPluginCommand("pixelPic")).setExecutor(new PixelPicsCommand());
Bukkit.getPluginManager().registerEvents(new CameraListener(), this);
Bukkit.getPluginManager().registerEvents(new CraftingListener(), this);
Bukkit.getPluginManager().registerEvents(new JoinListener(), this);
SurvivalRecipes.register();
initRenderer();
}
private void initRenderManager() {
int cores = Runtime.getRuntime().availableProcessors();
int threads = getConfig().getInt("render.threads", 0);
if (threads <= 0) threads = Math.max(1, cores - 2);
threads = Math.min(threads, cores);
int maxConcurrent = Math.max(1, getConfig().getInt("render.max-concurrent", 2));
int queueSize = Math.max(0, getConfig().getInt("render.queue-size", 8));
int timeoutSeconds = Math.max(1, getConfig().getInt("render.timeout-seconds", 30));
this.renderManager = new RenderManager(this, threads, maxConcurrent, queueSize, timeoutSeconds);
getLogger()
.info("Render pool: " + threads + " core(s), max " + maxConcurrent + " concurrent, queue " + queueSize
+ ", timeout " + timeoutSeconds + "s.");
}
private void initRenderer() {
File resourcePackDir = new File(getDataFolder(), "resourcepack");
if (!resourcePackDir.exists() && !resourcePackDir.mkdirs()) {
getLogger().warning("Could not create resource pack directory: " + resourcePackDir);
}
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;
}
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);
CemModelLoader cemLoader = new CemModelLoader();
try (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());
}
// Vanilla models dumped from the client jar (tools/VanillaModelDump.java) override the CEM set: an
// optional newer dump in the data folder (no recompile on MC updates), else the bundled copy.
File dump = new File(getDataFolder(), "vanilla_entity_models.json");
try (InputStream in =
dump.isFile() ? new java.io.FileInputStream(dump) : getResource("cem/vanilla_entity_models.json")) {
if (in != null) {
int n = cemLoader.load(in, getLogger());
getLogger()
.info("Loaded vanilla entity models" + (dump.isFile() ? " from " + dump.getName() : "") + " ("
+ n + " models total).");
}
} catch (Exception e) {
getLogger().severe("Failed to load vanilla entity models: " + e.getMessage());
}
SkinCache skinCache = new SkinCache();
BitmapFont font = FontLoader.load(resourcePack, textures, getLogger());
getLogger().info("Loaded sign font (" + (font.isEmpty() ? "no glyphs — text disabled" : "ok") + ").");
CemBaker entityBaker = new CemBaker(cemLoader, textures, skinCache);
BlockEntityBaker blockEntityBaker = new BlockEntityBaker(cemLoader, textures, skinCache, font);
this.screenRenderer = new DefaultScreenRenderer(
registry,
tintProvider,
textures,
entityBaker,
blockEntityBaker,
getLogger(),
renderManager.tracePool());
// Warm the map palette on the main thread so off-thread dithering never triggers its first init.
MapColorPalette.size();
getLogger().info("PixelPics renderer initialized with resource pack assets.");
}
@Override
public void onDisable() {
SurvivalRecipes.unregister();
if (renderManager != null) {
renderManager.shutdown();
renderManager = null;
}
if (resourcePack != null) {
resourcePack.close();
resourcePack = null;
}
}
/** The renderer, or {@code null} when no resource pack is available (degraded mode). */
public DefaultScreenRenderer getScreenRenderer() {
return this.screenRenderer;
}
/** The render queue/limiter; always available after {@code onEnable}. */
public RenderManager getRenderManager() {
return this.renderManager;
}
public static Main getInstance() {
return instance;
}
}
@@ -0,0 +1,36 @@
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/<ns>/font/<path>.json}, e.g. for {@code minecraft:default} or {@code minecraft:include/space}. */
public static String font(ResourceLocation id) {
return String.format("assets/%s/font/%s.json", id.namespace(), id.path());
}
}
@@ -0,0 +1,41 @@
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 <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,190 @@
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 java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.Material;
import org.bukkit.block.data.BlockData;
/**
* 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;
private final AssetReader reader;
/** The asset reader this registry resolves models with (shared by item-model resolution). */
public AssetReader reader() {
return reader;
}
public BlockModelRegistry(AssetReader reader, TextureCache textures) {
this.reader = reader;
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 ArrayList<>();
AverageColor.Accumulator avgColor = new AverageColor.Accumulator();
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()) {
avgColor.add(baked.averageColor());
}
}
if (!elements.isEmpty()) {
return new ResolvedModel(elements, avgColor.average(0xFF7F7F7F), 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()) {
faces[d.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, tintIndex);
}
Element cube = new Element(new double[] {0, 0, 0}, new double[] {1, 1, 1}, faces, null, -1, 0, false);
int avg = AverageColor.of(tex);
return new ResolvedModel(List.of(cube), avg, transparency, reflection, true, true);
}
}
@@ -0,0 +1,40 @@
package eu.mhsl.minecraft.pixelpics.assets;
import java.util.LinkedHashMap;
import java.util.Map;
import org.bukkit.block.data.BlockData;
/**
* 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,115 @@
package eu.mhsl.minecraft.pixelpics.assets;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.bukkit.block.data.BlockData;
/**
* 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,16 @@
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,30 @@
package eu.mhsl.minecraft.pixelpics.assets;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* Thread-safe cache of ARGB pixel grids keyed by {@code K}. Subclasses implement {@link #load} to
* produce the grid (or {@code null} on failure); a failure is cached as a sentinel so it is not
* retried, and {@link #get} returns empty for it. The grid is indexed {@code [y][x]}, top-left origin.
*/
public abstract class ImageCache<K> {
private static final int[][] MISSING = new int[0][0];
private final Map<K, int[][]> cache = new ConcurrentHashMap<>();
/** Loads the pixel grid for {@code key}, or returns {@code null} if it cannot be loaded. */
protected abstract int[][] load(K key);
public Optional<int[][]> get(K key) {
int[][] result = cache.computeIfAbsent(key, this::loadOrSentinel);
return result == MISSING ? Optional.empty() : Optional.of(result);
}
private int[][] loadOrSentinel(K key) {
int[][] loaded = load(key);
return loaded == null ? MISSING : loaded;
}
}
@@ -0,0 +1,244 @@
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<>();
AverageColor.Accumulator avgColor = new AverageColor.Accumulator();
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;
}
Element modelElement = new Element(from, to, faces, rotOrigin, rotAxis, rotAngle, 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);
AxisRotation na = rotateAxisX(rotAxis);
rotAxis = na.axis();
if (na.flip()) 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);
AxisRotation na = rotateAxisY(rotAxis);
rotAxis = na.axis();
if (na.flip()) rotAngle = -rotAngle;
}
}
// Without uvlock, textures turn with the model (vanilla): keep the model-space element so the
// intersectors can sample it exactly. With uvlock they stay world-aligned (the rotated faces).
Element unrotated = (!variant.uvlock() && (xSteps != 0 || ySteps != 0)) ? modelElement : null;
baked.add(new Element(from, to, faces, rotOrigin, rotAxis, rotAngle, rescale, unrotated, xSteps, ySteps));
// Accumulate average color from the element's face textures.
for (Face f : faces) {
if (f == null) continue;
avgColor.add(AverageColor.of(f.texture));
}
}
return new BakedGeometry(baked, avgColor.average(0xFF7F7F7F), !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) {
// Vanilla BlockElement.uvsByFace (u/v in 0..1).
case DOWN -> new double[] {f[0], 1 - t[2], t[0], 1 - f[2]};
case UP -> new double[] {f[0], f[2], t[0], t[2]};
case NORTH -> new double[] {1 - t[0], 1 - t[1], 1 - f[0], 1 - f[1]};
case SOUTH -> new double[] {f[0], 1 - t[1], t[0], 1 - f[1]};
case WEST -> new double[] {f[2], 1 - t[1], t[2], 1 - f[1]};
case EAST -> new double[] {1 - t[2], 1 - t[1], 1 - f[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) {
// 90 deg clockwise viewed from above (vanilla), see rotatePointY.
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) {
// Vanilla blockstate "y": clockwise seen from above (north -> east, south -> west), i.e. a rotation
// by -90° about +Y: (dx, dz) -> (-dz, dx). Must match Direction.rotateY used for the faces.
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.
// {@code flip} indicates the angle sign should invert.
private record AxisRotation(int axis, boolean flip) {}
private AxisRotation rotateAxisY(int axis) {
// Clockwise Y rotation maps +x -> +z and +z -> -x; the y axis is unchanged.
return switch (axis) {
case 0 -> new AxisRotation(2, false); // x -> z
case 2 -> new AxisRotation(0, true); // z -> -x
default -> new AxisRotation(axis, false);
};
}
private AxisRotation rotateAxisX(int axis) {
// X rotation maps y<->z; the x axis is unchanged.
return switch (axis) {
case 1 -> new AxisRotation(2, true); // y -> z
case 2 -> new AxisRotation(1, false); // z -> y
default -> new AxisRotation(axis, false);
};
}
}
@@ -0,0 +1,68 @@
package eu.mhsl.minecraft.pixelpics.assets;
import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto;
import java.util.HashMap;
import java.util.List;
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;
}
/** A texture value: a plain reference, or the {@code sprite} of a 26.x texture object; null otherwise. */
public static String textureRef(com.google.gson.JsonElement v) {
if (v == null) return null;
if (v.isJsonPrimitive()) return v.getAsString();
if (v.isJsonObject() && v.getAsJsonObject().has("sprite")) {
return v.getAsJsonObject().get("sprite").getAsString();
}
return null;
}
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<>();
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) {
dto.textures.forEach((key, value) -> {
String ref = textureRef(value);
if (ref != null) textures.put(key, ref);
});
}
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,77 @@
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,72 @@
package eu.mhsl.minecraft.pixelpics.assets;
import java.awt.image.BufferedImage;
import java.io.InputStream;
import java.net.URI;
import java.net.URL;
import java.net.URLConnection;
import java.util.Optional;
import javax.imageio.ImageIO;
/**
* 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 extends ImageCache<String> {
@Override
public Optional<int[][]> get(String url) {
if (url == null || url.isEmpty()) return Optional.empty();
return super.get(url);
}
@Override
protected int[][] load(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 null;
return toModern(img);
} catch (Exception e) {
return null;
}
}
/** 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,51 @@
package eu.mhsl.minecraft.pixelpics.assets;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import javax.imageio.ImageIO;
/**
* 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 extends ImageCache<ResourceLocation> {
private final ResourcePack pack;
public TextureCache(ResourcePack pack) {
this.pack = pack;
}
@Override
protected int[][] load(ResourceLocation id) {
var bytes = pack.read(AssetPaths.texture(id));
if (bytes.isEmpty()) return null;
BufferedImage img;
try {
img = ImageIO.read(new ByteArrayInputStream(bytes.get()));
} catch (Exception e) {
return null;
}
if (img == null) return null;
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,7 @@
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,51 @@
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,38 @@
package eu.mhsl.minecraft.pixelpics.assets.dto;
import com.google.gson.JsonElement;
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;
// Values are texture refs ("block/stone", "#side") or, since 26.x, objects {"sprite": …, "force_translucent": …}.
public Map<String, JsonElement> 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,56 @@
package eu.mhsl.minecraft.pixelpics.assets.font;
import java.util.Map;
/**
* A Minecraft bitmap font assembled from the resource pack's {@code font/default.json} providers
* (the {@code bitmap} and {@code space} providers; {@code unihex}/{@code legacy_unicode}/{@code ttf}
* are out of scope). Maps codepoints to {@link Glyph}s and supplies advance widths, enough to rasterize
* a line of text. Immutable after construction.
*/
public final class BitmapFont {
private final Map<Integer, Glyph> glyphs;
private final Map<Integer, Integer> spaceAdvances;
private final int maxAscent;
private final int maxDescent;
public BitmapFont(Map<Integer, Glyph> glyphs, Map<Integer, Integer> spaceAdvances, int maxAscent, int maxDescent) {
this.glyphs = glyphs;
this.spaceAdvances = spaceAdvances;
this.maxAscent = maxAscent;
this.maxDescent = maxDescent;
}
/** The glyph for a codepoint, or null when no bitmap provider supplies it (e.g. CJK via unihex). */
public Glyph glyph(int codepoint) {
return glyphs.get(codepoint);
}
/** Horizontal advance for a codepoint (font px): a space-provider advance, a glyph advance, else 0. */
public int advance(int codepoint) {
Integer sp = spaceAdvances.get(codepoint);
if (sp != null) return sp;
Glyph g = glyphs.get(codepoint);
return g != null ? g.advance() : 0;
}
/** Largest baseline offset across all glyphs (font px) — the common baseline for a rendered line. */
public int maxAscent() {
return maxAscent;
}
/** Largest below-baseline extent across all glyphs (font px). */
public int maxDescent() {
return maxDescent;
}
/** Total vertical extent of one line (ascent + descent, font px). */
public int lineHeight() {
return maxAscent + maxDescent;
}
public boolean isEmpty() {
return glyphs.isEmpty();
}
}
@@ -0,0 +1,180 @@
package eu.mhsl.minecraft.pixelpics.assets.font;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import eu.mhsl.minecraft.pixelpics.assets.AssetPaths;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import eu.mhsl.minecraft.pixelpics.assets.ResourcePack;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.logging.Logger;
/**
* Builds a {@link BitmapFont} from a resource pack's {@code font/default.json}. Resolves {@code reference}
* providers recursively, parses {@code bitmap} providers into per-codepoint {@link Glyph}s (cell size and
* per-glyph pixel width derived from the PNG), and {@code space} providers into advance overrides.
* {@code unihex}/{@code legacy_unicode}/{@code ttf} providers are skipped (CJK etc. render as gaps).
*
* <p>Provider order is priority order (first match wins, matching vanilla): an earlier provider's glyph is
* never overwritten by a later one.
*/
public final class FontLoader {
private FontLoader() {}
/** Loads {@code minecraft:default}. Returns an empty font (no glyphs) when the pack has no font assets. */
public static BitmapFont load(ResourcePack pack, TextureCache textures, Logger log) {
Builder b = new Builder(pack, textures, log);
b.loadFont(ResourceLocation.parse("minecraft:default"), new HashSet<>());
return b.build();
}
private static final class Builder {
private final ResourcePack pack;
private final TextureCache textures;
private final Logger log;
private final Map<Integer, Glyph> glyphs = new HashMap<>();
private final Map<Integer, Integer> spaceAdvances = new HashMap<>();
private int maxAscent = 0;
private int maxDescent = 0;
Builder(ResourcePack pack, TextureCache textures, Logger log) {
this.pack = pack;
this.textures = textures;
this.log = log;
}
BitmapFont build() {
return new BitmapFont(glyphs, spaceAdvances, maxAscent, maxDescent);
}
void loadFont(ResourceLocation id, Set<String> visited) {
String path = AssetPaths.font(id);
if (!visited.add(path)) return; // cycle guard
Optional<byte[]> bytes = pack.read(path);
// Packs built from just the textures/models lack font/*.json — use the bundled vanilla font
// definitions (tiny JSON; the glyph sheets still come from the pack's textures/font/).
if (bytes.isEmpty()) bytes = bundled(path);
if (bytes.isEmpty()) return;
JsonObject root;
try {
root = JsonParser.parseString(new String(bytes.get(), StandardCharsets.UTF_8))
.getAsJsonObject();
} catch (Exception e) {
if (log != null) log.warning("PixelPics: failed to parse font " + path + ": " + e.getMessage());
return;
}
JsonArray providers = root.getAsJsonArray("providers");
if (providers == null) return;
for (JsonElement el : providers) {
if (!el.isJsonObject()) continue;
provider(el.getAsJsonObject(), visited);
}
}
private Optional<byte[]> bundled(String path) {
try (java.io.InputStream in = FontLoader.class.getResourceAsStream("/fallback/" + path)) {
if (in == null) return Optional.empty();
if (log != null) log.info("PixelPics: resource pack has no " + path + ", using the bundled copy.");
return Optional.of(in.readAllBytes());
} catch (java.io.IOException e) {
return Optional.empty();
}
}
private void provider(JsonObject p, Set<String> visited) {
String type = p.has("type") ? p.get("type").getAsString() : "";
switch (type) {
case "reference" -> {
if (p.has("id")) loadFont(ResourceLocation.parse(p.get("id").getAsString()), visited);
}
case "space" -> space(p);
case "bitmap" -> bitmap(p);
default -> {
/* unihex, legacy_unicode, ttf, … — out of scope */
}
}
}
private void space(JsonObject p) {
JsonObject advances = p.getAsJsonObject("advances");
if (advances == null) return;
for (Map.Entry<String, JsonElement> e : advances.entrySet()) {
String key = e.getKey();
if (key.isEmpty()) continue;
int cp = key.codePointAt(0);
spaceAdvances.putIfAbsent(cp, e.getValue().getAsInt());
}
}
private void bitmap(JsonObject p) {
if (!p.has("file") || !p.has("chars")) return;
int height = p.has("height") ? p.get("height").getAsInt() : 8;
int ascent = p.has("ascent") ? p.get("ascent").getAsInt() : 7;
String file = p.get("file").getAsString();
if (file.endsWith(".png")) file = file.substring(0, file.length() - 4);
int[][] tex = textures.get(ResourceLocation.parse(file)).orElse(null);
if (tex == null || tex.length == 0) {
if (log != null) log.warning("PixelPics: font bitmap missing: " + file);
return;
}
int imgH = tex.length;
int imgW = tex[0].length;
JsonArray rows = p.getAsJsonArray("chars");
int nRows = rows.size();
if (nRows == 0) return;
int nCols = rows.get(0)
.getAsString()
.codePointCount(0, rows.get(0).getAsString().length());
if (nCols == 0) return;
int cellW = imgW / nCols;
int cellH = imgH / nRows;
if (cellW == 0 || cellH == 0) return;
double scale = height / (double) cellH;
for (int r = 0; r < nRows; r++) {
String row = rows.get(r).getAsString();
int col = 0;
for (int ci = 0; ci < row.length(); ) {
int cp = row.codePointAt(ci);
ci += Character.charCount(cp);
int thisCol = col++;
if (cp == 0 || thisCol >= nCols) continue; // 0x0000 = empty slot
if (glyphs.containsKey(cp)) continue; // earlier provider wins
int srcX = thisCol * cellW;
int srcY = r * cellH;
int glyphPx = rightmostOpaqueColumn(tex, srcX, srcY, cellW, cellH) + 1;
if (glyphPx <= 0) continue; // blank glyph (handled by space provider)
int advance = (int) (glyphPx * scale + 0.5) + 1;
glyphs.put(cp, new Glyph(tex, srcX, srcY, cellW, cellH, glyphPx, height, ascent, advance));
maxAscent = Math.max(maxAscent, ascent);
maxDescent = Math.max(maxDescent, height - ascent);
}
}
}
/** Rightmost column index (0-based, relative to the cell) containing an opaque pixel, or -1 if blank. */
private static int rightmostOpaqueColumn(int[][] tex, int srcX, int srcY, int cellW, int cellH) {
int last = -1;
for (int x = 0; x < cellW; x++) {
for (int y = 0; y < cellH; y++) {
int argb = tex[srcY + y][srcX + x];
if (((argb >>> 24) & 0xFF) != 0) {
last = x;
break;
}
}
}
return last;
}
}
}
@@ -0,0 +1,19 @@
package eu.mhsl.minecraft.pixelpics.assets.font;
/**
* A single bitmap-font glyph: a rectangular cell {@code [srcX, srcX+cellW) × [srcY, srcY+cellH)} inside
* its provider PNG ({@code tex}, ARGB top-left), the number of opaque pixel columns to actually draw
* ({@code glyphPx}, measured to the rightmost non-transparent column), the rendered {@code height} and
* {@code ascent} (baseline offset from the top, font px) and the horizontal {@code advance} (font px).
*
* <p>Glyphs from different providers may declare different cell sizes and ascents (e.g. ascii 8px/ascent
* 7 vs accented 12px/ascent 10); rendering aligns them on a common baseline so umlauts and Latin mix.
*/
public record Glyph(
int[][] tex, int srcX, int srcY, int cellW, int cellH, int glyphPx, int height, int ascent, int advance) {
/** Vertical pixels below the baseline (font px). */
public int descent() {
return height - ascent;
}
}
@@ -0,0 +1,47 @@
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));
}
/** Mutable accumulator that averages a set of already-opaque ARGB colors (e.g. per-face/per-variant). */
public static final class Accumulator {
private long r, g, b;
private int count;
public void add(int argb) {
r += ColorUtil.red(argb);
g += ColorUtil.green(argb);
b += ColorUtil.blue(argb);
count++;
}
/** Opaque average of the added colors, or {@code fallback} if none were added. */
public int average(int fallback) {
if (count == 0) return fallback;
return ColorUtil.argb(0xFF, (int) (r / count), (int) (g / count), (int) (b / count));
}
}
}
@@ -0,0 +1,65 @@
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;
// up/down unchanged
default -> d;};
}
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;
// east/west unchanged
default -> d;};
}
return d;
}
}
@@ -0,0 +1,89 @@
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;
// Blockstate rotation WITHOUT uvlock: the model-space element and the 90° steps (x first, then y) that
// turn it into this world-space one. Textures stay bound to the model, so intersectors hit the
// unrotated element with the inverse-rotated ray. Null/0 when unrotated or uvlocked.
public final Element unrotated;
public final int xSteps;
public final int ySteps;
public Element(
double[] from,
double[] to,
Face[] faces,
double[] rotOrigin,
int rotAxis,
double rotAngleRad,
boolean rescale) {
this(from, to, faces, rotOrigin, rotAxis, rotAngleRad, rescale, null, 0, 0);
}
public Element(
double[] from,
double[] to,
Face[] faces,
double[] rotOrigin,
int rotAxis,
double rotAngleRad,
boolean rescale,
Element unrotated,
int xSteps,
int ySteps) {
this.unrotated = unrotated;
this.xSteps = xSteps;
this.ySteps = ySteps;
this.from = from;
this.to = to;
this.faces = faces;
this.rotOrigin = rotOrigin;
this.rotAxis = rotAxis;
this.rotAngleRad = rotAngleRad;
this.rescale = rescale;
}
/** Model space -> world: vanilla blockstate rotation (x steps, then y steps; clockwise about the centre). */
public static double[] rotate(double x, double y, double z, int xSteps, int ySteps, boolean point) {
double c = point ? 0.5 : 0;
double dx = x - c, dy = y - c, dz = z - c;
for (int i = 0; i < xSteps; i++) {
double ny = dz, nz = -dy; // (dy, dz) -> (dz, -dy)
dy = ny;
dz = nz;
}
for (int i = 0; i < ySteps; i++) {
double nx = -dz, nz = dx; // (dx, dz) -> (-dz, dx)
dx = nx;
dz = nz;
}
return new double[] {dx + c, dy + c, dz + c};
}
/** World -> model space: the inverse of {@link #rotate}. */
public static double[] unrotate(double x, double y, double z, int xSteps, int ySteps, boolean point) {
double[] p = rotate(x, y, z, 0, (4 - ySteps) % 4, point);
return rotate(p[0], p[1], p[2], (4 - xSteps) % 4, 0, point);
}
public boolean isAxisAligned() {
return rotAxis < 0 || rotAngleRad == 0.0;
}
}
@@ -0,0 +1,62 @@
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.clamp(px, 0, w - 1);
py = Math.clamp(py, 0, h - 1);
return texture[py][px];
}
}
@@ -0,0 +1,36 @@
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;
}
}
@@ -0,0 +1,99 @@
package eu.mhsl.minecraft.pixelpics.commands;
import eu.mhsl.minecraft.pixelpics.survival.PhotoService;
import eu.mhsl.minecraft.pixelpics.utils.MapManager;
import java.util.List;
import java.util.Set;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
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 (args.length >= 1 && args[0].equalsIgnoreCase("cleanup")) {
return cleanup(sender, args);
}
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;
// Debug shortcut: render a photo from the player's view without needing a camera or film.
if (!player.hasPermission("pixelpic.admin")) {
player.sendActionBar(
Component.text("Dafür fehlt dir die Berechtigung — nutze eine Kamera.", NamedTextColor.RED));
return true;
}
PhotoService.takePhoto(player);
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,31 @@
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 java.awt.image.BufferedImage;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.server.MapInitializeEvent;
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,159 @@
package eu.mhsl.minecraft.pixelpics.render;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinWorkerThread;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Bukkit;
import org.bukkit.plugin.Plugin;
/**
* Server-friendly scheduling for photo renders. Bounds CPU use and protects the server tick by:
* <ul>
* <li><b>Per player:</b> at most one photo in the system at a time (running or queued).</li>
* <li><b>Globally:</b> at most {@code maxConcurrent} photos render at once, with up to
* {@code queueSize} more waiting; further requests are rejected.</li>
* <li><b>CPU:</b> the ray tracing runs on a dedicated {@link ForkJoinPool} of {@code threads}
* low-priority worker threads ({@link Thread#MIN_PRIORITY}), so it tends to use spare CPU and
* never fans out across every core like the default common pool would.</li>
* </ul>
*
* <p>Reserve a slot with {@link #tryReserve(UUID)}; if accepted, run the heavy work via
* {@link #dispatch} (which releases the slot on completion) — or call {@link #release(UUID)} if you
* bail out before dispatching.
*/
public final class RenderManager {
public enum Outcome {
ACCEPTED,
USER_BUSY,
QUEUE_FULL
}
private final Plugin plugin;
private final ForkJoinPool tracePool;
private final ThreadPoolExecutor dispatcher;
private final ScheduledExecutorService watchdog;
private final Set<UUID> activeUsers = ConcurrentHashMap.newKeySet();
private final AtomicInteger inFlight = new AtomicInteger();
private final int capacity;
private final long timeoutMillis;
public RenderManager(Plugin plugin, int threads, int maxConcurrent, int queueSize, int timeoutSeconds) {
this.plugin = plugin;
this.capacity = maxConcurrent + queueSize;
this.timeoutMillis = Math.max(1L, timeoutSeconds) * 1000L;
this.tracePool = new ForkJoinPool(threads, lowPriorityForkJoinFactory(), null, false);
this.dispatcher = new ThreadPoolExecutor(
maxConcurrent,
maxConcurrent,
30,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(), // capacity is enforced by inFlight, not this queue
lowPriorityThreadFactory());
this.dispatcher.allowCoreThreadTimeOut(true);
this.watchdog = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "PixelPics-render-watchdog");
thread.setDaemon(true);
return thread;
});
}
/** The bounded, low-priority pool the ray tracer must run its parallel work on. */
public ForkJoinPool tracePool() {
return tracePool;
}
/** Atomically reserves a slot for {@code user}. On {@code ACCEPTED}, you must later release it. */
public Outcome tryReserve(UUID user) {
if (!activeUsers.add(user)) return Outcome.USER_BUSY;
if (inFlight.incrementAndGet() > capacity) {
inFlight.decrementAndGet();
activeUsers.remove(user);
return Outcome.QUEUE_FULL;
}
return Outcome.ACCEPTED;
}
/** Releases a reservation made by {@link #tryReserve}. Safe to call once per accepted reserve. */
public void release(UUID user) {
if (activeUsers.remove(user)) {
inFlight.decrementAndGet();
}
}
/**
* Runs {@code work} off the main thread (honoring the global concurrency limit), then delivers the
* result back on the main thread via {@code onSuccess}, or {@code onFailure} if it fails, returns
* null, or exceeds the configured timeout. The {@link AtomicBoolean} handed to {@code work} is set
* once the deadline passes — {@code work} should poll it and bail out cooperatively. Releases the
* caller's reservation when done. Requires a prior {@link #tryReserve} success.
*/
public <T> void dispatch(UUID user, Function<AtomicBoolean, T> work, Consumer<T> onSuccess, Runnable onFailure) {
dispatcher.execute(() -> {
AtomicBoolean cancelled = new AtomicBoolean(false);
ScheduledFuture<?> deadline =
watchdog.schedule(() -> cancelled.set(true), timeoutMillis, TimeUnit.MILLISECONDS);
T result = null;
boolean ok = false;
try {
result = work.apply(cancelled);
ok = result != null && !cancelled.get();
} catch (Throwable t) {
plugin.getLogger().warning("Render job failed: " + t);
} finally {
deadline.cancel(false);
if (cancelled.get()) {
plugin.getLogger()
.warning(
"Render for " + user + " aborted after " + (timeoutMillis / 1000) + "s (timeout).");
}
release(user);
}
T finalResult = result;
boolean finalOk = ok;
Bukkit.getScheduler().runTask(plugin, () -> {
if (finalOk) onSuccess.accept(finalResult);
else onFailure.run();
});
});
}
public void shutdown() {
watchdog.shutdownNow();
dispatcher.shutdownNow();
tracePool.shutdownNow();
}
private static ForkJoinPool.ForkJoinWorkerThreadFactory lowPriorityForkJoinFactory() {
return pool -> {
ForkJoinWorkerThread thread = ForkJoinPool.defaultForkJoinWorkerThreadFactory.newThread(pool);
thread.setName("PixelPics-trace-" + thread.getPoolIndex());
thread.setPriority(Thread.MIN_PRIORITY);
thread.setDaemon(true);
return thread;
};
}
private static ThreadFactory lowPriorityThreadFactory() {
AtomicInteger counter = new AtomicInteger();
return runnable -> {
Thread thread = new Thread(runnable, "PixelPics-render-" + counter.incrementAndGet());
thread.setDaemon(true);
thread.setPriority(Thread.MIN_PRIORITY);
return thread;
};
}
}
@@ -0,0 +1,148 @@
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});
}
/** From a column-major 4x4 matrix (JOML / OpenGL layout). */
public static Affine fromColumnMajor(double[] m) {
return new Affine(
new double[] {m[0], m[4], m[8], m[1], m[5], m[9], m[2], m[6], m[10]},
new double[] {m[12], m[13], m[14]});
}
/** Rotation of a (normalised on the fly) quaternion {@code (x, y, z, w)}. */
public static Affine fromQuaternion(double x, double y, double z, double w) {
double n = Math.sqrt(x * x + y * y + z * z + w * w);
if (n < 1e-9) return identity();
x /= n;
y /= n;
z /= n;
w /= n;
return new Affine(
new double[] {
1 - 2 * (y * y + z * z),
2 * (x * y - z * w),
2 * (x * z + y * w),
2 * (x * y + z * w),
1 - 2 * (x * x + z * z),
2 * (y * z - x * w),
2 * (x * z - y * w),
2 * (y * z + x * w),
1 - 2 * (x * x + y * y)
},
new double[] {0, 0, 0});
}
/** Right-handed rotation by {@code a} radians about the unit axis {@code (x, y, z)} (Rodrigues). */
public static Affine rotAxis(double x, double y, double z, double a) {
double c = Math.cos(a), s = Math.sin(a), k = 1 - c;
return new Affine(
new double[] {
c + x * x * k,
x * y * k - z * s,
x * z * k + y * s,
y * x * k + z * s,
c + y * y * k,
y * z * k - x * s,
z * x * k - y * s,
z * y * k + x * s,
c + z * z * k
},
new double[] {0, 0, 0});
}
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 scale(double sx, double sy, double sz) {
return new Affine(new double[] {sx, 0, 0, 0, sy, 0, 0, 0, sz}, new double[] {0, 0, 0});
}
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,110 @@
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 "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,78 @@
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
SignText frontText, // sign front-side text; null when not a sign or blank
SignText backText // sign back-side text; null when not a sign or blank
) {
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) {}
/**
* One side of a sign: up to four plain-text lines plus the resolved colours. {@code fillArgb} is the
* glyph fill colour (already glow/darken-adjusted); {@code outlineArgb} is the 8-directional outline
* drawn only when {@code glowing}.
*/
public record SignText(List<String> lines, int fillArgb, int outlineArgb, boolean glowing) {}
}
@@ -0,0 +1,75 @@
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,158 @@
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 implements EntityBaker<DecorationState> {
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;
}
@Override
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 RenderedEntity.of(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.
// `wood` is never null here (orElse(leather) and leather is non-null past the guard above).
Face[] woodFace = new Face[6];
woodFace[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), woodFace, 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 RenderedEntity.of(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;
};
}
}
@@ -0,0 +1,53 @@
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,10 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
/**
* Bakes a captured state {@code S} (mob, block-entity or decoration) into a {@link RenderedEntity},
* or returns {@code null} when it has no renderable geometry. Lets {@link EntityScene} treat all three
* render paths uniformly.
*/
public interface EntityBaker<S> {
RenderedEntity bake(S state);
}
@@ -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,109 @@
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.ElementIntersector;
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 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 inAxis = -1, outAxis = -1;
boolean inNeg = false, outNeg = 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];
// Moving towards +a the ray enters through the min ("negative") face and leaves through the
// max face. Decided by the direction, not by comparing t1/t2: for a zero-thickness box
// (wing membranes, fins) both are equal and the comparison can't tell the sides apart.
boolean forward = inv > 0;
double tNear = ((forward ? cube.from[a] : cube.to[a]) - o[a]) * inv;
double tFar = ((forward ? cube.to[a] : cube.from[a]) - o[a]) * inv;
if (tNear > tmin) {
tmin = tNear;
inAxis = a;
inNeg = forward;
}
if (tFar < tmax) {
tmax = tFar;
outAxis = a;
outNeg = !forward;
}
if (tmin > tmax + EPS) return null;
}
if (inAxis < 0 || tmax < EPS) return null;
// Vanilla draws entity models without back-face culling: a ray looking through a transparent
// texel of the entry face still sees the inside of the far face (and both sides of a plane).
if (tmin >= EPS) {
FaceHit hit = sample(cube, o, d, tmin, inAxis, inNeg, ox, oy, oz, dx, dy, dz);
if (hit != null) return hit;
}
return outAxis < 0 ? null : sample(cube, o, d, tmax, outAxis, outNeg, ox, oy, oz, dx, dy, dz);
}
private static FaceHit sample(
EntityCube cube,
double[] o,
double[] d,
double t,
int axis,
boolean neg,
double ox,
double oy,
double oz,
double dx,
double dy,
double dz) {
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]);
// Vanilla face-UV orientation, shared by block-model faces and entity box-UV (see BoxUv).
double[] st = ElementIntersector.faceUv(dir, fx, fy, fz);
double s = st[0], tt = st[1];
int color = face.sample(s, tt);
if (!ColorUtil.opaque(color)) 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();
// A far/back face is seen from inside: shade it as facing the viewer.
if (normal.getX() * dx + normal.getY() * dy + normal.getZ() * dz > 0) normal.multiply(-1);
return new FaceHit(t, world, normal, 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,169 @@
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 where the vanilla dump's default texture (entities.<type>.texture) is missing
// or not the right default: variant-only mobs whose renderer default is a variant/"missingno", the dump's
// piglin_brute (= piglin), players/mannequins, and non-mob entities whose renderer texture isn't captured.
// Everything else comes from the dump (see CemBaker#resolveTexture).
private static final Map<String, String> TEX_OVERRIDE = Map.<String, String>ofEntries(
Map.entry("cow", "entity/cow/cow_temperate"),
Map.entry("player", "entity/player/wide/steve"),
Map.entry("ender_dragon", "entity/enderdragon/dragon"),
Map.entry("tropical_fish", "entity/fish/tropical_a"),
Map.entry("piglin_brute", "entity/piglin/piglin_brute"),
Map.entry("axolotl", "entity/axolotl/axolotl_wild"),
Map.entry("wind_charge", "entity/projectiles/wind_charge"),
Map.entry("mannequin", "entity/player/wide/steve"),
Map.entry("leash_knot", "entity/lead_knot/lead_knot"),
Map.entry("evoker_fangs", "entity/illager/evoker_fangs"),
Map.entry("shulker_bullet", "entity/shulker/spark"),
Map.entry("llama_spit", "entity/llama/llama_spit"),
Map.entry("trident", "entity/trident/trident"),
Map.entry("cushion", "entity/cushion/white_cushion"),
Map.entry("breeze_wind_charge", "entity/projectiles/wind_charge"),
Map.entry("chest_minecart", "entity/minecart/minecart"),
Map.entry("furnace_minecart", "entity/minecart/minecart"),
Map.entry("hopper_minecart", "entity/minecart/minecart"),
Map.entry("tnt_minecart", "entity/minecart/minecart"),
Map.entry("spawner_minecart", "entity/minecart/minecart"),
Map.entry("command_block_minecart", "entity/minecart/minecart"));
/** Ordered texture-path candidates; the baker uses the first that loads. */
public static List<ResourceLocation> textureCandidates(String typeKey, String variant) {
List<ResourceLocation> list = new ArrayList<>();
if (typeKey.endsWith("_boat") || typeKey.endsWith("_raft")) {
String wood = typeKey.substring(0, typeKey.length() - 5);
boolean chest = wood.endsWith("_chest");
if (chest) wood = wood.substring(0, wood.length() - "_chest".length());
list.add(ResourceLocation.parse("entity/" + (chest ? "chest_boat/" : "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;
}
/** How many leading {@link #textureCandidates} entries are type/variant-specific (not generic guesses). */
public static int specificCandidateCount(String typeKey, String variant) {
if (typeKey.endsWith("_boat") || typeKey.endsWith("_raft")) return 1;
return (variant != null ? variantPaths(typeKey, variant).size() : 0)
+ (TEX_OVERRIDE.containsKey(typeKey) ? 1 : 0);
}
/**
* 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) {
return switch (typeKey) {
case "cat" -> List.of("entity/cat/cat_" + v);
case "axolotl" -> List.of("entity/axolotl/axolotl_" + v);
case "wolf" -> List.of("entity/wolf/wolf_" + v, "entity/wolf/wolf");
case "horse" -> List.of("entity/horse/horse_" + HORSE_COLOR.getOrDefault(v, v));
case "llama" -> List.of("entity/llama/llama_" + v);
case "cow" -> List.of("entity/cow/cow_" + v);
case "pig" -> List.of("entity/pig/pig_" + v);
case "chicken" -> List.of("entity/chicken/chicken_" + v);
case "frog" -> List.of("entity/frog/frog_" + v);
case "panda" -> List.of(v.equals("normal") ? "entity/panda/panda" : "entity/panda/panda_" + v);
case "fox" -> List.of(v.equals("snow") ? "entity/fox/fox_snow" : "entity/fox/fox");
case "parrot" -> List.of("entity/parrot/parrot_" + PARROT_COLOR.getOrDefault(v, v));
case "rabbit" -> List.of("entity/rabbit/rabbit_" + RABBIT_TYPE.getOrDefault(v, v));
case "mooshroom" -> List.of("entity/cow/mooshroom_" + v);
case "shulker" -> List.of("entity/shulker/shulker_" + v);
case "cushion" -> List.of("entity/cushion/" + v + "_cushion");
case "tropical_fish" -> List.of("entity/fish/tropical_" + (tropicalPattern(v) >= 6 ? "b" : "a"));
case "zombie_nautilus" -> List.of(
v.equals("warm") ? "entity/nautilus/zombie_nautilus_coral" : "entity/nautilus/zombie_nautilus");
case "copper_golem" -> List.of(
v.equals("unaffected")
? "entity/copper_golem/copper_golem"
: "entity/copper_golem/copper_golem_" + 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 -> List.of();
};
}
/** Vanilla tropical-fish patterns in id order: 0-5 use body shape A (small), 6-11 shape B (large). */
private static final List<String> TROPICAL_PATTERNS = List.of(
"kob",
"sunstreak",
"snooper",
"dasher",
"brinely",
"spotty",
"flopper",
"stripey",
"glitter",
"blockfish",
"betty",
"clayfish");
/** Index of a tropical-fish pattern key (0-11), or -1 if unknown. */
public static int tropicalPattern(String pattern) {
return pattern == null ? -1 : TROPICAL_PATTERNS.indexOf(pattern);
}
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,102 @@
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;
private static volatile boolean warned;
public EntityScene(
List<EntityState> states,
CemBaker baker,
List<BlockEntityState> blockEntities,
BlockEntityBaker beBaker,
List<DecorationState> decorations,
DecorationBaker decoBaker,
List<ObjectState> objects,
ObjectBaker objectBaker) {
this.entities = new ArrayList<>(states.size() + blockEntities.size() + decorations.size() + objects.size());
addAll(states, baker);
addAll(blockEntities, beBaker);
addAll(decorations, decoBaker);
addAll(objects, objectBaker);
}
/** Bakes each state and keeps the ones with geometry. A null baker (path disabled) is skipped. */
private <S> void addAll(List<S> states, EntityBaker<S> baker) {
if (baker == null) return;
for (S s : states) {
RenderedEntity e;
try {
e = baker.bake(s);
} catch (RuntimeException ex) {
// One unexpected entity/asset must never fail the whole photo: skip just this one.
if (!warned) {
warned = true;
java.util.logging.Logger.getLogger("PixelPics")
.warning("PixelPics: skipped an entity that failed to bake: " + ex);
}
continue;
}
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,56 @@
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,
boolean baby,
double width,
double height,
boolean player,
String skinUrl,
boolean slim,
String variant, // texture-selecting variant key (e.g. "ashen", "warm", "tabby"); for villagers the biome type,
// or null
int tint, // ARGB multiplier for tintable layers (sheep wool); 0 = none
double sizeScale, // extra model scale (slime/magma-cube size); 1.0 = default
String profession, // villager profession key (e.g. "farmer", "librarian", "none"), or null
int villagerLevel, // villager profession level 1-5 (badge tier); 0 = none/unknown
String markings, // horse coat markings style (e.g. "white", "whitefield", "whitedots", "blackdots"), or null
boolean saddle, // horse/donkey/mule is saddled
boolean chest, // donkey/mule/llama is carrying a chest
String bodyEquip, // BODY-slot equipment asset (horse armor material, llama carpet, harness, wolf armor,
// nautilus armor) or "trader_llama"; null = none
Equipment equipment, // worn humanoid armor (players, armor stands, zombies, …); null = none/not a wearer
boolean invisible, // entity is invisible -> render only its equipment (like vanilla), not the body
float pitch, // entity pitch (deg) — orients projectiles (arrows, tridents, skulls)
int bodyDye, // ARGB dye of dyeable BODY equipment (leather horse armor, wolf armor); 0 = none
int tint2, // second tint (tropical-fish pattern colour); 0 = none
boolean passenger, // riding something (vanilla sitting pose)
ItemSpec rightHand, // item in the RIGHT hand (main or off hand, resolved by handedness); null = empty
ItemSpec leftHand, // item in the LEFT hand; null = empty
ItemSpec headItem // non-armor item worn in the head slot (pumpkin, skull, block, …); null = none
) {
/** Worn armor (4 slots) of a humanoid wearer; any field may be null. */
public record Equipment(EquipPiece head, EquipPiece chest, EquipPiece legs, EquipPiece feet) {
public boolean isEmpty() {
return head == null && chest == null && legs == null && feet == null;
}
}
/** One worn armor piece. */
public record EquipPiece(
String asset, // equipment asset id, e.g. "diamond", "leather", "elytra", "turtle_scute"
int dyeColor, // ARGB tint for dyeable (leather) armor; 0 = undyed/not dyeable
String trimMaterial, // armor-trim material key (e.g. "diamond"); null = no trim
String trimPattern, // armor-trim pattern key (e.g. "coast"); null = no trim
boolean glint // item is enchanted -> render the enchantment glint
) {}
}
@@ -0,0 +1,11 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
import org.bukkit.block.data.BlockData;
/**
* An item to render (held, worn on the head, in an item display, dropped): its item key, the block data
* when the item places a block (block-model geometry), the block-entity state when that block is
* block-entity rendered (chests, heads, banners, …), and whether it is enchanted. Captured on the main
* thread; immutable afterwards.
*/
public record ItemSpec(String id, BlockData block, BlockEntityState blockEntity, boolean glint) {}
@@ -0,0 +1,28 @@
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;
/** Optional modern per-face UV, indexed by {@code Direction.ordinal()}: {u, v, w, h} texels (h/w may be negative for flips). Null = use box-UV. */
public final double[][] faceUv;
public ModelCube(double[] origin, double[] size, double inflate, double[] uv, boolean mirror) {
this(origin, size, inflate, uv, mirror, null);
}
public ModelCube(double[] origin, double[] size, double inflate, double[] uv, boolean mirror, double[][] faceUv) {
this.origin = origin;
this.size = size;
this.inflate = inflate;
this.uv = uv;
this.mirror = mirror;
this.faceUv = faceUv;
}
}
@@ -0,0 +1,174 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.assets.font.BitmapFont;
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.ItemGeometry;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.SignTextRasterizer;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import java.util.ArrayList;
import java.util.List;
/**
* Bakes {@link ObjectState}s (display entities, dropped items, falling blocks, primed TNT, minecart
* contents) into world-space {@link EntityCube}s: block content reuses the resolved BLOCK MODEL elements
* (any block, any version — straight from the resource pack), items use their block model or a flat
* sprite, text is rasterized with the pack's bitmap font (as for signs).
*/
public final class ObjectBaker implements EntityBaker<ObjectState> {
private static final int MIN_BACKGROUND_ALPHA =
0x80; // translucent text backgrounds can't be drawn: skip faint ones
// Vanilla leash colours (LeashRenderer): brown, alternating with a darker shade per segment.
private static final int LEASH_LIGHT = 0xFF7F6650, LEASH_DARK = 0xFF59473A;
private static final int LEASH_SEGMENTS = 24;
private static final double LEASH_HALF_WIDTH = 0.025;
private final ItemGeometry items;
private final BitmapFont font;
public ObjectBaker(ItemGeometry items, BitmapFont font) {
this.items = items;
this.font = font;
}
@Override
public RenderedEntity bake(ObjectState s) {
List<EntityCube> cubes = new ArrayList<>();
switch (s.kind()) {
case BLOCK -> {
items.bakeBlock(s.block(), s.toWorld(), cubes);
if (cubes.isEmpty()) items.bakeBlockEntity(s.blockEntity(), s.toWorld(), cubes);
}
case ITEM -> {
Affine at = s.toWorld();
if (s.dropped()) {
double lift = 0.1 + 0.25 * items.displayScaleY(s.item(), s.context());
at = Affine.translation(0, lift, 0).mul(at);
}
items.bake(s.item(), s.context(), false, at, cubes);
}
case TEXT -> addText(s, cubes);
case LEASH -> addLeash(s.leash(), cubes);
}
return cubes.isEmpty() ? null : RenderedEntity.of(cubes);
}
// --- leash ---------------------------------------------------------------------------------------
/**
* Vanilla LeashRenderer curve: 24 segments from the leashed entity to the holder, horizontal position
* linear, height {@code dy·(t² + t)/2} (sags below the straight line), alternating light/dark brown.
*/
private static void addLeash(double[] p, List<EntityCube> cubes) {
if (p == null) return;
double dx = p[3] - p[0], dy = p[4] - p[1], dz = p[5] - p[2];
double[] prev = {p[0], p[1], p[2]};
for (int i = 1; i <= LEASH_SEGMENTS; i++) {
double t = (double) i / LEASH_SEGMENTS;
double[] cur = {p[0] + dx * t, p[1] + dy * (t * t + t) * 0.5, p[2] + dz * t};
int color = (i % 2 == 0) ? LEASH_LIGHT : LEASH_DARK;
EntityCube seg = segment(prev, cur, color);
if (seg != null) cubes.add(seg);
prev = cur;
}
}
/** A thin square rope box from a to b. */
private static EntityCube segment(double[] a, double[] b, int argb) {
double[] d = {b[0] - a[0], b[1] - a[1], b[2] - a[2]};
double len = Math.sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]);
if (len < 1e-6) return null;
for (int i = 0; i < 3; i++) d[i] /= len;
double[] ref = Math.abs(d[1]) < 0.9 ? new double[] {0, 1, 0} : new double[] {1, 0, 0};
double[] u = cross(ref, d);
normalize(u);
double[] v = cross(d, u);
Affine frame = new Affine(
new double[] {u[0], v[0], d[0], u[1], v[1], d[1], u[2], v[2], d[2]}, new double[] {a[0], a[1], a[2]});
int[][] solid = {{argb}};
Face[] f = new Face[6];
for (int i = 0; i < 6; i++) f[i] = new Face(solid, 0, 0, 1, 1, 0, -1);
double w = LEASH_HALF_WIDTH;
return new EntityCube(new double[] {-w, -w, 0}, new double[] {w, w, len + w}, f, frame);
}
private static double[] cross(double[] a, double[] b) {
return new double[] {a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]};
}
private static void normalize(double[] v) {
double l = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
if (l > 0) for (int i = 0; i < 3; i++) v[i] /= l;
}
// --- text ------------------------------------------------------------------------------------
private void addText(ObjectState s, List<EntityCube> cubes) {
if (font == null || font.isEmpty()) return;
List<String> lines = wrap(s.lines(), Math.max(1, s.lineWidth()));
boolean blank = lines.stream().allMatch(String::isBlank);
int[][] text = blank ? null : SignTextRasterizer.rasterize(lines, font, s.textArgb(), 0, false);
int w = text == null ? 0 : text[0].length, h = text == null ? 0 : text.length;
if (text != null && s.shadow()) text = withShadow(text, s.textArgb());
// Text block: bottom edge on the origin, horizontally centred, front facing +Z.
double x0 = -w / 2.0, x1 = x0 + (text == null ? 0 : text[0].length);
double y1 = h, y0 = text == null ? 0 : h - text.length;
if (text != null) {
cubes.add(
new EntityCube(new double[] {x0, y0, 0.05}, new double[] {x1, y1, 0.05}, card(text), s.toWorld()));
}
int bg = s.backgroundArgb();
if ((bg >>> 24) >= MIN_BACKGROUND_ALPHA && (w > 0 || !s.lines().isEmpty())) {
int[][] solid = {{bg | 0xFF000000}};
cubes.add(new EntityCube(
new double[] {-w / 2.0 - 1, -1, 0}, new double[] {w / 2.0 + 1, h, 0}, card(solid), s.toWorld()));
}
}
/**
* Faces of a planar (z-thickness 0) card: front (+Z) and back show the SAME texel at each point (the
* back is seen mirrored, as in vanilla), so a ray through a transparent texel misses both sides.
*/
private static Face[] card(int[][] tex) {
Face[] f = new Face[6];
f[Direction.SOUTH.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, -1);
f[Direction.NORTH.ordinal()] = new Face(tex, 1, 0, 0, 1, 0, -1);
return f;
}
/** Greedy word wrap to the display's line width (vanilla wraps at {@code line_width} font px). */
private List<String> wrap(List<String> lines, int maxWidth) {
List<String> out = new ArrayList<>();
for (String line : lines) {
StringBuilder cur = new StringBuilder();
int curW = 0;
for (String word : line.split(" ", -1)) {
int ww = SignTextRasterizer.lineWidth(word, font);
int sp = cur.isEmpty() ? 0 : font.advance(' ');
if (!cur.isEmpty() && curW + sp + ww > maxWidth) {
out.add(cur.toString());
cur.setLength(0);
curW = 0;
sp = 0;
}
if (sp > 0) cur.append(' ');
cur.append(word);
curW += sp + ww;
}
out.add(cur.toString());
}
return out;
}
/** Vanilla text shadow: the glyphs again, 1px right/down, at a quarter of the brightness. */
private static int[][] withShadow(int[][] text, int argb) {
int h = text.length, w = text[0].length;
int[][] out = new int[h + 1][w + 1];
int shadow = ColorUtil.shade(argb, 0.25);
for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) if ((text[y][x] >>> 24) > 0) out[y + 1][x + 1] = shadow;
for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) if ((text[y][x] >>> 24) > 0) out[y][x] = text[y][x];
return out;
}
}
@@ -0,0 +1,68 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
import java.util.List;
import org.bukkit.block.data.BlockData;
/**
* Snapshot of a non-mob object that renders block/item/text content instead of an entity model:
* block/item/text DISPLAY entities, dropped items, falling blocks, primed TNT and the block inside a
* minecart, and leash ropes. Captured on the main thread with its complete local→world transform already resolved
* (entity position, yaw/pitch or billboard facing, display transformation), so baking is pure geometry.
*
* <p>Local frames: BLOCK = the block cube {@code [0,1]³}; ITEM = an item centred on the origin
* ({@code [-0.5,0.5]³}); TEXT = font pixels (x right, y up, text front facing +Z), already scaled.
*/
public record ObjectState(
Kind kind,
Affine toWorld,
BlockData block, // BLOCK: the block (block model geometry)
BlockEntityState blockEntity, // BLOCK: set when the block is block-entity rendered (chest, banner, …)
ItemSpec item, // ITEM: the item
String context, // ITEM: display context ("none", "fixed", "ground", "head", …)
boolean dropped, // ITEM: a dropped item entity (vanilla lifts it by 0.1 + 0.25·groundScaleY)
double[] leash, // LEASH: rope points {fromX,fromY,fromZ, toX,toY,toZ} (entity -> holder)
List<String> lines, // TEXT: explicit lines (wrapped by the baker to lineWidth)
int lineWidth, // TEXT: wrap width in font px
int textArgb, // TEXT: text colour (alpha = text opacity)
int backgroundArgb, // TEXT: background colour (0 = none)
boolean shadow) { // TEXT: drop shadow
public static ObjectState block(Affine toWorld, BlockData block, BlockEntityState blockEntity) {
return new ObjectState(
Kind.BLOCK, toWorld, block, blockEntity, null, null, false, null, List.of(), 0, 0, 0, false);
}
public static ObjectState item(Affine toWorld, ItemSpec item, String context, boolean dropped) {
return new ObjectState(Kind.ITEM, toWorld, null, null, item, context, dropped, null, List.of(), 0, 0, 0, false);
}
public static ObjectState text(
Affine toWorld, List<String> lines, int lineWidth, int textArgb, int backgroundArgb, boolean shadow) {
return new ObjectState(
Kind.TEXT,
toWorld,
null,
null,
null,
null,
false,
null,
lines,
lineWidth,
textArgb,
backgroundArgb,
shadow);
}
public static ObjectState leash(double[] points) {
return new ObjectState(
Kind.LEASH, Affine.identity(), null, null, null, null, false, points, List.of(), 0, 0, 0, false);
}
public enum Kind {
BLOCK,
ITEM,
TEXT,
LEASH
}
}
@@ -0,0 +1,29 @@
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;
}
/** Wraps baked world-space cubes, computing the overall broad-phase AABB from their per-cube AABBs. */
public static RenderedEntity of(List<EntityCube> cubes) {
double[] min = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE};
double[] max = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE};
for (EntityCube c : cubes) {
for (int a = 0; a < 3; a++) {
if (c.aabbMin[a] < min[a]) min[a] = c.aabbMin[a];
if (c.aabbMax[a] > max[a]) max[a] = c.aabbMax[a];
}
}
return new RenderedEntity(cubes, min, max);
}
}
@@ -0,0 +1,120 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
/**
* In-place ARGB pixel-grid compositing shared by the entity/block-entity bakers: deep copy, tint and
* src-over alpha overlay. Grids are indexed {@code [y][x]}; all operations assume the ARGB layout used
* throughout the renderer.
*/
public final class TextureOps {
private TextureOps() {}
/** A row-by-row copy so callers can tint/overlay without mutating the cached source texture. */
public static int[][] deepCopy(int[][] src) {
int[][] out = new int[src.length][];
for (int y = 0; y < src.length; y++) out[y] = src[y].clone();
return out;
}
/** Multiplies every non-transparent texel by an ARGB tint (RGB channels only), in place. */
public static void tint(int[][] tex, int argb) {
int tr = (argb >> 16) & 0xFF, tg = (argb >> 8) & 0xFF, tb = argb & 0xFF;
for (int[] row : tex) {
for (int x = 0; x < row.length; x++) {
int p = row[x];
int a = (p >>> 24) & 0xFF;
if (a == 0) continue;
int r = ((p >> 16) & 0xFF) * tr / 255, g = ((p >> 8) & 0xFF) * tg / 255, b = (p & 0xFF) * tb / 255;
row[x] = (a << 24) | (r << 16) | (g << 8) | b;
}
}
}
/** Standard src-over alpha composite of {@code src} onto {@code dst} (clipped to the overlap), in place. */
public static void overlay(int[][] dst, int[][] src) {
int h = Math.min(dst.length, src.length);
for (int y = 0; y < h; y++) {
int w = Math.min(dst[y].length, src[y].length);
for (int x = 0; x < w; x++) {
int sp = src[y][x];
int sa = (sp >>> 24) & 0xFF;
if (sa == 0) continue;
if (sa == 255) {
dst[y][x] = sp;
continue;
}
int dp = dst[y][x];
int da = (dp >>> 24) & 0xFF;
int outA = sa + da * (255 - sa) / 255;
int sr = (sp >> 16) & 0xFF, sg = (sp >> 8) & 0xFF, sb = sp & 0xFF;
int dr = (dp >> 16) & 0xFF, dg = (dp >> 8) & 0xFF, db = dp & 0xFF;
int r = (sr * sa + dr * da * (255 - sa) / 255) / Math.max(1, outA);
int g = (sg * sa + dg * da * (255 - sa) / 255) / Math.max(1, outA);
int b = (sb * sa + db * da * (255 - sa) / 255) / Math.max(1, outA);
dst[y][x] = (outA << 24) | (r << 16) | (g << 8) | b;
}
}
}
/**
* Palette-swap recolouring used for armor trims: each (non-transparent) texel of {@code tex} is matched
* to its nearest entry in the grayscale source palette {@code from} (8-colour key, RGB distance) and
* replaced by the same-index colour from the material palette {@code to}, preserving alpha. The vanilla
* trim patterns are authored in those 8 key shades, so this reproduces the runtime palette swap.
*/
public static void paletteSwap(int[][] tex, int[] from, int[] to) {
int n = Math.min(from.length, to.length);
if (n == 0) return;
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 pr = (p >> 16) & 0xFF, pg = (p >> 8) & 0xFF, pb = p & 0xFF;
int best = 0, bestD = Integer.MAX_VALUE;
for (int i = 0; i < n; i++) {
int c = from[i];
int dr = pr - ((c >> 16) & 0xFF), dg = pg - ((c >> 8) & 0xFF), db = pb - (c & 0xFF);
int d = dr * dr + dg * dg + db * db;
if (d < bestD) {
bestD = d;
best = i;
}
}
row[x] = (a << 24) | (to[best] & 0xFFFFFF);
}
}
}
/**
* Static approximation of the (animated) enchantment glint: a single glint frame is tiled over the
* texture and additively blended — tinted by {@code glintColor} and scaled by {@code strength} and the
* frame's luminance — onto the texture's opaque texels only (so it sheens the armor, not the
* transparent background). Not animated; just a frozen purple shimmer.
*/
public static void addGlint(int[][] tex, int[][] glint, int glintColor, double strength) {
if (glint == null || glint.length == 0 || glint[0].length == 0) return;
int gh = glint.length, gw = glint[0].length;
int cr = (glintColor >> 16) & 0xFF, cg = (glintColor >> 8) & 0xFF, cb = glintColor & 0xFF;
for (int y = 0; y < tex.length; y++) {
for (int x = 0; x < tex[y].length; x++) {
int p = tex[y][x];
int a = (p >>> 24) & 0xFF;
if (a == 0) continue; // only sheen actual armor pixels
int gp = glint[y % gh][x % gw];
int ga = (gp >>> 24) & 0xFF;
int gl = Math.max((gp >> 16) & 0xFF, Math.max((gp >> 8) & 0xFF, gp & 0xFF));
double inten = strength * (ga / 255.0) * (gl / 255.0);
if (inten <= 0) continue;
int r = clamp(((p >> 16) & 0xFF) + (int) (cr * inten));
int g = clamp(((p >> 8) & 0xFF) + (int) (cg * inten));
int b = clamp((p & 0xFF) + (int) (cb * inten));
tex[y][x] = (a << 24) | (r << 16) | (g << 8) | b;
}
}
}
private static int clamp(int v) {
return v < 0 ? 0 : (Math.min(v, 255));
}
}
@@ -0,0 +1,313 @@
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.font.BitmapFont;
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityModels;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity;
import eu.mhsl.minecraft.pixelpics.render.entity.TextureOps;
import java.util.ArrayList;
import java.util.HashSet;
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 implements EntityBaker<BlockEntityState> {
private static final double SIGN_SCALE = 0.6666667; // vanilla SignRenderer model scale
// Sign-text placement (model px in the board's local frame; calibrated against the test harness).
// The text block is scaled to fill the board's writable area (W×H) so few-line signs are rendered as
// large as possible (legibility on low-res maps), capped so a single line isn't blown up absurdly.
private static final double SIGN_TEXT_W = 22.0, SIGN_TEXT_H = 11.0, SIGN_TEXT_CAP = 0.5; // board 24x12
private static final double HANG_TEXT_W = 13.0, HANG_TEXT_H = 9.0, HANG_TEXT_CAP = 0.42; // board 14x10
private static final double SIGN_TEXT_CY = 20.0; // board Y∈[14,26], centre 20
private static final double HANGING_TEXT_CY = 5.0; // board Y∈[0,10], centre 5
private static final double BOARD_FRONT_Z = 1.0; // both boards: front/back faces at Z=±1
private static final double TEXT_Z_EPS = 0.05; // lift the text just off the board face
private static final double TEXT_THICK = 0.5; // slab thickness so the ray test never degenerates
private final CemModelLoader models;
private final TextureCache textures;
private final SkinCache skins;
private final BitmapFont font;
/** The sign/text font (shared with text-display rendering). */
public BitmapFont font() {
return font;
}
public BlockEntityBaker(CemModelLoader models, TextureCache textures, SkinCache skins, BitmapFont font) {
this.models = models;
this.textures = textures;
this.skins = skins;
this.font = font;
}
/** Returns the baked block-entity, or null when it has no model/texture (then nothing renders). */
@Override
public RenderedEntity bake(BlockEntityState s) {
List<Layer> layers = layers(s);
// 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 = layers.isEmpty() ? null : models.get(modelName(s, layers.getFirst().tex));
// Since 26.x signs (and beds) are real block models (textures under block/, no entity/signs/): the
// board then comes from the block-model pipeline and only the sign TEXT is added here.
boolean signKind = s.kind() == BlockEntityState.Kind.SIGN
|| s.kind() == BlockEntityState.Kind.WALL_SIGN
|| s.kind() == BlockEntityState.Kind.HANGING_SIGN;
if (model == null && !signKind) 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<>();
if (model != null)
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())));
}
}
addSignText(s, placement.mul(pre), cubes);
return cubes.isEmpty() ? null : RenderedEntity.of(cubes);
}
/**
* Appends flat text quads in front of (and behind) the sign board, rasterized from the captured
* {@link BlockEntityState.SignText}. Built in the board's model-px frame and transformed by the same
* {@code placement·pre} as the CEM board, so the text rides exactly on the board.
*
* <p>The CEM sign model faces north by default and is aimed with {@code rotY(180−facingDeg)} where
* {@code facingDeg} is the direction the sign's FRONT faces — so the model's −Z (NORTH) face ends up
* being the front the player reads, and +Z (SOUTH) is the back. Each face uses an unflipped UV: the
* intersector already mirrors NORTH relative to SOUTH ({@code s=1−fx} vs {@code s=fx}), so both read
* left-to-right when viewed from outside.
*/
private void addSignText(BlockEntityState s, Affine toWorld, List<EntityCube> cubes) {
if (font == null || font.isEmpty()) return;
boolean hanging = s.kind() == BlockEntityState.Kind.HANGING_SIGN;
boolean sign = hanging || s.kind() == BlockEntityState.Kind.SIGN || s.kind() == BlockEntityState.Kind.WALL_SIGN;
if (!sign) return;
double cy = hanging ? HANGING_TEXT_CY : SIGN_TEXT_CY;
double tw = hanging ? HANG_TEXT_W : SIGN_TEXT_W;
double th = hanging ? HANG_TEXT_H : SIGN_TEXT_H;
double cap = hanging ? HANG_TEXT_CAP : SIGN_TEXT_CAP;
addSide(s.frontText(), Direction.NORTH, tw, th, cap, cy, toWorld, cubes);
addSide(s.backText(), Direction.SOUTH, tw, th, cap, cy, toWorld, cubes);
}
private void addSide(
BlockEntityState.SignText t,
Direction faceDir,
double tw,
double th,
double cap,
double cy,
Affine toWorld,
List<EntityCube> cubes) {
if (t == null) return;
int[][] bmp = SignTextRasterizer.rasterize(
trimBlankLines(t.lines()), font, t.fillArgb(), t.outlineArgb(), t.glowing());
if (bmp == null) return;
// Scale so the text block fills the board's writable area, whichever dimension binds, capped.
double fpm = Math.min(cap, Math.min(tw / bmp[0].length, th / bmp.length));
double blockW = bmp[0].length * fpm;
double blockH = bmp.length * fpm;
double z0, z1;
if (faceDir == Direction.NORTH) { // front: −Z face, text sits just past −1
z1 = -BOARD_FRONT_Z - TEXT_Z_EPS;
z0 = z1 - TEXT_THICK;
} else { // back: +Z face, text sits just past +1
z0 = BOARD_FRONT_Z + TEXT_Z_EPS;
z1 = z0 + TEXT_THICK;
}
Face[] faces = new Face[6];
faces[faceDir.ordinal()] = new Face(bmp, 0, 0, 1, 1, 0, -1);
double[] from = {-blockW / 2, cy - blockH / 2, z0};
double[] to = {blockW / 2, cy + blockH / 2, z1};
cubes.add(new EntityCube(from, to, faces, toWorld));
}
/** Drops leading/trailing blank lines so short text fills the board; keeps interior blanks. */
private static List<String> trimBlankLines(List<String> lines) {
int lo = 0, hi = lines.size();
while (lo < hi && lines.get(lo).isBlank()) lo++;
while (hi > lo && lines.get(hi - 1).isBlank()) hi--;
return lines.subList(lo, hi);
}
/** 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 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 HashSet<>(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 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 = TextureOps.deepCopy(base);
if (s.baseColorArgb() != 0) TextureOps.tint(out, s.baseColorArgb());
for (BlockEntityState.BannerPattern pat : s.patterns()) {
int[][] mask = textures.get(ResourceLocation.parse("entity/banner/" + pat.patternKey()))
.orElse(null);
if (mask == null) continue;
int[][] dyed = TextureOps.deepCopy(mask);
TextureOps.tint(dyed, pat.colorArgb());
TextureOps.overlay(out, dyed);
}
return out;
}
}
@@ -0,0 +1,652 @@
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.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.BoxUv;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityModels;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec;
import eu.mhsl.minecraft.pixelpics.render.entity.ModelCube;
import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity;
import eu.mhsl.minecraft.pixelpics.render.entity.TextureOps;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* Bakes an {@link EntityState} into world-space cubes.
*
* <p>Primary source: the VANILLA models dumped from the client jar ({@code tools/VanillaModelDump.java},
* "absolute" models). They are posed by vanilla's own {@code setupAnim} (idle, adult, peaceful), carry
* vanilla part visibility, real baby models, per-variant models, and the renderer's layer models
* (outer layers, saddles, body equipment, humanoid armor, elytra). Model choice is generic and
* name-based ({@code <type>}, {@code <type>/baby}, {@code <type>/<…variant…>}, {@code <type>/<Layer>.…}),
* so mobs of a newer dump work without code changes. Vanilla models are placed with their feet at the
* entity position (model y=0), exactly like vanilla — floating mobs keep their height.
*
* <p>Fallback: the CEM template set (rest geometry only, ground-snapped, no layers) for a type the dump
* lacks.
*/
public final class CemBaker implements EntityBaker<EntityState> {
// Textured render layers that are conditional in vanilla (charged creeper, tamed collar, riptide, …) or
// handled explicitly below; every OTHER textured layer of the dump is drawn automatically.
private static final Set<String> CONDITIONAL_LAYERS = Set.of(
"CreeperPowerLayer",
"CatCollarLayer",
"WolfCollarLayer",
"RopesLayer",
"ArrowLayer",
"BeeStingerLayer",
"SpinAttackEffectLayer",
"Deadmau5EarsLayer",
"CapeLayer",
"ParrotOnShoulderLayer",
"WitherArmorLayer",
"SheepWoolLayer",
"SheepWoolUndercoatLayer",
"TropicalFishPatternLayer",
"LlamaDecorLayer",
"HumanoidArmorLayer",
"WingsLayer",
"VillagerProfessionLayer");
// LivingEntityRenderer at yaw 0: rotY(180)·scale(-1,-1,1)·translate(0,-1.501,0) — used when the dump has no
// captured transform for a type.
private static final Affine DEFAULT_LIVING_TRANSFORM =
new Affine(new double[] {1, 0, 0, 0, -1, 0, 0, 0, -1}, new double[] {0, 1.501, 0});
private final CemModelLoader models;
private final TextureCache textures;
private final SkinCache skins;
private final Map<String, List<String>> subModelCache = new ConcurrentHashMap<>();
private volatile ItemGeometry items; // held/head items; null = not drawn
/** Enables held-item and head-item rendering. */
public void setItemGeometry(ItemGeometry items) {
this.items = items;
}
public CemModelLoader models() {
return models;
}
public CemBaker(CemModelLoader models, TextureCache textures, SkinCache skins) {
this.models = models;
this.textures = textures;
this.skins = skins;
}
/**
* The chosen body model: the model, its name, the base type its layers live under, and whether it IS a
* baby model ({@code model.absolute()} tells vanilla dump from legacy CEM template).
*/
private record Choice(CemModelLoader.CemModel model, String name, String base, boolean babyModel) {}
@Override
public RenderedEntity bake(EntityState s) {
Choice ch = chooseModel(s);
CemModelLoader.CemModel model = ch == null ? null : ch.model;
boolean vanilla = model != null && model.absolute();
String base = ch == null ? s.typeKey() : ch.base;
int[][] tex = resolveTexture(s, ch);
// Same-UV overlays are composited straight into the base texture (no extra geometry -> no Z-fighting).
if (base.equals("villager") || base.equals("zombie_villager")) {
tex = compositeVillager(s, tex, base, ch != null && ch.babyModel);
} else if (s.markings() != null && tex != null) {
tex = TextureOps.deepCopy(tex); // horse coat markings
String m = "entity/horse/horse_markings_" + s.markings();
overlayIfPresent(tex, ch != null && ch.babyModel ? m + "_baby" : m);
}
if (s.typeKey().equals("tropical_fish") && tex != null && s.tint() != 0) {
tex = TextureOps.deepCopy(tex);
TextureOps.tint(tex, s.tint()); // white base body tinted with the body colour
}
boolean invisible = s.invisible();
if (!invisible && (model == null || tex == null)) return fallbackBox(s, tex);
CemModelLoader.EntityInfo info = models.entityInfo(s.typeKey());
if (info == null) info = models.entityInfo(base);
boolean halfBaby = s.baby() && (ch == null || !ch.babyModel); // no dedicated baby model -> 0.5x adult
double extra = (halfBaby ? 0.5 : 1.0) * s.sizeScale();
Affine pre;
Affine place;
if (vanilla) {
// Vanilla placement: the renderer's own captured pose (incl. its scale / offsets), yaw and pitch
// applied the way that renderer applies them. CEM px -> Java model units: F·T(0,-24,0)/16.
boolean captured = info != null && info.transform() != null;
Affine t0 = captured ? Affine.fromColumnMajor(info.transform()) : DEFAULT_LIVING_TRANSFORM;
pre = t0.mul(Affine.scale(1 / 16.0)).mul(CemGeometry.CEM_TO_JAVA);
int yawSign = captured ? info.yawSign() : -1;
place = Affine.translation(s.x(), s.y(), s.z())
.mul(Affine.scale(extra))
.mul(Affine.rotY(yawSign * Math.toRadians(s.bodyYaw())));
if (info != null && info.pitchAxis() != null && s.pitch() != 0) {
double[] a = info.pitchAxis();
place = place.mul(Affine.rotAxis(a[0], a[1], a[2], Math.toRadians(s.pitch())));
}
} else {
pre = Affine.scale(extra / 16.0);
place = null; // legacy: computed after baking (ground snap)
}
// Parts that vanilla hides by default but this entity's state shows.
Set<String> show = new HashSet<>();
if (s.typeKey().equals("armor_stand") && "arms".equals(s.variant()))
show.addAll(Set.of("right_arm", "left_arm"));
if (s.chest()) show.addAll(Set.of("left_chest", "right_chest"));
// Body: baked even when invisible — not drawn, but it yields the part frames (and legacy ground snap).
Pose pose = new Pose(pre, vanilla ? selectPose(s, info) : Map.of());
Map<String, Affine> frames = new java.util.HashMap<>();
List<CemGeometry.Baked> body = (model != null && tex != null)
? CemGeometry.bakePosed(model, tex, pre, show, pose.parts, frames)
: List.of();
List<CemGeometry.Baked> baked = new ArrayList<>();
if (!invisible) {
baked.addAll(body);
if (vanilla) addVanillaLayers(s, ch, pose, show, baked);
}
// Humanoid armor / elytra: drawn even when invisible (vanilla "floating armor").
if (vanilla && s.equipment() != null) addArmorLayers(s, ch, pose, baked);
List<EntityCube> itemCubes = new ArrayList<>();
if (vanilla && items != null) addItems(s, frames, isIllager(info), itemCubes);
if (baked.isEmpty() && itemCubes.isEmpty()) return invisible ? null : fallbackBox(s, tex);
// Legacy CEM models: snap the lowest point to the ground (their rest geometry has no reliable origin).
if (place == null) {
List<CemGeometry.Baked> ref = (invisible && !body.isEmpty()) ? body : baked;
double minY = Double.MAX_VALUE;
for (CemGeometry.Baked b : ref) minY = Math.min(minY, b.minWorldY());
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())));
for (EntityCube c : itemCubes) cubes.add(new EntityCube(c.from, c.to, c.faces, place.mul(c.toWorld)));
return RenderedEntity.of(cubes);
}
// --- poses & items -------------------------------------------------------------------------------
/** The model→pre-space transform plus the pose overrides shared by every vanilla model of one entity. */
private record Pose(Affine pre, Map<String, CemModelLoader.PartPose> parts) {
List<CemGeometry.Baked> bake(CemModelLoader.CemModel m, int[][] tex, Set<String> show) {
return CemGeometry.bakePosed(m, tex, pre, show, parts, null);
}
}
/**
* Picks the vanilla pose variant for the entity's state: sitting ({@code passenger}), holding items
* ({@code item_r}/{@code item_l}), illagers' crossbow hold. Falls back to fewer flags when the exact
* combination has no own variant (identical to a simpler pose).
*/
private static Map<String, CemModelLoader.PartPose> selectPose(EntityState s, CemModelLoader.EntityInfo info) {
if (info == null || info.poses().isEmpty()) return Map.of();
List<String> flags = new ArrayList<>();
if (isIllager(info)) {
if (isCrossbow(s.rightHand()) || isCrossbow(s.leftHand())) flags.add("arms_crossbow_hold");
} else {
if (s.rightHand() != null) flags.add("item_r");
if (s.leftHand() != null) flags.add("item_l");
}
if (s.passenger()) flags.add("passenger");
// Exact combination first, then without the item flags (e.g. zombies' arms ignore held items).
List<List<String>> tries = new ArrayList<>();
tries.add(flags);
tries.add(flags.stream()
.filter(f -> f.equals("passenger") || f.startsWith("arms_"))
.toList());
tries.add(flags.stream().filter(f -> f.equals("passenger")).toList());
for (List<String> t : tries) {
if (t.isEmpty()) continue;
Map<String, CemModelLoader.PartPose> p =
info.poses().get(String.join("+", t.stream().sorted().toList()));
if (p != null) return p;
}
return Map.of();
}
private static boolean isCrossbow(ItemSpec i) {
return i != null && i.id().equals("crossbow");
}
/**
* Held items (vanilla ItemInHandLayer: hand frame · rotX(-90) · rotY(180) · T(±1/16, 0.125, -0.625),
* third-person display context), crossed-arms items (villagers/witches: root · T(0, 0.4, -0.4) ·
* rotX(180), ground context) and head items (CustomHeadLayer: blocks/items at 0.625 with the "head"
* context, skulls at 1.1875 through the block-entity model). Frames are Java-model frames (blocks).
*/
private void addItems(EntityState s, Map<String, Affine> frames, boolean illager, List<EntityCube> out) {
// Frames exist only for drawn parts: a visible arm holds the item; with crossed arms only villager-
// like models show it (illagers hide held items while their arms are crossed).
String d = CemGeometry.DRAWN;
boolean crossed = frames.containsKey(d + "arms") && !frames.containsKey(d + "right_arm") && !illager;
for (int side = 0; side < 2; side++) {
boolean left = side == 1;
ItemSpec item = left ? s.leftHand() : s.rightHand();
if (item == null) continue;
Affine arm = frames.get(left ? "left_arm" : "right_arm");
if (arm != null && !crossed && !(illager && !frames.containsKey(d + "right_arm"))) {
Affine x = arm.mul(CemGeometry.JAVA_LOCAL)
.mul(Affine.rotX(Math.toRadians(-90)))
.mul(Affine.rotY(Math.PI))
.mul(Affine.translation((left ? -1 : 1) / 16.0, 0.125, -0.625));
items.bake(item, left ? "thirdperson_lefthand" : "thirdperson_righthand", left, x, out);
} else if (crossed && !left && frames.containsKey("root")) {
Affine x = frames.get("root")
.mul(CemGeometry.JAVA_LOCAL)
.mul(Affine.translation(0, 0.4, -0.4))
.mul(Affine.rotX(Math.PI));
items.bake(item, "ground", false, x, out);
}
}
ItemSpec head = s.headItem();
Affine h = frames.get("head");
if (head != null && h != null) {
Affine hj = h.mul(CemGeometry.JAVA_LOCAL);
if (head.blockEntity() != null && head.blockEntity().kind() == BlockEntityState.Kind.HEAD) {
items.bakeBlockEntity(
head.blockEntity(),
hj.mul(Affine.scale(1.1875, -1.1875, -1.1875)).mul(Affine.translation(-0.5, 0, -0.5)),
out);
} else {
// Snow golems wear their pumpkin a bit lower (SnowGolemHeadLayer) than CustomHeadLayer items.
double dy = s.typeKey().equals("snow_golem") ? -0.34375 : -0.25;
Affine x = hj.mul(Affine.translation(0, dy, 0))
.mul(Affine.rotY(Math.PI))
.mul(Affine.scale(0.625, -0.625, -0.625));
items.bake(head, "head", false, x, out);
}
}
}
private static boolean isIllager(CemModelLoader.EntityInfo info) {
return info != null && info.poses().keySet().stream().anyMatch(k -> k.startsWith("arms_"));
}
// --- model choice --------------------------------------------------------------------------------
/**
* Picks the body model: a baby model for babies when one exists, a variant-specific model (cow/pig/
* chicken temperature models, salmon size, pufferfish puff state, …) matched by name token, else the
* type's main model; falls back to the legacy CEM template mapping.
*/
private Choice chooseModel(EntityState s) {
String type = s.typeKey();
if (s.player()) type = s.slim() && models.hasVanilla("player_slim") ? "player_slim" : "player";
String legacy = EntityModels.cemModel(type);
// Types whose dump entry only exists under another type (mannequin -> player) reuse that base.
String base = models.hasVanilla(type) || !subModels(type).isEmpty() ? type : legacy;
if (base.equals("player") && s.slim() && models.hasVanilla("player_slim")) base = "player_slim";
String variant = s.variant() == null ? null : s.variant().toLowerCase(Locale.ROOT);
if (type.equals("tropical_fish") && variant != null) {
variant = EntityModels.tropicalPattern(variant) >= 6 ? "large" : "small"; // pattern -> body shape
}
if (s.baby() && !models.hasVanilla(base + "/baby") && models.hasVanilla(base + "/smallModel")) {
return vanilla(base + "/smallModel", base, true); // renderers with a "small" instead of a baby model
}
if (s.baby()) {
String n = findSub(base, variant, true);
if (n == null && models.hasVanilla(base + "/baby")) n = base + "/baby";
if (n != null) return vanilla(n, base, true);
}
String n = findSub(base, variant, false);
if (n != null) return vanilla(n, base, false);
if (models.hasVanilla(base)) return vanilla(base, base, false);
// Legacy CEM-template model (rest pose, ground-snapped).
CemModelLoader.CemModel lm = models.get(legacy);
return lm == null ? null : new Choice(lm, legacy, base, false);
}
private Choice vanilla(String name, String base, boolean baby) {
return new Choice(models.vanilla(name), name, base, baby);
}
/** A body sub-model of {@code base} whose name contains the variant token (or "normal"), baby or adult. */
private String findSub(String base, String variant, boolean baby) {
List<String> subs = subModels(base);
if (subs.isEmpty()) return null;
String[] tokens = variant == null ? new String[] {"normal"} : new String[] {variant, "normal"};
for (String tok : tokens) {
for (String n : subs) {
String lower = n.substring(base.length() + 1).toLowerCase(Locale.ROOT);
boolean isBaby = lower.contains("baby");
if (isBaby != baby) continue;
// Segment match by prefix: "cold" ~ models.cold.adultModel, "large" ~ largeSalmonModel.
for (String seg : lower.split("[./_]")) if (seg.startsWith(tok)) return n;
}
}
return null;
}
/** Body sub-models of a type (variant/baby/size models), excluding render-layer models. */
private List<String> subModels(String base) {
return namesCached("B:" + base, n -> {
if (!n.startsWith(base + "/")) return false;
String rest = n.substring(base.length() + 1);
return !rest.contains("Layer.") && !rest.startsWith("equip.") && !rest.contains("waterPatch");
});
}
/** Render-layer models of a type: {@code <base>/<LayerClass>.<field>}. */
private List<String> layerModels(String base) {
return namesCached(
"L:" + base,
n -> n.startsWith(base + "/") && n.substring(base.length() + 1).contains("Layer."));
}
/** Equipment-layer models of a type: {@code <base>/equip.<layerType>.<field>}. */
private List<String> equipModels(String base) {
return namesCached("E:" + base, n -> n.startsWith(base + "/equip."));
}
private List<String> namesCached(String key, java.util.function.Predicate<String> filter) {
return subModelCache.computeIfAbsent(key, k -> {
List<String> out = new ArrayList<>();
for (String n : models.vanillaNames()) if (filter.test(n)) out.add(n);
out.sort(null);
return out;
});
}
// --- vanilla render layers -----------------------------------------------------------------------
private void addVanillaLayers(
EntityState s, Choice ch, Pose pose, Set<String> show, List<CemGeometry.Baked> baked) {
String base = ch.base;
boolean baby = ch.babyModel;
for (String n : layerModels(base)) {
String rest = n.substring(base.length() + 1);
String layerClass = rest.substring(0, rest.indexOf('.'));
if (rest.toLowerCase(Locale.ROOT).contains("baby") != baby) continue;
CemModelLoader.CemModel lm = models.vanilla(n);
if (lm == null) continue;
switch (layerClass) {
case "SheepWoolLayer", "SheepWoolUndercoatLayer" -> {
if ("sheared".equals(s.variant()) || lm.texture() == null) continue;
int[][] t = texture(lm.texture());
if (t == null) continue;
t = TextureOps.deepCopy(t);
if (s.tint() != 0) TextureOps.tint(t, s.tint());
baked.addAll(pose.bake(lm, t, show));
}
case "TropicalFishPatternLayer" -> addTropicalPattern(s, n, lm, pose, baked);
case "LlamaDecorLayer", "WolfArmorLayer" -> {
// Body equipment drawn by a dedicated layer: texture folder <base>_body (trader llama: llama_body).
if (s.bodyEquip() == null) continue;
int[][] t = equipmentTexture(base + "_body", s.bodyEquip(), baby, s.bodyDye());
if (t == null) {
t = equipmentTexture(
base.substring(base.indexOf('_') + 1) + "_body", s.bodyEquip(), baby, s.bodyDye());
}
if (t != null) baked.addAll(pose.bake(lm, t, show));
}
case "RopesLayer" -> {
if (s.bodyEquip() == null || lm.texture() == null) continue; // ropes only with a harness
int[][] t = texture(lm.texture());
if (t != null) baked.addAll(pose.bake(lm, t, show));
}
default -> {
if (CONDITIONAL_LAYERS.contains(layerClass) || lm.texture() == null) continue;
int[][] t = texture(lm.texture()); // always-on overlay (drowned/stray/bogged outer, wind, …)
if (t != null) baked.addAll(pose.bake(lm, t, show));
}
}
}
// Equipment layers (saddles, body armor/harness/carpets), named <base>/equip.<layerType>.<field>.
for (String n : equipModels(base)) {
String rest = n.substring(base.length() + "/equip.".length());
String layerType = rest.substring(0, rest.indexOf('.'));
if (rest.toLowerCase(Locale.ROOT).contains("baby") != baby) continue;
CemModelLoader.CemModel em = models.vanilla(n);
int[][] t = null;
if (layerType.endsWith("_saddle") && s.saddle()) {
t = texture("entity/equipment/" + layerType + "/saddle");
} else if (layerType.endsWith("_body")
&& s.bodyEquip() != null
&& !s.bodyEquip().equals("trader_llama")) {
t = equipmentTexture(layerType, s.bodyEquip(), baby, s.bodyDye());
}
if (t != null) baked.addAll(pose.bake(em, t, Set.of()));
}
}
/** Tropical fish pattern overlay: pattern texture (tinted with the pattern colour) on the pattern model. */
private void addTropicalPattern(
EntityState s, String name, CemModelLoader.CemModel lm, Pose pose, List<CemGeometry.Baked> baked) {
int idx = EntityModels.tropicalPattern(s.variant());
if (idx < 0) return;
boolean small = idx < 6; // patterns 0-5 use shape A (small model), 6-11 shape B (large model)
if (name.toLowerCase(Locale.ROOT).contains("small") != small) return;
int[][] t = texture("entity/fish/tropical_" + (small ? "a" : "b") + "_pattern_" + (idx % 6 + 1));
if (t == null) return;
t = TextureOps.deepCopy(t);
if (s.tint2() != 0) TextureOps.tint(t, s.tint2());
baked.addAll(pose.bake(lm, t, Set.of()));
}
/** entity/equipment/<layerType>/<asset> (+ a "_carpet"-stripped name; leather dye + overlay). */
private int[][] equipmentTexture(String layerType, String asset, boolean baby, int dye) {
String folder = "entity/equipment/" + layerType + "/";
int[][] t = null;
List<String> names = new ArrayList<>();
if (baby) names.add(asset + "_baby");
names.add(asset);
if (asset.endsWith("_carpet")) names.add(asset.substring(0, asset.length() - "_carpet".length()));
for (String n : names) {
t = texture(folder + n);
if (t != null) {
asset = n;
break;
}
}
if (t == null) return null;
int[][] out = TextureOps.deepCopy(t);
if (dye != 0) {
TextureOps.tint(out, dye);
overlayIfPresent(out, folder + asset + "_overlay");
}
return out;
}
// --- texture resolution --------------------------------------------------------------------------
private int[][] texture(String path) {
return textures.get(ResourceLocation.parse(path)).orElse(null);
}
/**
* Player skin, else the variant-aware candidates ({@link EntityModels}), the vanilla renderer's default
* texture as a generic fallback, and — for a dedicated baby model — the {@code _baby} counterparts first.
*/
private int[][] resolveTexture(EntityState s, Choice ch) {
if (s.player()) {
int[][] skin = skins.get(s.skinUrl()).orElse(null);
if (skin != null) return skin;
int[][] def = texture(s.slim() ? "entity/player/slim/steve" : "entity/player/wide/steve");
if (def != null) return def;
}
CemModelLoader.EntityInfo info = models.entityInfo(s.typeKey());
if (info == null && ch != null) info = models.entityInfo(ch.base);
List<String> adult = new ArrayList<>();
for (ResourceLocation rl : EntityModels.textureCandidates(s.typeKey(), s.variant())) adult.add(rl.path());
if (info != null && info.texture() != null && !info.texture().equals("missingno")) {
adult.add(
Math.min(adult.size(), EntityModels.specificCandidateCount(s.typeKey(), s.variant())),
info.texture());
}
List<String> order = new ArrayList<>();
if (ch != null && ch.babyModel) {
for (String a : adult) order.add(a + "_baby");
if (info != null && info.babyTexture() != null) order.add(info.babyTexture());
}
order.addAll(adult);
for (String p : order) {
int[][] t = texture(p);
if (t != null) return t;
}
return null;
}
// --- villager texture compositing (biome type + profession + level badge over the base body) ---
private static final String[] LEVEL_BADGE = {"stone", "iron", "gold", "emerald", "diamond"};
private int[][] compositeVillager(EntityState s, int[][] base, String folder, boolean babyModel) {
if (base == null) return null;
int[][] out = TextureOps.deepCopy(base);
if (babyModel) {
// Baby villagers: their own biome-type clothing set; no profession.
if (s.variant() != null) overlayIfPresent(out, "entity/" + folder + "/baby/" + s.variant());
return out;
}
if (s.variant() != null) overlayIfPresent(out, "entity/" + folder + "/type/" + s.variant());
String prof = s.profession();
if (prof != null && !prof.equals("none")) {
overlayIfPresent(out, "entity/" + folder + "/profession/" + prof);
if (!prof.equals("nitwit") && s.villagerLevel() >= 1) {
int lvl = Math.min(5, s.villagerLevel());
overlayIfPresent(out, "entity/" + folder + "/profession_level/" + LEVEL_BADGE[lvl - 1]);
}
}
return out;
}
/** Alpha-composite an overlay texture onto {@code dst} in place, if it exists and matches dst's size. */
private void overlayIfPresent(int[][] dst, String path) {
int[][] o = texture(path);
if (o == null || o.length != dst.length || o[0].length != dst[0].length) return; // missing or HD-mismatch
TextureOps.overlay(dst, o);
}
// --- humanoid armor ---------------------------------------------------------------------------
// Per-slot armor models of the wearer's renderer (<base>/HumanoidArmorLayer.modelSet.<slot>), posed
// like the body — raised zombie arms included.
private static final int GLINT_COLOR = 0xFF8040CC; // approximated enchantment-glint purple
private void addArmorLayers(EntityState s, Choice ch, Pose pose, List<CemGeometry.Baked> baked) {
EntityState.Equipment eq = s.equipment();
String base = ch == null ? s.typeKey() : ch.base;
boolean baby = ch != null && ch.babyModel;
String set = base + "/HumanoidArmorLayer." + (baby ? "babyModelSet" : "modelSet") + ".";
String folder = baby ? "humanoid_baby" : "humanoid";
bakeArmorPiece(eq.head(), set + "head", folder, pose, baked);
if (eq.chest() != null && eq.chest().asset().equals("elytra")) {
bakeElytra(eq.chest(), base, baby, pose, baked);
} else {
bakeArmorPiece(eq.chest(), set + "chest", folder, pose, baked);
}
bakeArmorPiece(eq.legs(), set + "legs", baby ? "humanoid_baby" : "humanoid_leggings", pose, baked);
bakeArmorPiece(eq.feet(), set + "feet", folder, pose, baked);
}
/** Bake one armor slot: its vanilla per-slot model, textured for the material. */
private void bakeArmorPiece(
EntityState.EquipPiece piece,
String modelName,
String layerFolder,
Pose pose,
List<CemGeometry.Baked> baked) {
if (piece == null) return;
CemModelLoader.CemModel model = models.vanilla(modelName);
if (model == null) return;
int[][] tex = buildArmorTexture(piece, layerFolder);
if (tex != null) baked.addAll(pose.bake(model, tex, Set.of()));
}
/** Resolve + composite a single armor slot's texture: material base (+leather dye/overlay), trim, glint. */
private int[][] buildArmorTexture(EntityState.EquipPiece piece, String layerFolder) {
String asset = piece.asset();
int[][] base = texture("entity/equipment/" + layerFolder + "/" + asset);
if (base == null) return null;
int[][] out = TextureOps.deepCopy(base);
if (asset.equals("leather")) {
if (piece.dyeColor() != 0) TextureOps.tint(out, piece.dyeColor());
overlayIfPresent(out, "entity/equipment/" + layerFolder + "/leather_overlay");
}
if (piece.trimMaterial() != null && piece.trimPattern() != null) {
applyTrim(out, layerFolder, piece.trimPattern(), piece.trimMaterial(), asset);
}
if (piece.glint()) applyGlint(out);
return out;
}
/** Armor trim: recolour the grayscale pattern via the material palette (same UV) and overlay it. */
private void applyTrim(int[][] armorTex, String layerFolder, String pattern, String material, String armorAsset) {
int[][] mask = texture("trims/entity/" + layerFolder + "/" + pattern);
if (mask == null || mask.length != armorTex.length || mask[0].length != armorTex[0].length) return;
int[] from = palette8("trims/color_palettes/trim_palette");
String palette = material;
if (material.equals(armorAsset) && texture("trims/color_palettes/" + material + "_darker") != null) {
palette = material + "_darker";
}
int[] to = palette8("trims/color_palettes/" + palette);
if (from == null || to == null) return;
int[][] colored = TextureOps.deepCopy(mask);
TextureOps.paletteSwap(colored, from, to);
TextureOps.overlay(armorTex, colored);
}
private int[] palette8(String path) {
int[][] p = texture(path);
return (p == null || p.length == 0) ? null : p[0];
}
private void applyGlint(int[][] tex) {
textures.get(ResourceLocation.parse("misc/enchanted_glint_armor"))
.ifPresent(glint -> TextureOps.addGlint(tex, glint, GLINT_COLOR, 0.6));
}
/** Elytra in the chest slot: the wearer's own wings model (vanilla), else the CEM elytra model. */
private void bakeElytra(
EntityState.EquipPiece piece, String base, boolean baby, Pose pose, List<CemGeometry.Baked> baked) {
CemModelLoader.CemModel model =
models.vanilla(base + "/WingsLayer." + (baby ? "elytraBabyModel" : "elytraModel"));
if (model == null) model = models.get("elytra");
if (model == null) return;
int[][] tex = texture("entity/equipment/wings/elytra");
if (tex == null) return;
int[][] out = TextureOps.deepCopy(tex);
if (piece.glint()) applyGlint(out);
baked.addAll(pose.bake(model, out, Set.of()));
}
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);
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 RenderedEntity.of(cubes);
}
private static int[][] flat(int argb) {
int[][] t = new int[1][1];
t[0][0] = argb;
return t;
}
}
@@ -0,0 +1,219 @@
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.ModelCube;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
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() {}
// CEM frame (x negated, y up, ground at 24 px) -> Java model px: x' = -x, y' = 24 - y, z' = z.
static final Affine CEM_TO_JAVA = new Affine(new double[] {-1, 0, 0, 0, -1, 0, 0, 0, 1}, new double[] {0, 24, 0});
// Java-model block offsets -> CEM px offsets of a part frame (x/y flip, 16 px per block, no translation).
static final Affine JAVA_LOCAL = new Affine(new double[] {-16, 0, 0, 0, -16, 0, 0, 0, 16}, new double[] {0, 0, 0});
/** 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, false);
}
/**
* Bake all parts with an explicit texture size for UV normalisation ({@code texW}/{@code texH} of 0
* fall back to the model's declared size; use a real size when the applied texture differs from the
* model's declared {@code textureSize}, e.g. a 16x16 sherd on a 32x32-authored pot face). When
* {@code ignoreFaceUv} is set, box-UV is forced even if the model declares per-face UV — used for a
* standalone texture (e.g. the conduit cage) whose layout is box-UV, not the combined-sheet layout.
*/
static List<Baked> bakeModel(
CemModelLoader.CemModel model,
int[][] tex,
Affine pre,
Set<String> hidden,
int texW,
int texH,
boolean ignoreFaceUv) {
return bake(model, tex, pre, hidden, Set.of(), texW, texH, ignoreFaceUv, Map.of(), null);
}
/**
* Posed variant for vanilla ("absolute") models: {@code show} names parts vanilla hides by default but
* this entity's state draws (chests, armor-stand arms); {@code overrides} replace parts' pivot/rotation/
* visibility by name (pose variants: sitting, holding items, …; boxes follow their pivot); {@code frames},
* when non-null, receives each part's frame {@code parent·T(P)·R·S} (pre-space, maps a part-local CEM px
* offset) — used to attach held/head items to hands and heads.
*/
static List<Baked> bakePosed(
CemModelLoader.CemModel model,
int[][] tex,
Affine pre,
Set<String> show,
Map<String, CemModelLoader.PartPose> overrides,
Map<String, Affine> frames) {
return bake(model, tex, pre, Set.of(), show, 0, 0, false, overrides, frames);
}
private static List<Baked> bake(
CemModelLoader.CemModel model,
int[][] tex,
Affine pre,
Set<String> hidden,
Set<String> show,
int texW,
int texH,
boolean ignoreFaceUv,
Map<String, CemModelLoader.PartPose> overrides,
Map<String, Affine> frames) {
int nw = texW > 0 ? texW : model.texW();
int nh = texH > 0 ? texH : model.texH();
Ctx ctx = new Ctx(hidden, show, nw, nh, tex, ignoreFaceUv, new ArrayList<>(), overrides, frames);
for (CemModelLoader.CemPart p : model.parts()) {
if (model.absolute()) {
bakeAbsolute(p, pre, ctx);
} else {
double[] o = {-p.translate()[0], -p.translate()[1], -p.translate()[2]};
bakePart(p, pre, o, 0, ctx);
}
}
return ctx.out;
}
/** Key prefix in the frames map marking parts that were actually drawn (visible). */
static final String DRAWN = "drawn:";
private record Ctx(
Set<String> hidden,
Set<String> show,
int texW,
int texH,
int[][] tex,
boolean ignoreFaceUv,
List<Baked> out,
Map<String, CemModelLoader.PartPose> overrides,
Map<String, Affine> frames) {}
/**
* Vanilla-dumped ("absolute") part: pivot and boxes are absolute model px, so the group transform is
* simply {@code parent · T(P) · Rz·Ry·Rx · S · T(-P)} — the same order as vanilla's
* {@code ModelPart.translateAndRotate}. Invisible parts (vanilla {@code visible=false}) skip their
* whole subtree unless named in {@code show}; {@code skipDraw} parts only skip their own boxes.
*/
private static void bakeAbsolute(CemModelLoader.CemPart part, Affine parentWorld, Ctx ctx) {
bakeAbsolute(part, parentWorld, ctx, true);
}
/**
* {@code draw=false} walks a hidden subtree only to record part frames: vanilla attaches held items to
* an arm's transform even when the arm itself isn't drawn (armor stands without arms, …).
*/
private static void bakeAbsolute(CemModelLoader.CemPart part, Affine parentWorld, Ctx ctx, boolean draw) {
if (ctx.hidden.contains(part.name())) return;
CemModelLoader.PartPose ov = ctx.overrides.get(part.name());
boolean invisible = ov != null ? !ov.visible() : part.invisible();
if (invisible && !ctx.show.contains(part.name())) {
if (ctx.frames == null) return;
draw = false;
}
double[] o = ov != null ? ov.pivot() : part.pivot();
double[] rot = ov != null ? ov.rotate() : part.rotate();
Affine world = parentWorld
.mul(Affine.translation(o[0], o[1], o[2]))
.mul(Affine.rotZ(Math.toRadians(rot[2])))
.mul(Affine.rotY(Math.toRadians(rot[1])))
.mul(Affine.rotX(Math.toRadians(rot[0])));
if (part.scale() != null) {
// A collapsed (zero) scale is vanilla's way to hide a part; it would also be non-invertible.
for (double v : part.scale()) if (Math.abs(v) < 1e-6) return;
world = world.mul(Affine.scale(part.scale()[0], part.scale()[1], part.scale()[2]));
}
if (ctx.frames != null) {
ctx.frames.putIfAbsent(part.name(), world);
if (draw) ctx.frames.putIfAbsent(DRAWN + part.name(), world);
}
world = world.mul(Affine.translation(-o[0], -o[1], -o[2]));
Affine boxWorld = world;
if (ov != null) {
// Boxes are stored relative to the idle pivot: move them along with the overridden pivot
// (children carry their own absolute pivots, so they use the un-shifted transform).
double[] p0 = part.pivot();
boxWorld = world.mul(Affine.translation(o[0] - p0[0], o[1] - p0[1], o[2] - p0[2]));
}
if (draw && !part.skipDraw()) {
for (CemModelLoader.CemBox b : part.boxes()) ctx.out.add(bakeBox(b, b.origin(), boxWorld, ctx));
}
for (CemModelLoader.CemPart child : part.children()) bakeAbsolute(child, world, ctx, draw);
}
private static Baked bakeBox(CemModelLoader.CemBox b, double[] org, Affine world, Ctx ctx) {
double[] inf = b.inflate();
double[] from = {org[0] - inf[0], org[1] - inf[1], org[2] - inf[2]};
double[] to = {org[0] + b.size()[0] + inf[0], org[1] + b.size()[1] + inf[1], org[2] + b.size()[2] + inf[2]};
double[][] faceUv = ctx.ignoreFaceUv ? null : b.faceUv();
ModelCube mc = new ModelCube(org, b.size(), inf[0], b.uv(), b.mirror(), faceUv);
// Vanilla texScale stretches the UV normalisation (texture treated as texSize*scale).
int tw = ctx.texW, th = ctx.texH;
if (b.texScale() != null) {
tw = (int) Math.round(tw * b.texScale()[0]);
th = (int) Math.round(th * b.texScale()[1]);
}
Face[] faces = BoxUv.build(mc, ctx.tex, tw, th);
if (b.hiddenFaces() != null) {
for (int i = 0; i < 6; i++) if (b.hiddenFaces()[i]) faces[i] = null;
}
return new Baked(from, to, faces, world);
}
/**
* 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, Ctx ctx) {
if (ctx.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[] org = {b.origin()[0] + ox, b.origin()[1] + oy, b.origin()[2] + oz};
ctx.out.add(bakeBox(b, org, world, ctx));
}
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, ctx);
}
}
}
@@ -0,0 +1,269 @@
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, // per-axis grow (px)
double[] uv,
boolean mirror,
double[][] faceUv,
double[] texScale, // vanilla CubeDefinition texScale (UV normalised by texSize*scale); null = 1
boolean[] hiddenFaces) {} // by Direction ordinal; null = all faces drawn
/** 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,
double[] pivot, // absolute rotation pivot (vanilla dump, "absolute" models); null = CEM translate rules
double[] scale, // part scale around the pivot; null = 1
boolean invisible, // vanilla ModelPart.visible == false after setupAnim (part + children hidden)
boolean skipDraw) {} // own boxes not drawn, children are
/**
* A whole model: declared texture size and its top-level parts. {@code absolute} models (dumped from
* the vanilla client by {@code tools/VanillaModelDump.java}) carry absolute part pivots + boxes.
*/
public record CemModel(int texW, int texH, List<CemPart> parts, boolean absolute, String texture) {}
/** Per-entity-type renderer info from the vanilla dump: extra render scale and default textures. */
public record EntityInfo(
String texture,
String babyTexture,
double[] transform, // vanilla renderer pose at yaw=pitch=0 (column-major 4x4, Java model -> blocks)
int yawSign, // how the entity yaw enters the renderer: -1 (mobs), +1 (projectiles), 0 (ignored)
double[] pitchAxis, // world-frame axis the pitch rotates about; null = pitch ignored
Map<String, Map<String, PartPose>> poses) {} // variant ("passenger+item_r", …) -> part overrides
/** A posed part override: absolute CEM pivot, CEM rotation (deg) and visibility. */
public record PartPose(double[] pivot, double[] rotate, boolean visible) {}
private final Map<String, CemModel> models = new HashMap<>();
// Vanilla dump models live in their own namespace so they never shadow CEM-template names the
// block-entity path relies on (e.g. "wither_skull" head vs. projectile).
private final Map<String, CemModel> vanilla = new HashMap<>();
private final Map<String, EntityInfo> entities = new HashMap<>();
/** A model from the vanilla client dump, or null. */
public CemModel vanilla(String name) {
return vanilla.get(name);
}
public boolean hasVanilla(String name) {
return vanilla.containsKey(name);
}
/** All vanilla dump model names. */
public java.util.Set<String> vanillaNames() {
return vanilla.keySet();
}
/** Vanilla renderer info for an entity type key, or null (not in the dump). */
public EntityInfo entityInfo(String typeKey) {
return entities.get(typeKey);
}
public CemModel get(String name) {
return models.get(name);
}
public int size() {
return models.size();
}
/**
* Parse a CEM template-models JSON stream (the bundled CEM template set, or the vanilla dump). Later
* loads override earlier models of the same name. Returns the total number of models loaded.
*/
public int load(InputStream in, Logger logger) {
JsonObject root = JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8))
.getAsJsonObject();
boolean isVanillaDump = root.has("entities");
Map<String, CemModel> target = isVanillaDump ? vanilla : models;
if (isVanillaDump) {
for (Map.Entry<String, JsonElement> e :
root.getAsJsonObject("entities").entrySet()) {
JsonObject o = e.getValue().getAsJsonObject();
entities.put(
e.getKey(),
new EntityInfo(
o.has("texture") ? o.get("texture").getAsString() : null,
o.has("babyTexture") ? o.get("babyTexture").getAsString() : null,
doubles(o, "transform"),
o.has("yawSign") ? o.get("yawSign").getAsInt() : -1,
doubles(o, "pitchAxis"),
poses(o)));
}
}
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()));
boolean absolute =
model.has("absolute") && model.get("absolute").getAsBoolean();
String texture = model.has("texture") ? model.get("texture").getAsString() : null;
target.put(e.getKey(), new CemModel(tw, th, parts, absolute, texture));
} catch (Exception ex) {
if (logger != null) logger.warning("Failed to parse CEM model " + e.getKey() + ": " + ex.getMessage());
}
}
return models.size() + vanilla.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 add = b.has("sizeAdd") ? b.get("sizeAdd").getAsDouble() : 0;
double[] inflate = b.has("sizeAddXYZ") ? arr3(b, "sizeAddXYZ") : new double[] {add, add, add};
double[] texScale = null;
if (b.has("textureScale")) {
JsonArray ts = b.getAsJsonArray("textureScale");
texScale = new double[] {ts.get(0).getAsDouble(), ts.get(1).getAsDouble()};
}
boolean[] hiddenFaces = null;
if (b.has("hiddenFaces")) {
hiddenFaces = new boolean[6];
for (JsonElement hf : b.getAsJsonArray("hiddenFaces")) {
hiddenFaces[
eu.mhsl.minecraft.pixelpics.assets.model.Direction.fromName(hf.getAsString())
.ordinal()] = true;
}
}
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), texScale, hiddenFaces));
}
}
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() : "");
double[] pivot = p.has("pivot") ? arr3(p, "pivot") : null;
double[] scale = p.has("scale") ? arr3(p, "scale") : null;
boolean invisible = p.has("invisible") && p.get("invisible").getAsBoolean();
boolean skipDraw = p.has("skipDraw") && p.get("skipDraw").getAsBoolean();
return new CemPart(name, translate, rotate, boxes, children, pivot, scale, invisible, skipDraw);
}
// 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 Map<String, Map<String, PartPose>> poses(JsonObject o) {
Map<String, Map<String, PartPose>> out = new HashMap<>();
if (!o.has("poses")) return out;
for (Map.Entry<String, JsonElement> v : o.getAsJsonObject("poses").entrySet()) {
Map<String, PartPose> parts = new HashMap<>();
for (Map.Entry<String, JsonElement> pe :
v.getValue().getAsJsonObject().entrySet()) {
JsonObject po = pe.getValue().getAsJsonObject();
parts.put(
pe.getKey(),
new PartPose(
arr3(po, "pivot"),
arr3(po, "rotate"),
!po.has("visible") || po.get("visible").getAsBoolean()));
}
out.put(v.getKey(), parts);
}
return out;
}
private static double[] doubles(JsonObject o, String key) {
if (!o.has(key) || !o.get(key).isJsonArray()) return null;
JsonArray a = o.getAsJsonArray(key);
double[] out = new double[a.size()];
for (int i = 0; i < out.length; i++) out[i] = a.get(i).getAsDouble();
return out;
}
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()
};
}
}
@@ -0,0 +1,473 @@
package eu.mhsl.minecraft.pixelpics.render.entity.cem;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import eu.mhsl.minecraft.pixelpics.assets.AssetPaths;
import eu.mhsl.minecraft.pixelpics.assets.AssetReader;
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
import eu.mhsl.minecraft.pixelpics.assets.ModelResolver;
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.Element;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel;
import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec;
import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity;
import eu.mhsl.minecraft.pixelpics.render.entity.TextureOps;
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTint;
import eu.mhsl.minecraft.pixelpics.render.tint.TintResolver;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.block.data.BlockData;
/**
* Item (and block) geometry the way vanilla's ItemRenderer draws it, straight from the resource pack:
* the 26.x item definition ({@code items/<id>.json}: model / special / select / condition) picks the
* model for the display context, the model's parent chain supplies the {@code display} transform and the
* {@code layerN} textures. Geometry: generated items as an extruded sprite (1px thick, like vanilla),
* block items as their block model, block-entity items (chest, heads, banners, …) through the block-entity
* baker, shield/trident through their entity models. Shared by held items, head items, item displays and
* dropped items.
*
* <p>All transforms are in vanilla item space: the caller's {@code toWorld} maps the ItemRenderer origin;
* this class applies the display transform and the {@code translate(-0.5)} to the 0..1 model cube.
*/
public final class ItemGeometry {
// Tinted block faces have no biome context here: plains colours.
private static final BiomeTint DEFAULT_TINT = new BiomeTint(0xFF91BD59, 0xFF77AB2F, 0xFFA9A05B, 0xFF3F76E4);
private final AssetReader reader;
private final BlockModelRegistry registry;
private final TextureCache textures;
private final CemModelLoader models;
private final BlockEntityBaker blockEntities;
private final Map<String, Info> infos = new ConcurrentHashMap<>();
private final Map<int[][], Map<Integer, int[][]>> tinted = new IdentityHashMap<>();
private final Map<int[][], List<Box>> extrusions = new IdentityHashMap<>();
private final Map<Info, Optional<int[][]>> composedSprites = new ConcurrentHashMap<>();
public ItemGeometry(
AssetReader reader,
BlockModelRegistry registry,
TextureCache textures,
CemModelLoader models,
BlockEntityBaker blockEntities) {
this.reader = reader;
this.registry = registry;
this.textures = textures;
this.models = models;
this.blockEntities = blockEntities;
}
/** Resolved item model for one display context. */
private record Info(
double[] display, // {rx,ry,rz, tx,ty,tz, sx,sy,sz} (deg, blocks, factor); null = identity
List<String> layers, // generated-item layer textures (layer0, layer1, …); empty if not generated
List<Integer> tints, // ARGB per layer (0 = none)
String special, // special model type ("shield", "trident", "chest", "head", …) or null
Affine specialTransform) {} // item-model transformation for special models (T·Rl·S·Rr) or null
// --- public API --------------------------------------------------------------------------------
/** Bake an item in a display context ("thirdperson_righthand", "head", "ground", "fixed", "none", …). */
public void bake(ItemSpec item, String context, boolean leftHand, Affine toWorld, List<EntityCube> out) {
if (item == null) return;
Info info = info(item.id(), context);
Affine m = toWorld.mul(displayTransform(info.display, leftHand)).mul(Affine.translation(-0.5, -0.5, -0.5));
int before = out.size();
if (info.special != null) bakeSpecial(item, info, m, out);
if (out.size() == before && !info.layers.isEmpty()) bakeGenerated(info, m, out);
if (out.size() == before && item.block() != null) bakeBlock(item.block(), m, out);
if (out.size() == before && item.blockEntity() != null) bakeBlockEntity(item.blockEntity(), m, out);
if (out.size() == before) {
int[][] sprite = sprite(item.id());
if (sprite != null) addExtruded(sprite, m, out);
}
}
/** The display transform's Y scale for a context (vanilla lifts dropped items by 0.25·scaleY). */
public double displayScaleY(ItemSpec item, String context) {
if (item == null) return 1;
Info info = info(item.id(), context);
return info.display == null ? 1 : info.display[7];
}
/** A block's model elements in the block cube [0,1]³ (tinted with plains colours). */
public void bakeBlock(BlockData data, Affine toWorld, List<EntityCube> out) {
if (data == null) return;
ResolvedModel model = registry.get(data);
if (model == null || !model.hasGeometry) return;
for (Element world : model.elements) {
// Rotated without uvlock: bake the model-space element turned by the blockstate rotation, so
// textures stay bound to the model (vanilla); otherwise the world-space element as is.
Element e = world.unrotated != null ? world.unrotated : world;
Affine variant = world.unrotated != null ? variantRotation(world.xSteps, world.ySteps) : Affine.identity();
Face[] faces = new Face[6];
for (int i = 0; i < 6; i++) faces[i] = tint(e.faces[i], data);
out.add(new EntityCube(e.from, e.to, faces, toWorld.mul(variant).mul(elementTransform(e))));
}
}
/** A block-entity block (chest, head, banner, …) in the block cube [0,1]³ via the block-entity baker. */
public void bakeBlockEntity(BlockEntityState state, Affine toWorld, List<EntityCube> out) {
if (state == null || blockEntities == null) return;
RenderedEntity r = blockEntities.bake(state); // placed at cell (bx,by,bz) = (0,0,0)
if (r == null) return;
for (EntityCube c : r.cubes) out.add(new EntityCube(c.from, c.to, c.faces, toWorld.mul(c.toWorld)));
}
// --- item definition / model resolution --------------------------------------------------------
private Info info(String id, String context) {
return infos.computeIfAbsent(id + "#" + context, k -> {
try {
return resolve(id, context);
} catch (RuntimeException e) {
// Unexpected item-definition format (newer version?): fall back to the plain sprite.
return new Info(null, List.of(), List.of(), null, null);
}
});
}
private Info resolve(String id, String context) {
String modelRef = null, special = null;
Affine specialXf = null;
List<Integer> tints = new ArrayList<>();
Optional<JsonObject> def = reader.readJsonObject("assets/minecraft/items/" + id + ".json");
if (def.isPresent() && def.get().has("model")) {
JsonObject node = pick(def.get().getAsJsonObject("model"), context);
if (node != null) {
String type = str(node, "type");
if (type.endsWith("special")) {
modelRef = str(node, "base");
JsonObject sm = node.has("model") ? node.getAsJsonObject("model") : null;
special = sm == null ? null : str(sm, "type").replace("minecraft:", "");
if (node.has("transformation")) specialXf = transformation(node.getAsJsonObject("transformation"));
} else {
modelRef = str(node, "model");
}
if (node.has("tints")) {
for (JsonElement t : node.getAsJsonArray("tints")) tints.add(tintOf(t));
}
}
}
if (modelRef == null || modelRef.isEmpty()) modelRef = "minecraft:item/" + id;
// Parent chain: nearest "display" entry for the context wins; layer textures; generated flag.
double[] display = null;
Map<String, String> tex = new HashMap<>();
boolean generated = false;
String ref = modelRef;
for (int depth = 0; ref != null && depth < 16; depth++) {
ResourceLocation rl = ResourceLocation.parse(ref);
if (rl.path().startsWith("builtin/generated")) {
generated = true;
break;
}
Optional<JsonObject> m = reader.readJsonObject(AssetPaths.model(rl));
if (m.isEmpty()) break;
JsonObject mo = m.get();
if (display == null && mo.has("display")) {
JsonObject d = mo.getAsJsonObject("display");
String ctx = context;
if (!d.has(ctx) && ctx.equals("thirdperson_lefthand")) ctx = "thirdperson_righthand";
if (d.has(ctx)) display = displayOf(d.getAsJsonObject(ctx));
}
if (mo.has("textures")) {
for (Map.Entry<String, JsonElement> e :
mo.getAsJsonObject("textures").entrySet()) {
String val = ModelResolver.textureRef(e.getValue());
if (val != null) tex.putIfAbsent(e.getKey(), val);
}
}
// Keep walking past element models: block/block.json (the root) holds the block display transforms.
ref = mo.has("parent") ? mo.get("parent").getAsString() : null;
}
List<String> layers = new ArrayList<>();
if (generated) {
for (int i = 0; tex.containsKey("layer" + i); i++) layers.add(resolveTex(tex, tex.get("layer" + i)));
}
return new Info(display, layers, tints, special, specialXf);
}
/** Walks an item-model node to the concrete model/special for a display context. */
private static JsonObject pick(JsonObject node, String context) {
for (int depth = 0; node != null && depth < 16; depth++) {
String type = str(node, "type").replace("minecraft:", "");
switch (type) {
case "model", "special" -> {
return node;
}
case "condition" -> node = obj(node, "on_false");
case "select" -> {
JsonObject chosen = null;
if (str(node, "property").endsWith("display_context") && node.has("cases")) {
for (JsonElement c : node.getAsJsonArray("cases")) {
JsonObject co = c.getAsJsonObject();
if (matches(co.get("when"), context)) {
chosen = obj(co, "model");
break;
}
}
}
if (chosen == null) chosen = obj(node, "fallback");
if (chosen == null && node.has("cases")) {
chosen = obj(node.getAsJsonArray("cases").get(0).getAsJsonObject(), "model");
}
node = chosen;
}
case "range_dispatch" -> {
JsonObject f = obj(node, "fallback");
if (f == null
&& node.has("entries")
&& !node.getAsJsonArray("entries").isEmpty()) {
f = obj(node.getAsJsonArray("entries").get(0).getAsJsonObject(), "model");
}
node = f;
}
case "composite" -> node =
node.has("models") && !node.getAsJsonArray("models").isEmpty()
? node.getAsJsonArray("models").get(0).getAsJsonObject()
: null;
default -> node = obj(node, "fallback");
}
}
return null;
}
private static boolean matches(JsonElement when, String context) {
if (when == null) return false;
if (when.isJsonArray()) {
for (JsonElement w : when.getAsJsonArray()) if (w.getAsString().equals(context)) return true;
return false;
}
return when.getAsString().equals(context);
}
private static int tintOf(JsonElement t) {
if (!t.isJsonObject()) return 0;
JsonObject o = t.getAsJsonObject();
for (String k : new String[] {"value", "default"}) {
if (o.has(k) && o.get(k).isJsonPrimitive()) return o.get(k).getAsInt() | 0xFF000000;
}
return 0;
}
private static String resolveTex(Map<String, String> tex, String v) {
for (int i = 0; i < 8 && v.startsWith("#"); i++) v = tex.getOrDefault(v.substring(1), v);
return v;
}
private static double[] displayOf(JsonObject d) {
double[] r = arr(d, "rotation", 0), t = arr(d, "translation", 0), s = arr(d, "scale", 1);
return new double[] {r[0], r[1], r[2], t[0] / 16, t[1] / 16, t[2] / 16, s[0], s[1], s[2]};
}
/** Vanilla ItemTransform.apply: T(±tx,ty,tz) · rotationXYZ(rx,±ry,±rz) · S. */
private static Affine displayTransform(double[] d, boolean left) {
if (d == null) return Affine.identity();
int i = left ? -1 : 1;
return Affine.translation(i * d[3], d[4], d[5])
.mul(Affine.rotX(Math.toRadians(d[0])))
.mul(Affine.rotY(Math.toRadians(i * d[1])))
.mul(Affine.rotZ(Math.toRadians(i * d[2])))
.mul(Affine.scale(d[6], d[7], d[8]));
}
/** Item-model "transformation" (translation, left_rotation quat, scale, right_rotation quat) as T·L·S·R. */
private static Affine transformation(JsonObject o) {
double[] t = arr(o, "translation", 0), s = arr(o, "scale", 1);
double[] l = quat(o, "left_rotation"), r = quat(o, "right_rotation");
return Affine.translation(t[0], t[1], t[2])
.mul(Affine.fromQuaternion(l[0], l[1], l[2], l[3]))
.mul(Affine.scale(s[0], s[1], s[2]))
.mul(Affine.fromQuaternion(r[0], r[1], r[2], r[3]));
}
private static double[] quat(JsonObject o, String k) {
if (!o.has(k) || !o.get(k).isJsonArray() || o.getAsJsonArray(k).size() != 4) return new double[] {0, 0, 0, 1};
JsonArray a = o.getAsJsonArray(k);
return new double[] {
a.get(0).getAsDouble(),
a.get(1).getAsDouble(),
a.get(2).getAsDouble(),
a.get(3).getAsDouble()
};
}
private static double[] arr(JsonObject o, String k, double def) {
double[] v = {def, def, def};
if (o.has(k) && o.get(k).isJsonArray()) {
JsonArray a = o.getAsJsonArray(k);
for (int i = 0; i < 3 && i < a.size(); i++) v[i] = a.get(i).getAsDouble();
}
return v;
}
private static String str(JsonObject o, String k) {
return o.has(k) && o.get(k).isJsonPrimitive() ? o.get(k).getAsString() : "";
}
private static JsonObject obj(JsonObject o, String k) {
return o.has(k) && o.get(k).isJsonObject() ? o.getAsJsonObject(k) : null;
}
// --- geometry ------------------------------------------------------------------------------------
private void bakeSpecial(ItemSpec item, Info info, Affine m, List<EntityCube> out) {
switch (info.special) {
case "shield", "trident" -> {
// Entity model in Java model space; the special renderer flips it with scale(1,-1,-1).
boolean shield = info.special.equals("shield");
CemModelLoader.CemModel model = models.vanilla(shield ? "layer/shield#main" : "trident");
if (model == null) model = models.vanilla("layer/" + info.special + "#main");
int[][] tex = textures.get(ResourceLocation.parse(
shield ? "entity/shield/shield_base_nopattern" : "entity/trident/trident"))
.orElse(null);
if (model == null || tex == null) return;
Affine xf = info.specialTransform != null ? m.mul(info.specialTransform) : m;
Affine pre = xf.mul(Affine.scale(1, -1, -1))
.mul(Affine.scale(1 / 16.0))
.mul(CemGeometry.CEM_TO_JAVA);
for (CemGeometry.Baked b : CemGeometry.bakeModel(model, tex, pre, Set.of())) {
out.add(new EntityCube(b.from(), b.to(), b.faces(), b.world()));
}
}
default -> {
// Block-entity specials (chest, head, banner, shulker box, bed, conduit, pot, signs, …).
if (item.blockEntity() != null) bakeBlockEntity(item.blockEntity(), m, out);
}
}
}
private void bakeGenerated(Info info, Affine m, List<EntityCube> out) {
// Cached per Info (one per item+context): a stable texture identity keeps the extrusion cache bounded.
int[][] composed = composedSprites
.computeIfAbsent(info, k -> Optional.ofNullable(compose(k)))
.orElse(null);
if (composed != null) addExtruded(composed, m, out);
}
/** The generated item's layers composited (tinted) into one sprite, or null. */
private int[][] compose(Info info) {
int[][] composed = null;
for (int i = 0; i < info.layers.size(); i++) {
int[][] t = textures.get(ResourceLocation.parse(info.layers.get(i))).orElse(null);
if (t == null) continue;
int tint = i < info.tints.size() ? info.tints.get(i) : 0;
if (composed == null) {
composed = TextureOps.deepCopy(t);
if (tint != 0) TextureOps.tint(composed, tint);
} else if (t.length == composed.length && t[0].length == composed[0].length) {
int[][] layer = t;
if (tint != 0) {
layer = TextureOps.deepCopy(t);
TextureOps.tint(layer, tint);
}
TextureOps.overlay(composed, layer);
}
}
return composed;
}
private int[][] sprite(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);
}
private record Box(double[] from, double[] to, Face[] faces) {}
/**
* Vanilla-style extruded sprite in the 0..1 cube, 1px thick at z 7.5..8.5 px: one box per horizontal run
* of opaque texels; front/back show the texture, the edges the run's texel colours.
*/
private void addExtruded(int[][] tex, Affine m, List<EntityCube> out) {
List<Box> boxes;
synchronized (extrusions) {
boxes = extrusions.computeIfAbsent(tex, ItemGeometry::extrude);
}
for (Box b : boxes) out.add(new EntityCube(b.from, b.to, b.faces, m));
}
private static List<Box> extrude(int[][] tex) {
int h = tex.length, w = h == 0 ? 0 : tex[0].length;
List<Box> boxes = new ArrayList<>();
double z0 = 7.5 / 16, z1 = 8.5 / 16;
for (int py = 0; py < h; py++) {
for (int px = 0; px < w; ) {
if (!ColorUtil.opaque(tex[py][px])) {
px++;
continue;
}
int start = px;
while (px < w && ColorUtil.opaque(tex[py][px])) px++;
double u1 = (double) start / w, u2 = (double) px / w, v1 = (double) py / h, v2 = (double) (py + 1) / h;
double vm = (py + 0.5) / h;
Face[] f = new Face[6];
f[Direction.SOUTH.ordinal()] = new Face(tex, u1, v1, u2, v2, 0, -1); // front (+Z)
f[Direction.NORTH.ordinal()] = new Face(tex, u2, v1, u1, v2, 0, -1); // back, same texel per point
f[Direction.UP.ordinal()] = new Face(tex, u1, vm, u2, vm, 0, -1);
f[Direction.DOWN.ordinal()] = new Face(tex, u1, vm, u2, vm, 0, -1);
double uw = (start + 0.5) / w, ue = (px - 0.5) / w;
f[Direction.WEST.ordinal()] = new Face(tex, uw, vm, uw, vm, 0, -1);
f[Direction.EAST.ordinal()] = new Face(tex, ue, vm, ue, vm, 0, -1);
boxes.add(new Box(new double[] {u1, 1 - v2, z0}, new double[] {u2, 1 - v1, z1}, f));
}
}
return boxes;
}
/** Vanilla blockstate rotation (x steps then y steps, clockwise, about the block centre); see Element#rotate. */
private static Affine variantRotation(int xSteps, int ySteps) {
return Affine.translation(0.5, 0.5, 0.5)
.mul(Affine.rotY(-Math.PI / 2 * ySteps))
.mul(Affine.rotX(-Math.PI / 2 * xSteps))
.mul(Affine.translation(-0.5, -0.5, -0.5));
}
/** Element rotation (origin/axis/angle, optional rescale) as an affine in block units. */
private static Affine elementTransform(Element e) {
if (e.isAxisAligned()) return Affine.identity();
double[] o = e.rotOrigin;
Affine r =
switch (e.rotAxis) {
case 0 -> Affine.rotX(e.rotAngleRad);
case 1 -> Affine.rotY(e.rotAngleRad);
default -> Affine.rotZ(e.rotAngleRad);
};
if (e.rescale) {
double f = 1 / Math.cos(e.rotAngleRad);
r = r.mul(Affine.scale(e.rotAxis == 0 ? 1 : f, e.rotAxis == 1 ? 1 : f, e.rotAxis == 2 ? 1 : f));
}
return Affine.translation(o[0], o[1], o[2]).mul(r).mul(Affine.translation(-o[0], -o[1], -o[2]));
}
/** Bakes a biome tint into a copy of the face texture (the entity path has no per-hit tinting). */
private Face tint(Face f, BlockData data) {
if (f == null || f.tintIndex < 0) return f;
int color = TintResolver.resolve(data, f.tintIndex, DEFAULT_TINT);
if (color == -1) return f;
int[][] t;
synchronized (tinted) {
t = tinted.computeIfAbsent(f.texture, k -> new HashMap<>()).computeIfAbsent(color, c -> {
int[][] copy = TextureOps.deepCopy(f.texture);
TextureOps.tint(copy, c);
return copy;
});
}
return new Face(t, f.u1, f.v1, f.u2, f.v2, f.rotation, -1);
}
}
@@ -0,0 +1,109 @@
package eu.mhsl.minecraft.pixelpics.render.entity.cem;
import eu.mhsl.minecraft.pixelpics.assets.font.BitmapFont;
import eu.mhsl.minecraft.pixelpics.assets.font.Glyph;
import java.util.List;
/**
* Rasterizes a sign side's text lines into a transparent ARGB grid ({@code int[][]}, top-left origin) at
* the font's native pixel resolution. Lines are centred horizontally; the block of lines fills the canvas
* top-to-bottom (blank lines reserve their slot so vertical centring matches vanilla, which always lays
* out four lines). The baker maps the result onto a quad whose model size keeps the canvas aspect, so the
* text is never stretched. Glyphs from different providers (ascii/accented) are aligned on a common
* baseline. Glowing text gets an 8-directional outline.
*/
public final class SignTextRasterizer {
private SignTextRasterizer() {}
/** Returns the rasterized panel, or null when there is nothing to draw (no font/glyphs). */
public static int[][] rasterize(List<String> lines, BitmapFont font, int fillArgb, int outlineArgb, boolean glow) {
if (font.isEmpty() || lines.isEmpty()) return null;
int ascent = font.maxAscent();
int pitch = font.lineHeight(); // ascent + descent
int margin = glow ? 1 : 0; // room for the outline
int contentW = 1;
for (String line : lines) contentW = Math.max(contentW, lineWidth(line, font));
int contentH = Math.max(1, lines.size() * pitch);
int w = contentW + 2 * margin;
int h = contentH + 2 * margin;
int[][] out = new int[h][w];
boolean[][] covered = new boolean[h][w];
// Pass 1: stamp glyph coverage (fill colour) into `out` + `covered`.
boolean any = false;
for (int li = 0; li < lines.size(); li++) {
String line = lines.get(li);
int penX = margin + (contentW - lineWidth(line, font)) / 2;
int baseline = margin + li * pitch + ascent;
for (int ci = 0; ci < line.length(); ) {
int cp = line.codePointAt(ci);
ci += Character.charCount(cp);
Glyph g = font.glyph(cp);
if (g != null) {
any |= blit(g, penX, baseline, fillArgb, out, covered);
}
penX += font.advance(cp);
}
}
if (!any) return null;
// Pass 2 (glow only): paint the outline into uncovered neighbours of covered pixels.
if (glow) {
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
if (!covered[y][x]) continue;
for (int dy = -1; dy <= 1; dy++) {
for (int dx = -1; dx <= 1; dx++) {
if (dx == 0 && dy == 0) continue;
int nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
if (!covered[ny][nx]) out[ny][nx] = outlineArgb;
}
}
}
}
}
return out;
}
/** Blits a glyph's opaque pixels as {@code fillArgb}; aligns the glyph's baseline to {@code baseline}. */
private static boolean blit(Glyph g, int penX, int baseline, int fillArgb, int[][] out, boolean[][] covered) {
double scale = g.height() / (double) g.cellH();
int rw = Math.max(1, (int) (g.glyphPx() * scale + 0.5));
int rh = g.height();
int top = baseline - g.ascent();
boolean any = false;
for (int dy = 0; dy < rh; dy++) {
int oy = top + dy;
if (oy < 0 || oy >= out.length) continue;
int sy = g.srcY() + (int) (dy / scale);
for (int dx = 0; dx < rw; dx++) {
int ox = penX + dx;
if (ox < 0 || ox >= out[0].length) continue;
int sx = g.srcX() + (int) (dx / scale);
int argb = g.tex()[sy][sx];
if (((argb >>> 24) & 0xFF) == 0) continue;
out[oy][ox] = fillArgb;
covered[oy][ox] = true;
any = true;
}
}
return any;
}
/** Total advance width of a line in font px. */
/** Width of a line in font pixels (sum of glyph advances). */
public static int lineWidth(String line, BitmapFont font) {
int w = 0;
for (int ci = 0; ci < line.length(); ) {
int cp = line.codePointAt(ci);
ci += Character.charCount(cp);
w += font.advance(cp);
}
return w;
}
}
@@ -0,0 +1,216 @@
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 java.util.ArrayList;
import java.util.List;
import org.bukkit.util.Vector;
/**
* 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 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;
if (element.unrotated != null) {
// Blockstate rotation without uvlock: hit the model-space element with the inverse-rotated ray
// (same t: rigid transform), so faces and UVs stay bound to the model like in vanilla.
Element m = element.unrotated;
double[] mo = Element.unrotate(ox, oy, oz, element.xSteps, element.ySteps, true);
double[] md = Element.unrotate(dx, dy, dz, element.xSteps, element.ySteps, false);
c = m.isAxisAligned()
? intersectAabb(m, mo[0], mo[1], mo[2], md[0], md[1], md[2])
: intersectObb(m, mo[0], mo[1], mo[2], md[0], md[1], md[2]);
if (c != null) {
double[] n = Element.rotate(
c.normal[0], c.normal[1], c.normal[2], element.xSteps, element.ySteps, false);
c = new Candidate(c.element, c.t, c.dir, c.s, c.t2, n, 0);
}
} else {
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.opaque(color)) 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[] st = faceUv(dir, fracX, fracY, fracZ);
double s = st[0], t = st[1];
return new Candidate(e, tEntry, dir, s, t, normal, 0);
}
/**
* Face-local (s, t) in 0..1 for a hit at block fractions (fx, fy, fz), matching vanilla's
* {@code BlockElement.uvsByFace}: a face's UV rect {@code [u1,v1,u2,v2]} is lerped with these, so north/east
* faces run their horizontal axis towards -x/-z (left-to-right seen from outside) and down runs v towards
* north. Shared with the entity intersector (entity box-UV uses the same orientation).
*/
public static double[] faceUv(Direction dir, double fx, double fy, double fz) {
return switch (dir) {
case UP -> new double[] {fx, fz};
case DOWN -> new double[] {fx, 1 - fz};
case NORTH -> new double[] {1 - fx, 1 - fy};
case SOUTH -> new double[] {fx, 1 - fy};
case WEST -> new double[] {fz, 1 - fy};
case EAST -> new double[] {1 - fz, 1 - fy};
};
}
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,9 @@
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) {}
@@ -0,0 +1,332 @@
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 java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
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 Map<Long, BiomeTint> tintCache = new 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(direction, hit.normal());
Vector reflectStart =
hit.point().clone().add(hit.normal().clone().multiply(1e-3));
reflectionColor = trace(snapshot, reflectStart, reflectDir, sky, scene, depth - 1);
reflectionFactor = model.reflection;
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 = ColorUtil.mix(
baseColor,
transparencyColor,
transparencyFactor,
(1 - transparencyFactor)
* (1
+ transparencyStart.distance(finalPoint == null ? transparencyStart : finalPoint)
/ 5.0));
}
if (reflected) {
baseColor = ColorUtil.mix(baseColor, reflectionColor, 1 - reflectionFactor, reflectionFactor);
}
// Distance fog (atmospheric perspective): fade distant geometry toward the sky color.
if (finalPoint != null) {
double fog = fogFactor(origin.distance(finalPoint));
if (fog > 0) baseColor = ColorUtil.mix(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;
}
}
}
}
@@ -0,0 +1,247 @@
package eu.mhsl.minecraft.pixelpics.render.render;
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import eu.mhsl.minecraft.pixelpics.render.entity.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.entity.ObjectBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.ObjectState;
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.cem.ItemGeometry;
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.FrustumBounds;
import eu.mhsl.minecraft.pixelpics.render.snapshot.ObjectSnapshotBuilder;
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 java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Logger;
import java.util.stream.IntStream;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Entity;
import org.bukkit.util.Vector;
/**
* 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 {
/** 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 static final double MAX_DISTANCE = 256;
private static final int REFLECTION_DEPTH = 4;
/** 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 ObjectBaker objectBaker;
private final Logger logger;
/** Bounds parallel ray tracing to a fixed, low-priority pool; {@code null} = use the common pool. */
private final ForkJoinPool tracePool;
public DefaultScreenRenderer(
BlockModelRegistry registry,
BiomeTintProvider tintProvider,
TextureCache textures,
CemBaker entityBaker,
BlockEntityBaker blockEntityBaker,
Logger logger) {
this(registry, tintProvider, textures, entityBaker, blockEntityBaker, logger, null);
}
public DefaultScreenRenderer(
BlockModelRegistry registry,
BiomeTintProvider tintProvider,
TextureCache textures,
CemBaker entityBaker,
BlockEntityBaker blockEntityBaker,
Logger logger,
ForkJoinPool tracePool) {
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);
ItemGeometry items = new ItemGeometry(
registry.reader(),
registry,
textures,
entityBaker == null ? null : entityBaker.models(),
blockEntityBaker);
if (entityBaker != null) entityBaker.setItemGeometry(items);
this.objectBaker = new ObjectBaker(items, blockEntityBaker == null ? null : blockEntityBaker.font());
this.logger = logger;
this.tracePool = tracePool;
}
/** Convenience: prepare and execute in one call (must run on the main thread). */
@Override
public BufferedImage render(Location eyeLocation, Resolution resolution) {
return execute(prepare(eyeLocation, resolution, null));
}
/** 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);
Collection<Entity> nearby = FrustumBounds.nearbyEntities(eyeLocation, rayMap, MAX_DISTANCE);
List<EntityState> entities = EntitySnapshotBuilder.build(nearby, shooter);
List<BlockEntityState> blockEntities = BlockEntitySnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE);
List<DecorationState> decorations = DecorationSnapshotBuilder.build(nearby);
List<ObjectState> objects = ObjectSnapshotBuilder.build(nearby, eyeLocation);
World world = eyeLocation.getWorld();
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,
objects);
}
/** Traces every (super)ray in parallel, then downsamples gamma-correctly. Safe off the main thread. */
public BufferedImage execute(RenderJob job) {
return execute(job, new AtomicBoolean(false));
}
/**
* As {@link #execute(RenderJob)}, but cooperatively abortable: once {@code cancelled} is set, the
* parallel loops short-circuit (remaining work becomes a no-op) so a stuck/overlong render drains
* out quickly. The returned image is then partial and should be discarded by the caller.
*/
public BufferedImage execute(RenderJob job, AtomicBoolean cancelled) {
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,
job.objects(),
objectBaker);
int[] superBuf = new int[rayMap.size()];
runParallel(() -> IntStream.range(0, rayMap.size()).parallel().forEach(i -> {
if (!cancelled.get()) 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();
runParallel(() -> IntStream.range(0, finalH).parallel().forEach(fy -> {
if (cancelled.get()) return;
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;
}
/**
* Runs a parallel-stream task. With a {@link #tracePool} the work is confined to that bounded,
* low-priority pool (parallel streams adopt the pool of the running fork-join worker); without one
* it runs inline on the common pool (used by the offline render tools).
*/
private void runParallel(Runnable task) {
if (tracePool == null) {
task.run();
} else {
tracePool.submit(task).join();
}
}
private List<Vector> buildRayMap(Location eyeLocation, int width, int height) {
Vector lineDirection = eyeLocation.getDirection();
double x = lineDirection.getX();
double y = lineDirection.getY();
double z = lineDirection.getZ();
double angleYaw = Math.atan2(z, x);
double anglePitch = Math.atan2(y, Math.sqrt(x * x + z * z));
// 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);
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));
for (int yaw = 0; yaw < width; yaw++) {
Vector ray =
leftPitch.clone().add(yawFraction.clone().multiply(yaw)).normalize();
rayMap.add(ray);
}
}
return rayMap;
}
}
@@ -0,0 +1,67 @@
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.entity.ObjectState;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
import java.util.List;
import org.bukkit.util.Vector;
/**
* 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,
List<ObjectState> objects) {
/** Without display/item/falling-block objects. */
public RenderJob(
WorldSnapshot snapshot,
List<Vector> rayMap,
Vector origin,
int width,
int height,
SkyContext sky,
List<EntityState> entities,
List<BlockEntityState> blockEntities,
List<DecorationState> decorations) {
this(snapshot, rayMap, origin, width, height, sky, entities, blockEntities, decorations, List.of());
}
/** 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,8 @@
package eu.mhsl.minecraft.pixelpics.render.render;
import java.awt.image.BufferedImage;
import org.bukkit.Location;
public interface Renderer {
BufferedImage render(Location eyeLocation, Resolution resolution);
}
@@ -0,0 +1,55 @@
package eu.mhsl.minecraft.pixelpics.render.render;
import com.google.common.base.Preconditions;
public final class Resolution {
private final int width;
private final int height;
public Resolution(Pixels pixels, AspectRatio aspectRatio) {
this(
(int) Math.round(
Preconditions.checkNotNull(pixels).height * Preconditions.checkNotNull(aspectRatio).ratio),
pixels.height);
}
public Resolution(int width, int height) {
Preconditions.checkArgument(width > 0);
Preconditions.checkArgument(height > 0);
this.width = width;
this.height = height;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
public enum Pixels {
_128P(128),
_256P(256);
private final int height;
Pixels(int height) {
this.height = height;
}
}
public enum AspectRatio {
_1_1(1),
_2_1(2),
_3_2(3 / 2.0);
private final double ratio;
AspectRatio(double ratio) {
this.ratio = ratio;
}
}
}
@@ -0,0 +1,8 @@
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,290 @@
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 = Math.clamp(dy, 0, 1);
int dayColor = lerp(DAY_HORIZON, DAY_ZENITH, up);
int nightColor = lerp(NIGHT_HORIZON, NIGHT_ZENITH, up);
int color = lerp(nightColor, dayColor, dayFactor);
// Sunrise/sunset: a full-sky warm wash (orange at the horizon -> red -> purple at the zenith),
// strongest while the sun is near the horizon and warmer toward its azimuth. Matches vanilla.
double twilight = Math.clamp(1 - Math.abs(sunY) / 0.45, 0, 1);
if (twilight > 0) {
double az = Math.clamp(dx * Math.signum(sunX) * 0.5 + 0.5, 0, 1); // 1 toward sun .. 0 away
int grad = up < 0.40
? lerp(SUNSET_ORANGE, SUNSET_RED, up / 0.40)
: lerp(SUNSET_RED, TWI_PURPLE, (up - 0.40) / 0.60);
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(Math.clamp(cosSun, 0, 1), 16) * Math.clamp(sunY + 0.3, 0, 1);
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 = Math.clamp((dy - 0.02) * 4, 0, 1); // 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 = Math.clamp(t, 0, 1);
return ColorUtil.mix(a, b, 1 - t, t);
}
private static int add(int c, int r, int g, int b) {
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 = Math.clamp((x - edge0) / (edge1 - edge0), 0, 1);
return t * t * (3 - 2 * t);
}
private static int clamp(int v, int lo, int hi) {
return v < lo ? lo : Math.min(v, hi);
}
}
@@ -0,0 +1,454 @@
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 eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
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.Sign;
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.block.data.type.Bed;
import org.bukkit.block.data.type.Bell;
import org.bukkit.block.data.type.Chest;
import org.bukkit.block.sign.Side;
import org.bukkit.block.sign.SignSide;
import org.bukkit.profile.PlayerProfile;
import org.bukkit.util.Vector;
/**
* 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();
FrustumBounds bounds = FrustumBounds.of(eye.toVector(), rayMap, maxDistance);
// 1-block margin so block-entities straddling the frustum edge are still captured.
int bMinX = (int) Math.floor(bounds.minX) - 1, bMaxX = (int) Math.ceil(bounds.maxX) + 1;
int bMinY = (int) Math.floor(bounds.minY) - 1, bMaxY = (int) Math.ceil(bounds.maxY) + 1;
int bMinZ = (int) Math.floor(bounds.minZ) - 1, bMaxZ = (int) Math.ceil(bounds.maxZ) + 1;
int minCX = bMinX >> 4, maxCX = bMaxX >> 4, minCZ = bMinZ >> 4, maxCZ = bMaxZ >> 4;
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) {
return classify(ts.getBlockData(), ts.getX(), ts.getY(), ts.getZ(), ts);
}
/**
* A block-entity state from bare block data (no world tile entity) at cell (0,0,0): for block-entity
* blocks shown by BLOCK DISPLAYS, minecarts and special ITEMS (chest/head/banner/shulker/… items).
* Tile-only details (banner patterns, sign text, head skins) are absent. MUST run on the main thread.
* Returns null for blocks that aren't block-entity rendered.
*/
public static BlockEntityState fromBlockData(BlockData data) {
try {
return classify(data, 0, 0, 0, null);
} catch (Throwable t) {
return null;
}
}
private static BlockEntityState classify(BlockData data, int bx, int by, int bz, BlockState ts) {
Material mat = data.getMaterial();
String n = mat.name();
// --- 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 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 Bed bed) {
BedPart part = bed.getPart() == 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)) {
try { // no tile: the base colour is in the block name
String colour =
stripColor(n, wall ? "_WALL_BANNER" : "_BANNER").toUpperCase(Locale.ROOT);
b.baseColorArgb(ColorUtil.dyeArgb(DyeColor.valueOf(colour), 0xFFFFFFFF));
} catch (IllegalArgumentException ignored) {
// unknown colour name
}
}
if (ts instanceof Banner banner) {
DyeColor base = banner.getBaseColor();
b.baseColorArgb(ColorUtil.dyeArgb(base, 0xFFFFFFFF));
List<BannerPattern> pats = new ArrayList<>();
for (Pattern p : banner.getPatterns()) {
pats.add(new BannerPattern(patternKey(p), ColorUtil.dyeArgb(p.getColor(), 0xFFFFFFFF)));
}
b.patterns(pats);
}
return b.build();
}
// --- signs ---
if (n.endsWith("_SIGN")) {
String wood = signWood(n);
Kind kind;
float yaw;
if (n.endsWith("_WALL_SIGN")) {
kind = Kind.WALL_SIGN;
yaw = facingYaw(data);
} else if (n.endsWith("_HANGING_SIGN")) {
kind = Kind.HANGING_SIGN;
yaw = n.endsWith("_WALL_HANGING_SIGN") ? facingYaw(data) : rotationYaw(data);
} else {
kind = Kind.SIGN;
yaw = rotationYaw(data);
}
Builder b = base(kind, bx, by, bz, yaw).wood(wood);
if (ts instanceof Sign sign) {
b.frontText(signText(sign.getSide(Side.FRONT)));
b.backText(signText(sign.getSide(Side.BACK)));
}
return b.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 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 ---
/** Yaw preferring the block's {@link Directional} facing (wall-mounted block-entities). */
private static float facingYaw(BlockData data) {
if (data instanceof Directional d) return faceToYaw(d.getFacing());
if (data instanceof Rotatable r) return faceToYaw(r.getRotation());
return 0;
}
/** Yaw preferring the block's {@link Rotatable} rotation (free-standing block-entities). */
private static float rotationYaw(BlockData data) {
if (data instanceof Rotatable r) return faceToYaw(r.getRotation());
return facingYaw(data);
}
/** 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(Locale.ROOT);
}
/** One sign side → {@link BlockEntityState.SignText}, or null when all four lines are blank. */
private static BlockEntityState.SignText signText(SignSide side) {
String[] raw = side.getLines();
List<String> lines = new ArrayList<>(raw.length);
boolean any = false;
for (String l : raw) {
String s = l == null ? "" : l.replaceAll("§.", ""); // strip legacy §-codes
if (!s.isEmpty()) any = true;
lines.add(s);
}
if (!any) return null;
DyeColor dye = side.getColor();
boolean glow = side.isGlowingText();
return new BlockEntityState.SignText(
lines, ColorUtil.signFillArgb(dye, glow), ColorUtil.signOutlineArgb(dye), glow);
}
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(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 "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(Locale.ROOT));
}
return out;
}
private static String skinUrl(Skull skull) {
try {
PlayerProfile profile = skull.getOwnerProfile();
if (profile != null && profile.getTextures().getSkin() != null) {
return profile.getTextures().getSkin().toString();
}
} catch (Throwable ignored) {
}
return null;
}
/** The banner pattern's path key (e.g. "stripe_bottom"); every PatternType key accessor is marked
* for removal in this API with no stable replacement, so the warning is suppressed here. */
@SuppressWarnings("removal")
private static String patternKey(Pattern p) {
return p.getPattern().getKey().getKey();
}
// --- small fluent builder to keep the 15-field record construction readable ---
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;
private BlockEntityState.SignText frontText;
private BlockEntityState.SignText backText;
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;
}
Builder frontText(BlockEntityState.SignText v) {
this.frontText = v;
return this;
}
Builder backText(BlockEntityState.SignText v) {
this.backText = v;
return this;
}
BlockEntityState build() {
return new BlockEntityState(
kind,
bx,
by,
bz,
yaw,
chestKind,
baseColorArgb,
colorName,
wood,
bedPart,
headType,
skinUrl,
patterns,
sherds,
bellAttach,
frontText,
backText);
}
}
}
@@ -0,0 +1,94 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.bukkit.block.BlockFace;
import org.bukkit.entity.Entity;
import org.bukkit.entity.GlowItemFrame;
import org.bukkit.entity.ItemFrame;
import org.bukkit.entity.Painting;
import org.bukkit.inventory.ItemStack;
import org.bukkit.util.BoundingBox;
/**
* 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() {}
/** {@code nearby}: {@link FrustumBounds#nearbyEntities} of this photo. */
public static List<DecorationState> build(Collection<Entity> nearby) {
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 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,479 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import com.destroystokyo.paper.profile.ProfileProperty;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import io.papermc.paper.datacomponent.DataComponentTypes;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import java.util.function.DoubleSupplier;
import org.bukkit.Keyed;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.entity.AbstractHorse;
import org.bukkit.entity.Ageable;
import org.bukkit.entity.ArmorStand;
import org.bukkit.entity.Axolotl;
import org.bukkit.entity.Cat;
import org.bukkit.entity.ChestedHorse;
import org.bukkit.entity.Chicken;
import org.bukkit.entity.Cow;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Fox;
import org.bukkit.entity.Frog;
import org.bukkit.entity.Horse;
import org.bukkit.entity.HumanEntity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Llama;
import org.bukkit.entity.Mob;
import org.bukkit.entity.MushroomCow;
import org.bukkit.entity.Panda;
import org.bukkit.entity.Parrot;
import org.bukkit.entity.Pig;
import org.bukkit.entity.Player;
import org.bukkit.entity.PufferFish;
import org.bukkit.entity.Rabbit;
import org.bukkit.entity.Sheep;
import org.bukkit.entity.Shulker;
import org.bukkit.entity.Slime;
import org.bukkit.entity.Snowman;
import org.bukkit.entity.TraderLlama;
import org.bukkit.entity.TropicalFish;
import org.bukkit.entity.Villager;
import org.bukkit.entity.Wolf;
import org.bukkit.entity.ZombieVillager;
import org.bukkit.inventory.EntityEquipment;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.MainHand;
import org.bukkit.inventory.meta.ArmorMeta;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.inventory.meta.LeatherArmorMeta;
import org.bukkit.potion.PotionEffectType;
/**
* 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 Set<String> NON_RENDERABLE = 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");
// Entities whose vanilla renderer draws the humanoid armor layers (HumanoidArmorLayer) and held
// items. Their CEM bodies share standard humanoid proportions, so the armor_layer_1/2 models align.
private static final Set<String> HUMANOID_ARMOR_WEARERS = Set.of(
"player",
"mannequin",
"armor_stand",
"giant",
"zombie",
"husk",
"drowned",
"zombie_villager",
"zombified_piglin",
"skeleton",
"stray",
"wither_skeleton",
"bogged",
"piglin",
"piglin_brute");
/** {@code nearby}: {@link FrustumBounds#nearbyEntities} of this photo. */
public static List<EntityState> build(Collection<Entity> nearby, UUID shooter) {
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)) return null;
float bodyYaw = loc.getYaw();
if (e instanceof LivingEntity le) {
bodyYaw = le.getBodyYaw();
}
// (Zombie implements Ageable — no extra case; an earlier "|| zombie.isAdult()" made ADULT zombies babies.)
boolean baby = (e instanceof Ageable a && !a.isAdult()) || (e instanceof ArmorStand st && st.isSmall());
// Invisible entities render only their equipment (like vanilla): the generic invisible flag, an
// invisibility potion effect, or an explicitly-hidden armor stand.
boolean invisible = e.isInvisible()
|| (e instanceof ArmorStand as && !as.isVisible())
|| (e instanceof LivingEntity inv && inv.hasPotionEffect(PotionEffectType.INVISIBILITY));
double width = safeDim(e::getWidth, () -> e.getBoundingBox().getWidthX());
double height = safeDim(e::getHeight, () -> e.getBoundingBox().getHeight());
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;
String profession = null;
int villagerLevel = 0;
String markings = null;
boolean saddle = false;
boolean chest = false;
String bodyEquip = null;
int bodyDye = 0;
int tint2 = 0;
try {
// Slime & magma cube (MagmaCube extends Slime) scale their model by size (1/2/4).
if (e instanceof Slime sl) sizeScale = sl.getSize();
// MushroomCow extends Cow, ZombieVillager does not extend Villager — order matters.
if (e instanceof Sheep sh) {
tint = ColorUtil.dyeArgb(sh.getColor(), 0);
if (sh.isSheared()) variant = "sheared";
} else if (e instanceof TropicalFish tf) {
variant = keyOf(tf.getPattern());
tint = ColorUtil.dyeArgb(tf.getBodyColor(), 0);
tint2 = ColorUtil.dyeArgb(tf.getPatternColor(), 0);
} else if (e instanceof PufferFish pf) {
variant = switch (pf.getPuffState()) {
case 0 -> "small";
case 1 -> "mid";
default -> "big";};
} else if (e instanceof Cat c) {
variant = keyOf(c.getCatType());
} else if (e instanceof Wolf w) {
variant = keyOf(w.getVariant());
} else if (e instanceof Axolotl a) {
variant = keyOf(a.getVariant());
} else if (e instanceof Parrot p) {
variant = keyOf(p.getVariant());
} else if (e instanceof Rabbit r) {
variant = keyOf(r.getRabbitType());
} else if (e instanceof Horse h) {
variant = keyOf(h.getColor());
markings = markingsKey(h.getStyle());
saddle = isSaddled(h);
bodyEquip = horseArmorKey(h);
} else if (e instanceof Llama l) {
variant = keyOf(l.getColor());
chest = l.isCarryingChest();
// Trader llamas wear a fixed decor; normal llamas carry a dyed carpet in the decor slot.
bodyEquip = (e instanceof TraderLlama) ? "trader_llama" : carpetKey(l);
} else if (e instanceof ChestedHorse ch) {
// Donkey & mule (llama already handled above).
chest = ch.isCarryingChest();
saddle = isSaddled(ch);
} else if (e instanceof AbstractHorse ah) {
// Skeleton/zombie horse: only saddle (no colour/markings/armor variants).
saddle = isSaddled(ah);
} else if (e instanceof Fox f) {
variant = keyOf(f.getFoxType());
} else if (e instanceof MushroomCow mc) {
variant = keyOf(mc.getVariant());
} else if (e instanceof Panda pa) {
variant = keyOf(pa.getMainGene());
} else if (e instanceof Frog fr) {
variant = keyOf(fr.getVariant());
} else if (e instanceof Shulker s) {
variant = s.getColor() == null ? null : keyOf(s.getColor());
} else if (e instanceof ZombieVillager zv) {
variant = keyOf(zv.getVillagerType());
// ZombieVillager exposes no level via Bukkit -> no profession-level badge (matches vanilla).
} else if (e instanceof Villager vi) {
variant = keyOf(vi.getVillagerType());
profession = keyOf(vi.getProfession());
villagerLevel = vi.getVillagerLevel();
} else if (e instanceof Cow co) {
variant = keyOf(co.getVariant());
} else if (e instanceof Pig pg) {
variant = keyOf(pg.getVariant());
} else if (e instanceof Chicken ch) {
variant = keyOf(ch.getVariant());
}
if (e instanceof ArmorStand st && st.hasArms()) variant = "arms";
// Any other mob with a variant/weathering getter (new mobs, copper golem, zombie nautilus, …).
if (variant == null) variant = genericVariant(e);
} catch (Throwable ignored) {
// Unsupported on this server version — fall back to the base texture.
}
// Equipment slots: saddle + BODY equipment (horse armor, llama carpet, harness, wolf/nautilus armor, …)
// for every mount — including ones added in newer versions — plus held items (by hand side, like
// vanilla: a left-handed mob holds its main-hand item left) and a non-armor head item.
ItemSpec rightHand = null, leftHand = null, headItem = null;
EntityEquipment eq = e instanceof LivingEntity le ? le.getEquipment() : null;
if (eq != null) {
try {
ItemStack sd = eq.getItem(EquipmentSlot.SADDLE);
if (sd != null && !sd.getType().isAir()) saddle = true;
ItemStack body = eq.getItem(EquipmentSlot.BODY);
String asset = equipAsset(body);
if (asset != null && bodyEquip == null) bodyEquip = asset;
if (asset != null) bodyDye = dyeOf(body);
} catch (Throwable ignored) {
// slot not supported by this entity / server version
}
try {
boolean leftHanded = (e instanceof Mob mob && mob.isLeftHanded())
|| (e instanceof HumanEntity he && he.getMainHand() == MainHand.LEFT);
ItemSpec main = ItemSpecs.of(eq.getItemInMainHand());
ItemSpec off = ItemSpecs.of(eq.getItemInOffHand());
rightHand = leftHanded ? off : main;
leftHand = leftHanded ? main : off;
ItemStack helmet = eq.getHelmet();
if (helmet != null && !helmet.getType().isAir() && armorAsset(helmet.getType()) == null) {
headItem = ItemSpecs.of(helmet);
}
// Snow golems wear a carved pumpkin unless sheared (vanilla SnowGolemHeadLayer).
if (headItem == null && e instanceof Snowman sg && !sg.isDerp()) {
headItem = ItemSpecs.of(new ItemStack(Material.CARVED_PUMPKIN));
}
} catch (Throwable ignored) {
// no hands / equipment on this entity
}
}
EntityState.Equipment equipment = null;
if (HUMANOID_ARMOR_WEARERS.contains(type) && e instanceof LivingEntity wearer) {
equipment = captureEquipment(wearer);
}
return new EntityState(
type,
loc.getX(),
loc.getY(),
loc.getZ(),
bodyYaw,
baby,
width,
height,
player,
skinUrl,
slim,
variant,
tint,
sizeScale,
profession,
villagerLevel,
markings,
saddle,
chest,
bodyEquip,
equipment,
invisible,
loc.getPitch(),
bodyDye,
tint2,
e.isInsideVehicle(),
rightHand,
leftHand,
headItem);
}
/** Worn armor (4 slots) from a humanoid wearer; null when nothing is equipped. */
private static EntityState.Equipment captureEquipment(LivingEntity le) {
try {
EntityEquipment eq = le.getEquipment();
if (eq == null) return null;
EntityState.Equipment equip = new EntityState.Equipment(
armorPiece(eq.getHelmet()), armorPiece(eq.getChestplate()),
armorPiece(eq.getLeggings()), armorPiece(eq.getBoots()));
return equip.isEmpty() ? null : equip;
} catch (Throwable t) {
return null;
}
}
/** One armor slot -> EquipPiece (asset, leather dye, trim, glint); null for empty / non-armor items. */
private static EntityState.EquipPiece armorPiece(ItemStack it) {
if (it == null || it.getType().isAir()) return null;
String asset = armorAsset(it.getType());
if (asset == null) return null; // not a humanoid-armor item (e.g. a mob head / pumpkin)
int dye = 0;
String trimMat = null, trimPat = null;
boolean glint = false;
if (it.hasItemMeta()) {
ItemMeta meta = it.getItemMeta();
glint = meta.hasEnchants();
if (meta instanceof LeatherArmorMeta lam) dye = lam.getColor().asARGB();
if (meta instanceof ArmorMeta am && am.getTrim() != null) {
trimMat = keyOf(am.getTrim().getMaterial());
trimPat = keyOf(am.getTrim().getPattern());
}
}
return new EntityState.EquipPiece(asset, dye, trimMat, trimPat, glint);
}
/** Item Material -> equipment asset id (= texture name): strips the slot suffix; null if not armor. */
private static String armorAsset(Material m) {
String key = m.getKey().getKey();
if (key.equals("elytra")) return "elytra";
if (key.equals("turtle_helmet")) return "turtle_scute";
String base = null;
for (String suf : new String[] {"_helmet", "_chestplate", "_leggings", "_boots"}) {
if (key.endsWith(suf)) {
base = key.substring(0, key.length() - suf.length());
break;
}
}
if (base == null) return null;
return base.equals("golden") ? "gold" : base;
}
/** Horse coat markings overlay key (vanilla texture suffix); null for the plain NONE style. */
private static String markingsKey(Horse.Style style) {
if (style == null || style == Horse.Style.NONE) return null;
return style.name().toLowerCase(Locale.ROOT).replace("_", ""); // WHITE_DOTS -> whitedots
}
/** Horse armor material -> equipment/horse_body texture key (golden uses the "gold" file); null if none. */
private static String horseArmorKey(Horse h) {
ItemStack a = h.getInventory().getArmor();
if (a == null || a.getType().isAir()) return null;
String k = a.getType().getKey().getKey().replace("_horse_armor", "");
return k.equals("golden") ? "gold" : k;
}
/** Llama carpet decor -> equipment/llama_body colour key; null if none. */
private static String carpetKey(Llama l) {
ItemStack d = l.getInventory().getDecor();
if (d == null || d.getType().isAir()) return null;
String k = d.getType().getKey().getKey();
return k.endsWith("_carpet") ? k.substring(0, k.length() - "_carpet".length()) : null;
}
/** Equipment asset id (= equipment/<asset> texture name) from an item's EQUIPPABLE component; null if none. */
private static String equipAsset(ItemStack it) {
if (it == null || it.getType().isAir()) return null;
try {
var comp = it.getData(DataComponentTypes.EQUIPPABLE);
if (comp != null && comp.assetId() != null) return comp.assetId().value();
} catch (Throwable ignored) {
}
return null;
}
/** ARGB of an item's DYED_COLOR component (leather horse armor, wolf armor); 0 = undyed. */
private static int dyeOf(ItemStack it) {
try {
var c = it.getData(DataComponentTypes.DYED_COLOR);
return c == null ? 0 : c.color().asARGB();
} catch (Throwable t) {
return 0;
}
}
/** Per entity class: its {@code getVariant()} / {@code getWeatheringState()} getter, or null. */
private static final ClassValue<java.lang.reflect.Method> VARIANT_GETTER = new ClassValue<>() {
@Override
protected java.lang.reflect.Method computeValue(Class<?> c) {
for (String m : new String[] {"getVariant", "getWeatheringState"}) {
try {
return c.getMethod(m);
} catch (NoSuchMethodException ignored) {
// try the next name
}
}
return null;
}
};
/** Variant key via a {@code getVariant()} / {@code getWeatheringState()} getter, if the entity has one. */
private static String genericVariant(Entity e) {
java.lang.reflect.Method m = VARIANT_GETTER.get(e.getClass());
if (m == null) return null;
try {
return keyOf(m.invoke(e));
} catch (ReflectiveOperationException ex) {
return null;
}
}
/** Whether a horse-like mount carries a saddle in its dedicated saddle slot. */
private static boolean isSaddled(AbstractHorse h) {
ItemStack st = h.getInventory().getSaddle();
return st != null && !st.getType().isAir();
}
/** Registry/Keyed values yield their key path; plain enums yield their lower-case name. */
private static String keyOf(Object o) {
return switch (o) {
case null -> null;
case Keyed k -> k.getKey().getKey();
case Enum<?> en -> en.name().toLowerCase(Locale.ROOT);
default -> o.toString().toLowerCase(Locale.ROOT);
};
}
/** Returns {skinUrl, model} from the player's profile texture property, or {null, null}. */
private static String[] resolveSkin(Player player) {
try {
for (ProfileProperty prop : player.getPlayerProfile().getProperties()) {
if (!prop.getName().equals("textures")) continue;
String json = new String(Base64.getDecoder().decode(prop.getValue()), StandardCharsets.UTF_8);
JsonObject root = JsonParser.parseString(json).getAsJsonObject();
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};
}
/** Reads a dimension via {@code primary}, falling back to {@code fallback} on any version mismatch. */
private static double safeDim(DoubleSupplier primary, DoubleSupplier fallback) {
try {
return primary.getAsDouble();
} catch (Throwable t) {
return fallback.getAsDouble();
}
}
}
@@ -0,0 +1,62 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import java.util.Collection;
import java.util.List;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Entity;
import org.bukkit.util.Vector;
/**
* Axis-aligned world-space bounds of the camera frustum: the min/max corner of the camera origin
* together with every ray endpoint at {@code maxDistance}. Shared by all snapshot builders to size
* their chunk/entity queries identically.
*/
public final class FrustumBounds {
final double minX, minY, minZ;
final double maxX, maxY, maxZ;
private FrustumBounds(double minX, double minY, double minZ, double maxX, double maxY, double maxZ) {
this.minX = minX;
this.minY = minY;
this.minZ = minZ;
this.maxX = maxX;
this.maxY = maxY;
this.maxZ = maxZ;
}
static FrustumBounds of(Vector origin, List<Vector> rayMap, double maxDistance) {
double minX = origin.getX(), minY = origin.getY(), minZ = origin.getZ();
double maxX = origin.getX(), maxY = origin.getY(), maxZ = origin.getZ();
for (Vector ray : rayMap) {
double fx = origin.getX() + ray.getX() * maxDistance;
double fy = origin.getY() + ray.getY() * maxDistance;
double fz = origin.getZ() + ray.getZ() * maxDistance;
minX = Math.min(minX, fx);
maxX = Math.max(maxX, fx);
minY = Math.min(minY, fy);
maxY = Math.max(maxY, fy);
minZ = Math.min(minZ, fz);
maxZ = Math.max(maxZ, fz);
}
return new FrustumBounds(minX, minY, minZ, maxX, maxY, maxZ);
}
/** All entities whose centre falls within these bounds expanded by {@code margin} on every side. */
/**
* All entities near the view frustum, queried ONCE per photo and shared by the entity, decoration and
* object snapshot builders. MUST run on the main thread.
*/
public static Collection<Entity> nearbyEntities(Location eye, List<Vector> rayMap, double maxDistance) {
return of(eye.toVector(), rayMap, maxDistance).nearbyEntities(eye.getWorld(), 2);
}
Collection<Entity> nearbyEntities(World world, double margin) {
Location center = new Location(world, (minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2);
double hx = (maxX - minX) / 2 + margin;
double hy = (maxY - minY) / 2 + margin;
double hz = (maxZ - minZ) / 2 + margin;
return world.getNearbyEntities(center, hx, hy, hz);
}
}
@@ -0,0 +1,44 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.Material;
import org.bukkit.block.data.BlockData;
import org.bukkit.inventory.ItemStack;
/** Builds {@link ItemSpec}s from Bukkit item stacks. MUST run on the main thread. */
public final class ItemSpecs {
private ItemSpecs() {}
/** Block data + block-entity state depend only on the material: computed once per material. */
private record BlockInfo(BlockData data, BlockEntityState blockEntity) {}
private static final Map<Material, BlockInfo> BLOCKS = new ConcurrentHashMap<>();
/** The spec for a stack, or null for empty/air. */
public static ItemSpec of(ItemStack stack) {
if (stack == null || stack.getType().isAir()) return null;
Material m = stack.getType();
BlockInfo b = BLOCKS.computeIfAbsent(m, ItemSpecs::blockInfo);
boolean glint;
try {
glint = !stack.getEnchantments().isEmpty(); // no ItemMeta copy
} catch (Throwable t) {
glint = false;
}
return new ItemSpec(m.getKey().getKey(), b.data, b.blockEntity, glint);
}
private static BlockInfo blockInfo(Material m) {
BlockData data = null;
try {
if (m.isBlock()) data = m.createBlockData();
} catch (Throwable ignored) {
// item-only material
}
return new BlockInfo(data, data == null ? null : BlockEntitySnapshotBuilder.fromBlockData(data));
}
}
@@ -0,0 +1,221 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec;
import eu.mhsl.minecraft.pixelpics.render.entity.ObjectState;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.TextColor;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.block.data.BlockData;
import org.bukkit.entity.BlockDisplay;
import org.bukkit.entity.Display;
import org.bukkit.entity.Entity;
import org.bukkit.entity.FallingBlock;
import org.bukkit.entity.HumanEntity;
import org.bukkit.entity.Item;
import org.bukkit.entity.ItemDisplay;
import org.bukkit.entity.LeashHitch;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Minecart;
import org.bukkit.entity.TNTPrimed;
import org.bukkit.entity.TextDisplay;
import org.bukkit.inventory.ItemStack;
import org.bukkit.util.Transformation;
import org.joml.Quaternionf;
/**
* Captures block/item/text content objects into {@link ObjectState}s on the main thread: display
* entities (with their transformation and billboard mode, resolved against the camera), dropped items,
* falling blocks, primed TNT and the block shown inside minecarts.
*/
public final class ObjectSnapshotBuilder {
private ObjectSnapshotBuilder() {}
/** {@code nearby}: {@link FrustumBounds#nearbyEntities} of this photo; {@code eye} orients billboards. */
public static List<ObjectState> build(Collection<Entity> nearby, Location eye) {
List<ObjectState> out = new ArrayList<>();
for (Entity e : nearby) {
try {
ObjectState s = toState(e, eye);
if (s != null) out.add(s);
} catch (Throwable ignored) {
// API differences on other server versions: skip this object
}
try {
ObjectState leash = leash(e);
if (leash != null) out.add(leash);
} catch (Throwable ignored) {
// not leashable on this version
}
}
return out;
}
private static ObjectState toState(Entity e, Location eye) {
Location l = e.getLocation();
Affine at = Affine.translation(l.getX(), l.getY(), l.getZ());
if (e instanceof Display d) {
Affine base = at.mul(billboard(d, eye)).mul(transformation(d.getTransformation()));
if (d instanceof BlockDisplay bd) {
return block(base, bd.getBlock());
}
if (d instanceof ItemDisplay id) {
String ctx = id.getItemDisplayTransform().name().toLowerCase(java.util.Locale.ROOT);
return item(base, id.getItemStack(), ctx, false);
}
if (d instanceof TextDisplay td) {
return text(base, td);
}
return null;
}
if (e instanceof FallingBlock fb) {
return block(at.mul(Affine.translation(-0.5, 0, -0.5)), fb.getBlockData());
}
if (e instanceof TNTPrimed) {
return block(at.mul(Affine.translation(-0.5, 0, -0.5)), Material.TNT.createBlockData());
}
if (e instanceof Item it) {
// Dropped item: vanilla GROUND display transform, lifted by the baker; a fixed per-entity spin.
double spin = (e.getUniqueId().getLeastSignificantBits() & 0xFFFF) / 65536.0 * 2 * Math.PI;
return item(at.mul(Affine.rotY(spin)), it.getItemStack(), "ground", true);
}
if (e instanceof Minecart mc) {
BlockData data = mc.getDisplayBlockData();
if (data == null || data.getMaterial().isAir()) return null;
// Vanilla AbstractMinecartRenderer: cart pivot 0.375 up, yaw/pitch, then the block at 3/4 size.
Affine base = at.mul(Affine.translation(0, 0.375, 0))
.mul(Affine.rotY(Math.PI - Math.toRadians(l.getYaw())))
.mul(Affine.rotZ(-Math.toRadians(l.getPitch())))
.mul(Affine.scale(0.75))
.mul(Affine.translation(-0.5, (mc.getDisplayBlockOffset() - 8) / 16.0, 0.5))
.mul(Affine.rotY(Math.PI / 2));
return block(base, data);
}
return null;
}
private static ObjectState block(Affine toWorld, BlockData data) {
if (data == null || data.getMaterial().isAir()) return null;
BlockEntityState be = BlockEntitySnapshotBuilder.fromBlockData(data); // chest/banner/head/… content
return ObjectState.block(toWorld, data, be);
}
private static ObjectState item(Affine toWorld, ItemStack stack, String context, boolean dropped) {
ItemSpec spec = ItemSpecs.of(stack);
if (spec == null) return null;
return ObjectState.item(toWorld, spec, context, dropped);
}
/** Per class: {isLeashed, getLeashHolder} for non-living leashables (boats, …), or null. */
private static final ClassValue<java.lang.reflect.Method[]> LEASH_METHODS = new ClassValue<>() {
@Override
protected java.lang.reflect.Method[] computeValue(Class<?> c) {
try {
return new java.lang.reflect.Method[] {c.getMethod("isLeashed"), c.getMethod("getLeashHolder")};
} catch (NoSuchMethodException e) {
return null;
}
}
};
/**
* A leash rope from a leashed entity to its holder (vanilla LeashRenderer endpoints, approximated): the
* entity end at eye height, 0.4·width in front; the holder end at a knot's centre, a humanoid holder's
* right hand, or 0.7·eye height for other holders. Works for any entity exposing
* {@code isLeashed()/getLeashHolder()} (mobs, and boats on versions where they are leashable).
*/
private static ObjectState leash(Entity e) throws ReflectiveOperationException {
Entity holder;
if (e instanceof LivingEntity le) {
if (!le.isLeashed()) return null;
holder = le.getLeashHolder();
} else {
java.lang.reflect.Method[] m = LEASH_METHODS.get(e.getClass());
if (m == null || !(Boolean) m[0].invoke(e)) return null;
holder = (Entity) m[1].invoke(e);
}
if (holder == null) return null;
Location l = e.getLocation();
double yaw = Math.toRadians(e instanceof LivingEntity liv ? liv.getBodyYaw() : l.getYaw());
double eye = e instanceof LivingEntity liv ? liv.getEyeHeight() : e.getHeight() * 0.5;
double fwd = e.getWidth() * 0.4;
double[] from = {l.getX() - Math.sin(yaw) * fwd, l.getY() + eye, l.getZ() + Math.cos(yaw) * fwd};
Location h = holder.getLocation();
double[] to;
if (holder instanceof LeashHitch) {
to = new double[] {h.getX(), h.getY() + 0.2, h.getZ()};
} else if (holder instanceof HumanEntity
|| holder.getType().getKey().getKey().equals("mannequin")) {
double hy = Math.toRadians(holder instanceof LivingEntity hl ? hl.getBodyYaw() : h.getYaw());
double side = 0.35, f = 0.1;
to = new double[] {
h.getX() - Math.cos(hy) * side - Math.sin(hy) * f,
h.getY() + holder.getHeight() * 0.42,
h.getZ() - Math.sin(hy) * side + Math.cos(hy) * f
};
} else {
double he = holder instanceof LivingEntity hl ? hl.getEyeHeight() : holder.getHeight() * 0.5;
to = new double[] {h.getX(), h.getY() + he * 0.7, h.getZ()};
}
return ObjectState.leash(new double[] {from[0], from[1], from[2], to[0], to[1], to[2]});
}
private static ObjectState text(Affine base, TextDisplay td) {
Component c = td.text();
String plain = PlainTextComponentSerializer.plainText().serialize(c);
if (plain.isBlank()) return null;
TextColor color = c.color();
int opacity = td.getTextOpacity() & 0xFF;
if (opacity == 0) opacity = 255; // -1 (default) = fully opaque
if (opacity < 26) opacity = 255; // vanilla treats very low values as opaque too
int rgb = color == null ? 0xFFFFFF : color.value();
int textArgb = (opacity << 24) | rgb;
Color bg = td.getBackgroundColor();
int bgArgb = td.isDefaultBackground() || bg == null ? 0x40000000 : bg.asARGB();
// Vanilla text displays render font pixels at 1/40 block.
Affine toWorld = base.mul(Affine.scale(0.025));
return ObjectState.text(
toWorld, Arrays.asList(plain.split("\n", -1)), td.getLineWidth(), textArgb, bgArgb, td.isShadowed());
}
/**
* Display orientation (vanilla DisplayRenderer): FIXED uses the entity yaw/pitch; CENTER faces the
* camera; VERTICAL turns only around Y towards it; HORIZONTAL keeps the entity yaw, tilts with the camera.
*/
private static Affine billboard(Display d, Location eye) {
Location l = d.getLocation();
double yaw = Math.toRadians(l.getYaw()), pitch = Math.toRadians(l.getPitch());
double camYaw = Math.toRadians(eye.getYaw()), camPitch = Math.toRadians(eye.getPitch());
return switch (d.getBillboard()) {
case FIXED -> Affine.rotY(-yaw).mul(Affine.rotX(pitch));
case CENTER -> Affine.rotY(Math.PI - camYaw).mul(Affine.rotX(-camPitch));
case VERTICAL -> Affine.rotY(Math.PI - camYaw);
case HORIZONTAL -> Affine.rotY(-yaw).mul(Affine.rotX(-camPitch));
};
}
/** Bukkit display transformation = T(translation) · leftRotation · S(scale) · rightRotation. */
private static Affine transformation(Transformation t) {
Affine tr = Affine.translation(
t.getTranslation().x(),
t.getTranslation().y(),
t.getTranslation().z());
return tr.mul(rot(t.getLeftRotation()))
.mul(Affine.scale(
t.getScale().x(), t.getScale().y(), t.getScale().z()))
.mul(rot(t.getRightRotation()));
}
private static Affine rot(Quaternionf q) {
return Affine.fromQuaternion(q.x(), q.y(), q.z(), q.w());
}
}
@@ -0,0 +1,66 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;
import org.bukkit.ChunkSnapshot;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.util.Vector;
/**
* 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();
FrustumBounds bounds = FrustumBounds.of(eye.toVector(), rayMap, maxDistance);
int worldMinY = world.getMinHeight();
int worldMaxY = world.getMaxHeight();
int clampedMinY = Math.max(worldMinY, (int) Math.floor(bounds.minY) - 1);
int clampedMaxY = Math.min(worldMaxY, (int) Math.ceil(bounds.maxY) + 1);
int minCX = (int) Math.floor(bounds.minX) >> 4;
int maxCX = (int) Math.floor(bounds.maxX) >> 4;
int minCZ = (int) Math.floor(bounds.minZ) >> 4;
int maxCZ = (int) Math.floor(bounds.maxZ) >> 4;
Map<Long, ChunkSnapshot> chunks = new HashMap<>();
int captured = 0;
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,51 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import java.util.Map;
import org.bukkit.ChunkSnapshot;
import org.bukkit.block.Biome;
import org.bukkit.block.data.BlockData;
/**
* 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,171 @@
package eu.mhsl.minecraft.pixelpics.render.tint;
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.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* Per-biome climate (temperature/downfall) and color effects (water, grass/foliage/dry-foliage
* overrides, grass color modifier) as defined by the vanilla biome data.
*
* <p>The definitions are read at runtime from the server's built-in data pack
* ({@code data/minecraft/worldgen/biome/<name>.json} on the server classpath), so new or changed
* vanilla biomes are picked up automatically after a server update. If that lookup fails, a
* snapshot of the vanilla 26.3 values is used; unknown biomes fall back to a plains-like default.
*/
public final class BiomeClimate {
/** Marker for "no explicit color" (use the colormap). */
public static final int NONE = -1;
/**
* @param water ARGB-less RGB water color
* @param grass explicit grass color override, or {@link #NONE}
* @param foliage explicit foliage color override, or {@link #NONE}
* @param dryFoliage explicit dry-foliage color override, or {@link #NONE}
* @param grassModifier vanilla {@code grass_color_modifier} ("swamp", "dark_forest") or null
*/
public record Climate(
double temperature,
double downfall,
int water,
int grass,
int foliage,
int dryFoliage,
String grassModifier) {}
public static final int DEFAULT_WATER = 0x3F76E4;
public static final Climate DEFAULT = new Climate(0.8, 0.4, DEFAULT_WATER, NONE, NONE, NONE, null);
private static final String DATA_PATH = "data/minecraft/worldgen/biome/";
private static final Map<String, Climate> FALLBACK = new HashMap<>();
private static final Map<String, Optional<Climate>> LOADED = new ConcurrentHashMap<>();
private static void put(
String key, double t, double d, int water, int grass, int foliage, int dry, String modifier) {
FALLBACK.put(key, new Climate(t, d, water, grass, foliage, dry, modifier));
}
// Snapshot of vanilla 26.3 data/minecraft/worldgen/biome/*.json
static {
put("badlands", 2.0, 0.0, 0x3F76E4, 0x90814D, 0x9E814D, NONE, null);
put("bamboo_jungle", 0.95, 0.9, 0x3F76E4, NONE, NONE, NONE, null);
put("basalt_deltas", 2.0, 0.0, 0x3F76E4, NONE, NONE, NONE, null);
put("beach", 0.8, 0.4, 0x3F76E4, NONE, NONE, NONE, null);
put("birch_forest", 0.6, 0.6, 0x3F76E4, NONE, NONE, NONE, null);
put("cherry_grove", 0.5, 0.8, 0x5DB7EF, 0xB6DB61, 0xB6DB61, NONE, null);
put("cold_ocean", 0.5, 0.5, 0x3D57D6, NONE, NONE, NONE, null);
put("crimson_forest", 2.0, 0.0, 0x3F76E4, NONE, NONE, NONE, null);
put("dappled_forest", 0.6, 0.6, 0x375154, 0xDF6827, 0xE68E30, 0x8C3A04, null);
put("dark_forest", 0.7, 0.8, 0x3F76E4, NONE, NONE, 0x7B5334, "dark_forest");
put("deep_cold_ocean", 0.5, 0.5, 0x3D57D6, NONE, NONE, NONE, null);
put("deep_dark", 0.8, 0.4, 0x3F76E4, NONE, NONE, NONE, null);
put("deep_frozen_ocean", 0.5, 0.5, 0x3938C9, NONE, NONE, NONE, null);
put("deep_lukewarm_ocean", 0.5, 0.5, 0x45ADF2, NONE, NONE, NONE, null);
put("deep_ocean", 0.5, 0.5, 0x3F76E4, NONE, NONE, NONE, null);
put("desert", 2.0, 0.0, 0x3F76E4, NONE, NONE, NONE, null);
put("dripstone_caves", 0.8, 0.4, 0x3F76E4, NONE, NONE, NONE, null);
put("end_barrens", 0.5, 0.5, 0x3F76E4, NONE, NONE, NONE, null);
put("end_highlands", 0.5, 0.5, 0x3F76E4, NONE, NONE, NONE, null);
put("end_midlands", 0.5, 0.5, 0x3F76E4, NONE, NONE, NONE, null);
put("eroded_badlands", 2.0, 0.0, 0x3F76E4, 0x90814D, 0x9E814D, NONE, null);
put("flower_forest", 0.7, 0.8, 0x3F76E4, NONE, NONE, NONE, null);
put("forest", 0.7, 0.8, 0x3F76E4, NONE, NONE, NONE, null);
put("frozen_ocean", 0.0, 0.5, 0x3938C9, NONE, NONE, NONE, null);
put("frozen_peaks", -0.7, 0.9, 0x3F76E4, NONE, NONE, NONE, null);
put("frozen_river", 0.0, 0.5, 0x3938C9, NONE, NONE, NONE, null);
put("grove", -0.2, 0.8, 0x3F76E4, NONE, NONE, NONE, null);
put("ice_spikes", 0.0, 0.5, 0x3F76E4, NONE, NONE, NONE, null);
put("jagged_peaks", -0.7, 0.9, 0x3F76E4, NONE, NONE, NONE, null);
put("jungle", 0.95, 0.9, 0x3F76E4, NONE, NONE, NONE, null);
put("lukewarm_ocean", 0.5, 0.5, 0x45ADF2, NONE, NONE, NONE, null);
put("lush_caves", 0.5, 0.5, 0x3F76E4, NONE, NONE, NONE, null);
put("mangrove_swamp", 0.8, 0.9, 0x3A7A6A, NONE, 0x8DB127, 0x7B5334, "swamp");
put("meadow", 0.5, 0.8, 0x0E4ECF, NONE, NONE, NONE, null);
put("mushroom_fields", 0.9, 1.0, 0x3F76E4, NONE, NONE, NONE, null);
put("nether_wastes", 2.0, 0.0, 0x3F76E4, NONE, NONE, NONE, null);
put("ocean", 0.5, 0.5, 0x3F76E4, NONE, NONE, NONE, null);
put("old_growth_birch_forest", 0.6, 0.6, 0x3F76E4, NONE, NONE, NONE, null);
put("old_growth_pine_taiga", 0.3, 0.8, 0x3F76E4, NONE, NONE, NONE, null);
put("old_growth_spruce_taiga", 0.25, 0.8, 0x3F76E4, NONE, NONE, NONE, null);
put("pale_garden", 0.7, 0.8, 0x76889D, 0x778272, 0x878D76, 0xA0A69C, null);
put("plains", 0.8, 0.4, 0x3F76E4, NONE, NONE, NONE, null);
put("river", 0.5, 0.5, 0x3F76E4, NONE, NONE, NONE, null);
put("savanna", 2.0, 0.0, 0x3F76E4, NONE, NONE, NONE, null);
put("savanna_plateau", 2.0, 0.0, 0x3F76E4, NONE, NONE, NONE, null);
put("small_end_islands", 0.5, 0.5, 0x3F76E4, NONE, NONE, NONE, null);
put("snowy_beach", 0.05, 0.3, 0x3D57D6, NONE, NONE, NONE, null);
put("snowy_plains", 0.0, 0.5, 0x3F76E4, NONE, NONE, NONE, null);
put("snowy_slopes", -0.3, 0.9, 0x3F76E4, NONE, NONE, NONE, null);
put("snowy_taiga", -0.5, 0.4, 0x3D57D6, NONE, NONE, NONE, null);
put("soul_sand_valley", 2.0, 0.0, 0x3F76E4, NONE, NONE, NONE, null);
put("sparse_jungle", 0.95, 0.8, 0x3F76E4, NONE, NONE, NONE, null);
put("stony_peaks", 1.0, 0.3, 0x3F76E4, NONE, NONE, NONE, null);
put("stony_shore", 0.2, 0.3, 0x3F76E4, NONE, NONE, NONE, null);
put("sulfur_caves", 0.8, 0.4, 0x34BF89, 0xABA64F, NONE, NONE, null);
put("sunflower_plains", 0.8, 0.4, 0x3F76E4, NONE, NONE, NONE, null);
put("swamp", 0.8, 0.9, 0x617B64, NONE, 0x6A7039, 0x7B5334, "swamp");
put("taiga", 0.25, 0.8, 0x3F76E4, NONE, NONE, NONE, null);
put("the_end", 0.5, 0.5, 0x3F76E4, NONE, NONE, NONE, null);
put("the_void", 0.5, 0.5, 0x3F76E4, NONE, NONE, NONE, null);
put("warm_ocean", 0.5, 0.5, 0x43D5EE, NONE, NONE, NONE, null);
put("warped_forest", 2.0, 0.0, 0x3F76E4, NONE, NONE, NONE, null);
put("windswept_forest", 0.2, 0.3, 0x3F76E4, NONE, NONE, NONE, null);
put("windswept_gravelly_hills", 0.2, 0.3, 0x3F76E4, NONE, NONE, NONE, null);
put("windswept_hills", 0.2, 0.3, 0x3F76E4, NONE, NONE, NONE, null);
put("windswept_savanna", 2.0, 0.0, 0x3F76E4, NONE, NONE, NONE, null);
put("wooded_badlands", 2.0, 0.0, 0x3F76E4, 0x90814D, 0x9E814D, NONE, null);
}
private BiomeClimate() {}
public static Climate forKey(String biomePath) {
Optional<Climate> loaded = LOADED.computeIfAbsent(biomePath, BiomeClimate::loadFromServerData);
return loaded.orElseGet(() -> FALLBACK.getOrDefault(biomePath, DEFAULT));
}
private static Optional<Climate> loadFromServerData(String biomePath) {
if (!biomePath.matches("[a-z0-9_./-]+")) return Optional.empty();
ClassLoader loader = BiomeClimate.class.getClassLoader();
if (loader == null) return Optional.empty();
try (InputStream in = loader.getResourceAsStream(DATA_PATH + biomePath + ".json")) {
if (in == null) return Optional.empty();
JsonObject root = JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8))
.getAsJsonObject();
JsonObject effects = root.has("effects") ? root.getAsJsonObject("effects") : new JsonObject();
JsonElement modifier = effects.get("grass_color_modifier");
return Optional.of(new Climate(
root.has("temperature") ? root.get("temperature").getAsDouble() : DEFAULT.temperature(),
root.has("downfall") ? root.get("downfall").getAsDouble() : DEFAULT.downfall(),
color(effects, "water_color", DEFAULT_WATER),
color(effects, "grass_color", NONE),
color(effects, "foliage_color", NONE),
color(effects, "dry_foliage_color", NONE),
modifier != null && modifier.isJsonPrimitive() ? modifier.getAsString() : null));
} catch (Exception e) {
return Optional.empty();
}
}
/** Colors are either "#rrggbb" strings or packed integers, depending on the data version. */
private static int color(JsonObject effects, String key, int fallback) {
JsonElement el = effects.get(key);
if (el == null || !el.isJsonPrimitive()) return fallback;
if (el.getAsJsonPrimitive().isNumber()) return el.getAsInt() & 0xFFFFFF;
String s = el.getAsString().trim();
if (s.startsWith("#")) s = s.substring(1);
try {
return Integer.parseInt(s, 16) & 0xFFFFFF;
} catch (NumberFormatException e) {
return fallback;
}
}
}
@@ -0,0 +1,6 @@
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,76 @@
package eu.mhsl.minecraft.pixelpics.render.tint;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.block.Biome;
/**
* Computes per-biome grass/foliage tint colors by sampling the resource pack's colormaps using the
* vanilla temperature/downfall formula, plus a per-biome water color. Results are cached per biome.
*/
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);
// Explicit biome colors (biome "effects") win over the colormap, like in vanilla.
int grass = climate.grass() != BiomeClimate.NONE
? 0xFF000000 | climate.grass()
: sample(grassMap, climate.temperature(), climate.downfall(), 0xFF91BD59);
int foliage = climate.foliage() != BiomeClimate.NONE
? 0xFF000000 | climate.foliage()
: sample(foliageMap, climate.temperature(), climate.downfall(), 0xFF77AB2F);
int dry = climate.dryFoliage() != BiomeClimate.NONE
? 0xFF000000 | climate.dryFoliage()
: sample(dryFoliageMap, climate.temperature(), climate.downfall(), 0xFFA9A05B);
// Vanilla grass_color_modifier (applies to grass only).
if ("swamp".equals(climate.grassModifier())) {
// Vanilla picks 0x4C763C/0x6A7039 via position noise; use the dominant color.
grass = 0xFF6A7039;
} else if ("dark_forest".equals(climate.grassModifier())) {
grass = 0xFF000000 | (((grass & 0xFEFEFE) + 0x28340A) >> 1);
}
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 = Math.clamp(temperature, 0, 1);
double down = Math.clamp(downfall, 0, 1) * temp;
int x = (int) ((1.0 - temp) * 255.0);
int y = (int) ((1.0 - down) * 255.0);
int h = colormap.length;
int w = colormap[0].length;
x = Math.clamp(x, 0, w - 1);
y = Math.clamp(y, 0, h - 1);
return 0xFF000000 | (colormap[y][x] & 0xFFFFFF);
}
}
@@ -0,0 +1,63 @@
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);
}
}
@@ -0,0 +1,159 @@
package eu.mhsl.minecraft.pixelpics.render.util;
import org.bukkit.Color;
import org.bukkit.DyeColor;
/**
* 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;
}
/** Opaque ARGB for a Bukkit dye colour, or {@code fallback} when {@code dye} is null. */
public static int dyeArgb(DyeColor dye, int fallback) {
if (dye == null) return fallback;
Color c = dye.getColor();
return argb(0xFF, c.getRed(), c.getGreen(), c.getBlue());
}
/**
* The vanilla sign-text colour for a dye (opaque ARGB), from Mojang's {@code DyeColor.getTextColor()}
* table (the firework/text colours, NOT the cloth colours). Null = black (the default sign ink).
*/
public static int signTextColor(DyeColor dye) {
int rgb =
switch (dye == null ? DyeColor.BLACK : dye) {
case WHITE -> 0xF9FFFE;
case ORANGE -> 0xF9801D;
case MAGENTA -> 0xC74EBD;
case LIGHT_BLUE -> 0x3AB3DA;
case YELLOW -> 0xFED83D;
case LIME -> 0x80C71F;
case PINK -> 0xF38BAA;
case GRAY -> 0x474F52;
case LIGHT_GRAY -> 0x9D9D97;
case CYAN -> 0x169C9C;
case PURPLE -> 0x8932B8;
case BLUE -> 0x3C44AA;
case BROWN -> 0x835432;
case GREEN -> 0x5E7C16;
case RED -> 0xB02E26;
case BLACK -> 0x1D1D21;
};
return 0xFF000000 | rgb;
}
/**
* The fill colour for sign text: glowing text uses the full dye colour; non-glowing text is the dye
* colour darkened to 40% (matching vanilla {@code SignRenderer}, which is why normal ink looks dim).
*/
public static int signFillArgb(DyeColor dye, boolean glowing) {
int base = signTextColor(dye);
return glowing ? base : (0xFF000000 | (shade(base, 0.4) & 0xFFFFFF));
}
/**
* The 8-directional outline colour drawn around glowing sign text (vanilla {@code getDarkColor}):
* the dye colour darkened to 40%, except glowing BLACK ink which gets a light cream outline so it
* stays readable.
*/
public static int signOutlineArgb(DyeColor dye) {
if ((dye == null ? DyeColor.BLACK : dye) == DyeColor.BLACK) return 0xFFF0EBCC;
return 0xFF000000 | (shade(signTextColor(dye), 0.4) & 0xFFFFFF);
}
/** 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. */
/** Texels with alpha at or below this are holes (alpha cutout) for every intersector and the extrusion. */
public static final int ALPHA_CUTOUT = 16;
/** Whether a texel is drawn (not cut out). */
public static boolean opaque(int argb) {
return alpha(argb) > ALPHA_CUTOUT;
}
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);
}
/**
* Normalized weighted average of two RGB colors (alpha ignored, result opaque-RGB in the low 24 bits).
* Weights need not sum to 1; they are normalized by their total.
*/
public static int mix(int rgbA, int rgbB, double weightA, double weightB) {
double total = weightA + weightB;
int r = (int) ((red(rgbA) * weightA + red(rgbB) * weightB) / total);
int g = (int) ((green(rgbA) * weightA + green(rgbB) * weightB) / total);
int b = (int) ((blue(rgbA) * weightA + blue(rgbB) * weightB) / total);
return (r << 16) | (g << 8) | b;
}
// --- Gamma-correct (linear-light) averaging ---
private static final float[] SRGB_TO_LINEAR = new float[256];
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));
}
}
@@ -0,0 +1,40 @@
package eu.mhsl.minecraft.pixelpics.render.util;
import org.bukkit.block.BlockFace;
import org.bukkit.util.Vector;
public class MathUtil {
private MathUtil() {}
private static Vector yawPitchRotation(Vector base, double angleYaw, double anglePitch) {
double oldX = base.getX();
double oldY = base.getY();
double oldZ = base.getZ();
double sinOne = Math.sin(angleYaw);
double sinTwo = Math.sin(anglePitch);
double cosOne = Math.cos(angleYaw);
double cosTwo = Math.cos(anglePitch);
double newX = oldX * cosOne * cosTwo - oldY * cosOne * sinTwo - oldZ * sinOne;
double newY = oldX * sinTwo + oldY * cosTwo;
double newZ = oldX * sinOne * cosTwo - oldY * sinOne * sinTwo + oldZ * cosOne;
return new Vector(newX, newY, newZ);
}
public static Vector doubleYawPitchRotation(
Vector base, double firstYaw, double firstPitch, double secondYaw, double secondPitch) {
return yawPitchRotation(yawPitchRotation(base, firstYaw, firstPitch), secondYaw, secondPitch);
}
/** Reflects {@code direction} across the plane with the given (unit) {@code normal}. */
public static Vector reflectVector(Vector direction, Vector normal) {
return direction.clone().subtract(normal.clone().multiply(2 * direction.dot(normal)));
}
public static Vector toVector(BlockFace face) {
return new Vector(face.getModX(), face.getModY(), face.getModZ());
}
}
@@ -0,0 +1,166 @@
package eu.mhsl.minecraft.pixelpics.survival;
import com.destroystokyo.paper.profile.PlayerProfile;
import com.destroystokyo.paper.profile.ProfileProperty;
import eu.mhsl.minecraft.pixelpics.Main;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.UUID;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.inventory.meta.SkullMeta;
import org.bukkit.inventory.meta.components.EquippableComponent;
import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType;
/**
* Factory and identity helpers for the survival items: the camera and the film roll are custom-skinned
* {@code PLAYER_HEAD}s (the texture renders client-side without a resource pack), the photo is the
* existing {@code FILLED_MAP}. Identity is carried by PDC markers (robust against renames), and the
* camera's loaded film count lives in PDC as well.
*/
public final class CameraItems {
/** Maximum film rolls a camera can hold; one roll yields one photo. */
public static final int MAX_FILM = 8;
private static final int CAMERA_MODEL_DATA = 1001;
private static final int FILM_MODEL_DATA = 1002;
static final String CAMERA_TEXTURE_B64 =
"eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZDlkMmNiZjAyZDMwOGI2MDY1YTZmZThjNjU3MWI2MzU2NjMzZjQxOTJlOGVjNzEyMTNjNzcwNzgwZTNkZTRlMiJ9fX0=";
static final String FILM_TEXTURE_B64 =
"eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvMTVkMGY0OGJlNzNkYmIwZDJjYjE1NTRjMmUzODZiNWNjM2FiMjFhNGRjYWU4ZmYzOGI3NzRhZDNkMDFkMGE1OSJ9fX0=";
private CameraItems() {}
// --- factories ---
/** A camera holding {@code filmCount} (clamped to 0..{@link #MAX_FILM}) loaded film rolls. */
public static ItemStack createCamera(int filmCount) {
int count = Math.clamp(filmCount, 0, MAX_FILM);
ItemStack item = new ItemStack(Material.PLAYER_HEAD);
SkullMeta meta = (SkullMeta) item.getItemMeta();
applyHead(meta, "pixelpics:camera", CAMERA_TEXTURE_B64);
meta.displayName(Component.text("Kamera", NamedTextColor.WHITE).decoration(TextDecoration.ITALIC, false));
meta.setCustomModelData(CAMERA_MODEL_DATA);
// A camera carries per-item state (loaded film count), so it must not stack — otherwise loading
// or shooting a stacked camera would duplicate or desync the whole stack.
meta.setMaxStackSize(1);
meta.getPersistentDataContainer().set(Main.getInstance().cameraMarker, PersistentDataType.BYTE, (byte) 1);
meta.getPersistentDataContainer().set(Main.getInstance().filmCountKey, PersistentDataType.INTEGER, count);
applyCameraLore(meta, count);
makeUnwearable(meta);
item.setItemMeta(meta);
return item;
}
/** A single film roll. */
public static ItemStack createFilm() {
ItemStack item = new ItemStack(Material.PLAYER_HEAD);
SkullMeta meta = (SkullMeta) item.getItemMeta();
applyHead(meta, "pixelpics:film", FILM_TEXTURE_B64);
meta.displayName(Component.text("Filmrolle", NamedTextColor.WHITE).decoration(TextDecoration.ITALIC, false));
meta.setCustomModelData(FILM_MODEL_DATA);
// Lore leads with "Filmrolle" so the recipe book search finds it by name (see applyCameraLore).
meta.lore(List.of(Component.text("Filmrolle – lädt eine Kamera auf.", NamedTextColor.GRAY)
.decoration(TextDecoration.ITALIC, false)));
meta.getPersistentDataContainer().set(Main.getInstance().filmMarker, PersistentDataType.BYTE, (byte) 1);
makeUnwearable(meta);
item.setItemMeta(meta);
return item;
}
/** Display name applied to a developed photo map (white, like a normally renamed item). */
public static Component photoDisplayName() {
return Component.text("Foto", NamedTextColor.WHITE).decoration(TextDecoration.ITALIC, false);
}
/** Returns a copy of {@code camera} with its film count set to {@code newCount} and lore refreshed. */
public static ItemStack withFilmCount(ItemStack camera, int newCount) {
int count = Math.clamp(newCount, 0, MAX_FILM);
ItemStack copy = camera.clone();
SkullMeta meta = (SkullMeta) copy.getItemMeta();
meta.getPersistentDataContainer().set(Main.getInstance().filmCountKey, PersistentDataType.INTEGER, count);
applyCameraLore(meta, count);
copy.setItemMeta(meta);
return copy;
}
// --- identity ---
public static boolean isCamera(ItemStack item) {
return hasMarker(item, Main.getInstance().cameraMarker);
}
public static boolean isFilm(ItemStack item) {
return hasMarker(item, Main.getInstance().filmMarker);
}
/** A photo is a filled map carrying our picture id. */
public static boolean isPhoto(ItemStack item) {
if (item == null || item.getType() != Material.FILLED_MAP || !item.hasItemMeta()) return false;
return item.getItemMeta()
.getPersistentDataContainer()
.has(Main.getInstance().pictureIdFlag, PersistentDataType.STRING);
}
/** Loaded film on a camera, or 0 if the item is not a camera. */
public static int getFilmCount(ItemStack item) {
if (!isCamera(item)) return 0;
Integer v = item.getItemMeta()
.getPersistentDataContainer()
.get(Main.getInstance().filmCountKey, PersistentDataType.INTEGER);
return v == null ? 0 : v;
}
// --- internals ---
private static boolean hasMarker(ItemStack item, NamespacedKey key) {
if (item == null || item.getType() != Material.PLAYER_HEAD || !item.hasItemMeta()) return false;
PersistentDataContainer pdc = item.getItemMeta().getPersistentDataContainer();
return pdc.has(key, PersistentDataType.BYTE);
}
private static void applyCameraLore(ItemMeta meta, int count) {
// The first line carries the word "Kamera" so the recipe book search (which indexes the result
// item's lore, not its custom display name — every item here is a PLAYER_HEAD named "Spielerkopf"
// by default) can find it when the player types "kamera".
meta.lore(List.of(
Component.text("Kamera für Sofortbildfotos", NamedTextColor.GRAY)
.decoration(TextDecoration.ITALIC, false),
Component.text(
"Film: " + count + " / " + MAX_FILM,
count > 0 ? NamedTextColor.YELLOW : NamedTextColor.RED)
.decoration(TextDecoration.ITALIC, false),
Component.text("Rechtsklick: Foto aufnehmen", NamedTextColor.DARK_GRAY)
.decoration(TextDecoration.ITALIC, false)));
}
/**
* Overrides the {@code PLAYER_HEAD}'s default head-equippable component so the item cannot be worn:
* the slot is moved to {@code SADDLE} (no player slot accepts it, so the helmet slot rejects it —
* blocking right-click, shift-click and dispenser equipping) and right-click swap is disabled.
*/
private static void makeUnwearable(ItemMeta meta) {
EquippableComponent eq = meta.getEquippable();
eq.setSlot(EquipmentSlot.SADDLE);
eq.setSwappable(false);
meta.setEquippable(eq);
}
private static void applyHead(SkullMeta meta, String seed, String textureB64) {
if (textureB64 == null || textureB64.isBlank()) return;
UUID synthetic = UUID.nameUUIDFromBytes(seed.getBytes(StandardCharsets.UTF_8));
PlayerProfile profile = Bukkit.createProfileExact(synthetic, null);
profile.setProperty(new ProfileProperty("textures", textureB64));
meta.setPlayerProfile(profile);
}
}
@@ -0,0 +1,59 @@
package eu.mhsl.minecraft.pixelpics.survival;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
/**
* Takes a photo when a player right-clicks while holding a camera. Consumes one loaded film per shot;
* with no film loaded it gives a short fail feedback. Guards against the well-known double-fire of
* {@link PlayerInteractEvent} (off-hand fire + air/block) via a hand filter and a per-player cooldown.
*/
public class CameraListener implements Listener {
private static final long COOLDOWN_MILLIS = 500;
private final Map<UUID, Long> lastUse = new HashMap<>();
@EventHandler
public void onInteract(PlayerInteractEvent event) {
if (event.getHand() != EquipmentSlot.HAND) return;
Action action = event.getAction();
if (action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK) return;
ItemStack inHand = event.getItem();
if (!CameraItems.isCamera(inHand)) return;
Player player = event.getPlayer();
long now = System.currentTimeMillis();
if (now - lastUse.getOrDefault(player.getUniqueId(), 0L) < COOLDOWN_MILLIS) {
event.setCancelled(true);
return;
}
lastUse.put(player.getUniqueId(), now);
event.setCancelled(true);
int film = CameraItems.getFilmCount(inHand);
if (film <= 0) {
// Dry "empty shutter" click — no film loaded.
player.playSound(player.getLocation(), Sound.BLOCK_DISPENSER_FAIL, 1f, 1.2f);
player.sendActionBar(Component.text("Kein Film geladen!", NamedTextColor.RED));
return;
}
// Only charge film if the shot was actually accepted (renderer ready, not queue-rejected).
if (PhotoService.takePhoto(player)) {
player.getInventory().setItemInMainHand(CameraItems.withFilmCount(inHand, film - 1));
}
}
}
@@ -0,0 +1,188 @@
package eu.mhsl.minecraft.pixelpics.survival;
import eu.mhsl.minecraft.pixelpics.Main;
import java.util.function.Predicate;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.Keyed;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.CraftItemEvent;
import org.bukkit.event.inventory.PrepareItemCraftEvent;
import org.bukkit.inventory.CraftingInventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.Recipe;
import org.bukkit.inventory.meta.MapMeta;
import org.bukkit.persistence.PersistentDataType;
/**
* Drives the two dynamic recipes whose inputs carry variable NBT: <em>load film</em>
* (camera + film → camera+1) and <em>copy photo</em> (photo + film → 2 photos sharing the same map).
* {@link PrepareItemCraftEvent} computes the result preview and validates the loose
* {@code MaterialChoice} matches; {@link CraftItemEvent} performs the consumption/doubling manually
* (the vanilla machinery cannot, given the variable-NBT ingredients) and blocks shift-click and any
* attempt to leak our custom items into other recipes.
*/
public class CraftingListener implements Listener {
private enum Kind {
LOAD,
COPY,
NONE
}
/** Scanned crafting grid: the relevant items and whether any of our marked items are present. */
private record Scan(Kind kind, ItemStack camera, ItemStack film, ItemStack photo, boolean hasOurItems) {}
@EventHandler
public void onPrepare(PrepareItemCraftEvent event) {
CraftingInventory inv = event.getInventory();
Scan scan = scan(inv.getMatrix());
switch (scan.kind()) {
case LOAD -> {
int count = CameraItems.getFilmCount(scan.camera());
inv.setResult(
count >= CameraItems.MAX_FILM
? null
: single(CameraItems.withFilmCount(scan.camera(), count + 1)));
}
case COPY -> inv.setResult(buildPhotoCopy(scan.photo()));
case NONE -> {
// Block our loose MaterialChoice recipes from matching garbage, and stop our items
// from being consumed by any unrelated (vanilla) recipe.
if (isDynamicRecipe(event.getRecipe()) || scan.hasOurItems()) {
inv.setResult(null);
}
}
}
}
@EventHandler
public void onCraft(CraftItemEvent event) {
CraftingInventory inv = event.getInventory();
Scan scan = scan(inv.getMatrix());
if (scan.kind() == Kind.NONE) {
// Defensive: never let our items be consumed by another recipe.
if (scan.hasOurItems()) event.setCancelled(true);
return;
}
event.setCancelled(true);
Player player = (Player) event.getWhoClicked();
if (event.isShiftClick()) {
player.sendActionBar(Component.text("Bitte einzeln herstellen.", NamedTextColor.YELLOW));
return;
}
ItemStack result;
ItemStack[] matrix = inv.getMatrix();
if (scan.kind() == Kind.LOAD) {
int count = CameraItems.getFilmCount(scan.camera());
if (count >= CameraItems.MAX_FILM) return;
result = single(CameraItems.withFilmCount(scan.camera(), count + 1));
consumeFirst(matrix, CameraItems::isCamera);
consumeFirst(matrix, CameraItems::isFilm);
} else { // COPY
result = buildPhotoCopy(scan.photo());
if (result == null) return;
consumeFirst(matrix, CameraItems::isFilm); // original photo stays
}
// Apply on the next tick so the click settles before we mutate the grid and hand over the item.
ItemStack finalResult = result;
Bukkit.getScheduler().runTask(Main.getInstance(), () -> {
inv.setMatrix(matrix);
give(player, finalResult);
player.playSound(player.getLocation(), Sound.UI_TOAST_IN, 0.7f, 1.4f);
player.updateInventory();
});
}
// --- helpers ---
private static Scan scan(ItemStack[] matrix) {
int cameras = 0, films = 0, photos = 0, others = 0;
ItemStack camera = null, film = null, photo = null;
for (ItemStack it : matrix) {
if (it == null || it.getType().isAir()) continue;
if (CameraItems.isCamera(it)) {
cameras++;
camera = it;
} else if (CameraItems.isFilm(it)) {
films++;
film = it;
} else if (CameraItems.isPhoto(it)) {
photos++;
photo = it;
} else others++;
}
boolean ours = cameras + films + photos > 0;
Kind kind = Kind.NONE;
if (others == 0) {
if (cameras == 1 && films == 1 && photos == 0) kind = Kind.LOAD;
else if (photos == 1 && films == 1 && cameras == 0) kind = Kind.COPY;
}
return new Scan(kind, camera, film, photo, ours);
}
private static boolean isDynamicRecipe(Recipe recipe) {
if (!(recipe instanceof Keyed keyed)) return false;
NamespacedKey key = keyed.getKey();
return SurvivalRecipes.LOAD.equals(key) || SurvivalRecipes.COPY.equals(key);
}
/** A second photo referencing the same {@link org.bukkit.map.MapView} and picture id as {@code photo}. */
private static ItemStack buildPhotoCopy(ItemStack photo) {
MapMeta src = (MapMeta) photo.getItemMeta();
if (!src.hasMapView() || src.getMapView() == null) return null;
ItemStack copy = new ItemStack(Material.FILLED_MAP);
MapMeta dst = (MapMeta) copy.getItemMeta();
dst.setMapView(src.getMapView());
String pid = src.getPersistentDataContainer().get(Main.getInstance().pictureIdFlag, PersistentDataType.STRING);
if (pid != null) {
dst.getPersistentDataContainer().set(Main.getInstance().pictureIdFlag, PersistentDataType.STRING, pid);
}
dst.displayName(CameraItems.photoDisplayName());
copy.setItemMeta(dst);
return copy;
}
/** Forces a crafting result to a single item — one craft yields exactly one camera, never a stack. */
private static ItemStack single(ItemStack item) {
item.setAmount(1);
return item;
}
private static void consumeFirst(ItemStack[] matrix, Predicate<ItemStack> pred) {
for (int i = 0; i < matrix.length; i++) {
ItemStack it = matrix[i];
if (it != null && !it.getType().isAir() && pred.test(it)) {
int amt = it.getAmount();
if (amt <= 1) matrix[i] = null;
else {
it.setAmount(amt - 1);
matrix[i] = it;
}
return;
}
}
}
private static void give(Player player, ItemStack item) {
ItemStack cursor = player.getItemOnCursor();
if (cursor.getType().isAir()) {
player.setItemOnCursor(item);
} else {
player.getInventory().addItem(item).values().forEach(left -> player.getWorld()
.dropItemNaturally(player.getLocation(), left));
}
}
}
@@ -0,0 +1,14 @@
package eu.mhsl.minecraft.pixelpics.survival;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
/** Unlocks the survival recipes in each player's recipe book on join (idempotent). */
public class JoinListener implements Listener {
@EventHandler
public void onJoin(PlayerJoinEvent event) {
event.getPlayer().discoverRecipes(SurvivalRecipes.allKeys());
}
}
@@ -0,0 +1,128 @@
package eu.mhsl.minecraft.pixelpics.survival;
import eu.mhsl.minecraft.pixelpics.Main;
import eu.mhsl.minecraft.pixelpics.render.RenderManager;
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 java.awt.image.BufferedImage;
import java.util.Objects;
import java.util.UUID;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.MapMeta;
import org.bukkit.map.MapView;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.util.Vector;
/**
* Renders a photo of a player's current view and delivers it as a developing {@code FILLED_MAP},
* with a shutter sound + flash particles on capture and a chime when the picture finishes developing.
* Heavy work is funneled through {@link RenderManager} (per-player single slot, global concurrency
* limit + queue, bounded low-priority CPU). Shared by the camera item (survival) and the
* {@code /pixelPic} debug command. Film accounting is the caller's responsibility.
*/
public final class PhotoService {
/** Render result carried back to the main thread: PNG source + dithered map indices. */
private record RenderOutput(BufferedImage image, byte[] indices) {}
private PhotoService() {}
/**
* Captures and delivers a photo. Returns {@code false} (with action-bar feedback) if the renderer
* is unavailable or the request is rejected by the queue — callers should not charge film then.
*/
public static boolean takePhoto(Player player) {
Main plugin = Main.getInstance();
DefaultScreenRenderer renderer = plugin.getScreenRenderer();
if (renderer == null) {
player.sendActionBar(
Component.text("PixelPics ist nicht einsatzbereit (kein Resource-Pack).", NamedTextColor.RED));
return false;
}
RenderManager manager = plugin.getRenderManager();
UUID user = player.getUniqueId();
RenderManager.Outcome outcome = manager.tryReserve(user);
if (outcome != RenderManager.Outcome.ACCEPTED) {
player.sendActionBar(Component.text(
outcome == RenderManager.Outcome.USER_BUSY
? "Deine letzte Aufnahme wird noch entwickelt …"
: "Zu viele Aufnahmen gerade — versuch es gleich erneut.",
NamedTextColor.RED));
return false;
}
boolean dispatched = false;
try {
Resolution resolution = new Resolution(Resolution.Pixels._128P, Resolution.AspectRatio._1_1);
// Capture the world snapshot on the main thread (the moment of the shot).
RenderJob job = renderer.prepare(player.getEyeLocation(), resolution, user);
// 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(
plugin.pictureIdFlag,
PersistentDataType.STRING,
UUID.randomUUID().toString());
meta.setMapView(mapView);
meta.displayName(CameraItems.photoDisplayName());
map.setItemMeta(meta);
player.getInventory().addItem(map);
// Feedback is sound + particles only; no action-bar text on a normal shot.
playShutter(player);
// Trace + dither off-thread (queued), then develop on the main thread. The cancel token is
// tripped by the watchdog on timeout; we bail out before the (now pointless) dithering.
manager.dispatch(
user,
cancelled -> {
BufferedImage image = renderer.execute(job, cancelled);
if (cancelled.get()) return null;
return new RenderOutput(image, MapImageDither.dither(image));
},
out -> {
MapManager.saveImage(Objects.requireNonNull(out).image(), id);
MapManager.saveIndices(out.indices(), id);
mapRenderer.develop(out.indices());
player.playSound(player.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 0.8f, 1.2f);
},
() -> player.sendActionBar(Component.text("Rendern fehlgeschlagen.", NamedTextColor.RED)));
dispatched = true;
} finally {
// prepare()/map setup threw before handing off — free the reserved slot.
if (!dispatched) manager.release(user);
}
return true;
}
private static void playShutter(Player player) {
player.playSound(player.getLocation(), Sound.BLOCK_VAULT_OPEN_SHUTTER, 1f, 1.4f);
Location eye = player.getEyeLocation();
Vector forward = eye.getDirection().normalize().multiply(1.0);
Location flash = eye.clone().add(forward);
player.getWorld().spawnParticle(Particle.END_ROD, flash, 12, 0.2, 0.2, 0.2, 0.01);
player.getWorld().spawnParticle(Particle.FIREWORK, flash, 6, 0.1, 0.1, 0.1, 0.02);
}
}
@@ -0,0 +1,87 @@
package eu.mhsl.minecraft.pixelpics.survival;
import eu.mhsl.minecraft.pixelpics.Main;
import java.util.ArrayList;
import java.util.List;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.RecipeChoice;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.inventory.ShapelessRecipe;
/**
* Registers the four survival recipes and makes them discoverable in the recipe book. Camera and
* film have fixed results; load-film and copy-photo use {@code MaterialChoice} ingredients (their
* inputs carry variable NBT) and their real results are computed dynamically in {@link CraftingListener}
* — the registered result here is only the book/preview icon.
*/
public final class SurvivalRecipes {
public static final NamespacedKey CAMERA = key("camera_recipe");
public static final NamespacedKey FILM = key("film_recipe");
public static final NamespacedKey LOAD = key("load_recipe");
public static final NamespacedKey COPY = key("copy_recipe");
private SurvivalRecipes() {}
private static NamespacedKey key(String name) {
return new NamespacedKey(Main.getInstance(), name);
}
public static List<NamespacedKey> allKeys() {
return List.of(CAMERA, FILM, LOAD, COPY);
}
public static void register() {
unregister();
// Camera
ShapedRecipe camera = new ShapedRecipe(CAMERA, CameraItems.createCamera(0));
camera.shape("IDI", "GLG", "IRI");
camera.setIngredient('I', Material.IRON_INGOT);
camera.setIngredient('D', Material.DIAMOND);
camera.setIngredient('G', Material.GLASS_PANE);
camera.setIngredient('L', Material.ENDER_EYE);
camera.setIngredient('R', Material.REDSTONE);
Bukkit.addRecipe(camera, false);
// Film roll
ShapedRecipe film = new ShapedRecipe(FILM, CameraItems.createFilm());
film.shape(" P ", "PIP", " P ");
film.setIngredient('P', Material.PAPER);
film.setIngredient('I', Material.INK_SAC);
Bukkit.addRecipe(film, false);
// Load film: camera + film. Use ExactChoice so the recipe book renders the real camera/film
// textures instead of generic player heads. The camera choice enumerates every loadable fill
// level (0..MAX_FILM-1) so a partly loaded camera still matches; the actual result is still
// validated and computed dynamically in CraftingListener.
List<ItemStack> loadableCameras = new ArrayList<>();
for (int i = 0; i < CameraItems.MAX_FILM; i++) {
loadableCameras.add(CameraItems.createCamera(i));
}
ShapelessRecipe load = new ShapelessRecipe(LOAD, CameraItems.createCamera(1));
load.addIngredient(RecipeChoice.exactChoice(loadableCameras));
load.addIngredient(RecipeChoice.exactChoice(CameraItems.createFilm()));
Bukkit.addRecipe(load, false);
// Copy photo: photo + film. The photo carries a variable map id (cannot be enumerated), so it
// stays a MaterialChoice (a filled-map icon, not a player head); the film uses ExactChoice so
// its real texture shows in the book.
ShapelessRecipe copy = new ShapelessRecipe(COPY, new ItemStack(Material.FILLED_MAP));
copy.addIngredient(new RecipeChoice.MaterialChoice(Material.FILLED_MAP));
copy.addIngredient(RecipeChoice.exactChoice(CameraItems.createFilm()));
Bukkit.addRecipe(copy, false);
// Cover /reload while players are online; fresh joins are handled by JoinListener.
Bukkit.getOnlinePlayers().forEach(p -> p.discoverRecipes(allKeys()));
}
public static void unregister() {
for (NamespacedKey k : allKeys()) {
Bukkit.removeRecipe(k, false);
}
}
}
@@ -0,0 +1,119 @@
package eu.mhsl.minecraft.pixelpics.utils;
import java.awt.image.BufferedImage;
import org.bukkit.entity.Player;
import org.bukkit.map.MapCanvas;
import org.bukkit.map.MapRenderer;
import org.bukkit.map.MapView;
import org.jetbrains.annotations.NotNull;
/**
* 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 = 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(MapImageDither.dither(image));
}
/** Pending capture: shows blank film until {@link #develop} supplies the picture. */
public ImageMapRenderer() {
this.indices = null;
this.animate = true;
}
public byte[] getIndices() {
return indices;
}
/** 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 (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,106 @@
package eu.mhsl.minecraft.pixelpics.utils;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import java.awt.Color;
import java.util.ArrayList;
import java.util.List;
import org.bukkit.map.MapPalette;
/**
* 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() {}
// MapPalette.getColor(byte) is deprecated for removal with no replacement for enumerating the
// index->colour palette; suppress until the API offers an alternative.
@SuppressWarnings("removal")
private static synchronized void ensure() {
if (initialized) return;
List<Byte> idx = new ArrayList<>();
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.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;
}
/** CIELAB of the palette color at {@code pos} (do not modify). */
public static double[] lab(int pos) {
ensure();
return lab[pos];
}
public static byte mapIndex(int pos) {
return indices[pos];
}
public static int mapRgb(int pos) {
return rgb[pos];
}
public static double[] rgbToLab(int r, int g, int b) {
double rl = ColorUtil.toLinear(r), gl = ColorUtil.toLinear(g), bl = ColorUtil.toLinear(b);
// sRGB -> XYZ (D65)
double x = rl * 0.4124 + gl * 0.3576 + bl * 0.1805;
double y = rl * 0.2126 + gl * 0.7152 + bl * 0.0722;
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 pivotXyz(double t) {
return t > 0.008856 ? Math.cbrt(t) : (7.787 * t + 16.0 / 116.0);
}
}
@@ -0,0 +1,106 @@
package eu.mhsl.minecraft.pixelpics.utils;
import java.awt.image.BufferedImage;
/**
* Quantizes a rendered image to Minecraft map color indices. The result is a {@code 128*128} byte
* array of palette indices, ready to blit onto a {@link org.bukkit.map.MapCanvas}.
*
* <p>Uses dampened Floyd–Steinberg error diffusion in CIELAB space with <em>constrained</em>
* candidates: a pixel may only take a palette color that is nearly as close to its original color
* as the best match. Colors the map palette cannot represent well (e.g. light sky blue, sunset
* tones) would otherwise be approximated by mixing far-apart colors, producing a harsh,
* high-contrast checkerboard; the constraint keeps the dither between neighbouring colors only,
* so flat areas stay calm while gradients still blend.
*
* <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. */
private static final double DITHER_STRENGTH = 0.6;
/**
* A candidate is allowed when its Lab distance to the original pixel is at most
* {@code best * CANDIDATE_RATIO + CANDIDATE_SLACK}, where {@code best} is the distance of the
* nearest palette color.
*/
private static final double CANDIDATE_RATIO = 1.1;
private static final double CANDIDATE_SLACK = 1.5;
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());
int paletteSize = MapColorPalette.size();
byte[] out = new byte[SIZE * SIZE];
double[][] original = new double[w * h][];
double[][] buf = new double[w * h][];
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
int argb = image.getRGB(x, y);
double[] lab = MapColorPalette.rgbToLab((argb >> 16) & 0xFF, (argb >> 8) & 0xFF, argb & 0xFF);
original[y * w + x] = lab;
buf[y * w + x] = lab.clone();
}
}
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
int i = y * w + x;
double[] orig = original[i];
double[] want = buf[i];
double bestToOrig = Double.MAX_VALUE;
for (int p = 0; p < paletteSize; p++) {
bestToOrig = Math.min(bestToOrig, dist2(MapColorPalette.lab(p), orig));
}
double limit = Math.sqrt(bestToOrig) * CANDIDATE_RATIO + CANDIDATE_SLACK;
limit *= limit;
int chosen = 0;
double chosenDist = Double.MAX_VALUE;
for (int p = 0; p < paletteSize; p++) {
double[] lab = MapColorPalette.lab(p);
if (dist2(lab, orig) > limit) continue;
double d = dist2(lab, want);
if (d < chosenDist) {
chosenDist = d;
chosen = p;
}
}
out[y * SIZE + x] = MapColorPalette.mapIndex(chosen);
double[] c = MapColorPalette.lab(chosen);
double el = (want[0] - c[0]) * DITHER_STRENGTH;
double ea = (want[1] - c[1]) * DITHER_STRENGTH;
double eb = (want[2] - c[2]) * DITHER_STRENGTH;
spread(buf, w, h, x + 1, y, el, ea, eb, 7.0 / 16.0);
spread(buf, w, h, x - 1, y + 1, el, ea, eb, 3.0 / 16.0);
spread(buf, w, h, x, y + 1, el, ea, eb, 5.0 / 16.0);
spread(buf, w, h, x + 1, y + 1, el, ea, eb, 1.0 / 16.0);
}
}
return out;
}
private static double dist2(double[] a, double[] b) {
double dl = a[0] - b[0], da = a[1] - b[1], db = a[2] - b[2];
return dl * dl + da * da + db * db;
}
private static void spread(double[][] buf, int w, int h, int x, int y, double el, double ea, double eb, double f) {
if (x < 0 || x >= w || y >= h) return;
double[] v = buf[y * w + x];
v[0] += el * f;
v[1] += ea * f;
v[2] += eb * f;
}
}
@@ -0,0 +1,172 @@
package eu.mhsl.minecraft.pixelpics.utils;
import eu.mhsl.minecraft.pixelpics.Main;
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;
import javax.imageio.ImageIO;
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;
/**
* 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;
}
}
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more