Compare commits
76
Commits
master
..
1797436519
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1797436519 | ||
|
|
57a3a06cdf | ||
|
|
492bcb3a39 | ||
|
|
be94c33ea6 | ||
|
|
a082271f28 | ||
|
|
c75c7a4b60 | ||
|
|
bf192588a9 | ||
|
|
3a064a5ffa | ||
|
|
faeda0e0da | ||
|
|
5b4f879158 | ||
|
|
3339c45dda | ||
|
|
0b781c3538 | ||
|
|
291d51891f | ||
|
|
4be03afa3f | ||
|
|
e364d76da0 | ||
|
|
a890370cc5 | ||
|
|
e08bfc81f3 | ||
|
|
5d581dc482 | ||
|
|
4c52bc2dca | ||
|
|
a3fbe122e3 | ||
|
|
02e052787c | ||
|
|
b242adb6d2 | ||
|
|
44f2b3c59c | ||
|
|
7f2520a52d | ||
|
|
cbc859b565 | ||
|
|
2a3db3d7aa | ||
|
|
bc07631fec | ||
|
|
4348b5fca0 | ||
|
|
9093c1ccfb | ||
|
|
9e48e35680 | ||
|
|
318a309580 | ||
|
|
2c2e9e2e32 | ||
|
|
45d033bd89 | ||
|
|
6660ebe557 | ||
|
|
88bae41a03 | ||
|
|
b5baffebbd | ||
|
|
2d56a1910c | ||
|
|
a743dc32c7 | ||
|
|
022b080fce | ||
|
|
c66ebaa942 | ||
|
|
fbea9dfe08 | ||
|
|
addebffba1 | ||
|
|
a0b7ec3a90 | ||
|
|
5852d4b367 | ||
|
|
9a6c8b538d | ||
|
|
37fa1d8f56 | ||
|
|
73e521131b | ||
|
|
9e59435e13 | ||
|
|
a99acad80c | ||
|
|
32afe08f03 | ||
|
|
5904814e69 | ||
|
|
2d84a35cc4 | ||
|
|
8dd295e5d6 | ||
|
|
6d60b4f74b | ||
|
|
7063d27fb4 | ||
|
|
dce7804fed | ||
|
|
1d3040610e | ||
|
|
f83ccdc7ff | ||
|
|
220cda1deb | ||
|
|
fed94f97d1 | ||
|
|
5330948dbd | ||
|
|
094aa463c5 | ||
|
|
c5d5eae1c1 | ||
|
|
f1844a9dd9 | ||
|
|
211c7e8479 | ||
|
|
18c5fc4ffc | ||
|
|
3f0814639d | ||
|
|
bf7e52138d | ||
|
|
683cf8d80c | ||
|
|
0a0075ce05 | ||
|
|
2d4f820f04 | ||
|
|
838eafcf56 | ||
|
|
ab97315910 | ||
|
|
c5c21d89b6 | ||
|
|
ddbe59a5ac | ||
|
|
0103a654c3 |
No files matched your search
@@ -168,3 +168,5 @@ gradle-app.setting
|
||||
*.hprof
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/java,intellij,gradle
|
||||
|
||||
/local.gradle
|
||||
Generated
+2
@@ -3,6 +3,8 @@
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/modules/PixelPic.main.iml" filepath="$PROJECT_DIR$/.idea/modules/PixelPic.main.iml" />
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/modules/PixelPics.PixelPic.main.iml" filepath="$PROJECT_DIR$/.idea/modules/PixelPics.PixelPic.main.iml" />
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/modules/PixelPics.main.iml" filepath="$PROJECT_DIR$/.idea/modules/PixelPics.main.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+4
@@ -11,4 +11,8 @@
|
||||
</configuration>
|
||||
</facet>
|
||||
</component>
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
Generated
+14
@@ -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>
|
||||
+22
-3
@@ -1,8 +1,9 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'com.diffplug.spotless' version '7.0.2'
|
||||
}
|
||||
|
||||
group = 'eu.mhsl.minecraft.pixelpic'
|
||||
group = 'eu.mhsl.minecraft.pixelpics'
|
||||
version = '1.0-SNAPSHOT'
|
||||
|
||||
repositories {
|
||||
@@ -18,10 +19,10 @@ repositories {
|
||||
}
|
||||
|
||||
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 {
|
||||
def javaVersion = JavaVersion.toVersion(targetJavaVersion)
|
||||
sourceCompatibility = javaVersion
|
||||
@@ -47,3 +48,21 @@ processResources {
|
||||
expand props
|
||||
}
|
||||
}
|
||||
|
||||
spotless {
|
||||
java {
|
||||
target 'src/main/java/**/*.java'
|
||||
palantirJavaFormat()
|
||||
removeUnusedImports()
|
||||
}
|
||||
}
|
||||
|
||||
if (file("local.gradle").exists()) {
|
||||
apply from: "local.gradle"
|
||||
}
|
||||
|
||||
// Classpath for the standalone tools (tools/*.java harnesses, tools/edgecase-plugin/build.sh).
|
||||
tasks.register('printCompileClasspath') {
|
||||
def cp = sourceSets.main.compileClasspath
|
||||
doLast { println cp.asPath }
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
+1
-1
@@ -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
|
||||
@@ -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
@@ -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,174 @@
|
||||
package eu.mhsl.minecraft.pixelpics;
|
||||
|
||||
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.render.DefaultScreenRenderer;
|
||||
import eu.mhsl.minecraft.pixelpics.setup.SetupManager;
|
||||
import eu.mhsl.minecraft.pixelpics.survival.CameraListener;
|
||||
import eu.mhsl.minecraft.pixelpics.survival.CraftingListener;
|
||||
import eu.mhsl.minecraft.pixelpics.survival.ItemGuardListener;
|
||||
import eu.mhsl.minecraft.pixelpics.survival.JoinListener;
|
||||
import eu.mhsl.minecraft.pixelpics.survival.PendingPhotos;
|
||||
import eu.mhsl.minecraft.pixelpics.survival.SurvivalRecipes;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.DebugLog;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.MapColorPalette;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.MapManager;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.Reader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.PluginCommand;
|
||||
import org.bukkit.configuration.Configuration;
|
||||
import org.bukkit.configuration.InvalidConfigurationException;
|
||||
import org.bukkit.configuration.file.FileConfiguration;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
public final class Main extends JavaPlugin {
|
||||
|
||||
private static Main instance;
|
||||
|
||||
private SetupManager setup;
|
||||
private RenderManager renderManager;
|
||||
private PendingPhotos pendingPhotos;
|
||||
|
||||
// PDC keys: photo picture id (STRING), camera/film markers (BYTE), loaded film on a camera (INTEGER)
|
||||
public final NamespacedKey pictureIdFlag = new NamespacedKey(this, "imageid");
|
||||
public final NamespacedKey cameraMarker = new NamespacedKey(this, "camera");
|
||||
public final NamespacedKey filmCountKey = new NamespacedKey(this, "filmcount");
|
||||
public final NamespacedKey filmMarker = new NamespacedKey(this, "film");
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
instance = this;
|
||||
|
||||
saveDefaultConfig();
|
||||
RenderManager.Settings settings = applyConfig();
|
||||
MapManager.init(getServer().getWorlds().getFirst().getUID());
|
||||
pendingPhotos = new PendingPhotos(getDataFolder());
|
||||
pendingPhotos.recoverAbandoned();
|
||||
renderManager = new RenderManager(this, settings);
|
||||
getLogger().info("Render pool: " + settings + ".");
|
||||
|
||||
PluginCommand command = Objects.requireNonNull(getCommand("pixelpic"));
|
||||
PixelPicsCommand executor = new PixelPicsCommand();
|
||||
command.setExecutor(executor);
|
||||
command.setTabCompleter(executor);
|
||||
for (Listener listener : List.of(
|
||||
new OnMapInitialize(),
|
||||
new CameraListener(),
|
||||
new CraftingListener(),
|
||||
new ItemGuardListener(),
|
||||
new JoinListener())) {
|
||||
Bukkit.getPluginManager().registerEvents(listener, this);
|
||||
}
|
||||
SurvivalRecipes.register();
|
||||
MapManager.reattachAfterReload(OnMapInitialize::attach);
|
||||
|
||||
// Initialise the map palette on the main thread (MapPalette) before any off-thread dithering.
|
||||
MapColorPalette.size();
|
||||
setup = new SetupManager(this);
|
||||
setup.start(null);
|
||||
Bukkit.getScheduler().runTaskTimer(this, () -> setup.closeRetired(false), 100, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings an existing config.yml up to date when this version added options: the file is rewritten from the
|
||||
* bundled config.yml (layout and comments) with all of the admin's values (and their own comments) kept.
|
||||
*/
|
||||
private void addNewConfigOptions() {
|
||||
FileConfiguration config = getConfig();
|
||||
Configuration defaults = config.getDefaults();
|
||||
if (defaults == null) return;
|
||||
List<String> added = defaults.getKeys(true).stream()
|
||||
.filter(k -> !defaults.isConfigurationSection(k) && !config.isSet(k))
|
||||
.toList();
|
||||
if (added.isEmpty()) return;
|
||||
YamlConfiguration merged = new YamlConfiguration();
|
||||
try (InputStream in = Objects.requireNonNull(getResource("config.yml"));
|
||||
Reader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) {
|
||||
merged.load(reader);
|
||||
for (String key : config.getKeys(true)) {
|
||||
if (config.isConfigurationSection(key)) continue;
|
||||
merged.set(key, config.get(key));
|
||||
List<String> comments = config.getComments(key);
|
||||
if (!comments.isEmpty()) merged.setComments(key, comments);
|
||||
}
|
||||
merged.save(new File(getDataFolder(), "config.yml"));
|
||||
reloadConfig();
|
||||
getLogger().info("Added new options to config.yml (values kept): " + String.join(", ", added) + ".");
|
||||
} catch (IOException | InvalidConfigurationException e) {
|
||||
getLogger()
|
||||
.warning("Could not add the new options to config.yml (" + e.getMessage()
|
||||
+ "); their defaults apply: " + String.join(", ", added) + ".");
|
||||
}
|
||||
}
|
||||
|
||||
/** Brings config.yml up to date, applies the debug switch and returns the render limits. */
|
||||
private RenderManager.Settings applyConfig() {
|
||||
addNewConfigOptions();
|
||||
DebugLog.configure(getLogger(), getConfig().getBoolean("debug", false));
|
||||
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));
|
||||
return new RenderManager.Settings(threads, maxConcurrent, queueSize, timeoutSeconds);
|
||||
}
|
||||
|
||||
/** {@code /pixelpic reload}: config, render limits (live), resource pack and models. False while a setup runs. */
|
||||
public boolean reload(CommandSender requester) {
|
||||
if (setup.running()) return false;
|
||||
reloadConfig();
|
||||
RenderManager.Settings settings = applyConfig();
|
||||
renderManager.configure(settings);
|
||||
getLogger().info("Reloaded config.yml: render pool " + settings + ", debug " + DebugLog.enabled() + ".");
|
||||
return setup.start(requester);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
SurvivalRecipes.unregister();
|
||||
if (renderManager != null) {
|
||||
renderManager.shutdown();
|
||||
}
|
||||
MapManager.detachAll();
|
||||
if (setup != null) {
|
||||
setup.close();
|
||||
setup = null;
|
||||
}
|
||||
renderManager = null;
|
||||
}
|
||||
|
||||
/** The renderer, or {@code null} while it is set up or when no resource pack is available. */
|
||||
public DefaultScreenRenderer getScreenRenderer() {
|
||||
return setup == null ? null : setup.renderer();
|
||||
}
|
||||
|
||||
public SetupManager getSetup() {
|
||||
return setup;
|
||||
}
|
||||
|
||||
public PendingPhotos getPendingPhotos() {
|
||||
return this.pendingPhotos;
|
||||
}
|
||||
|
||||
public RenderManager getRenderManager() {
|
||||
return this.renderManager;
|
||||
}
|
||||
|
||||
public static Main getInstance() {
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
/** Pack-relative asset paths in the vanilla layout {@code assets/<namespace>/<category>/<path>.<ext>}. */
|
||||
public final class AssetPaths {
|
||||
|
||||
private AssetPaths() {}
|
||||
|
||||
/** For a plain block name such as {@code oak_stairs}. */
|
||||
public static String blockState(String blockName) {
|
||||
return "assets/" + ResourceLocation.DEFAULT_NAMESPACE + "/blockstates/" + blockName.toLowerCase() + ".json";
|
||||
}
|
||||
|
||||
/** The id path includes the folder, e.g. {@code block/stone}. */
|
||||
public static String model(ResourceLocation id) {
|
||||
return path(id, "models", ".json");
|
||||
}
|
||||
|
||||
public static String texture(ResourceLocation id) {
|
||||
return path(id, "textures", ".png");
|
||||
}
|
||||
|
||||
/** Animation metadata of a texture. */
|
||||
public static String textureMeta(ResourceLocation id) {
|
||||
return texture(id) + ".mcmeta";
|
||||
}
|
||||
|
||||
public static String font(ResourceLocation id) {
|
||||
return path(id, "font", ".json");
|
||||
}
|
||||
|
||||
private static String path(ResourceLocation id, String category, String extension) {
|
||||
return "assets/" + id.namespace() + "/" + category + "/" + id.path() + extension;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** Reads JSON assets from a {@link ResourcePack}; unreadable or malformed files are empty. */
|
||||
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 read(path, text -> gson.fromJson(text, type));
|
||||
}
|
||||
|
||||
public Optional<JsonObject> readJsonObject(String path) {
|
||||
return read(path, text -> JsonParser.parseString(text).getAsJsonObject());
|
||||
}
|
||||
|
||||
private <T> Optional<T> read(String path, Function<String, T> parser) {
|
||||
return pack.read(path).flatMap(bytes -> {
|
||||
try {
|
||||
return Optional.ofNullable(parser.apply(new String(bytes, StandardCharsets.UTF_8)));
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import java.util.EnumSet;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.data.type.Chest;
|
||||
import org.bukkit.block.data.type.CopperGolemStatue;
|
||||
import org.bukkit.block.data.type.HangingSign;
|
||||
import org.bukkit.block.data.type.Sign;
|
||||
import org.bukkit.block.data.type.Skull;
|
||||
import org.bukkit.block.data.type.WallHangingSign;
|
||||
import org.bukkit.block.data.type.WallSign;
|
||||
import org.bukkit.block.data.type.WallSkull;
|
||||
|
||||
/**
|
||||
* The block families drawn by block-entity renderers, shared by the block-model registry and the block-entity
|
||||
* snapshot. Matched by the material's BlockData type ({@link Material#data}, needs no live registry) where it is
|
||||
* unique, otherwise by name (banners, shulker boxes).
|
||||
*/
|
||||
public final class BlockEntityBlocks {
|
||||
|
||||
private BlockEntityBlocks() {}
|
||||
|
||||
// No block-model geometry, drawn only by the entity scene (classify also covers kinds that keep their model).
|
||||
private static final EnumSet<Material> NO_BLOCK_MODEL = EnumSet.noneOf(Material.class);
|
||||
|
||||
static {
|
||||
for (Material m : Material.values()) {
|
||||
if (isSign(m)
|
||||
|| isBanner(m)
|
||||
|| isShulkerBox(m)
|
||||
|| isHead(m)
|
||||
|| isChest(m)
|
||||
|| m == Material.ENDER_CHEST
|
||||
|| m == Material.CONDUIT
|
||||
|| m == Material.DECORATED_POT
|
||||
|| m == Material.BELL
|
||||
|| isCopperGolemStatue(m)
|
||||
|| m == Material.END_PORTAL
|
||||
|| m == Material.END_GATEWAY) {
|
||||
NO_BLOCK_MODEL.add(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean hasNoBlockModel(Material m) {
|
||||
return NO_BLOCK_MODEL.contains(m);
|
||||
}
|
||||
|
||||
/** Chest, trapped chest and the copper chests (not the ender chest). */
|
||||
public static boolean isChest(Material m) {
|
||||
return m.data == Chest.class;
|
||||
}
|
||||
|
||||
public static boolean isCopperChest(Material m) {
|
||||
return isChest(m) && m.name().endsWith("COPPER_CHEST");
|
||||
}
|
||||
|
||||
/** Mob heads and skulls, standing or on a wall (not the piston head). */
|
||||
public static boolean isHead(Material m) {
|
||||
return m.data == Skull.class || isWallHead(m);
|
||||
}
|
||||
|
||||
public static boolean isWallHead(Material m) {
|
||||
return m.data == WallSkull.class;
|
||||
}
|
||||
|
||||
public static boolean isSign(Material m) {
|
||||
return m.data == Sign.class || m.data == WallSign.class || isHangingSign(m);
|
||||
}
|
||||
|
||||
public static boolean isWallSign(Material m) {
|
||||
return m.data == WallSign.class;
|
||||
}
|
||||
|
||||
public static boolean isHangingSign(Material m) {
|
||||
return m.data == HangingSign.class || m.data == WallHangingSign.class;
|
||||
}
|
||||
|
||||
public static boolean isBanner(Material m) {
|
||||
return m.name().endsWith("_BANNER");
|
||||
}
|
||||
|
||||
public static boolean isWallBanner(Material m) {
|
||||
return m.name().endsWith("_WALL_BANNER");
|
||||
}
|
||||
|
||||
public static boolean isShulkerBox(Material m) {
|
||||
return m.name().endsWith("SHULKER_BOX");
|
||||
}
|
||||
|
||||
public static boolean isCopperGolemStatue(Material m) {
|
||||
return m.data == CopperGolemStatue.class;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
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.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.block.data.Bisected;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.block.data.type.Bed;
|
||||
import org.bukkit.block.data.type.Stairs;
|
||||
import org.bukkit.block.data.type.TrapDoor;
|
||||
|
||||
/**
|
||||
* Turns a {@link BlockData} into a baked {@link ResolvedModel} (blockstate -> variants -> flattened models ->
|
||||
* elements). Thread-safe; models are cached per BlockData and choice of weighted variants, the baked elements per
|
||||
* {@link Variant}.
|
||||
*
|
||||
* <p>Weighted variant lists (stone, grass, dirt, sand, … in four rotations; bamboo/chorus/fire parts) are picked
|
||||
* like vanilla 26.3: blocks in the world with {@code random.setSeed(state.getSeed(pos))} ({@code Mth.getSeed},
|
||||
* doors/double plants by their lower half, beds by their head; {@code SectionCompiler} →
|
||||
* {@code ModelBlockRenderer.tesselateBlock}), then {@code WeightedList.getRandomOrThrow} = {@code nextInt(total
|
||||
* weight)}; multipart draws one {@code nextLong} and re-seeds every part with it ({@code MultiPartModel}). Block
|
||||
* models outside the world (block displays, minecart blocks; {@code BlockModelResolver}) use the fixed seed 42.
|
||||
*/
|
||||
public final class BlockModelRegistry {
|
||||
|
||||
private static final int FALLBACK_GREY = 0xFF7F7F7F;
|
||||
/** {@code BlockModelResolver.MODEL_SEED}: block models drawn outside the world (displays, minecart contents). */
|
||||
public static final long MODEL_SEED = 42L;
|
||||
// Combinations of weighted choices beyond this are cached in a map instead of an array.
|
||||
private static final int MAX_COMBO_ARRAY = 4096;
|
||||
|
||||
// Render nothing in the world: hand-only technical blocks, and liquids (vanilla RenderShape.INVISIBLE; drawn by
|
||||
// render.raytrace.FluidRenderer, block displays of them show nothing like vanilla).
|
||||
private static final EnumSet<Material> INVISIBLE_MATERIALS = EnumSet.of(
|
||||
Material.BARRIER,
|
||||
Material.LIGHT,
|
||||
Material.STRUCTURE_VOID,
|
||||
Material.WATER,
|
||||
Material.LAVA,
|
||||
Material.BUBBLE_COLUMN);
|
||||
|
||||
private final AssetReader reader;
|
||||
private final TextureCache textures;
|
||||
private final BlockStateResolver blockStateResolver;
|
||||
private final ModelResolver modelResolver;
|
||||
private final ModelBaker baker;
|
||||
|
||||
private final Map<BlockData, States> cache = new ConcurrentHashMap<>();
|
||||
private final Map<ResourceLocation, ResolvedModel> modelCache = new ConcurrentHashMap<>();
|
||||
private final Map<Variant, List<Element>> variantCache = new ConcurrentHashMap<>();
|
||||
|
||||
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 TextureCache textures() {
|
||||
return textures;
|
||||
}
|
||||
|
||||
public AssetReader reader() {
|
||||
return reader;
|
||||
}
|
||||
|
||||
/** The model of {@code data} at world position (x, y, z) (picks weighted variants like vanilla's chunks). */
|
||||
public ResolvedModel get(BlockData data, int x, int y, int z) {
|
||||
States states = states(data);
|
||||
if (states.fixed != null) return states.fixed;
|
||||
return states.pick(this, mthSeed(x + states.seedDx, y + states.seedDy, z + states.seedDz));
|
||||
}
|
||||
|
||||
/** The model of {@code data} outside the world ({@link #MODEL_SEED}): block displays, minecart blocks, … */
|
||||
public ResolvedModel get(BlockData data) {
|
||||
return get(data, MODEL_SEED);
|
||||
}
|
||||
|
||||
/** The model of {@code data} with the weighted variants picked by {@code seed} (vanilla's model random seed). */
|
||||
public ResolvedModel get(BlockData data, long seed) {
|
||||
States states = states(data);
|
||||
return states.fixed != null ? states.fixed : states.pick(this, seed);
|
||||
}
|
||||
|
||||
/**
|
||||
* The model of {@code data} with the FIRST entry of every weighted list: block items drawn from their blockstate
|
||||
* (vanilla draws the item model, which for weighted blocks is the first, unrotated variant's model).
|
||||
*/
|
||||
public ResolvedModel firstVariants(BlockData data) {
|
||||
States states = states(data);
|
||||
return states.fixed != null ? states.fixed : states.model(this, 0);
|
||||
}
|
||||
|
||||
private States states(BlockData data) {
|
||||
States states = cache.get(data);
|
||||
return states != null ? states : cache.computeIfAbsent(data, this::resolveStates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vanilla {@code BlockState.getSeed(pos)}: {@code Mth.getSeed(pos)}; doors and double plants
|
||||
* ({@code DoublePlantBlock}: tall grass/flowers/seagrass, small dripleaf, pitcher crop) use their lower half,
|
||||
* beds their head ({@code AbstractBedBlock}).
|
||||
*/
|
||||
public static long seed(BlockData data, int x, int y, int z) {
|
||||
int[] d = seedOffset(data);
|
||||
return mthSeed(x + d[0], y + d[1], z + d[2]);
|
||||
}
|
||||
|
||||
/** The position offset of {@link #seed}: lower half of doors/double plants, head of a bed foot. */
|
||||
private static int[] seedOffset(BlockData data) {
|
||||
if (data instanceof Bisected b
|
||||
&& !(data instanceof Stairs)
|
||||
&& !(data instanceof TrapDoor)
|
||||
&& b.getHalf() == Bisected.Half.TOP) {
|
||||
return new int[] {0, -1, 0};
|
||||
}
|
||||
if (data instanceof Bed bed && bed.getPart() == Bed.Part.FOOT) {
|
||||
BlockFace f = bed.getFacing();
|
||||
return new int[] {f.getModX(), 0, f.getModZ()};
|
||||
}
|
||||
return new int[3];
|
||||
}
|
||||
|
||||
/** {@code Mth.getSeed(x, y, z)}, including its int overflow of {@code x * 3129871}. */
|
||||
static long mthSeed(int x, int y, int z) {
|
||||
long seed = (long) (x * 3129871) ^ z * 116129781L ^ y;
|
||||
seed = seed * seed * 42317861L + seed * 11L;
|
||||
return seed >> 16;
|
||||
}
|
||||
|
||||
/**
|
||||
* The picked entry of every part, as a mixed-radix index over the parts' list sizes (first part most
|
||||
* significant): {@code ModelBlockRenderer} seeds the random, {@code WeightedVariants} draws
|
||||
* {@code nextInt(totalWeight)}, {@code MultiPartModel} first draws a {@code nextLong} and re-seeds with it before
|
||||
* every part.
|
||||
*/
|
||||
static long comboIndex(boolean multipart, BlockStateResolver.Choice[] parts, long seed) {
|
||||
if (!multipart) {
|
||||
// Hot path (stone, grass, dirt, … in every chunk): one list, the first draw of a fresh seed.
|
||||
BlockStateResolver.Choice c = parts[0];
|
||||
return c.index(Lcg.firstNextInt(seed, c.totalWeight()));
|
||||
}
|
||||
Lcg random = new Lcg(seed);
|
||||
long partSeed = multipart ? random.nextLong() : 0;
|
||||
long index = 0;
|
||||
for (BlockStateResolver.Choice c : parts) {
|
||||
if (multipart) random.setSeed(partSeed);
|
||||
// WeightedVariants draws even for a one-entry list, SingleVariant does not: the same pick either way.
|
||||
int pick = c.variants().size() == 1 ? 0 : c.index(random.nextInt(c.totalWeight()));
|
||||
index = index * c.variants().size() + pick;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/** Vanilla's {@code LegacyRandomSource}/{@code SingleThreadedRandomSource} (java.util.Random's LCG). */
|
||||
private static final class Lcg {
|
||||
private static final long MULTIPLIER = 0x5DEECE66DL;
|
||||
private static final long MASK = (1L << 48) - 1;
|
||||
private long seed;
|
||||
|
||||
Lcg(long seed) {
|
||||
setSeed(seed);
|
||||
}
|
||||
|
||||
void setSeed(long seed) {
|
||||
this.seed = (seed ^ MULTIPLIER) & MASK;
|
||||
}
|
||||
|
||||
/** {@code setSeed(seed); nextInt(bound)} without an instance. */
|
||||
static int firstNextInt(long seed, int bound) {
|
||||
long s = (seed ^ MULTIPLIER) & MASK;
|
||||
s = s * MULTIPLIER + 11L & MASK;
|
||||
int sample = (int) (s >> 17);
|
||||
if ((bound & bound - 1) == 0) return (int) ((long) bound * sample >> 31);
|
||||
int modulo = sample % bound;
|
||||
while (sample - modulo + (bound - 1) < 0) {
|
||||
s = s * MULTIPLIER + 11L & MASK;
|
||||
sample = (int) (s >> 17);
|
||||
modulo = sample % bound;
|
||||
}
|
||||
return modulo;
|
||||
}
|
||||
|
||||
private int next(int bits) {
|
||||
seed = seed * MULTIPLIER + 11L & MASK;
|
||||
return (int) (seed >> 48 - bits);
|
||||
}
|
||||
|
||||
/** {@code BitRandomSource.nextInt(bound)}. */
|
||||
int nextInt(int bound) {
|
||||
if ((bound & bound - 1) == 0) return (int) ((long) bound * next(31) >> 31);
|
||||
int sample, modulo;
|
||||
do {
|
||||
sample = next(31);
|
||||
modulo = sample % bound;
|
||||
} while (sample - modulo + (bound - 1) < 0);
|
||||
return modulo;
|
||||
}
|
||||
|
||||
long nextLong() {
|
||||
int upper = next(32);
|
||||
int lower = next(32);
|
||||
return ((long) upper << 32) + lower;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The model choices of one BlockData: {@code fixed} when nothing is random (every choice has one entry),
|
||||
* else the choices and the models per combination of picked entries (built on first use).
|
||||
*/
|
||||
private static final class States {
|
||||
final ResolvedModel fixed;
|
||||
final BlockData data;
|
||||
final boolean multipart;
|
||||
final BlockStateResolver.Choice[] parts;
|
||||
final ResolvedModel[] combos; // index = mixed radix over the parts' picks; null when too many
|
||||
final Map<Long, ResolvedModel> comboMap;
|
||||
final int seedDx, seedDy, seedDz; // seedOffset of data
|
||||
|
||||
States(ResolvedModel fixed) {
|
||||
this.seedDx = this.seedDy = this.seedDz = 0;
|
||||
this.fixed = fixed;
|
||||
this.data = null;
|
||||
this.multipart = false;
|
||||
this.parts = null;
|
||||
this.combos = null;
|
||||
this.comboMap = null;
|
||||
}
|
||||
|
||||
States(BlockData data, BlockStateResolver.StateModel model) {
|
||||
int[] d = seedOffset(data);
|
||||
this.seedDx = d[0];
|
||||
this.seedDy = d[1];
|
||||
this.seedDz = d[2];
|
||||
this.fixed = null;
|
||||
this.data = data;
|
||||
this.multipart = model.multipart();
|
||||
this.parts = model.parts().toArray(BlockStateResolver.Choice[]::new);
|
||||
long count = 1;
|
||||
for (BlockStateResolver.Choice c : parts)
|
||||
count = Math.min(count * c.variants().size(), Long.MAX_VALUE / 64);
|
||||
this.combos = count <= MAX_COMBO_ARRAY ? new ResolvedModel[(int) count] : null;
|
||||
this.comboMap = combos == null ? new ConcurrentHashMap<>() : null;
|
||||
}
|
||||
|
||||
ResolvedModel pick(BlockModelRegistry registry, long seed) {
|
||||
return model(registry, comboIndex(multipart, parts, seed));
|
||||
}
|
||||
|
||||
/** The model of a combination index (0 = the first entry of every list). */
|
||||
ResolvedModel model(BlockModelRegistry registry, long index) {
|
||||
if (combos != null) {
|
||||
ResolvedModel m = combos[(int) index];
|
||||
if (m == null) combos[(int) index] = m = registry.resolve(data, variants(index)); // immutable, racy ok
|
||||
return m;
|
||||
}
|
||||
long key = index;
|
||||
return comboMap.computeIfAbsent(key, k -> registry.resolve(data, variants(k)));
|
||||
}
|
||||
|
||||
/** The picked variant of every part for a combination index. */
|
||||
private List<Variant> variants(long index) {
|
||||
Variant[] picked = new Variant[parts.length];
|
||||
for (int i = parts.length - 1; i >= 0; i--) {
|
||||
int n = parts[i].variants().size();
|
||||
picked[i] = parts[i].variants().get((int) (index % n));
|
||||
index /= n;
|
||||
}
|
||||
return List.of(picked);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A block model by id (e.g. {@code block/item_frame_map}) without a blockstate, unrotated: for models vanilla
|
||||
* draws from a fake block state (item frames). Empty when the model is missing.
|
||||
*/
|
||||
public ResolvedModel model(ResourceLocation modelId) {
|
||||
return modelCache.computeIfAbsent(modelId, id -> {
|
||||
List<Element> elements = bake(new Variant(id, 0, 0, false));
|
||||
return new ResolvedModel(elements, !elements.isEmpty());
|
||||
});
|
||||
}
|
||||
|
||||
private List<Element> bake(Variant variant) {
|
||||
return variantCache.computeIfAbsent(variant, v -> baker.bake(modelResolver.resolve(v.model()), v));
|
||||
}
|
||||
|
||||
private States resolveStates(BlockData data) {
|
||||
if (INVISIBLE_MATERIALS.contains(data.getMaterial())) return new States(ResolvedModel.EMPTY);
|
||||
BlockStateResolver.StateModel model = blockStateResolver.resolve(data);
|
||||
boolean random = false;
|
||||
for (BlockStateResolver.Choice c : model.parts()) random |= c.variants().size() > 1;
|
||||
if (random) return new States(data, model);
|
||||
List<Variant> variants = new ArrayList<>(model.parts().size());
|
||||
for (BlockStateResolver.Choice c : model.parts())
|
||||
variants.add(c.variants().getFirst());
|
||||
return new States(resolve(data, variants));
|
||||
}
|
||||
|
||||
/** The model of {@code data} from the picked {@code variants} (one per part). */
|
||||
private ResolvedModel resolve(BlockData data, List<Variant> variants) {
|
||||
Material material = data.getMaterial();
|
||||
List<Element> elements;
|
||||
if (variants.size() == 1) {
|
||||
elements = bake(variants.getFirst()); // shared with other states of the same variant; never mutated
|
||||
} else {
|
||||
elements = new ArrayList<>();
|
||||
for (Variant variant : variants) elements.addAll(bake(variant));
|
||||
}
|
||||
if (!elements.isEmpty()) return new ResolvedModel(elements, true);
|
||||
|
||||
// Block entities (builtin/entity models) are drawn by the entity scene: no grey cube in front of them.
|
||||
if (BlockEntityBlocks.hasNoBlockModel(material)) return ResolvedModel.EMPTY;
|
||||
FlatModel last = variants.isEmpty()
|
||||
? null
|
||||
: modelResolver.resolve(variants.getLast().model());
|
||||
return new ResolvedModel(List.of(solidCube(particleColor(last))), false);
|
||||
}
|
||||
|
||||
/** A full cube whose faces sample one solid colour. */
|
||||
private static 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);
|
||||
}
|
||||
|
||||
/** Average colour of the model's particle texture (texels with alpha > 16), else grey. */
|
||||
private int particleColor(FlatModel flat) {
|
||||
String particle = flat == null ? null : flat.textures().get("particle");
|
||||
if (particle == null || particle.startsWith("#")) return FALLBACK_GREY;
|
||||
int[][] tex = textures.get(ResourceLocation.parse(particle)).orElse(null);
|
||||
if (tex == null) return FALLBACK_GREY;
|
||||
long r = 0, g = 0, b = 0;
|
||||
int count = 0;
|
||||
for (int[] row : tex) {
|
||||
for (int argb : row) {
|
||||
if (!ColorUtil.opaque(argb)) continue;
|
||||
r += ColorUtil.red(argb);
|
||||
g += ColorUtil.green(argb);
|
||||
b += ColorUtil.blue(argb);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (count == 0) return FALLBACK_GREY;
|
||||
return ColorUtil.argb(0xFF, (int) (r / count), (int) (g / count), (int) (b / count));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
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.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
|
||||
/**
|
||||
* Resolves a {@link BlockData} to the model choices of its {@code blockstates/<name>.json}: the matching entry of
|
||||
* {@code variants}, or every applying {@code multipart} case. Each choice is a weighted list (vanilla
|
||||
* {@code WeightedVariants}; a single object = one entry); {@link BlockModelRegistry} picks per position.
|
||||
*/
|
||||
public final class BlockStateResolver {
|
||||
|
||||
/** A weighted variant list (vanilla {@code WeightedVariants}); a single variant has one entry. */
|
||||
public record Choice(List<Variant> variants, int[] weights, int totalWeight) {
|
||||
|
||||
/** {@code WeightedList.Selector.get}: the entry whose weight range contains {@code selection}. */
|
||||
public int index(int selection) {
|
||||
for (int i = 0; i < weights.length; i++) {
|
||||
selection -= weights[i];
|
||||
if (selection < 0) return i;
|
||||
}
|
||||
return weights.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The model of a block state: {@code multipart} (vanilla {@code MultiPartModel}: every part picks with the
|
||||
* same seed) or a single {@code variants} entry ({@code parts} has one choice); no parts = no model.
|
||||
*/
|
||||
public record StateModel(boolean multipart, List<Choice> parts) {
|
||||
static final StateModel NONE = new StateModel(false, List.of());
|
||||
}
|
||||
|
||||
private final AssetReader reader;
|
||||
|
||||
public BlockStateResolver(AssetReader reader) {
|
||||
this.reader = reader;
|
||||
}
|
||||
|
||||
public StateModel resolve(BlockData data) {
|
||||
// e.g. minecraft:oak_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]
|
||||
String state = data.getAsString(false);
|
||||
int open = state.indexOf('[');
|
||||
String id = open < 0 ? state : state.substring(0, open);
|
||||
String name = id.substring(id.indexOf(':') + 1).trim();
|
||||
Map<String, String> props = new HashMap<>();
|
||||
int close = state.lastIndexOf(']');
|
||||
if (open >= 0 && close > open) {
|
||||
for (String pair : state.substring(open + 1, close).split(",")) {
|
||||
int eq = pair.indexOf('=');
|
||||
if (eq >= 0)
|
||||
props.put(
|
||||
pair.substring(0, eq).trim(), pair.substring(eq + 1).trim());
|
||||
}
|
||||
}
|
||||
|
||||
JsonObject root = reader.readJsonObject(AssetPaths.blockState(name)).orElse(null);
|
||||
if (root == null) return StateModel.NONE;
|
||||
|
||||
if (root.has("variants")) {
|
||||
return resolveVariants(root.getAsJsonObject("variants"), props);
|
||||
}
|
||||
if (root.has("multipart")) {
|
||||
return resolveMultipart(root.getAsJsonArray("multipart"), props);
|
||||
}
|
||||
return StateModel.NONE;
|
||||
}
|
||||
|
||||
private static StateModel resolveVariants(JsonObject variants, Map<String, String> props) {
|
||||
for (Map.Entry<String, JsonElement> entry : variants.entrySet()) {
|
||||
if (variantKeyMatches(entry.getKey(), props)) {
|
||||
return new StateModel(false, List.of(choice(entry.getValue())));
|
||||
}
|
||||
}
|
||||
return StateModel.NONE;
|
||||
}
|
||||
|
||||
private static StateModel resolveMultipart(JsonArray multipart, Map<String, String> props) {
|
||||
List<Choice> result = new ArrayList<>();
|
||||
for (JsonElement caseEl : multipart) {
|
||||
JsonObject caseObj = caseEl.getAsJsonObject();
|
||||
if (caseObj.has("when") && !whenMatches(caseObj.get("when"), props)) continue;
|
||||
result.add(choice(caseObj.get("apply")));
|
||||
}
|
||||
return new StateModel(true, result);
|
||||
}
|
||||
|
||||
/** A variant key like {@code facing=east,half=bottom} matches when every pair holds. */
|
||||
private static 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 static 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;
|
||||
}
|
||||
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 static boolean valueMatches(String actual, String expected) {
|
||||
if (actual == null) return false;
|
||||
for (String option : expected.split("\\|")) {
|
||||
if (option.equals(actual)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A variant object or a weighted list of them ({@code "weight"}, default 1). */
|
||||
private static Choice choice(JsonElement element) {
|
||||
if (!element.isJsonArray())
|
||||
return new Choice(List.of(parseVariant(element.getAsJsonObject())), new int[] {1}, 1);
|
||||
JsonArray array = element.getAsJsonArray();
|
||||
List<Variant> variants = new ArrayList<>(array.size());
|
||||
int[] weights = new int[array.size()];
|
||||
int total = 0;
|
||||
for (int i = 0; i < array.size(); i++) {
|
||||
JsonObject obj = array.get(i).getAsJsonObject();
|
||||
variants.add(parseVariant(obj));
|
||||
weights[i] = obj.has("weight") ? Math.max(1, obj.get("weight").getAsInt()) : 1;
|
||||
total += weights[i];
|
||||
}
|
||||
return new Choice(List.copyOf(variants), weights, total);
|
||||
}
|
||||
|
||||
private static 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,33 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Several packs as one; the first pack containing an asset wins. */
|
||||
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,43 @@
|
||||
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 (the one containing {@code assets/}). */
|
||||
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();
|
||||
// No 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() {}
|
||||
}
|
||||
@@ -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), elements from the nearest model that has
|
||||
* any (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,36 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Thread-safe cache of ARGB pixel grids ({@code [y][x]}, top-left origin). Failed loads ({@link #load} returns null)
|
||||
* are cached too and never retried.
|
||||
*/
|
||||
public abstract class ImageCache<K> {
|
||||
|
||||
private static final int[][] MISSING = new int[0][0];
|
||||
|
||||
private final Map<K, int[][]> cache = new ConcurrentHashMap<>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/** The {@code w×h} pixels of an image at ({@code x0}, {@code y0}) as ARGB rows. */
|
||||
protected static int[][] pixels(BufferedImage img, int x0, int y0, int w, int h) {
|
||||
int[][] out = new int[h][];
|
||||
for (int y = 0; y < h; y++) out[y] = img.getRGB(x0, y0 + y, w, 1, null, 0, w);
|
||||
return out;
|
||||
}
|
||||
|
||||
private int[][] loadOrSentinel(K key) {
|
||||
int[][] loaded = load(key);
|
||||
return loaded == null ? MISSING : loaded;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto;
|
||||
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 {@link Element} boxes. Blockstate x/y rotations
|
||||
* (90° steps) are applied to box, faces and the element's own rotation; rotated elements keep their model-space copy.
|
||||
*/
|
||||
public final class ModelBaker {
|
||||
|
||||
private final TextureCache textures;
|
||||
|
||||
public ModelBaker(TextureCache textures) {
|
||||
this.textures = textures;
|
||||
}
|
||||
|
||||
public List<Element> bake(FlatModel model, Variant variant) {
|
||||
if (!model.hasElements()) return List.of();
|
||||
|
||||
int xSteps = Math.floorMod(variant.x() / 90, 4);
|
||||
int ySteps = Math.floorMod(variant.y() / 90, 4);
|
||||
|
||||
List<Element> baked = new ArrayList<>();
|
||||
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};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
double[] rotOrigin = null;
|
||||
int rotAxis = -1;
|
||||
double rotAngle = 0;
|
||||
double[] rotMatrix = null;
|
||||
boolean rescale = false;
|
||||
ModelFileDto.RotationDto rot = dto.rotation;
|
||||
if (rot != null && rot.origin != null) {
|
||||
if (rot.axis == null && (rot.x != null || rot.y != null || rot.z != null)) {
|
||||
rotMatrix = eulerXyz(rot.x, rot.y, rot.z);
|
||||
if (rotMatrix != null) rotAxis = Element.AXIS_MATRIX;
|
||||
} else if (rot.angle != 0) {
|
||||
rotAxis = axisIndex(rot.axis);
|
||||
rotAngle = Math.toRadians(rot.angle);
|
||||
}
|
||||
if (rotAxis >= 0) {
|
||||
rotOrigin = new double[] {rot.origin[0] / 16.0, rot.origin[1] / 16.0, rot.origin[2] / 16.0};
|
||||
rescale = rot.rescale;
|
||||
}
|
||||
}
|
||||
|
||||
Element modelElement =
|
||||
new Element(from, to, faces, rotOrigin, rotAxis, rotAngle, rotMatrix, rescale, null, 0, 0);
|
||||
|
||||
// Rotated variants keep the model-space element; intersectors hit it with the inverse-rotated ray (as
|
||||
// vanilla's FaceBakery, textures turn with the model). Under uvlock its faces get a UV transform instead.
|
||||
// The world box is min/max-normalised, so only the model-space copy of an inverted element stays inverted.
|
||||
Element unrotated = null;
|
||||
if (xSteps != 0 || ySteps != 0) {
|
||||
unrotated = variant.uvlock() ? uvLocked(modelElement, xSteps, ySteps) : modelElement;
|
||||
double[][] box = minMax(rotatePoint(from, xSteps, ySteps), rotatePoint(to, xSteps, ySteps));
|
||||
from = box[0];
|
||||
to = box[1];
|
||||
Face[] rotated = new Face[6];
|
||||
for (Direction d : Direction.values()) {
|
||||
rotated[d.rotateX(xSteps).rotateY(ySteps).ordinal()] = faces[d.ordinal()];
|
||||
}
|
||||
faces = rotated;
|
||||
if (rotAxis >= 0) rotOrigin = rotatePoint(rotOrigin, xSteps, ySteps);
|
||||
if (rotAxis >= 0 && rotAxis < Element.AXIS_MATRIX) {
|
||||
Direction axis =
|
||||
Direction.of(rotAxis, false).rotateX(xSteps).rotateY(ySteps);
|
||||
rotAxis = axis.axis;
|
||||
if (axis.nx + axis.ny + axis.nz < 0) rotAngle = -rotAngle;
|
||||
}
|
||||
}
|
||||
baked.add(new Element(
|
||||
from,
|
||||
to,
|
||||
faces,
|
||||
rotOrigin,
|
||||
rotAxis,
|
||||
rotAngle,
|
||||
rotMatrix == null ? null : conjugate(rotMatrix, xSteps, ySteps),
|
||||
rescale,
|
||||
unrotated,
|
||||
xSteps,
|
||||
ySteps));
|
||||
}
|
||||
return baked;
|
||||
}
|
||||
|
||||
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[] uv = dto.uv != null && dto.uv.length == 4
|
||||
? new double[] {dto.uv[0] / 16.0, dto.uv[1] / 16.0, dto.uv[2] / 16.0, dto.uv[3] / 16.0}
|
||||
: defaultUv(dir, from, to);
|
||||
int tint = dto.tintindex != null ? dto.tintindex : -1;
|
||||
return new Face(tex, uv[0], uv[1], uv[2], uv[3], dto.rotation, tint);
|
||||
}
|
||||
|
||||
/** Vanilla {@code BlockElement.uvsByFace}, normalised (texture v is top-down). */
|
||||
private static double[] defaultUv(Direction dir, double[] f, double[] t) {
|
||||
return switch (dir) {
|
||||
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]};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vanilla {@code EulerXYZRotation}: {@code Matrix4f.rotationZYX(z, y, x)} = Rz·Ry·Rx (degrees), row-major;
|
||||
* null when it is the identity (vanilla then skips the rotation and its rescale).
|
||||
*/
|
||||
static double[] eulerXyz(Double xDeg, Double yDeg, Double zDeg) {
|
||||
double x = Math.toRadians(xDeg == null ? 0 : xDeg);
|
||||
double y = Math.toRadians(yDeg == null ? 0 : yDeg);
|
||||
double z = Math.toRadians(zDeg == null ? 0 : zDeg);
|
||||
if (x == 0 && y == 0 && z == 0) return null;
|
||||
double cx = Math.cos(x),
|
||||
sx = Math.sin(x),
|
||||
cy = Math.cos(y),
|
||||
sy = Math.sin(y),
|
||||
cz = Math.cos(z),
|
||||
sz = Math.sin(z);
|
||||
double[] rz = {cz, -sz, 0, sz, cz, 0, 0, 0, 1};
|
||||
double[] ry = {cy, 0, sy, 0, 1, 0, -sy, 0, cy};
|
||||
double[] rx = {1, 0, 0, 0, cx, -sx, 0, sx, cx};
|
||||
return mul(mul(rz, ry), rx);
|
||||
}
|
||||
|
||||
/** The element rotation as seen after the blockstate rotation: V·M·Vᵀ (V = Element#rotate of the axes). */
|
||||
private static double[] conjugate(double[] m, int xSteps, int ySteps) {
|
||||
double[] v = new double[9];
|
||||
for (int a = 0; a < 3; a++) {
|
||||
double[] col = Element.rotate(a == 0 ? 1 : 0, a == 1 ? 1 : 0, a == 2 ? 1 : 0, xSteps, ySteps, false);
|
||||
v[a] = col[0];
|
||||
v[3 + a] = col[1];
|
||||
v[6 + a] = col[2];
|
||||
}
|
||||
double[] vt = {v[0], v[3], v[6], v[1], v[4], v[7], v[2], v[5], v[8]};
|
||||
return mul(mul(v, m), vt);
|
||||
}
|
||||
|
||||
private static double[] mul(double[] a, double[] b) {
|
||||
double[] r = new double[9];
|
||||
for (int i = 0; i < 3; i++)
|
||||
for (int j = 0; j < 3; j++)
|
||||
r[3 * i + j] = a[3 * i] * b[j] + a[3 * i + 1] * b[3 + j] + a[3 * i + 2] * b[6 + j];
|
||||
return r;
|
||||
}
|
||||
|
||||
private static Element uvLocked(Element e, int xSteps, int ySteps) {
|
||||
Face[] locked = new Face[6];
|
||||
for (Direction d : Direction.values()) {
|
||||
Face f = e.faces[d.ordinal()];
|
||||
if (f != null) locked[d.ordinal()] = f.withUvLock(uvLockTransform(d, xSteps, ySteps));
|
||||
}
|
||||
return e.withFaces(locked);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vanilla uvlock ({@code BlockMath.getFaceTransformation}, inverted as in {@code FaceBakery}): the final
|
||||
* texture coordinates of model face {@code d}, centred on the texture, are mapped by
|
||||
* {@code M = G2L(d) · R⁻¹ · L2G(D)} where R is the blockstate rotation, D the world face {@code d} turns into,
|
||||
* and L2G the per-face UV frames (south = identity). Up/down under x rotations follow vanilla's frames (not
|
||||
* strictly world-aligned). M is one of the 8 symmetries of the unit UV square, as the affine of {@link Face#uvLock}.
|
||||
*/
|
||||
static double[] uvLockTransform(Direction d, int xSteps, int ySteps) {
|
||||
Direction world = d.rotateX(xSteps).rotateY(ySteps);
|
||||
double[] cu = lockAxis(d, world, 1, 0, xSteps, ySteps);
|
||||
double[] cv = lockAxis(d, world, 0, 1, xSteps, ySteps);
|
||||
// u' = ½ + cu.x (u-½) + cv.x (v-½), v' = ½ + cu.y (u-½) + cv.y (v-½)
|
||||
return new double[] {0.5 - 0.5 * (cu[0] + cv[0]), cu[0], cv[0], 0.5 - 0.5 * (cu[1] + cv[1]), cu[1], cv[1]};
|
||||
}
|
||||
|
||||
private static double[] lockAxis(Direction d, Direction world, double u, double v, int xSteps, int ySteps) {
|
||||
double[] w = uvFrame(world, u, v, 0, false);
|
||||
double[] m = Element.unrotate(w[0], w[1], w[2], xSteps, ySteps, false);
|
||||
return uvFrame(d, m[0], m[1], m[2], true);
|
||||
}
|
||||
|
||||
/** Vanilla {@code BlockMath.VANILLA_UV_TRANSFORM_LOCAL_TO_GLOBAL} (or its inverse) applied to a vector. */
|
||||
private static double[] uvFrame(Direction d, double x, double y, double z, boolean inverse) {
|
||||
return switch (d) {
|
||||
case SOUTH -> new double[] {x, y, z};
|
||||
case NORTH -> new double[] {-x, y, -z}; // rotY(180°)
|
||||
case EAST -> inverse ? new double[] {-z, y, x} : new double[] {z, y, -x}; // rotY(90°)
|
||||
case WEST -> inverse ? new double[] {z, y, -x} : new double[] {-z, y, x}; // rotY(-90°)
|
||||
case UP -> inverse ? new double[] {x, -z, y} : new double[] {x, z, -y}; // rotX(-90°)
|
||||
case DOWN -> inverse ? new double[] {x, z, -y} : new double[] {x, -z, y}; // rotX(90°)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The texture of a face: like vanilla 26.3 {@code TextureSlots.getMaterial} the face's {@code texture} always
|
||||
* names a texture slot, with or without the leading {@code #} (heavy_core uses {@code "all"}); slot values
|
||||
* starting with {@code #} refer to other slots. An unresolved slot or a missing/unreadable texture is the
|
||||
* magenta-black missing texture ({@link TextureCache#MISSINGNO}), not a hole.
|
||||
*/
|
||||
private int[][] resolveTexture(String ref, Map<String, String> vars) {
|
||||
if (ref == null || ref.isEmpty()) return null;
|
||||
String current = ref.startsWith("#") ? ref : "#" + ref;
|
||||
int guard = 0;
|
||||
while (current.startsWith("#") && guard++ < 16) {
|
||||
current = vars.get(current.substring(1));
|
||||
if (current == null) return TextureCache.MISSINGNO;
|
||||
}
|
||||
if (current.startsWith("#")) return TextureCache.MISSINGNO;
|
||||
return textures.get(ResourceLocation.parse(current)).orElse(TextureCache.MISSINGNO);
|
||||
}
|
||||
|
||||
private static int axisIndex(String axis) {
|
||||
if (axis == null) return -1;
|
||||
return switch (axis.toLowerCase()) {
|
||||
case "x" -> 0;
|
||||
case "y" -> 1;
|
||||
case "z" -> 2;
|
||||
default -> -1;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Blockstate rotation of a point about the block centre: x steps, then y steps, both clockwise like vanilla
|
||||
* (y: north -> east). Re-centred after every step instead of using {@link Element#rotate}: that one adds
|
||||
* {@code (p - 0.5) + 0.5} on the fixed axis, which is not exact for every coordinate.
|
||||
*/
|
||||
private static double[] rotatePoint(double[] p, int xSteps, int ySteps) {
|
||||
double[] r = p;
|
||||
for (int i = 0; i < xSteps; i++) {
|
||||
double y = r[1] - 0.5, z = r[2] - 0.5;
|
||||
r = new double[] {r[0], 0.5 + z, 0.5 - y};
|
||||
}
|
||||
for (int i = 0; i < ySteps; i++) {
|
||||
double x = r[0] - 0.5, z = r[2] - 0.5;
|
||||
r = new double[] {0.5 - z, r[1], 0.5 + x};
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
private static 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};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
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, cached per 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) {
|
||||
return resolve(modelId, 0).model();
|
||||
}
|
||||
|
||||
/** A flattened model; {@code complete} = false when its parent chain was cut at {@link #MAX_DEPTH} (cycle). */
|
||||
private record Resolved(FlatModel model, boolean complete) {}
|
||||
|
||||
/** A texture value: a plain reference, or the {@code sprite} of a 26.x texture object; null otherwise. */
|
||||
public static String textureRef(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;
|
||||
}
|
||||
|
||||
// Parents go through the cache too (shared chains like block/cube_all are parsed once). No computeIfAbsent:
|
||||
// the recursion would modify the map from inside its own compute. depth only guards against parent cycles; a
|
||||
// chain cut there is not cached, so a later direct lookup of a model in it does not get the cut version.
|
||||
private Resolved resolve(ResourceLocation modelId, int depth) {
|
||||
FlatModel cached = cache.get(modelId);
|
||||
if (cached != null) return new Resolved(cached, true);
|
||||
|
||||
ModelFileDto dto =
|
||||
reader.readJson(AssetPaths.model(modelId), ModelFileDto.class).orElse(null);
|
||||
FlatModel resolved;
|
||||
boolean complete = true;
|
||||
if (dto == null) {
|
||||
resolved = new FlatModel(new HashMap<>(), null);
|
||||
} else {
|
||||
Map<String, String> textures = new HashMap<>();
|
||||
List<ModelFileDto.ElementDto> elements = dto.elements;
|
||||
if (dto.parent != null && !dto.parent.startsWith("builtin/")) {
|
||||
if (depth < MAX_DEPTH) {
|
||||
Resolved parent = resolve(ResourceLocation.parse(dto.parent), depth + 1);
|
||||
complete = parent.complete();
|
||||
textures.putAll(parent.model().textures());
|
||||
if (elements == null || elements.isEmpty())
|
||||
elements = parent.model().elements();
|
||||
} else {
|
||||
complete = false;
|
||||
}
|
||||
}
|
||||
if (dto.textures != null) {
|
||||
dto.textures.forEach((key, value) -> {
|
||||
String ref = textureRef(value);
|
||||
if (ref != null) textures.put(key, ref);
|
||||
});
|
||||
}
|
||||
resolved = new FlatModel(textures, elements);
|
||||
}
|
||||
if (!complete) return new Resolved(resolved, false);
|
||||
FlatModel raced = cache.putIfAbsent(modelId, resolved);
|
||||
return new Resolved(raced != null ? raced : resolved, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
/** A namespaced asset id such as {@code minecraft:block/stone}; lower-cased, namespace defaults to minecraft. */
|
||||
public record ResourceLocation(String namespace, String path) {
|
||||
|
||||
public static final String DEFAULT_NAMESPACE = "minecraft";
|
||||
|
||||
public ResourceLocation {
|
||||
namespace = namespace.toLowerCase();
|
||||
path = path.toLowerCase();
|
||||
}
|
||||
|
||||
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,18 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* A read-only source of assets by pack-relative {@code /}-separated path, e.g.
|
||||
* {@code assets/minecraft/blockstates/oak_fence.json}. Must be safe for concurrent reads (parallel renderer).
|
||||
*/
|
||||
public interface ResourcePack extends Closeable {
|
||||
|
||||
Optional<byte[]> read(String path);
|
||||
|
||||
boolean exists(String path);
|
||||
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
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.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Builds a {@link ResourcePack} from the plugin's {@code resourcepack/} folder: unpacked packs (the folder itself or
|
||||
* a direct sub-folder with {@code assets/minecraft}) and {@code .zip} packs anywhere below it. Priority (the first
|
||||
* pack containing an asset wins) is deterministic, independent of the file system's listing order: the folder
|
||||
* itself, then its sub-folders sorted by name, then the zips sorted by their path relative to the folder ('/'
|
||||
* separated, plain {@code String} order: case-sensitive, digits before letters). To let a pack override another,
|
||||
* give it a name that sorts first (e.g. {@code 0-custom.zip} before {@code vanilla.zip}). The packs are logged in
|
||||
* that order.
|
||||
*/
|
||||
public final class ResourcePackLoader {
|
||||
|
||||
private static final String MARKER = "assets/minecraft";
|
||||
|
||||
private ResourcePackLoader() {}
|
||||
|
||||
private static String relative(Path root, Path path) {
|
||||
return root.relativize(path).toString().replace(File.separatorChar, '/');
|
||||
}
|
||||
|
||||
/** The {@code .zip} files below {@code root} in load order (see the class doc). */
|
||||
public static List<Path> zips(Path root) throws IOException {
|
||||
try (Stream<Path> walk = Files.walk(root)) {
|
||||
return walk.filter(Files::isRegularFile)
|
||||
.filter(p -> p.getFileName().toString().toLowerCase().endsWith(".zip"))
|
||||
.sorted(Comparator.comparing(p -> relative(root, p)))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
public static Optional<ResourcePack> load(File resourcePackDir, Logger logger) {
|
||||
return load(resourcePackDir, logger, Set.of());
|
||||
}
|
||||
|
||||
/** Like {@link #load(File, Logger)}, skipping the zips in {@code exclude}. */
|
||||
public static Optional<ResourcePack> load(File resourcePackDir, Logger logger, Set<Path> exclude) {
|
||||
if (!resourcePackDir.isDirectory()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
List<ResourcePack> packs = new ArrayList<>();
|
||||
|
||||
List<Path> dirCandidates = new ArrayList<>();
|
||||
dirCandidates.add(resourcePackDir.toPath());
|
||||
File[] children = resourcePackDir.listFiles(File::isDirectory);
|
||||
if (children != null) {
|
||||
Arrays.sort(children, Comparator.comparing(File::getName));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
for (Path zip : zips(resourcePackDir.toPath())) {
|
||||
if (exclude.contains(zip)) continue;
|
||||
try {
|
||||
ZipResourcePack pack = new ZipResourcePack(zip);
|
||||
// Zip entries are files only; the client jar has no pack.mcmeta, so probe two vanilla files.
|
||||
if (pack.exists("pack.mcmeta")
|
||||
|| pack.exists(AssetPaths.blockState("stone"))
|
||||
|| pack.exists(AssetPaths.blockState("dirt"))) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageReader;
|
||||
import javax.imageio.stream.ImageInputStream;
|
||||
|
||||
/**
|
||||
* Player skins by profile texture URL as 64x64 ARGB grids, processed like vanilla's
|
||||
* {@code SkinTextureDownloader.processLegacySkin} (legacy 64x32 skins converted). Downloaded off the main thread, from
|
||||
* the baking step.
|
||||
*
|
||||
* <p>Profile textures of heads and NPCs are not signed, so the URL is untrusted: only Mojang's texture server is
|
||||
* contacted ({@code http(s)://textures.minecraft.net/texture/…}, fetched over https without redirects), the
|
||||
* download is capped at {@link #MAX_BYTES} and the PNG header must declare 64x64 or 64x32 (the only sizes vanilla
|
||||
* accepts) before anything is decoded. Concurrent requests for one URL share a single download; other URLs are not
|
||||
* blocked meanwhile. Failed downloads are retried after a growing backoff. The cache keeps the {@link #CAPACITY}
|
||||
* most recently used skins.
|
||||
*/
|
||||
public final class SkinCache {
|
||||
|
||||
static final String HOST = "textures.minecraft.net";
|
||||
static final int MAX_BYTES = 128 * 1024;
|
||||
private static final int CAPACITY = 512;
|
||||
private static final int TIMEOUT_MILLIS = 5000;
|
||||
private static final long FIRST_BACKOFF_MILLIS = 60_000;
|
||||
private static final long MAX_BACKOFF_MILLIS = 60 * 60_000;
|
||||
|
||||
private record Failure(long retryAt, long backoff) {}
|
||||
|
||||
private final Map<String, int[][]> skins = new LinkedHashMap<>(64, 0.75f, true) {
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<String, int[][]> eldest) {
|
||||
return size() > CAPACITY;
|
||||
}
|
||||
};
|
||||
private final Map<String, Failure> failures = new LinkedHashMap<>();
|
||||
private final Map<String, CompletableFuture<int[][]>> downloads = new ConcurrentHashMap<>();
|
||||
|
||||
/** The skin at {@code url}, or empty (no/disallowed URL, download failed or backing off, invalid image). */
|
||||
public Optional<int[][]> get(String url) {
|
||||
String fetch = httpsUrl(url);
|
||||
if (fetch == null) return Optional.empty();
|
||||
synchronized (this) {
|
||||
int[][] cached = skins.get(fetch);
|
||||
if (cached != null) return Optional.of(cached);
|
||||
Failure failed = failures.get(fetch);
|
||||
if (failed != null && System.currentTimeMillis() < failed.retryAt()) return Optional.empty();
|
||||
}
|
||||
CompletableFuture<int[][]> mine = new CompletableFuture<>();
|
||||
CompletableFuture<int[][]> running = downloads.putIfAbsent(fetch, mine);
|
||||
if (running != null) return Optional.ofNullable(running.join());
|
||||
|
||||
int[][] skin = null;
|
||||
try {
|
||||
synchronized (this) {
|
||||
skin = skins.get(fetch); // finished between the cache check and putIfAbsent
|
||||
}
|
||||
if (skin == null) skin = load(fetch);
|
||||
} finally {
|
||||
synchronized (this) {
|
||||
if (skin != null) {
|
||||
skins.put(fetch, skin);
|
||||
failures.remove(fetch);
|
||||
} else {
|
||||
Failure before = failures.get(fetch);
|
||||
long backoff =
|
||||
before == null ? FIRST_BACKOFF_MILLIS : Math.min(MAX_BACKOFF_MILLIS, before.backoff() * 2);
|
||||
failures.put(fetch, new Failure(System.currentTimeMillis() + backoff, backoff));
|
||||
pruneFailures();
|
||||
}
|
||||
}
|
||||
downloads.remove(fetch);
|
||||
mine.complete(skin);
|
||||
}
|
||||
return Optional.ofNullable(skin);
|
||||
}
|
||||
|
||||
/** Drops failures whose backoff ran out, and the oldest ones beyond the capacity. */
|
||||
private void pruneFailures() {
|
||||
long now = System.currentTimeMillis();
|
||||
Iterator<Failure> it = failures.values().iterator();
|
||||
while (it.hasNext()) {
|
||||
if (it.next().retryAt() <= now || failures.size() > CAPACITY) it.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The https form of a texture URL on {@link #HOST} ({@code /texture/<hash>}, Mojang profiles use http), or null
|
||||
* for any other URL.
|
||||
*/
|
||||
static String httpsUrl(String url) {
|
||||
if (url == null || url.isEmpty()) return null;
|
||||
try {
|
||||
URI u = new URI(url);
|
||||
String scheme = u.getScheme() == null ? "" : u.getScheme().toLowerCase(Locale.ROOT);
|
||||
if (!scheme.equals("http") && !scheme.equals("https")) return null;
|
||||
if (u.getRawUserInfo() != null || u.getPort() != -1 || u.getRawQuery() != null) return null;
|
||||
if (u.getHost() == null || !u.getHost().equalsIgnoreCase(HOST)) return null;
|
||||
String path = u.getRawPath();
|
||||
if (path == null || !path.matches("/texture/[0-9a-zA-Z]+")) return null;
|
||||
return "https://" + HOST + path;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static int[][] load(String url) {
|
||||
try {
|
||||
HttpURLConnection conn = (HttpURLConnection) URI.create(url).toURL().openConnection();
|
||||
try {
|
||||
conn.setInstanceFollowRedirects(false);
|
||||
conn.setConnectTimeout(TIMEOUT_MILLIS);
|
||||
conn.setReadTimeout(TIMEOUT_MILLIS);
|
||||
conn.setRequestProperty("User-Agent", "PixelPics");
|
||||
if (conn.getResponseCode() / 100 != 2) return null;
|
||||
if (conn.getContentLengthLong() > MAX_BYTES) return null;
|
||||
byte[] data;
|
||||
try (InputStream in = conn.getInputStream()) {
|
||||
data = readLimited(in);
|
||||
}
|
||||
return data == null ? null : decode(data);
|
||||
} finally {
|
||||
conn.disconnect();
|
||||
}
|
||||
} catch (IOException | RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** At most {@link #MAX_BYTES} bytes of {@code in}; null when there are more. */
|
||||
static byte[] readLimited(InputStream in) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
byte[] buf = new byte[8192];
|
||||
int total = 0;
|
||||
for (int n; (n = in.read(buf)) != -1; ) {
|
||||
total += n;
|
||||
if (total > MAX_BYTES) return null;
|
||||
out.write(buf, 0, n);
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
/** Decodes a PNG skin whose header declares 64x64 or 64x32 (checked before decoding); null otherwise. */
|
||||
static int[][] decode(byte[] png) throws IOException {
|
||||
try (ImageInputStream in = ImageIO.createImageInputStream(new ByteArrayInputStream(png))) {
|
||||
Iterator<ImageReader> readers = ImageIO.getImageReadersByFormatName("png");
|
||||
if (in == null || !readers.hasNext()) return null;
|
||||
ImageReader reader = readers.next();
|
||||
try {
|
||||
reader.setInput(in, true, true);
|
||||
int w = reader.getWidth(0), h = reader.getHeight(0);
|
||||
if (w != 64 || (h != 64 && h != 32)) return null; // vanilla discards other sizes
|
||||
BufferedImage img = reader.read(0);
|
||||
int[][] argb = new int[h][];
|
||||
for (int y = 0; y < h; y++) argb[y] = img.getRGB(0, y, w, 1, null, 0, w);
|
||||
return processSkin(argb);
|
||||
} finally {
|
||||
reader.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SkinTextureDownloader.processLegacySkin (26.3) on a 64x64 or 64x32 ARGB grid: a legacy skin is extended to
|
||||
* 64x64 with the left arm and leg made from the right ones (each face mirrored in place), its hat layer gets the
|
||||
* "Notch transparency hack", and the base layers are made opaque.
|
||||
*/
|
||||
static int[][] processSkin(int[][] src) {
|
||||
boolean legacy = src.length == 32;
|
||||
int[][] img = new int[64][64];
|
||||
for (int y = 0; y < src.length; y++) System.arraycopy(src[y], 0, img[y], 0, 64);
|
||||
if (legacy) {
|
||||
copyRect(img, 4, 16, 16, 32, 4, 4);
|
||||
copyRect(img, 8, 16, 16, 32, 4, 4);
|
||||
copyRect(img, 0, 20, 24, 32, 4, 12);
|
||||
copyRect(img, 4, 20, 16, 32, 4, 12);
|
||||
copyRect(img, 8, 20, 8, 32, 4, 12);
|
||||
copyRect(img, 12, 20, 16, 32, 4, 12);
|
||||
copyRect(img, 44, 16, -8, 32, 4, 4);
|
||||
copyRect(img, 48, 16, -8, 32, 4, 4);
|
||||
copyRect(img, 40, 20, 0, 32, 4, 12);
|
||||
copyRect(img, 44, 20, -8, 32, 4, 12);
|
||||
copyRect(img, 48, 20, -16, 32, 4, 12);
|
||||
copyRect(img, 52, 20, -8, 32, 4, 12);
|
||||
}
|
||||
setNoAlpha(img, 0, 0, 32, 16);
|
||||
if (legacy) notchTransparencyHack(img, 32, 0, 64, 32);
|
||||
setNoAlpha(img, 0, 16, 64, 32);
|
||||
setNoAlpha(img, 16, 48, 48, 64);
|
||||
return img;
|
||||
}
|
||||
|
||||
/** NativeImage.copyRect(startX, startY, offsetX, offsetY, sizeX, sizeY, swapX = true, swapY = false). */
|
||||
private static void copyRect(int[][] img, int sx, int sy, int offX, int offY, int w, int h) {
|
||||
for (int y = 0; y < h; y++) {
|
||||
for (int x = 0; x < w; x++) {
|
||||
img[sy + offY + y][sx + offX + (w - 1 - x)] = img[sy + y][sx + x];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears the alpha of the whole region only if every texel in it has alpha >= 128 (doNotchTransparencyHack). */
|
||||
private static void notchTransparencyHack(int[][] img, int x0, int y0, int x1, int y1) {
|
||||
for (int y = y0; y < y1; y++) {
|
||||
for (int x = x0; x < x1; x++) {
|
||||
if ((img[y][x] >>> 24) < 128) return;
|
||||
}
|
||||
}
|
||||
for (int y = y0; y < y1; y++) {
|
||||
for (int x = x0; x < x1; x++) img[y][x] &= 0xFFFFFF;
|
||||
}
|
||||
}
|
||||
|
||||
private static void setNoAlpha(int[][] img, int x0, int y0, int x1, int y1) {
|
||||
for (int y = y0; y < y1; y++) {
|
||||
for (int x = x0; x < x1; x++) img[y][x] |= 0xFF000000;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Optional;
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
/**
|
||||
* Pack textures as ARGB grids by id (unflipped; the UV origin is top-left like vanilla). Animated textures (a
|
||||
* {@code .mcmeta} with an {@code animation} section) keep the frame vanilla shows first.
|
||||
*/
|
||||
public final class TextureCache extends ImageCache<ResourceLocation> {
|
||||
|
||||
/**
|
||||
* Vanilla {@code MissingTextureAtlasSprite} (16x16): magenta #F800F8 / black quarters, black top-left. Shared:
|
||||
* never mutate.
|
||||
*/
|
||||
public static final int[][] MISSINGNO = missingno();
|
||||
|
||||
private final ResourcePack pack;
|
||||
|
||||
public TextureCache(ResourcePack pack) {
|
||||
this.pack = pack;
|
||||
}
|
||||
|
||||
private static int[][] missingno() {
|
||||
int[][] t = new int[16][16];
|
||||
for (int y = 0; y < 16; y++) {
|
||||
for (int x = 0; x < 16; x++) t[y][x] = (y < 8 ^ x < 8) ? 0xFFF800F8 : 0xFF000000;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
@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();
|
||||
JsonObject animation = animation(id);
|
||||
if (animation == null) return pixels(img, 0, 0, width, height);
|
||||
try {
|
||||
return firstFrame(img, animation, width, height);
|
||||
} catch (RuntimeException e) {
|
||||
return null; // unreadable animation metadata: vanilla uses the missing texture
|
||||
}
|
||||
}
|
||||
|
||||
/** The {@code animation} section of the texture's {@code .mcmeta}, or null (none, or not parseable). */
|
||||
private JsonObject animation(ResourceLocation id) {
|
||||
Optional<byte[]> meta = pack.read(AssetPaths.textureMeta(id));
|
||||
if (meta.isEmpty()) return null;
|
||||
try {
|
||||
JsonElement root = JsonParser.parseString(new String(meta.get(), StandardCharsets.UTF_8));
|
||||
if (!root.isJsonObject()) return null;
|
||||
JsonElement anim = root.getAsJsonObject().get("animation");
|
||||
return anim != null && anim.isJsonObject() ? anim.getAsJsonObject() : null;
|
||||
} catch (RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame vanilla's animation starts with (26.3 {@code SpriteResourceLoader} + {@code SpriteContents}): frame
|
||||
* size from {@code width}/{@code height} (each defaulting to the image's; both absent = the smaller image side),
|
||||
* frames numbered row by row; the first valid entry of {@code frames} (index in range, time > 0), else frame 0
|
||||
* (also with at most one valid entry: then vanilla does not animate and the atlas keeps the top-left frame).
|
||||
* An image that is not a multiple of the frame size is the missing texture in vanilla (null here).
|
||||
*/
|
||||
private static int[][] firstFrame(BufferedImage img, JsonObject anim, int width, int height) {
|
||||
Integer fw = positiveInt(anim, "width"), fh = positiveInt(anim, "height");
|
||||
int frameW, frameH;
|
||||
if (fw != null) {
|
||||
frameW = fw;
|
||||
frameH = fh != null ? fh : height;
|
||||
} else if (fh != null) {
|
||||
frameW = width;
|
||||
frameH = fh;
|
||||
} else {
|
||||
frameW = frameH = Math.min(width, height);
|
||||
}
|
||||
if (width % frameW != 0 || height % frameH != 0) return null;
|
||||
int perRow = width / frameW;
|
||||
int total = perRow * (height / frameH);
|
||||
int defaultTime = anim.has("frametime") ? anim.get("frametime").getAsInt() : 1;
|
||||
int index = 0, valid = 0;
|
||||
JsonElement frames = anim.get("frames");
|
||||
if (frames != null && frames.isJsonArray()) {
|
||||
int first = 0;
|
||||
for (JsonElement f : (JsonArray) frames) {
|
||||
int i, time = defaultTime;
|
||||
if (f.isJsonObject()) {
|
||||
JsonObject o = f.getAsJsonObject();
|
||||
i = o.get("index").getAsInt();
|
||||
if (o.has("time")) time = o.get("time").getAsInt();
|
||||
} else {
|
||||
i = f.getAsInt();
|
||||
}
|
||||
if (time > 0 && i >= 0 && i < total && valid++ == 0) first = i;
|
||||
}
|
||||
// One valid frame or none: no animation, the atlas keeps the top-left frame.
|
||||
if (valid > 1) index = first;
|
||||
}
|
||||
return pixels(img, index % perRow * frameW, index / perRow * frameH, frameW, frameH);
|
||||
}
|
||||
|
||||
private static Integer positiveInt(JsonObject o, String key) {
|
||||
if (!o.has(key)) return null;
|
||||
int v = o.get(key).getAsInt();
|
||||
if (v <= 0) throw new IllegalArgumentException(key + " must be positive");
|
||||
return v;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
/** A blockstate variant: model, {@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,46 @@
|
||||
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}; {@link ZipFile} allows concurrent reads. */
|
||||
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,37 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets.dto;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Gson binding of a block model JSON; coordinates and UVs in 0..16. */
|
||||
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;
|
||||
public double[] to;
|
||||
public RotationDto rotation;
|
||||
public Map<String, FaceDto> faces; // keyed by direction name
|
||||
}
|
||||
|
||||
public static class FaceDto {
|
||||
public double[] uv; // u1, v1, u2, v2; default from the element extents
|
||||
public String texture; // e.g. "#side" or "minecraft:block/oak_planks"
|
||||
public Integer tintindex;
|
||||
public int rotation; // 0/90/180/270
|
||||
}
|
||||
|
||||
public static class RotationDto {
|
||||
public double[] origin;
|
||||
public String axis;
|
||||
public double angle; // degrees
|
||||
// Since 26.x alternatively Euler angles in degrees (CuboidRotation.EulerXYZRotation, Rz·Ry·Rx), e.g. the
|
||||
// chains of template_hanging_sign_rot_3.
|
||||
public Double x, y, z;
|
||||
public boolean rescale;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets.font;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/** Glyphs and advances of the pack's bitmap font (see {@link FontLoader}); immutable. */
|
||||
public final class BitmapFont {
|
||||
|
||||
private final Map<Integer, Glyph> glyphs;
|
||||
private final Map<Integer, Integer> spaceAdvances;
|
||||
private final int maxAscent;
|
||||
private final int lineHeight;
|
||||
|
||||
public BitmapFont(Map<Integer, Glyph> glyphs, Map<Integer, Integer> spaceAdvances, int maxAscent, int maxDescent) {
|
||||
this.glyphs = glyphs;
|
||||
this.spaceAdvances = spaceAdvances;
|
||||
this.maxAscent = maxAscent;
|
||||
this.lineHeight = maxAscent + 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 ascent of all glyphs (font px): the common baseline of a line. */
|
||||
public int maxAscent() {
|
||||
return maxAscent;
|
||||
}
|
||||
|
||||
/** Largest ascent + largest descent (font px). */
|
||||
public int lineHeight() {
|
||||
return lineHeight;
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
return glyphs.isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
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.io.IOException;
|
||||
import java.io.InputStream;
|
||||
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 the pack's {@code font/default.json}: {@code reference}, {@code bitmap} and
|
||||
* {@code space} providers; {@code unihex}/{@code legacy_unicode}/{@code ttf} are skipped (CJK etc. render as
|
||||
* gaps). Earlier providers win, like vanilla.
|
||||
*/
|
||||
public final class FontLoader {
|
||||
|
||||
private FontLoader() {}
|
||||
|
||||
/** {@code minecraft:default}; a font without 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;
|
||||
Optional<byte[]> bytes = pack.read(path);
|
||||
// Packs without font/*.json: the bundled vanilla definitions (glyph sheets still come from the pack).
|
||||
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) {
|
||||
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 (InputStream in = FontLoader.class.getResourceAsStream("/fallback/" + path)) {
|
||||
if (in == null) return Optional.empty();
|
||||
log.info("PixelPics: resource pack has no " + path + ", using the bundled copy.");
|
||||
return Optional.of(in.readAllBytes());
|
||||
} catch (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 -> {}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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;
|
||||
String firstRow = rows.get(0).getAsString();
|
||||
int nCols = firstRow.codePointCount(0, firstRow.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: spaces come from the 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 cell column with a non-transparent pixel, -1 when 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 != 0) {
|
||||
last = x;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return last;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets.font;
|
||||
|
||||
/**
|
||||
* A bitmap-font glyph: its cell in the provider sheet {@code tex}, the drawn width {@code glyphPx} (up to the
|
||||
* rightmost non-transparent column), and {@code height}, {@code ascent} and {@code advance} in font px. Providers
|
||||
* differ in cell size and ascent (ascii 8/7, accented 12/10), so lines align glyphs on a common baseline.
|
||||
*/
|
||||
public record Glyph(
|
||||
int[][] tex, int srcX, int srcY, int cellW, int cellH, int glyphPx, int height, int ascent, int advance) {}
|
||||
@@ -0,0 +1,83 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets.model;
|
||||
|
||||
/** The six block face directions with unit normals and the blockstate 90° 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;
|
||||
/** The axis of the normal: 0 = x, 1 = y, 2 = z. */
|
||||
public final int axis;
|
||||
|
||||
Direction(int nx, int ny, int nz) {
|
||||
this.nx = nx;
|
||||
this.ny = ny;
|
||||
this.nz = nz;
|
||||
this.axis = nx != 0 ? 0 : ny != 0 ? 1 : 2;
|
||||
}
|
||||
|
||||
/** The face of a box on {@code axis}: the low-coordinate one (WEST/DOWN/NORTH) when {@code negative}. */
|
||||
public static Direction of(int axis, boolean negative) {
|
||||
return switch (axis) {
|
||||
case 0 -> negative ? WEST : EAST;
|
||||
case 1 -> negative ? DOWN : UP;
|
||||
default -> negative ? NORTH : SOUTH;
|
||||
};
|
||||
}
|
||||
|
||||
public Direction opposite() {
|
||||
return switch (this) {
|
||||
case DOWN -> UP;
|
||||
case UP -> DOWN;
|
||||
case NORTH -> SOUTH;
|
||||
case SOUTH -> NORTH;
|
||||
case WEST -> EAST;
|
||||
case EAST -> WEST;
|
||||
};
|
||||
}
|
||||
|
||||
public static Direction fromName(String name) {
|
||||
return switch (name.toLowerCase()) {
|
||||
case "down" -> DOWN;
|
||||
case "up" -> UP;
|
||||
case "north" -> NORTH;
|
||||
case "south" -> SOUTH;
|
||||
case "west" -> WEST;
|
||||
case "east" -> EAST;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Rotated by {@code steps}·90° about Y, clockwise seen from above (vanilla blockstate {@code y}). */
|
||||
public Direction rotateY(int steps) {
|
||||
steps = Math.floorMod(steps, 4);
|
||||
Direction d = this;
|
||||
for (int i = 0; i < steps; i++) {
|
||||
d = switch (d) {
|
||||
case NORTH -> EAST;
|
||||
case EAST -> SOUTH;
|
||||
case SOUTH -> WEST;
|
||||
case WEST -> NORTH;
|
||||
default -> d;};
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Rotated by {@code steps}·90° about X (vanilla blockstate {@code x}). */
|
||||
public Direction rotateX(int steps) {
|
||||
steps = Math.floorMod(steps, 4);
|
||||
Direction d = this;
|
||||
for (int i = 0; i < steps; i++) {
|
||||
d = switch (d) {
|
||||
case UP -> NORTH;
|
||||
case NORTH -> DOWN;
|
||||
case DOWN -> SOUTH;
|
||||
case SOUTH -> UP;
|
||||
default -> d;};
|
||||
}
|
||||
return d;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets.model;
|
||||
|
||||
/**
|
||||
* A box of a baked block model in block units (0..1 inside the cell); faces indexed by {@link Direction#ordinal()},
|
||||
* null when absent. {@code from > to} on an axis marks an inverted element (inward-facing faces, like vanilla).
|
||||
*/
|
||||
public final class Element {
|
||||
|
||||
public final double[] from;
|
||||
public final double[] to;
|
||||
public final Face[] faces;
|
||||
|
||||
// Own element rotation about rotOrigin: single axis (0=x, 1=y, 2=z) by rotAngleRad, or AXIS_MATRIX for a
|
||||
// general (Euler x/y/z) rotation in rotMatrix; rotAxis -1 and rotOrigin null when unrotated.
|
||||
public final double[] rotOrigin;
|
||||
public final int rotAxis;
|
||||
public final double rotAngleRad;
|
||||
public final boolean rescale;
|
||||
|
||||
public static final int AXIS_MATRIX = 3;
|
||||
|
||||
// Precomputed for the intersector's hot path: cos/sin of +angle (local -> block) and -angle (block -> local).
|
||||
public final double rotCos, rotSin, invRotCos, invRotSin;
|
||||
|
||||
// Row-major 3x3 local -> block and its transpose, only for AXIS_MATRIX.
|
||||
public final double[] rotMatrix, invRotMatrix;
|
||||
|
||||
// Vanilla rescale (CuboidRotation.computeRescale): scale per LOCAL axis by 1 / max|component of R·axis| before
|
||||
// rotating, so a tilted cross plane still spans the block. Stored as reciprocals (block -> local); null without.
|
||||
public final double[] invScale;
|
||||
|
||||
// Blockstate rotation: the model-space element and the 90° steps (x first, then y) that turn it into this
|
||||
// world-space one; null/0 when unrotated. Intersectors hit the unrotated element with the inverse-rotated ray.
|
||||
public final Element unrotated;
|
||||
public final int xSteps;
|
||||
public final int ySteps;
|
||||
|
||||
/** An unrotated box. */
|
||||
public Element(double[] from, double[] to, Face[] faces) {
|
||||
this(from, to, faces, null, -1, 0, null, false, null, 0, 0);
|
||||
}
|
||||
|
||||
public Element(
|
||||
double[] from,
|
||||
double[] to,
|
||||
Face[] faces,
|
||||
double[] rotOrigin,
|
||||
int rotAxis,
|
||||
double rotAngleRad,
|
||||
double[] rotMatrix,
|
||||
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;
|
||||
this.rotCos = Math.cos(rotAngleRad);
|
||||
this.rotSin = Math.sin(rotAngleRad);
|
||||
this.invRotCos = Math.cos(-rotAngleRad);
|
||||
this.invRotSin = Math.sin(-rotAngleRad);
|
||||
this.rotMatrix = rotAxis == AXIS_MATRIX ? rotMatrix : null;
|
||||
this.invRotMatrix = this.rotMatrix == null ? null : transpose(this.rotMatrix);
|
||||
this.invScale = rescale && !isAxisAligned() ? inverseRescale(linearRotation()) : null;
|
||||
}
|
||||
|
||||
/** This element with other faces (rotation and blockstate rotation kept). */
|
||||
public Element withFaces(Face[] faces) {
|
||||
return new Element(
|
||||
from, to, faces, rotOrigin, rotAxis, rotAngleRad, rotMatrix, rescale, unrotated, xSteps, ySteps);
|
||||
}
|
||||
|
||||
/** The rotation as a row-major 3x3 (local -> block), identity when axis-aligned. */
|
||||
public double[] linearRotation() {
|
||||
if (rotMatrix != null) return rotMatrix.clone();
|
||||
double c = rotCos, s = rotSin;
|
||||
return switch (isAxisAligned() ? -1 : rotAxis) {
|
||||
case 0 -> new double[] {1, 0, 0, 0, c, -s, 0, s, c};
|
||||
case 1 -> new double[] {c, 0, s, 0, 1, 0, -s, 0, c};
|
||||
case 2 -> new double[] {c, -s, 0, s, c, 0, 0, 0, 1};
|
||||
default -> new double[] {1, 0, 0, 0, 1, 0, 0, 0, 1};
|
||||
};
|
||||
}
|
||||
|
||||
/** Reciprocal rescale per local axis: max|component of R·axis| (column a of R). */
|
||||
private static double[] inverseRescale(double[] r) {
|
||||
double[] inv = new double[3];
|
||||
for (int a = 0; a < 3; a++) {
|
||||
inv[a] = Math.max(Math.max(Math.abs(r[a]), Math.abs(r[3 + a])), Math.abs(r[6 + a]));
|
||||
}
|
||||
return inv;
|
||||
}
|
||||
|
||||
/** The rescale factors per local axis (local -> block), {1,1,1} without rescale. */
|
||||
public double[] rescaleFactors() {
|
||||
if (invScale == null) return new double[] {1, 1, 1};
|
||||
return new double[] {1 / invScale[0], 1 / invScale[1], 1 / invScale[2]};
|
||||
}
|
||||
|
||||
private static double[] transpose(double[] m) {
|
||||
return new double[] {m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8]};
|
||||
}
|
||||
|
||||
/** Model space -> world: vanilla blockstate rotation (x steps, then y steps; clockwise about the centre). */
|
||||
public static double[] rotate(double x, double y, double z, int xSteps, int ySteps, boolean point) {
|
||||
double[] out = new double[3];
|
||||
rotate(out, x, y, z, xSteps, ySteps, point);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** {@link #rotate} into {@code out} (allocation-free). */
|
||||
public static void rotate(double[] out, double x, double y, double z, int xSteps, int ySteps, boolean point) {
|
||||
double c = point ? 0.5 : 0;
|
||||
double dx = x - c, dy = y - c, dz = z - c;
|
||||
for (int i = 0; i < xSteps; i++) {
|
||||
double ny = dz, nz = -dy; // (dy, dz) -> (dz, -dy)
|
||||
dy = ny;
|
||||
dz = nz;
|
||||
}
|
||||
for (int i = 0; i < ySteps; i++) {
|
||||
double nx = -dz, nz = dx; // (dx, dz) -> (-dz, dx)
|
||||
dx = nx;
|
||||
dz = nz;
|
||||
}
|
||||
out[0] = dx + c;
|
||||
out[1] = dy + c;
|
||||
out[2] = dz + c;
|
||||
}
|
||||
|
||||
/** 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[] out = new double[3];
|
||||
unrotate(out, x, y, z, xSteps, ySteps, point);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** {@link #unrotate} into {@code out}. */
|
||||
public static void unrotate(double[] out, double x, double y, double z, int xSteps, int ySteps, boolean point) {
|
||||
rotate(out, x, y, z, 0, (4 - ySteps) % 4, point);
|
||||
rotate(out, out[0], out[1], out[2], (4 - xSteps) % 4, 0, point);
|
||||
}
|
||||
|
||||
public boolean isAxisAligned() {
|
||||
return rotAxis < 0 || (rotAxis != AXIS_MATRIX && rotAngleRad == 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets.model;
|
||||
|
||||
/** A textured face of an {@link Element}; UVs in 0..1 with a top-left origin (v down), like vanilla. */
|
||||
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 (normalised), applied to the sampled UV
|
||||
public final int tintIndex; // -1 = no tint
|
||||
|
||||
// uvlock: affine applied to the final texture (u, v) as {a, b, c, d, e, f} with u' = a + b*u + c*v and
|
||||
// v' = d + e*u + f*v (one of the 8 symmetries of the unit square), null when not locked.
|
||||
public final double[] uvLock;
|
||||
|
||||
public Face(int[][] texture, double u1, double v1, double u2, double v2, int rotation, int tintIndex) {
|
||||
this(texture, u1, v1, u2, v2, rotation, tintIndex, null);
|
||||
}
|
||||
|
||||
public Face(
|
||||
int[][] texture, double u1, double v1, double u2, double v2, int rotation, int tintIndex, double[] uvLock) {
|
||||
this.uvLock = uvLock;
|
||||
this.texture = texture;
|
||||
this.u1 = u1;
|
||||
this.v1 = v1;
|
||||
this.u2 = u2;
|
||||
this.v2 = v2;
|
||||
this.rotation = Math.floorMod(rotation, 360);
|
||||
this.tintIndex = tintIndex;
|
||||
}
|
||||
|
||||
/** This face with another texture and tint index (UVs, rotation and uvlock kept). */
|
||||
public Face withTexture(int[][] texture, int tintIndex) {
|
||||
return new Face(texture, u1, v1, u2, v2, rotation, tintIndex, uvLock);
|
||||
}
|
||||
|
||||
/** This face with its final texture coordinates transformed by a uvlock affine (see {@link #uvLock}). */
|
||||
public Face withUvLock(double[] lock) {
|
||||
return new Face(texture, u1, v1, u2, v2, rotation, tintIndex, lock);
|
||||
}
|
||||
|
||||
/** The ARGB texel at {@code (s, t)} in 0..1 across the face (s horizontal, t vertical, top-left origin). */
|
||||
public int sample(double s, double t) {
|
||||
double rs = s, rt = t;
|
||||
switch (rotation) {
|
||||
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;
|
||||
if (uvLock != null) {
|
||||
double lu = uvLock[0] + uvLock[1] * u + uvLock[2] * v;
|
||||
v = uvLock[3] + uvLock[4] * u + uvLock[5] * v;
|
||||
u = lu;
|
||||
}
|
||||
|
||||
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,20 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A baked block model. {@code hasGeometry} is false for empty models and for the solid fallback cube of blocks
|
||||
* without model elements. Deliberately not a record: callers key caches on identity (one instance per BlockData).
|
||||
*/
|
||||
public final class ResolvedModel {
|
||||
|
||||
public static final ResolvedModel EMPTY = new ResolvedModel(List.of(), false);
|
||||
|
||||
public final List<Element> elements;
|
||||
public final boolean hasGeometry;
|
||||
|
||||
public ResolvedModel(List<Element> elements, boolean hasGeometry) {
|
||||
this.elements = elements;
|
||||
this.hasGeometry = hasGeometry;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package eu.mhsl.minecraft.pixelpics.commands;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.Main;
|
||||
import eu.mhsl.minecraft.pixelpics.render.RenderManager;
|
||||
import eu.mhsl.minecraft.pixelpics.render.RenderStats;
|
||||
import eu.mhsl.minecraft.pixelpics.setup.SetupManager;
|
||||
import eu.mhsl.minecraft.pixelpics.survival.CameraItems;
|
||||
import eu.mhsl.minecraft.pixelpics.survival.PhotoService;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.DebugLog;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.MainThread;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.MapManager;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.MapUsageScan;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
import net.kyori.adventure.text.event.HoverEvent;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.TabExecutor;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
/** {@code /pixelpic help} for everyone, the other subcommands need {@code pixelpic.admin}. Messages are German. */
|
||||
public class PixelPicsCommand implements TabExecutor {
|
||||
|
||||
private static final String ADMIN = "pixelpic.admin";
|
||||
private static final int DEFAULT_CLEANUP_DAYS = 30;
|
||||
private static final int MAX_GIVE = 64;
|
||||
|
||||
/** An admin subcommand as listed by {@code help}. */
|
||||
private record Sub(String name, String params, String help) {}
|
||||
|
||||
private static final List<Sub> ADMIN_SUBS = List.of(
|
||||
new Sub("info", "", "Status, Versionen, Resource Pack, Warteschlange, Statistik"),
|
||||
new Sub("photo", " [Spieler]", "Testfoto ohne Kamera und Film, mit Zeitmessung"),
|
||||
new Sub("give", " <Spieler> <camera|film> [Anzahl]", "Kamera oder Filmrollen geben"),
|
||||
new Sub("reload", "", "config.yml, Resource Pack und Modelle neu laden"),
|
||||
new Sub(
|
||||
"cleanup",
|
||||
" [confirm] [Tage]",
|
||||
"ungenutzte Fotos löschen (ohne confirm nur Vorschau, Standard " + DEFAULT_CLEANUP_DAYS
|
||||
+ " Tage)"));
|
||||
private static final List<String> ADMIN_SUBCOMMANDS =
|
||||
ADMIN_SUBS.stream().map(Sub::name).toList();
|
||||
|
||||
private static boolean cleanupRunning = false;
|
||||
|
||||
@Override
|
||||
public boolean onCommand(
|
||||
@NotNull CommandSender sender,
|
||||
@NotNull Command command,
|
||||
@NotNull String label,
|
||||
@NotNull String @NotNull [] args) {
|
||||
String sub = args.length == 0 ? "help" : args[0].toLowerCase(Locale.ROOT);
|
||||
if (sub.equals("help") || sub.equals("?")) {
|
||||
help(sender, label);
|
||||
return true;
|
||||
}
|
||||
if (!ADMIN_SUBCOMMANDS.contains(sub)) {
|
||||
error(sender, "Unbekannter Unterbefehl „" + args[0] + "“. Übersicht: /" + label + " help");
|
||||
return true;
|
||||
}
|
||||
if (!sender.hasPermission(ADMIN)) {
|
||||
error(sender, "Dafür fehlt dir die Berechtigung (" + ADMIN + ").");
|
||||
return true;
|
||||
}
|
||||
switch (sub) {
|
||||
case "info" -> info(sender);
|
||||
case "photo" -> photo(sender, label, args);
|
||||
case "give" -> give(sender, label, args);
|
||||
case "reload" -> reload(sender);
|
||||
case "cleanup" -> cleanup(sender, label, args);
|
||||
default -> throw new IllegalStateException(sub);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> onTabComplete(
|
||||
@NotNull CommandSender sender,
|
||||
@NotNull Command command,
|
||||
@NotNull String label,
|
||||
@NotNull String @NotNull [] args) {
|
||||
boolean admin = sender.hasPermission(ADMIN);
|
||||
List<String> options = new ArrayList<>();
|
||||
if (args.length == 1) {
|
||||
options.add("help");
|
||||
if (admin) options.addAll(ADMIN_SUBCOMMANDS);
|
||||
} else if (admin) {
|
||||
String sub = args[0].toLowerCase(Locale.ROOT);
|
||||
int i = args.length - 1;
|
||||
switch (sub) {
|
||||
case "photo" -> {
|
||||
if (i == 1) options.addAll(playerNames());
|
||||
}
|
||||
case "give" -> {
|
||||
if (i == 1) {
|
||||
options.addAll(playerNames());
|
||||
options.add("@a");
|
||||
} else if (i == 2) {
|
||||
options.addAll(List.of("camera", "film"));
|
||||
} else if (i == 3) {
|
||||
options.addAll(List.of("1", "8", "16", "64"));
|
||||
}
|
||||
}
|
||||
case "cleanup" -> {
|
||||
if (i <= 2) {
|
||||
if (Stream.of(args).noneMatch("confirm"::equalsIgnoreCase)) options.add("confirm");
|
||||
options.add(String.valueOf(DEFAULT_CLEANUP_DAYS));
|
||||
}
|
||||
}
|
||||
default -> {}
|
||||
}
|
||||
}
|
||||
String prefix = args[args.length - 1].toLowerCase(Locale.ROOT);
|
||||
return options.stream()
|
||||
.filter(o -> o.toLowerCase(Locale.ROOT).startsWith(prefix))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static List<String> playerNames() {
|
||||
return Bukkit.getOnlinePlayers().stream().map(Player::getName).toList();
|
||||
}
|
||||
|
||||
private static void help(CommandSender sender, String label) {
|
||||
Main plugin = Main.getInstance();
|
||||
sender.sendMessage(Component.text(
|
||||
"PixelPics " + plugin.getPluginMeta().getVersion() + " — Sofortbildkamera", NamedTextColor.GOLD));
|
||||
for (String line : List.of(
|
||||
"Kamera craften: Eisen, Diamant, Glasscheiben, Enderauge und Redstone (Rezeptbuch: „Kamera“).",
|
||||
"Filmrolle craften: 4 Papier und 1 Tintenbeutel (Rezeptbuch: „Filmrolle“).",
|
||||
"Film laden: Kamera und Filmrolle zusammen ins Crafting-Feld (bis zu " + CameraItems.MAX_FILM
|
||||
+ " Filme).",
|
||||
"Foto: Rechtsklick mit der Kamera (1 Film). Das Bild entwickelt sich nach ein paar Sekunden.",
|
||||
"Kopie: Foto und Filmrolle zusammen ins Crafting-Feld.")) {
|
||||
sender.sendMessage(Component.text("• " + line, NamedTextColor.GRAY));
|
||||
}
|
||||
if (!sender.hasPermission(ADMIN)) return;
|
||||
sender.sendMessage(Component.text("Admin-Befehle:", NamedTextColor.GOLD));
|
||||
for (Sub sub : ADMIN_SUBS) {
|
||||
String command = "/" + label + " " + sub.name();
|
||||
sender.sendMessage(Component.text(command + sub.params(), NamedTextColor.YELLOW)
|
||||
.clickEvent(ClickEvent.suggestCommand(command + " "))
|
||||
.hoverEvent(HoverEvent.showText(Component.text("Klicken zum Einfügen")))
|
||||
.append(Component.text(" – " + sub.help(), NamedTextColor.GRAY)));
|
||||
}
|
||||
}
|
||||
|
||||
private static void info(CommandSender sender) {
|
||||
Main plugin = Main.getInstance();
|
||||
SetupManager setup = plugin.getSetup();
|
||||
SetupManager.Report report = setup.report();
|
||||
String server = Bukkit.getMinecraftVersion();
|
||||
sender.sendMessage(Component.text(
|
||||
"PixelPics " + plugin.getPluginMeta().getVersion() + " auf " + Bukkit.getName() + " "
|
||||
+ Bukkit.getVersion(),
|
||||
NamedTextColor.GOLD));
|
||||
|
||||
String task = setup.task();
|
||||
if (plugin.getScreenRenderer() == null) {
|
||||
String status = task != null
|
||||
? "wird eingerichtet: " + task
|
||||
: setup.running() ? "wird eingerichtet" : "nicht bereit, Fotos sind deaktiviert";
|
||||
line(sender, "Status", status, NamedTextColor.RED);
|
||||
} else {
|
||||
line(sender, "Status", "bereit" + (task != null ? " (im Hintergrund: " + task + ")" : ""), null);
|
||||
}
|
||||
if (setup.problem() != null) line(sender, "Problem", setup.problem(), NamedTextColor.RED);
|
||||
|
||||
if (report != null) {
|
||||
List<String> packs = new ArrayList<>();
|
||||
for (SetupManager.PackInfo p : report.packs()) {
|
||||
String v = p.version() == null ? "Version unbekannt" : p.version();
|
||||
packs.add(p.name() + " (" + v + (p.loaded() ? "" : ", nicht geladen") + ")");
|
||||
}
|
||||
line(
|
||||
sender,
|
||||
"Resource Pack",
|
||||
packs.isEmpty() ? "Ordner-Pack" : String.join(", ", packs),
|
||||
report.packs().stream()
|
||||
.anyMatch(p -> p.loaded()
|
||||
&& p.version() != null
|
||||
&& !p.version().equals(server))
|
||||
? NamedTextColor.RED
|
||||
: null);
|
||||
coverageLine(sender, "Blockzustände", report.blocks());
|
||||
coverageLine(sender, "Item-Modelle", report.items());
|
||||
if (!report.fontOk()) line(sender, "Schrift", "fehlt im Pack — Schilder bleiben leer", NamedTextColor.RED);
|
||||
String dump = report.dumpVersion() == null
|
||||
? "kein Dump, nur CEM-Vorlagen"
|
||||
: "Dump " + report.dumpVersion() + " (" + report.dumpSource() + "), " + report.entityModels()
|
||||
+ " Modelle"
|
||||
+ (report.datapackVariants() > 0
|
||||
? ", " + report.datapackVariants() + " Datapack-Varianten"
|
||||
: "");
|
||||
boolean dumpOk = server.equals(report.dumpVersion());
|
||||
line(
|
||||
sender,
|
||||
"Entity-Modelle",
|
||||
dump + (dumpOk ? "" : " — passt nicht zu " + server + ", siehe Server-Log"),
|
||||
dumpOk ? null : NamedTextColor.RED);
|
||||
}
|
||||
|
||||
RenderManager manager = plugin.getRenderManager();
|
||||
RenderManager.Load load = manager.load();
|
||||
line(
|
||||
sender,
|
||||
"Rendering",
|
||||
load.running() + " laufen, " + load.queued() + " warten (max. " + load.maxConcurrent()
|
||||
+ " parallel + " + load.queueSize() + " in der Warteschlange, " + load.threads()
|
||||
+ " Kerne, Timeout " + load.timeoutSeconds() + " s)",
|
||||
null);
|
||||
RenderStats stats = manager.stats();
|
||||
String photos = stats.developedCount() + " entwickelt, " + stats.failedCount() + " fehlgeschlagen, "
|
||||
+ stats.rejectedCount() + " abgelehnt";
|
||||
if (stats.developedCount() > 0) {
|
||||
RenderStats.Timings avg = stats.average();
|
||||
photos += "; Ø " + seconds(avg.total()) + " (" + phases(avg, "") + "), max. " + seconds(stats.maxTotal())
|
||||
+ ", Snapshot max. " + stats.maxSnapshot() + " ms";
|
||||
}
|
||||
line(sender, "Fotos seit Start", photos, null);
|
||||
line(sender, "Debug-Logging", DebugLog.enabled() ? "an" : "aus (config.yml: debug)", null);
|
||||
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||
MapManager.Storage storage = MapManager.storage();
|
||||
String text = storage.photos() + (storage.photos() == 1 ? " Foto, " : " Fotos, ")
|
||||
+ String.format(Locale.GERMANY, "%.1f MB", storage.bytes() / 1048576.0) + " in "
|
||||
+ storage.folder();
|
||||
MainThread.run(plugin, () -> line(sender, "Gespeichert", text, null));
|
||||
});
|
||||
}
|
||||
|
||||
private static void coverageLine(CommandSender sender, String name, SetupManager.Coverage c) {
|
||||
String text = (c.total() - c.missing()) + " / " + c.total() + " vorhanden";
|
||||
if (c.missing() > 0) {
|
||||
text += ", fehlen z. B.: " + String.join(", ", c.sample()) + " — Pack älter als der Server?";
|
||||
}
|
||||
line(sender, " " + name, text, c.missing() > 0 ? NamedTextColor.RED : null);
|
||||
}
|
||||
|
||||
private static void line(CommandSender sender, String key, String value, NamedTextColor valueColor) {
|
||||
sender.sendMessage(Component.text(key + ": ", NamedTextColor.GRAY)
|
||||
.append(Component.text(value, valueColor != null ? valueColor : NamedTextColor.WHITE)));
|
||||
}
|
||||
|
||||
private static String phases(RenderStats.Timings t, String snapshotNote) {
|
||||
return "Warten " + t.queued() + " ms, Snapshot " + t.snapshot() + " ms" + snapshotNote + ", Raytracing "
|
||||
+ seconds(t.trace()) + ", Dithern " + t.dither() + " ms";
|
||||
}
|
||||
|
||||
private static String seconds(long ms) {
|
||||
return String.format(Locale.GERMANY, "%.2f s", ms / 1000.0);
|
||||
}
|
||||
|
||||
private static void photo(CommandSender sender, String label, String[] args) {
|
||||
Player target;
|
||||
if (args.length >= 2) {
|
||||
List<Player> found = players(sender, args[1]);
|
||||
if (found == null) return;
|
||||
if (found.size() != 1) {
|
||||
error(sender, "Bitte genau einen Spieler angeben.");
|
||||
return;
|
||||
}
|
||||
target = found.getFirst();
|
||||
} else if (sender instanceof Player player) {
|
||||
target = player;
|
||||
} else {
|
||||
error(sender, "Von der Konsole aus: /" + label + " photo <Spieler> (fotografiert aus dessen Sicht).");
|
||||
return;
|
||||
}
|
||||
String who = target.equals(sender) ? "" : " aus der Sicht von " + target.getName();
|
||||
Component refusal = PhotoService.takeDebugPhoto(target, timings -> {
|
||||
if (timings == null) {
|
||||
error(sender, "Testfoto" + who + " fehlgeschlagen (Details im Server-Log).");
|
||||
return;
|
||||
}
|
||||
sender.sendMessage(Component.text(
|
||||
"Testfoto" + who + " entwickelt in " + seconds(timings.total()) + ": "
|
||||
+ phases(timings, " (Main-Thread)") + ".",
|
||||
NamedTextColor.GREEN));
|
||||
});
|
||||
if (refusal != null) {
|
||||
sender.sendMessage(refusal);
|
||||
return;
|
||||
}
|
||||
sender.sendMessage(Component.text(
|
||||
"Testfoto" + who + " ausgelöst (ohne Film), die Karte landet im Inventar von " + target.getName() + ".",
|
||||
NamedTextColor.GRAY));
|
||||
}
|
||||
|
||||
private static void give(CommandSender sender, String label, String[] args) {
|
||||
if (args.length < 3) {
|
||||
error(sender, "Verwendung: /" + label + " give <Spieler> <camera|film> [Anzahl]");
|
||||
return;
|
||||
}
|
||||
List<Player> targets = players(sender, args[1]);
|
||||
if (targets == null) return;
|
||||
String kind = args[2].toLowerCase(Locale.ROOT);
|
||||
boolean camera = kind.equals("camera") || kind.equals("kamera");
|
||||
if (!camera && !kind.equals("film")) {
|
||||
error(sender, "Unbekanntes Item „" + args[2] + "“ — möglich sind camera und film.");
|
||||
return;
|
||||
}
|
||||
int amount = args.length >= 4 ? parse(args[3], 1, MAX_GIVE) : 1;
|
||||
if (amount < 0) {
|
||||
error(sender, "Die Anzahl muss eine Zahl von 1 bis " + MAX_GIVE + " sein.");
|
||||
return;
|
||||
}
|
||||
for (Player target : targets) {
|
||||
if (camera) {
|
||||
for (int i = 0; i < amount; i++) CameraItems.give(target, CameraItems.createCamera(0));
|
||||
} else {
|
||||
ItemStack film = CameraItems.createFilm();
|
||||
film.setAmount(amount);
|
||||
CameraItems.give(target, film);
|
||||
}
|
||||
}
|
||||
String item = camera
|
||||
? (amount == 1 ? "Kamera (leer)" : "Kameras (leer)")
|
||||
: (amount == 1 ? "Filmrolle" : "Filmrollen");
|
||||
String who = targets.size() == 1 ? targets.getFirst().getName() : targets.size() + " Spieler";
|
||||
sender.sendMessage(Component.text(amount + " " + item + " an " + who + " gegeben.", NamedTextColor.GREEN));
|
||||
}
|
||||
|
||||
/** Online players for a name or selector, or null after telling the sender why there are none. */
|
||||
private static List<Player> players(CommandSender sender, String arg) {
|
||||
if (arg.startsWith("@")) {
|
||||
List<Player> found = new ArrayList<>();
|
||||
try {
|
||||
for (Entity e : Bukkit.selectEntities(sender, arg)) {
|
||||
if (e instanceof Player p) found.add(p);
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
error(sender, "Ungültiger Selektor „" + arg + "“.");
|
||||
return null;
|
||||
}
|
||||
if (found.isEmpty()) {
|
||||
error(sender, "Der Selektor „" + arg + "“ trifft keinen Spieler.");
|
||||
return null;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
Player player = Bukkit.getPlayerExact(arg);
|
||||
if (player == null) {
|
||||
error(sender, "Spieler „" + arg + "“ ist nicht online.");
|
||||
return null;
|
||||
}
|
||||
return List.of(player);
|
||||
}
|
||||
|
||||
private static void reload(CommandSender sender) {
|
||||
Main plugin = Main.getInstance();
|
||||
if (!plugin.reload(sender)) {
|
||||
String task = plugin.getSetup().task();
|
||||
error(
|
||||
sender,
|
||||
"PixelPics wird gerade schon eingerichtet" + (task != null ? " (" + task + ")" : "")
|
||||
+ " — bitte kurz warten.");
|
||||
return;
|
||||
}
|
||||
sender.sendMessage(Component.text(
|
||||
"config.yml neu geladen; Resource Pack und Modelle werden im Hintergrund neu eingelesen …",
|
||||
NamedTextColor.GRAY));
|
||||
}
|
||||
|
||||
/** Without {@code confirm} a dry run; deletes photos no item references that nobody saw for {@code days} days. */
|
||||
private static void cleanup(CommandSender sender, String label, String[] args) {
|
||||
boolean confirm = false;
|
||||
int days = DEFAULT_CLEANUP_DAYS;
|
||||
for (int i = 1; i < args.length; i++) {
|
||||
if (args[i].equalsIgnoreCase("confirm")) {
|
||||
confirm = true;
|
||||
continue;
|
||||
}
|
||||
days = parse(args[i], 0, Integer.MAX_VALUE);
|
||||
if (days < 0) {
|
||||
error(
|
||||
sender,
|
||||
"„" + args[i] + "“ ist keine gültige Anzahl Tage. Verwendung: /" + label
|
||||
+ " cleanup [confirm] [Tage]");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (cleanupRunning) {
|
||||
sender.sendMessage(Component.text("Es läuft bereits ein Aufräum-Scan.", NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
|
||||
// Loaded state now, saved world and player data off the main thread, loaded state again before deciding.
|
||||
Set<Integer> liveBefore = MapUsageScan.live();
|
||||
cleanupRunning = true;
|
||||
sender.sendMessage(Component.text("Durchsuche Welt- und Spielerdaten nach Fotos …", NamedTextColor.GRAY));
|
||||
boolean finalConfirm = confirm;
|
||||
int finalDays = days;
|
||||
MapUsageScan.diskAsync(Main.getInstance(), (onDisk, failure) -> {
|
||||
cleanupRunning = false;
|
||||
if (onDisk == null) {
|
||||
error(sender, "Konnte nicht alle Weltdaten lesen, es wurde nichts gelöscht: " + failure.getMessage());
|
||||
return;
|
||||
}
|
||||
Set<Integer> inUse = new HashSet<>(liveBefore);
|
||||
inUse.addAll(onDisk);
|
||||
inUse.addAll(MapUsageScan.live());
|
||||
finishCleanup(sender, label, inUse, finalConfirm, finalDays);
|
||||
});
|
||||
}
|
||||
|
||||
private static void finishCleanup(
|
||||
CommandSender sender, String label, Set<Integer> inUse, boolean confirm, int days) {
|
||||
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 Fotos gefunden (nirgends mehr vorhanden und seit " + days
|
||||
+ " Tagen nicht gesehen).",
|
||||
NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm) {
|
||||
sender.sendMessage(Component.text(
|
||||
candidates.size() + " Foto(s) könnten gelöscht werden: in keiner geladenen oder gespeicherten"
|
||||
+ " Welt, keinem Spielerinventar/Enderchest (auch offline) und keinem Container/Shulker/"
|
||||
+ "Bündel gefunden und seit " + days + " Tagen nicht geladen oder angesehen.",
|
||||
NamedTextColor.YELLOW));
|
||||
sender.sendMessage(Component.text(
|
||||
"Achtung: Nicht erkannt werden Karten in gerade nicht geladenen Welten und in Speichern anderer"
|
||||
+ " Plugins (z. B. Rucksäcke, Auktionshäuser). Zum Löschen: /" + label
|
||||
+ " cleanup confirm " + days,
|
||||
NamedTextColor.GRAY));
|
||||
return;
|
||||
}
|
||||
|
||||
int deleted = 0;
|
||||
for (MapManager.StoredMap s : candidates) {
|
||||
if (MapManager.delete(s.id())) deleted++;
|
||||
}
|
||||
sender.sendMessage(Component.text(deleted + " Foto(s) gelöscht.", NamedTextColor.GREEN));
|
||||
}
|
||||
|
||||
/** {@code arg} as a number in {@code [min, max]}, else -1. */
|
||||
private static int parse(String arg, int min, int max) {
|
||||
try {
|
||||
int n = Integer.parseInt(arg);
|
||||
return n >= min && n <= max ? n : -1;
|
||||
} catch (NumberFormatException e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
private static void error(CommandSender sender, String message) {
|
||||
sender.sendMessage(Component.text(message, NamedTextColor.RED));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package eu.mhsl.minecraft.pixelpics.listeners;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.Main;
|
||||
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.Bukkit;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.server.MapInitializeEvent;
|
||||
import org.bukkit.map.MapView;
|
||||
|
||||
/** Reattaches stored photos to their maps when the server loads them. */
|
||||
public class OnMapInitialize implements Listener {
|
||||
|
||||
@EventHandler
|
||||
public void onMapInitialize(MapInitializeEvent event) {
|
||||
attach(event.getMap());
|
||||
}
|
||||
|
||||
/** Gives a map its stored photo, if it has one. */
|
||||
public static void attach(MapView view) {
|
||||
int mapId = view.getId();
|
||||
if (!MapManager.isManaged(mapId)) return;
|
||||
MapManager.touch(mapId);
|
||||
byte[] indices = MapManager.readIndices(mapId);
|
||||
if (indices != null) {
|
||||
MapManager.attachView(view, new ImageMapRenderer(indices));
|
||||
return;
|
||||
}
|
||||
// No index cache (yet): show blank film and dither the stored PNG off the main thread.
|
||||
ImageMapRenderer renderer = new ImageMapRenderer();
|
||||
MapManager.attachView(view, renderer);
|
||||
Bukkit.getScheduler().runTaskAsynchronously(Main.getInstance(), () -> {
|
||||
BufferedImage image = MapManager.readImage(mapId);
|
||||
if (image == null) return;
|
||||
byte[] dithered = MapImageDither.dither(image);
|
||||
MapManager.saveIndices(dithered, mapId);
|
||||
renderer.show(dithered);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.snapshot.SnapshotBuilder;
|
||||
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.LongStream;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/**
|
||||
* Keeps the chunks of a queued photo loaded until its snapshot is taken, so they do not unload when the photographer
|
||||
* leaves meanwhile. Plugin chunk tickets are not counted per plugin, hence the reference count. Only chunks loaded at
|
||||
* the shot get a ticket (a hold never loads one). Main thread only.
|
||||
*/
|
||||
public final class ChunkHolds {
|
||||
|
||||
private final Plugin plugin;
|
||||
private final Map<UUID, Map<Long, Integer>> counts = new HashMap<>();
|
||||
|
||||
ChunkHolds(Plugin plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
/** Holds the loaded chunks of {@code area} in {@code world} until {@link Hold#release}. */
|
||||
public Hold hold(World world, SnapshotBuilder.ChunkArea area) {
|
||||
Map<Long, Integer> held = counts.computeIfAbsent(world.getUID(), k -> new HashMap<>());
|
||||
LongStream.Builder keys = LongStream.builder();
|
||||
for (int cx = area.minCX(); cx <= area.maxCX(); cx++) {
|
||||
for (int cz = area.minCZ(); cz <= area.maxCZ(); cz++) {
|
||||
if (!world.isChunkLoaded(cx, cz)) continue;
|
||||
long key = WorldSnapshot.chunkKey(cx, cz);
|
||||
if (held.merge(key, 1, Integer::sum) == 1) world.addPluginChunkTicket(cx, cz, plugin);
|
||||
keys.add(key);
|
||||
}
|
||||
}
|
||||
return new Hold(world.getUID(), keys.build().toArray());
|
||||
}
|
||||
|
||||
/** The chunks of one photo; {@link #release} is idempotent. */
|
||||
public final class Hold {
|
||||
private final UUID world;
|
||||
private final long[] keys;
|
||||
private boolean released;
|
||||
|
||||
private Hold(UUID world, long[] keys) {
|
||||
this.world = world;
|
||||
this.keys = keys;
|
||||
}
|
||||
|
||||
public void release() {
|
||||
if (released) return;
|
||||
released = true;
|
||||
Map<Long, Integer> held = counts.get(world);
|
||||
if (held == null) return;
|
||||
World w = Bukkit.getWorld(world); // null once unloaded: its tickets are gone with it
|
||||
for (long key : keys) {
|
||||
Integer left = held.computeIfPresent(key, (k, c) -> c > 1 ? c - 1 : null);
|
||||
if (left == null && w != null) w.removePluginChunkTicket((int) (key >> 32), (int) key, plugin);
|
||||
}
|
||||
if (held.isEmpty()) counts.remove(world);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.utils.DebugLog;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.MainThread;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
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.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.logging.Level;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
|
||||
/**
|
||||
* 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}
|
||||
* {@link Thread#MIN_PRIORITY} workers instead of fanning out over the common pool.</li>
|
||||
* <li><b>Main thread:</b> at most one world snapshot ({@link #onMainThread}) per tick, also when several
|
||||
* jobs start at once.</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
|
||||
}
|
||||
|
||||
/** The {@code render.*} limits from config.yml, already normalised. */
|
||||
public record Settings(int threads, int maxConcurrent, int queueSize, int timeoutSeconds) {
|
||||
@Override
|
||||
public String toString() {
|
||||
return threads + " core(s), max " + maxConcurrent + " concurrent, queue " + queueSize + ", timeout "
|
||||
+ timeoutSeconds + "s";
|
||||
}
|
||||
}
|
||||
|
||||
private static final int SHUTDOWN_WAIT_SECONDS = 5;
|
||||
|
||||
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 Set<Job<?>> jobs = ConcurrentHashMap.newKeySet();
|
||||
private final ConcurrentLinkedQueue<MainTask> mainTasks = new ConcurrentLinkedQueue<>();
|
||||
|
||||
private final BukkitTask mainTaskPump;
|
||||
private final ChunkHolds chunkHolds;
|
||||
private volatile boolean shuttingDown = false;
|
||||
private final AtomicInteger inFlight = new AtomicInteger();
|
||||
private final AtomicInteger running = new AtomicInteger();
|
||||
private final RenderStats stats = new RenderStats();
|
||||
private volatile int capacity;
|
||||
private volatile int maxConcurrent;
|
||||
private volatile long timeoutMillis;
|
||||
|
||||
public RenderManager(Plugin plugin, Settings settings) {
|
||||
this.plugin = plugin;
|
||||
this.tracePool = new ForkJoinPool(settings.threads(), lowPriorityForkJoinFactory(), null, false);
|
||||
this.dispatcher = new ThreadPoolExecutor(
|
||||
settings.maxConcurrent(),
|
||||
settings.maxConcurrent(),
|
||||
30,
|
||||
TimeUnit.SECONDS,
|
||||
new LinkedBlockingQueue<>(), // capacity is enforced by inFlight, not this queue
|
||||
lowPriorityThreadFactory());
|
||||
this.dispatcher.allowCoreThreadTimeOut(true);
|
||||
applyLimits(settings);
|
||||
this.watchdog = Executors.newSingleThreadScheduledExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable, "PixelPics-render-watchdog");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
this.mainTaskPump = Bukkit.getScheduler().runTaskTimer(plugin, this::runNextMainTask, 1, 1);
|
||||
this.chunkHolds = new ChunkHolds(plugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies new limits to the running manager ({@code /pixelpic reload}). Running and queued jobs are kept; a smaller
|
||||
* capacity only rejects new photos until enough are done.
|
||||
*/
|
||||
public void configure(Settings settings) {
|
||||
tracePool.setParallelism(settings.threads());
|
||||
int max = settings.maxConcurrent();
|
||||
// ThreadPoolExecutor rejects core > max at every step, so the order depends on the direction.
|
||||
if (max >= dispatcher.getMaximumPoolSize()) {
|
||||
dispatcher.setMaximumPoolSize(max);
|
||||
dispatcher.setCorePoolSize(max);
|
||||
} else {
|
||||
dispatcher.setCorePoolSize(max);
|
||||
dispatcher.setMaximumPoolSize(max);
|
||||
}
|
||||
applyLimits(settings);
|
||||
}
|
||||
|
||||
private void applyLimits(Settings settings) {
|
||||
maxConcurrent = settings.maxConcurrent();
|
||||
capacity = settings.maxConcurrent() + settings.queueSize();
|
||||
timeoutMillis = settings.timeoutSeconds() * 1000L;
|
||||
}
|
||||
|
||||
/** Photos rendering now (incl. waiting for their snapshot), photos waiting for a worker, and the limits. */
|
||||
public record Load(int running, int queued, int maxConcurrent, int queueSize, int threads, long timeoutSeconds) {}
|
||||
|
||||
public Load load() {
|
||||
int total = inFlight.get();
|
||||
int run = Math.min(running.get(), total);
|
||||
return new Load(
|
||||
run,
|
||||
Math.max(0, total - run),
|
||||
maxConcurrent,
|
||||
capacity - maxConcurrent,
|
||||
tracePool.getParallelism(),
|
||||
timeoutMillis / 1000);
|
||||
}
|
||||
|
||||
public RenderStats stats() {
|
||||
return stats;
|
||||
}
|
||||
|
||||
/** True while no job is running, queued or waiting for its callback. */
|
||||
public boolean isIdle() {
|
||||
return jobs.isEmpty();
|
||||
}
|
||||
|
||||
public ChunkHolds chunkHolds() {
|
||||
return chunkHolds;
|
||||
}
|
||||
|
||||
/** 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)) {
|
||||
stats.rejected();
|
||||
return Outcome.USER_BUSY;
|
||||
}
|
||||
if (inFlight.incrementAndGet() > capacity) {
|
||||
inFlight.decrementAndGet();
|
||||
activeUsers.remove(user);
|
||||
stats.rejected();
|
||||
return Outcome.QUEUE_FULL;
|
||||
}
|
||||
return Outcome.ACCEPTED;
|
||||
}
|
||||
|
||||
/** Releases a reservation made by {@link #tryReserve}; a no-op when {@code user} holds none. */
|
||||
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 or the plugin shuts down — {@code work} should poll it and bail out
|
||||
* cooperatively. Exactly one callback runs per job, also when the plugin is disabled meanwhile
|
||||
* ({@link #shutdown} runs it synchronously). 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) {
|
||||
Job<T> job = new Job<>(onSuccess, onFailure);
|
||||
jobs.add(job);
|
||||
dispatcher.execute(() -> {
|
||||
running.incrementAndGet();
|
||||
long timeout = timeoutMillis;
|
||||
AtomicBoolean timedOut = new AtomicBoolean(false);
|
||||
ScheduledFuture<?> deadline = watchdog.schedule(
|
||||
() -> {
|
||||
timedOut.set(true);
|
||||
job.cancelled.set(true);
|
||||
},
|
||||
timeout,
|
||||
TimeUnit.MILLISECONDS);
|
||||
try {
|
||||
T result = job.cancelled.get() ? null : work.apply(job.cancelled);
|
||||
if (result != null && !job.cancelled.get()) job.result = result;
|
||||
} catch (Throwable t) {
|
||||
if (!shuttingDown) {
|
||||
plugin.getLogger().warning("Render job failed: " + t);
|
||||
if (DebugLog.enabled()) plugin.getLogger().log(Level.WARNING, "Render job failure", t);
|
||||
}
|
||||
} finally {
|
||||
deadline.cancel(false);
|
||||
running.decrementAndGet();
|
||||
if (timedOut.get()) {
|
||||
plugin.getLogger()
|
||||
.warning("Render for " + user + " aborted after " + (timeout / 1000) + "s (timeout).");
|
||||
}
|
||||
release(user);
|
||||
}
|
||||
// Not scheduled while shutting down or once disabled: shutdown() delivers it then.
|
||||
if (!shuttingDown) MainThread.run(plugin, job::finish);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* From a dispatched job's {@code work}: runs {@code task} on the main thread and waits for its result. The tasks of
|
||||
* all jobs run one per tick in request order, so jobs starting together never stack their snapshots into one long
|
||||
* tick. Returns null without running {@code task} once {@code cancelled} is set or the worker is interrupted.
|
||||
*/
|
||||
public <T> T onMainThread(Supplier<T> task, AtomicBoolean cancelled) {
|
||||
if (shuttingDown) return null;
|
||||
CompletableFuture<T> result = new CompletableFuture<>();
|
||||
mainTasks.add(new MainTask(
|
||||
() -> {
|
||||
try {
|
||||
result.complete(task.get());
|
||||
} catch (Throwable t) {
|
||||
result.completeExceptionally(t);
|
||||
}
|
||||
},
|
||||
cancelled));
|
||||
while (!cancelled.get()) {
|
||||
try {
|
||||
return result.get(50, TimeUnit.MILLISECONDS);
|
||||
} catch (TimeoutException e) {
|
||||
// poll cancelled again: shutdown() runs on the main thread, the task may never run
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
cancelled.set(true);
|
||||
return null;
|
||||
} catch (ExecutionException e) {
|
||||
if (e.getCause() instanceof RuntimeException re) throw re;
|
||||
throw new IllegalStateException(e.getCause());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Every tick: the oldest main-thread task whose job is not cancelled. */
|
||||
private void runNextMainTask() {
|
||||
MainTask next;
|
||||
while ((next = mainTasks.poll()) != null) {
|
||||
if (next.cancelled.get()) continue; // its worker returns null on its own
|
||||
next.run.run();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private record MainTask(Runnable run, AtomicBoolean cancelled) {}
|
||||
|
||||
/**
|
||||
* Stops all rendering without leaving a job undelivered: running and queued jobs are cancelled, the workers get
|
||||
* a few seconds to wind down, then every job's callback runs here (on the main thread) — {@code onSuccess} for
|
||||
* jobs that had completed but were not delivered yet, {@code onFailure} for the rest. Nothing is scheduled.
|
||||
*/
|
||||
public void shutdown() {
|
||||
shuttingDown = true;
|
||||
mainTaskPump.cancel();
|
||||
for (Job<?> job : jobs) job.cancelled.set(true);
|
||||
mainTasks.clear();
|
||||
watchdog.shutdownNow();
|
||||
dispatcher.shutdownNow();
|
||||
try {
|
||||
if (!dispatcher.awaitTermination(SHUTDOWN_WAIT_SECONDS, TimeUnit.SECONDS)) {
|
||||
plugin.getLogger().warning("Render workers did not stop within " + SHUTDOWN_WAIT_SECONDS + "s.");
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
tracePool.shutdownNow();
|
||||
for (Job<?> job : List.copyOf(jobs)) {
|
||||
try {
|
||||
job.finish();
|
||||
} catch (Throwable t) {
|
||||
plugin.getLogger().warning("Failed to finish a render job on shutdown: " + t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A dispatched render; {@link #finish} runs exactly one of its callbacks, exactly once. */
|
||||
private final class Job<T> {
|
||||
final AtomicBoolean cancelled = new AtomicBoolean(false);
|
||||
private final AtomicBoolean finished = new AtomicBoolean(false);
|
||||
private final Consumer<T> onSuccess;
|
||||
private final Runnable onFailure;
|
||||
volatile T result; // set once the work completed successfully
|
||||
|
||||
Job(Consumer<T> onSuccess, Runnable onFailure) {
|
||||
this.onSuccess = onSuccess;
|
||||
this.onFailure = onFailure;
|
||||
}
|
||||
|
||||
void finish() {
|
||||
if (!finished.compareAndSet(false, true)) return;
|
||||
jobs.remove(this);
|
||||
T r = result;
|
||||
if (r != null) onSuccess.accept(r);
|
||||
else onFailure.run();
|
||||
}
|
||||
}
|
||||
|
||||
private static ForkJoinPool.ForkJoinWorkerThreadFactory lowPriorityForkJoinFactory() {
|
||||
AtomicInteger counter = new AtomicInteger();
|
||||
return pool -> {
|
||||
ForkJoinWorkerThread thread = ForkJoinPool.defaultForkJoinWorkerThreadFactory.newThread(pool);
|
||||
// not getPoolIndex(): it is only assigned once the worker registers, here every name would end in -0
|
||||
thread.setName("PixelPics-trace-" + counter.incrementAndGet());
|
||||
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,69 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/** Photo counters and summed phase timings since the start (thread-safe), shown by {@code /pixelpic info}. */
|
||||
public final class RenderStats {
|
||||
|
||||
/**
|
||||
* Phase durations of one developed photo in ms: waiting for a worker and its snapshot turn, the world snapshot on
|
||||
* the main thread, ray tracing, dithering to map colours, and the whole time from the shot to the result.
|
||||
*/
|
||||
public record Timings(long queued, long snapshot, long trace, long dither, long total) {}
|
||||
|
||||
private final AtomicLong developed = new AtomicLong();
|
||||
private final AtomicLong failed = new AtomicLong();
|
||||
private final AtomicLong rejected = new AtomicLong();
|
||||
private final AtomicLong queued = new AtomicLong();
|
||||
private final AtomicLong snapshot = new AtomicLong();
|
||||
private final AtomicLong trace = new AtomicLong();
|
||||
private final AtomicLong dither = new AtomicLong();
|
||||
private final AtomicLong total = new AtomicLong();
|
||||
private final AtomicLong maxTotal = new AtomicLong();
|
||||
private final AtomicLong maxSnapshot = new AtomicLong();
|
||||
|
||||
public void developed(Timings t) {
|
||||
developed.incrementAndGet();
|
||||
queued.addAndGet(t.queued());
|
||||
snapshot.addAndGet(t.snapshot());
|
||||
trace.addAndGet(t.trace());
|
||||
dither.addAndGet(t.dither());
|
||||
total.addAndGet(t.total());
|
||||
maxTotal.accumulateAndGet(t.total(), Math::max);
|
||||
maxSnapshot.accumulateAndGet(t.snapshot(), Math::max);
|
||||
}
|
||||
|
||||
public void failed() {
|
||||
failed.incrementAndGet();
|
||||
}
|
||||
|
||||
public void rejected() {
|
||||
rejected.incrementAndGet();
|
||||
}
|
||||
|
||||
public long developedCount() {
|
||||
return developed.get();
|
||||
}
|
||||
|
||||
public long failedCount() {
|
||||
return failed.get();
|
||||
}
|
||||
|
||||
public long rejectedCount() {
|
||||
return rejected.get();
|
||||
}
|
||||
|
||||
/** Average timings of the developed photos (all zero before the first one). */
|
||||
public Timings average() {
|
||||
long n = Math.max(1, developed.get());
|
||||
return new Timings(queued.get() / n, snapshot.get() / n, trace.get() / n, dither.get() / n, total.get() / n);
|
||||
}
|
||||
|
||||
public long maxTotal() {
|
||||
return maxTotal.get();
|
||||
}
|
||||
|
||||
public long maxSnapshot() {
|
||||
return maxSnapshot.get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||
|
||||
/**
|
||||
* A 3x3 linear transform (row-major) plus translation. Ray parameters {@code t} are preserved under affine maps, so
|
||||
* distances stay consistent between world and local space.
|
||||
*/
|
||||
public final class Affine {
|
||||
|
||||
public final double[] r;
|
||||
public final double[] t;
|
||||
|
||||
public Affine(double[] r, double[] t) {
|
||||
this.r = r;
|
||||
this.t = t;
|
||||
}
|
||||
|
||||
/** A linear map (no translation) from its row-major matrix. */
|
||||
private static Affine linear(double... r) {
|
||||
return new Affine(r, new double[] {0, 0, 0});
|
||||
}
|
||||
|
||||
public static Affine identity() {
|
||||
return linear(1, 0, 0, 0, 1, 0, 0, 0, 1);
|
||||
}
|
||||
|
||||
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 linear(
|
||||
1 - 2 * (y * y + z * z),
|
||||
2 * (x * y - z * w),
|
||||
2 * (x * z + y * w),
|
||||
2 * (x * y + z * w),
|
||||
1 - 2 * (x * x + z * z),
|
||||
2 * (y * z - x * w),
|
||||
2 * (x * z - y * w),
|
||||
2 * (y * z + x * w),
|
||||
1 - 2 * (x * x + y * y));
|
||||
}
|
||||
|
||||
/** Right-handed rotation by {@code a} radians about the unit axis {@code (x, y, z)} (Rodrigues). */
|
||||
public static Affine rotAxis(double x, double y, double z, double a) {
|
||||
double c = Math.cos(a), s = Math.sin(a), k = 1 - c;
|
||||
return linear(
|
||||
c + x * x * k,
|
||||
x * y * k - z * s,
|
||||
x * z * k + y * s,
|
||||
y * x * k + z * s,
|
||||
c + y * y * k,
|
||||
y * z * k - x * s,
|
||||
z * x * k - y * s,
|
||||
z * y * k + x * s,
|
||||
c + z * z * k);
|
||||
}
|
||||
|
||||
public static Affine scale(double s) {
|
||||
return linear(s, 0, 0, 0, s, 0, 0, 0, s);
|
||||
}
|
||||
|
||||
public static Affine scale(double sx, double sy, double sz) {
|
||||
return linear(sx, 0, 0, 0, sy, 0, 0, 0, sz);
|
||||
}
|
||||
|
||||
public static Affine rotX(double a) {
|
||||
double c = Math.cos(a), s = Math.sin(a);
|
||||
return linear(1, 0, 0, 0, c, -s, 0, s, c);
|
||||
}
|
||||
|
||||
public static Affine rotY(double a) {
|
||||
double c = Math.cos(a), s = Math.sin(a);
|
||||
return linear(c, 0, s, 0, 1, 0, -s, 0, c);
|
||||
}
|
||||
|
||||
public static Affine rotZ(double a) {
|
||||
double c = Math.cos(a), s = Math.sin(a);
|
||||
return linear(c, -s, 0, s, c, 0, 0, 0, 1);
|
||||
}
|
||||
|
||||
/** JOML {@code rotationAround}: {@code T(c) · rot · T(-c)}. */
|
||||
public static Affine rotationAround(Affine rot, double cx, double cy, double cz) {
|
||||
return translation(cx, cy, cz).mul(rot).mul(translation(-cx, -cy, -cz));
|
||||
}
|
||||
|
||||
/** this ∘ o (apply o first, then this). */
|
||||
public Affine mul(Affine o) {
|
||||
double[] a = this.r, b = o.r;
|
||||
double[] nr = new double[9];
|
||||
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]
|
||||
};
|
||||
}
|
||||
|
||||
public double determinant() {
|
||||
return r[0] * (r[4] * r[8] - r[5] * r[7])
|
||||
- r[1] * (r[3] * r[8] - r[5] * r[6])
|
||||
+ r[2] * (r[3] * r[7] - r[4] * r[6]);
|
||||
}
|
||||
|
||||
/** Affine inverse; a singular matrix yields a zero linear part. */
|
||||
public Affine inverse() {
|
||||
double a = r[0], b = r[1], c = r[2], d = r[3], e = r[4], f = r[5], g = r[6], h = r[7], i = r[8];
|
||||
double det = determinant();
|
||||
double inv = Math.abs(det) < 1e-12 ? 0 : 1.0 / det;
|
||||
double[] ir = new double[] {
|
||||
(e * i - f * h) * inv, (c * h - b * i) * inv, (b * f - c * e) * inv,
|
||||
(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,265 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.SpecialDates;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Maps a {@link BlockEntityState} to the dumped models its vanilla BlockEntityRenderer draws (see
|
||||
* {@code tools/VanillaModelDump.java}) and to the renderer's PoseStack. Every pose is transcribed from the decompiled
|
||||
* 26.3 client renderers, without calibration offsets. Beds and sign boards are block models since 26.x; only the sign
|
||||
* text is added here.
|
||||
*/
|
||||
public final class BlockEntityModels {
|
||||
|
||||
private BlockEntityModels() {}
|
||||
|
||||
/** How a part's texture is produced. */
|
||||
public enum Paint {
|
||||
/** First loadable texture of {@link Part#textures}. */
|
||||
PLAIN,
|
||||
/** Banner flag: {@code banner_base}, the base-colour {@code base} mask, then each pattern (BannerRenderer). */
|
||||
BANNER_FLAG,
|
||||
/** Player head: the owner's skin, else {@link Part#textures} (default Steve). */
|
||||
SKIN
|
||||
}
|
||||
|
||||
/**
|
||||
* One {@code submitModel}/{@code submitModelPart} call: a dump model ({@code layer/<model>#<layer>}
|
||||
* in rest pose, or {@code posed/…} posed by its setupAnim), its texture candidates and the top-level
|
||||
* parts it leaves out (pot sides each carry their own sherd texture).
|
||||
*/
|
||||
public record Part(String model, List<String> textures, Set<String> hidden, Paint paint) {
|
||||
static Part of(String model, String texture) {
|
||||
return of(model, List.of(texture));
|
||||
}
|
||||
|
||||
static Part of(String model, List<String> textures) {
|
||||
return new Part(model, textures, Set.of(), Paint.PLAIN);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The renderer's models plus its block pose: {@code pose} maps Java model space (already scaled to
|
||||
* blocks) into the block cell, origin at the cell's min corner — exactly the PoseStack the renderer
|
||||
* sets up before submitting. Items and worn heads use the parts WITHOUT this pose (the special item
|
||||
* renderers submit the bare model; the item model's own transformation places it).
|
||||
*/
|
||||
public record VanillaModel(Affine pose, List<Part> parts) {}
|
||||
|
||||
/** The vanilla model + pose of a block-entity, or null without an entity model (signs, contents only). */
|
||||
public static VanillaModel vanillaModel(BlockEntityState s) {
|
||||
double deg = s.facingDeg();
|
||||
return switch (s.kind()) {
|
||||
// ChestRenderer: rotationAround(Y, -facing.toYRot(), 0.5, 0, 0.5); model y is up (no flip).
|
||||
case CHEST, TRAPPED_CHEST, ENDER_CHEST, COPPER_CHEST -> {
|
||||
String layer =
|
||||
switch (chestKind(s)) {
|
||||
case LEFT -> "layer/double_chest_left#main";
|
||||
case RIGHT -> "layer/double_chest_right#main";
|
||||
case SINGLE -> "layer/chest#main";
|
||||
};
|
||||
yield new VanillaModel(
|
||||
Affine.rotationAround(Affine.rotY(Math.toRadians(-deg)), 0.5, 0, 0.5),
|
||||
List.of(Part.of(layer, chestTextures(s))));
|
||||
}
|
||||
// SkullBlockRenderer ground: T(0.5,0,0.5)·rotY(-segment·22.5)·S(-1,-1,1); wall: T(0.5 - step·0.25,
|
||||
// 0.25, …)·rotY(-opposite.toYRot())·S(-1,-1,1).
|
||||
case HEAD -> new VanillaModel(
|
||||
Affine.translation(0.5, 0, 0.5)
|
||||
.mul(Affine.rotY(Math.toRadians(-deg)))
|
||||
.mul(Affine.scale(-1, -1, 1)),
|
||||
List.of(headPart(s.headType())));
|
||||
case WALL_HEAD -> {
|
||||
double sx = -Math.sin(Math.toRadians(deg)), sz = Math.cos(Math.toRadians(deg)); // facing step
|
||||
yield new VanillaModel(
|
||||
Affine.translation(0.5 - sx * 0.25, 0.25, 0.5 - sz * 0.25)
|
||||
.mul(Affine.rotY(Math.toRadians(-(deg + 180))))
|
||||
.mul(Affine.scale(-1, -1, 1)),
|
||||
List.of(headPart(s.headType())));
|
||||
}
|
||||
// BannerRenderer: T(0.5,0,0.5)·rotY(-angle)·S(2/3,-2/3,-2/3), angle = rotation or wall facing; the
|
||||
// pole/bar use banner_base, the flag (wave phase 0) gets base colour + patterns.
|
||||
case BANNER, WALL_BANNER -> {
|
||||
String model = s.kind() == BlockEntityState.Kind.BANNER ? "standing_banner" : "wall_banner";
|
||||
double k = 2 / 3.0;
|
||||
yield new VanillaModel(
|
||||
Affine.translation(0.5, 0, 0.5)
|
||||
.mul(Affine.rotY(Math.toRadians(-deg)))
|
||||
.mul(Affine.scale(k, -k, -k)),
|
||||
List.of(
|
||||
Part.of("layer/" + model + "#main", "entity/banner/banner_base"),
|
||||
new Part(
|
||||
"posed/" + model + "#flag",
|
||||
List.of("entity/banner/banner_base"),
|
||||
Set.of(),
|
||||
Paint.BANNER_FLAG)));
|
||||
}
|
||||
// ShulkerBoxRenderer: T(.5,.5,.5)·S(0.9995)·direction.getRotation()·S(1,-1,-1)·T(0,-1,0); lid closed.
|
||||
case SHULKER_BOX -> new VanillaModel(
|
||||
Affine.translation(0.5, 0.5, 0.5)
|
||||
.mul(Affine.scale(0.9995))
|
||||
.mul(directionRotation(s.direction()))
|
||||
.mul(Affine.scale(1, -1, -1))
|
||||
.mul(Affine.translation(0, -1, 0)),
|
||||
List.of(Part.of(
|
||||
"layer/shulker_box#main", variantThenBase("entity/shulker/shulker", s.colorName()))));
|
||||
// DecoratedPotRenderer: rotateAround(Y, 180 - facing.toYRot(), .5,.5,.5); neck/top/bottom from the
|
||||
// base sheet, each side its sherd pattern (or the blank side).
|
||||
case DECORATED_POT -> new VanillaModel(
|
||||
Affine.rotationAround(Affine.rotY(Math.toRadians(180 - deg)), 0.5, 0.5, 0.5), potParts(s));
|
||||
// BellRenderer: the model as is (pivot 8,12,8 in block px); the frame is the block model.
|
||||
case BELL -> new VanillaModel(
|
||||
Affine.identity(), List.of(Part.of("layer/bell#main", "entity/bell/bell_body")));
|
||||
// ConduitRenderer: inactive → shell at T(.5,.5,.5); active → cage at T(.5, 0.3 + hh·0.2, .5) with
|
||||
// hh at animation time 0 (= 0.75), unrotated. Wind and the camera-facing eye are not drawn.
|
||||
case CONDUIT -> s.active()
|
||||
? new VanillaModel(
|
||||
Affine.translation(0.5, 0.3 + 0.75 * 0.2, 0.5),
|
||||
List.of(Part.of("layer/conduit#cage", "entity/conduit/cage")))
|
||||
: new VanillaModel(
|
||||
Affine.translation(0.5, 0.5, 0.5),
|
||||
List.of(Part.of("layer/conduit#shell", "entity/conduit/base")));
|
||||
// LecternRenderer (only with a book): T(.5,1.0625,.5)·rotY(-clockwise(facing).toYRot())·rotZ(67.5°)·
|
||||
// T(0,-0.125,0), book open as BOOK_STATE (dumped posed).
|
||||
case LECTERN -> s.active()
|
||||
? new VanillaModel(
|
||||
Affine.translation(0.5, 1.0625, 0.5)
|
||||
.mul(Affine.rotY(Math.toRadians(-(deg + 90))))
|
||||
.mul(Affine.rotZ(Math.toRadians(67.5)))
|
||||
.mul(Affine.translation(0, -0.125, 0)),
|
||||
List.of(Part.of("posed/book#main", "entity/enchantment/enchanting_table_book")))
|
||||
: null;
|
||||
// EnchantTableRenderer: T(.5,.75,.5)·T(0, 0.1 + sin(time·0.1)·0.01, 0)·rotY(-rot)·rotZ(80°), book state
|
||||
// forAnimation(time, flip pages, open) at time 0 / flip 0, closed or open (dumped posed).
|
||||
case ENCHANTING_TABLE -> new VanillaModel(
|
||||
Affine.translation(0.5, 0.75 + 0.1, 0.5)
|
||||
.mul(Affine.rotY(Math.toRadians(-deg)))
|
||||
.mul(Affine.rotZ(Math.toRadians(80))),
|
||||
List.of(Part.of(
|
||||
s.active() ? "posed/enchanting_book_open#main" : "posed/enchanting_book_closed#main",
|
||||
"entity/enchantment/enchanting_table_book")));
|
||||
// CopperGolemStatueBlockRenderer: T(.5,0,.5)·rotY(-opposite(facing).toYRot()), one model per pose
|
||||
// (CopperGolemStatueModel.setupAnim: root.y 0, root.zRot π), texture per weathering stage.
|
||||
case COPPER_GOLEM_STATUE -> new VanillaModel(
|
||||
Affine.translation(0.5, 0, 0.5).mul(Affine.rotY(Math.toRadians(-(deg + 180)))),
|
||||
List.of(Part.of(
|
||||
"posed/copper_golem_statue_" + (s.pose() == null ? "standing" : s.pose()) + "#main",
|
||||
variantThenBase("entity/copper_golem/copper_golem", s.colorName()))));
|
||||
// Signs: text only (BlockEntityBaker). Items, spawner mobs, beams and portals are not entity models
|
||||
// (BlockEntityBaker.addContents).
|
||||
case SIGN,
|
||||
WALL_SIGN,
|
||||
HANGING_SIGN,
|
||||
CAMPFIRE,
|
||||
SHELF,
|
||||
SPAWNER,
|
||||
VAULT,
|
||||
BEACON,
|
||||
END_PORTAL,
|
||||
END_GATEWAY -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Standing/HangingSignRenderer.textTransformation WITHOUT its final {@code scale(s, -s, s)}: the frame whose
|
||||
* origin is the text anchor (centre of the four lines), x right / y up / +z towards the reader; the font-pixel
|
||||
* size {@code s} in blocks goes to {@code pxSize[0]}. Back text is the front transform turned by 180°.
|
||||
*/
|
||||
public static Affine signTextFrame(BlockEntityState s, boolean front, double[] pxSize) {
|
||||
double deg = s.facingDeg();
|
||||
Affine m;
|
||||
if (s.kind() == BlockEntityState.Kind.HANGING_SIGN) {
|
||||
m = Affine.translation(0.5, 0.9375, 0.5)
|
||||
.mul(Affine.rotY(Math.toRadians(-deg)))
|
||||
.mul(Affine.translation(0, -0.3125, 0));
|
||||
if (!front) m = m.mul(Affine.rotY(Math.PI));
|
||||
m = m.mul(Affine.translation(0, -0.32, 0.073));
|
||||
pxSize[0] = 0.0140625;
|
||||
} else {
|
||||
m = Affine.translation(0.5, 0.5, 0.5).mul(Affine.rotY(Math.toRadians(-deg)));
|
||||
if (s.kind() == BlockEntityState.Kind.WALL_SIGN) m = m.mul(Affine.translation(0, -0.3125, -0.4375));
|
||||
if (!front) m = m.mul(Affine.rotY(Math.PI));
|
||||
m = m.mul(Affine.translation(0, 0.33333334, 0.046666667));
|
||||
pxSize[0] = 0.010416667;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
private static Part headPart(String headType) {
|
||||
String t = headType == null ? "skeleton" : headType;
|
||||
return switch (t) {
|
||||
case "wither_skeleton" -> Part.of("layer/wither_skeleton_skull#main", "entity/skeleton/wither_skeleton");
|
||||
case "zombie" -> Part.of("layer/zombie_head#main", "entity/zombie/zombie");
|
||||
case "creeper" -> Part.of("layer/creeper_head#main", "entity/creeper/creeper");
|
||||
case "dragon" -> Part.of("posed/dragon_skull#main", "entity/enderdragon/dragon");
|
||||
case "piglin" -> Part.of("posed/piglin_head#main", "entity/piglin/piglin");
|
||||
case "player" -> new Part(
|
||||
"layer/player_head#main", List.of("entity/player/wide/steve"), Set.of(), Paint.SKIN);
|
||||
default -> Part.of("layer/skeleton_skull#main", "entity/skeleton/skeleton");
|
||||
};
|
||||
}
|
||||
|
||||
private static final List<String> POT_SIDES = List.of("front", "left", "right", "back"); // = sherd order
|
||||
|
||||
private static List<Part> potParts(BlockEntityState s) {
|
||||
List<Part> parts = new ArrayList<>(5);
|
||||
parts.add(Part.of("layer/decorated_pot_base#main", "entity/decorated_pot/decorated_pot_base"));
|
||||
for (int i = 0; i < POT_SIDES.size(); i++) {
|
||||
List<String> tex = new ArrayList<>(2);
|
||||
String sherd = i < s.sherds().size() ? s.sherds().get(i) : null;
|
||||
if (sherd != null && sherd.endsWith("_pottery_sherd")) {
|
||||
tex.add("entity/decorated_pot/" + sherd.replace("_pottery_sherd", "_pottery_pattern"));
|
||||
}
|
||||
tex.add("entity/decorated_pot/decorated_pot_side");
|
||||
List<String> others = new ArrayList<>(POT_SIDES);
|
||||
others.remove(i);
|
||||
parts.add(new Part("layer/decorated_pot_sides#main", tex, Set.copyOf(others), Paint.PLAIN));
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
private static BlockEntityState.ChestKind chestKind(BlockEntityState s) {
|
||||
return s.chestKind() == null ? BlockEntityState.ChestKind.SINGLE : s.chestKind();
|
||||
}
|
||||
|
||||
/**
|
||||
* ChestRenderer.getChestMaterial + Sheets.chooseSprite: copper by stage, ender, CHRISTMAS for regular AND
|
||||
* trapped chests between 24 and 26 December ({@link SpecialDates#isExtendedChristmas}), else normal/trapped.
|
||||
*/
|
||||
private static List<String> chestTextures(BlockEntityState s) {
|
||||
if (s.kind() == BlockEntityState.Kind.ENDER_CHEST) return List.of("entity/chest/ender");
|
||||
boolean xmas = SpecialDates.isExtendedChristmas();
|
||||
String base =
|
||||
switch (s.kind()) {
|
||||
case TRAPPED_CHEST -> xmas ? "christmas" : "trapped";
|
||||
case COPPER_CHEST -> s.colorName() == null ? "copper" : "copper_" + s.colorName();
|
||||
default -> xmas ? "christmas" : "normal";
|
||||
};
|
||||
return switch (chestKind(s)) {
|
||||
case LEFT -> List.of("entity/chest/" + base + "_left", "entity/chest/" + base);
|
||||
case RIGHT -> List.of("entity/chest/" + base + "_right", "entity/chest/" + base);
|
||||
case SINGLE -> List.of("entity/chest/" + base);
|
||||
};
|
||||
}
|
||||
|
||||
/** {@code <base>_<variant>} first, then the base texture; just the base without a variant. */
|
||||
private static List<String> variantThenBase(String base, String variant) {
|
||||
return variant == null ? List.of(base) : List.of(base + "_" + variant, base);
|
||||
}
|
||||
|
||||
/** Minecraft {@code Direction.getRotation()} (JOML rotationXYZ = Rx·Ry·Rz); null/unknown = UP. */
|
||||
private static Affine directionRotation(String direction) {
|
||||
double h = Math.PI / 2;
|
||||
if (direction == null) return Affine.identity();
|
||||
return switch (direction) {
|
||||
case "down" -> Affine.rotX(Math.PI);
|
||||
case "north" -> Affine.rotX(h).mul(Affine.rotZ(Math.PI));
|
||||
case "south" -> Affine.rotX(h);
|
||||
case "west" -> Affine.rotX(h).mul(Affine.rotZ(h));
|
||||
case "east" -> Affine.rotX(h).mul(Affine.rotZ(-h));
|
||||
default -> Affine.identity();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Bukkit-free snapshot of one block-entity, captured on the main thread and baked off-thread through the entity path
|
||||
* (see {@link BlockEntityModels}). {@code facingDeg} is the vanilla yaw of the facing/rotation (0 = south, 90 = west;
|
||||
* 16-step rotations are {@code segment · 22.5}). Type-specific fields are null/0/false/empty when unused.
|
||||
*/
|
||||
public record BlockEntityState(
|
||||
Kind kind,
|
||||
int bx,
|
||||
int by,
|
||||
int bz,
|
||||
float facingDeg,
|
||||
String direction, // 6-way facing ("up", "north", …) of shulker boxes; null otherwise
|
||||
boolean active, // lectern: has a book; conduit: active; enchanting table: book open
|
||||
ChestKind chestKind, // double-chest half (CHEST/TRAPPED_CHEST/COPPER_CHEST)
|
||||
int baseColorArgb, // banner base colour; 0 = none
|
||||
String colorName, // shulker colour ("red", …) or copper-chest stage ("exposed", …); null = default
|
||||
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/left/right/back item keys (may be empty)
|
||||
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
|
||||
List<ItemSpec> items, // campfire (4) / shelf (3) / vault (1) slots, null = empty slot; may be empty
|
||||
EntityState displayEntity, // spawner / trial spawner mob (captured at its own position); null = none
|
||||
Beam beam, // beacon beam (sections bottom-up); null = no beam
|
||||
String pose // copper golem statue pose ("standing", "running", "sitting", "star"); null otherwise
|
||||
) {
|
||||
|
||||
/** Without contents (items, display entity, beam, pose); used by the test harnesses. */
|
||||
public BlockEntityState(
|
||||
Kind kind,
|
||||
int bx,
|
||||
int by,
|
||||
int bz,
|
||||
float facingDeg,
|
||||
String direction,
|
||||
boolean active,
|
||||
ChestKind chestKind,
|
||||
int baseColorArgb,
|
||||
String colorName,
|
||||
String headType,
|
||||
String skinUrl,
|
||||
List<BannerPattern> patterns,
|
||||
List<String> sherds,
|
||||
SignText frontText,
|
||||
SignText backText) {
|
||||
this(
|
||||
kind,
|
||||
bx,
|
||||
by,
|
||||
bz,
|
||||
facingDeg,
|
||||
direction,
|
||||
active,
|
||||
chestKind,
|
||||
baseColorArgb,
|
||||
colorName,
|
||||
headType,
|
||||
skinUrl,
|
||||
patterns,
|
||||
sherds,
|
||||
frontText,
|
||||
backText,
|
||||
List.of(),
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
public enum Kind {
|
||||
CHEST,
|
||||
TRAPPED_CHEST,
|
||||
ENDER_CHEST,
|
||||
COPPER_CHEST,
|
||||
SIGN,
|
||||
WALL_SIGN,
|
||||
HANGING_SIGN,
|
||||
BANNER,
|
||||
WALL_BANNER,
|
||||
SHULKER_BOX,
|
||||
HEAD,
|
||||
WALL_HEAD,
|
||||
CONDUIT,
|
||||
DECORATED_POT,
|
||||
BELL,
|
||||
LECTERN,
|
||||
ENCHANTING_TABLE, // facingDeg = book yaw (vanilla rot, degrees), active = open (player within 3 blocks)
|
||||
CAMPFIRE,
|
||||
SHELF,
|
||||
COPPER_GOLEM_STATUE, // colorName = weathering stage, pose = statue pose
|
||||
SPAWNER, // spawner and trial spawner: displayEntity
|
||||
VAULT, // items[0] = displayed key/reward item
|
||||
BEACON,
|
||||
END_PORTAL,
|
||||
END_GATEWAY // direction = "+"-joined faces to draw (neighbour not occluding)
|
||||
}
|
||||
|
||||
/**
|
||||
* A beacon beam: coloured sections bottom-up (BeaconBlockEntity: white from the beacon, a stained-glass
|
||||
* colour or the average with the previous one per change; the last runs to the top) and the radius scale
|
||||
* (BeaconRenderer: {@code max(1, horizontal camera distance / 96)}).
|
||||
*/
|
||||
public record Beam(List<BeamSection> sections, float radiusScale) {}
|
||||
|
||||
/** One beam section: ARGB colour and height in blocks. */
|
||||
public record BeamSection(int argb, int height) {}
|
||||
|
||||
public enum ChestKind {
|
||||
SINGLE,
|
||||
LEFT,
|
||||
RIGHT
|
||||
}
|
||||
|
||||
/** 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,71 @@
|
||||
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 with the Java box-UV layout (the cross around the {@code uv}
|
||||
* offset). Mirror swaps the left/right faces and flips each face horizontally.
|
||||
*/
|
||||
public final class BoxUv {
|
||||
|
||||
private BoxUv() {}
|
||||
|
||||
/**
|
||||
* Faces by {@link Direction#ordinal()}. UVs are normalised by the model's DECLARED texture size, so a
|
||||
* higher-resolution pack texture maps proportionally; the actual size is used when undeclared.
|
||||
*/
|
||||
public static Face[] build(ModelCube cube, int[][] texture, int declaredW, int declaredH) {
|
||||
int texW = texture.length > 0 ? texture[0].length : 64;
|
||||
int texH = texture.length > 0 ? texture.length : 64;
|
||||
int nW = declaredW > 0 ? declaredW : texW;
|
||||
int nH = declaredH > 0 ? declaredH : texH;
|
||||
|
||||
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];
|
||||
|
||||
// {x, y, w, h} texels, SIGNED (negative = flipped axis): the Blockbench/OptiFine box-UV layout (up/down
|
||||
// flipped). Must stay in sync with the face (s, t) orientation of ElementIntersector.faceS/faceT.
|
||||
double[] east = {u, v + dz, dz, dy};
|
||||
double[] west = {u + dz + dx, v + dz, dz, dy};
|
||||
double[] north = {u + dz, v + dz, dx, dy};
|
||||
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,155 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
|
||||
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 eu.mhsl.minecraft.pixelpics.render.entity.cem.ItemGeometry;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Bakes wall decorations: paintings as a thin box over the captured world bounding box, item frames as their vanilla
|
||||
* frame block model plus the framed item or map.
|
||||
*/
|
||||
public final class DecorationBaker implements EntityBaker<DecorationState> {
|
||||
|
||||
private static final double MIN_THICKNESS = 0.0625; // 1px, so the slab test never degenerates
|
||||
|
||||
private final TextureCache textures;
|
||||
private final BlockModelRegistry registry;
|
||||
private final ItemGeometry items;
|
||||
|
||||
public DecorationBaker(TextureCache textures, BlockModelRegistry registry, ItemGeometry items) {
|
||||
this.textures = textures;
|
||||
this.registry = registry;
|
||||
this.items = items;
|
||||
}
|
||||
|
||||
@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.valueOf(s.facing().name());
|
||||
faces[front.ordinal()] = frontFace(art, s.facing());
|
||||
faces[front.opposite().ordinal()] = new Face(back, 0, 0, 1, 1, 0, -1);
|
||||
EntityCube cube = new EntityCube(from, to, faces, Affine.identity());
|
||||
return RenderedEntity.of(List.of(cube));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item frames as vanilla's ItemFrameRenderer (decompiled 26.3) draws them. Pose from the entity position
|
||||
* (the bbox centre; the renderer's own offset cancels the dispatcher's): {@code T(dir·0.46875)} to the
|
||||
* block centre, then {@code rotX(xRot)·rotY(yRot)} (horizontal: 0 / 180−yRot(dir); up/down: ∓90 / 180),
|
||||
* which turns local +Z toward the wall. Frame = the fake-blockstate block model
|
||||
* ({@code block/[glow_]item_frame[_map]}) at {@code T(-0.5)}, skipped when invisible. Content at
|
||||
* {@code T(0,0,0.4375)} ({@code 0.5} when invisible): a map as a 128x128 quad
|
||||
* ({@code rotZ(rot%4·90)·rotZ(180)·S(1/128)·T(-64,-64,-1)}, z −0.01 map px, MapRenderer), any other item
|
||||
* through the item model in the {@code fixed} display context at {@code rotZ(rot·45)·S(0.5)}.
|
||||
*/
|
||||
private RenderedEntity bakeItemFrame(DecorationState s) {
|
||||
DecorationState.Facing f = s.facing();
|
||||
double off = 0.46875;
|
||||
double cx = (s.minX() + s.maxX()) / 2 + (f.axis() == 0 ? f.sign() * off : 0);
|
||||
double cy = (s.minY() + s.maxY()) / 2 + (f.axis() == 1 ? f.sign() * off : 0);
|
||||
double cz = (s.minZ() + s.maxZ()) / 2 + (f.axis() == 2 ? f.sign() * off : 0);
|
||||
double xRot, yRot;
|
||||
if (f.axis() != 1) {
|
||||
xRot = 0;
|
||||
yRot = 180 - yRot(f);
|
||||
} else {
|
||||
xRot = -90 * f.sign();
|
||||
yRot = 180;
|
||||
}
|
||||
Affine pose = Affine.translation(cx, cy, cz)
|
||||
.mul(Affine.rotX(Math.toRadians(xRot)))
|
||||
.mul(Affine.rotY(Math.toRadians(yRot)));
|
||||
|
||||
List<EntityCube> cubes = new ArrayList<>();
|
||||
boolean map = s.mapPixels() != null;
|
||||
if (!s.invisible()) {
|
||||
String model = "block/" + (s.glow() ? "glow_item_frame" : "item_frame") + (map ? "_map" : "");
|
||||
items.bakeModel(
|
||||
registry.model(ResourceLocation.parse(model)),
|
||||
null,
|
||||
pose.mul(Affine.translation(-0.5, -0.5, -0.5)),
|
||||
cubes);
|
||||
}
|
||||
Affine content = pose.mul(Affine.translation(0, 0, s.invisible() ? 0.5 : 0.4375));
|
||||
if (map) {
|
||||
Affine m = content.mul(Affine.rotZ(Math.toRadians(s.rotation() % 4 * 90)))
|
||||
.mul(Affine.rotZ(Math.PI))
|
||||
.mul(Affine.scale(1 / 128.0))
|
||||
.mul(Affine.translation(-64, -64, -1));
|
||||
// Quad (0,0)..(128,128) facing local −Z (away from the wall); u along +x, v along +y.
|
||||
Face[] faces = new Face[6];
|
||||
faces[Direction.NORTH.ordinal()] = new Face(s.mapPixels(), 1, 1, 0, 0, 0, -1);
|
||||
faces[Direction.SOUTH.ordinal()] = new Face(s.mapPixels(), 0, 1, 1, 0, 0, -1);
|
||||
cubes.add(new EntityCube(new double[] {0, 0, -0.01}, new double[] {128, 128, -0.01}, faces, m));
|
||||
addMapMarkers(s, m, cubes);
|
||||
} else if (s.item() != null) {
|
||||
Affine m =
|
||||
content.mul(Affine.rotZ(Math.toRadians(s.rotation() * 45))).mul(Affine.scale(0.5));
|
||||
items.bake(s.item(), "fixed", false, m, cubes);
|
||||
}
|
||||
return cubes.isEmpty() ? null : RenderedEntity.of(cubes);
|
||||
}
|
||||
|
||||
/**
|
||||
* MapRenderer decorations (only those shown on frames, see {@code MapPixels.markers}): in map pixel space
|
||||
* {@code T(x/2 + 64, y/2 + 64, -0.02)·rotZ(rot·22.5°)·S(4, 4, 3)·T(-0.125, 0.125, 0)}, the sprite quad
|
||||
* (-1,1)..(1,-1) at z = n·-0.001 (n-th drawn decoration), {@code map/decorations/<asset>} with v0 at +y.
|
||||
*/
|
||||
private void addMapMarkers(DecorationState s, Affine map, List<EntityCube> cubes) {
|
||||
List<DecorationState.MapMarker> markers = s.mapMarkers();
|
||||
for (int n = 0; n < markers.size(); n++) {
|
||||
DecorationState.MapMarker mk = markers.get(n);
|
||||
int[][] sprite = textures.get(ResourceLocation.parse("map/decorations/" + mk.asset()))
|
||||
.orElse(null);
|
||||
if (sprite == null) continue;
|
||||
Affine m = map.mul(Affine.translation(mk.x() / 2.0 + 64, mk.y() / 2.0 + 64, -0.02))
|
||||
.mul(Affine.rotZ(Math.toRadians(mk.rot() * 360 / 16.0)))
|
||||
.mul(Affine.scale(4, 4, 3))
|
||||
.mul(Affine.translation(-0.125, 0.125, 0));
|
||||
double z = n * -0.001;
|
||||
cubes.add(new EntityCube(new double[] {-1, -1, z}, new double[] {1, 1, z}, ObjectBaker.card(sprite), m));
|
||||
}
|
||||
}
|
||||
|
||||
/** Vanilla Direction#toYRot for horizontal facings (south 0, west 90, north 180, east 270). */
|
||||
private static double yRot(DecorationState.Facing f) {
|
||||
return switch (f) {
|
||||
case WEST -> 90;
|
||||
case NORTH -> 180;
|
||||
case EAST -> 270;
|
||||
default -> 0;
|
||||
};
|
||||
}
|
||||
|
||||
/** Front face with the per-facing horizontal flip so the texture reads upright and unmirrored. */
|
||||
private static Face frontFace(int[][] tex, DecorationState.Facing facing) {
|
||||
boolean flipH = facing == DecorationState.Facing.SOUTH || facing == DecorationState.Facing.WEST;
|
||||
double u1 = flipH ? 1 : 0, u2 = flipH ? 0 : 1;
|
||||
return new Face(tex, u1, 0, u2, 1, 0, -1);
|
||||
}
|
||||
|
||||
private static void thicken(double[] from, double[] to, DecorationState.Facing facing) {
|
||||
int a = facing.axis();
|
||||
if (to[a] - from[a] >= MIN_THICKNESS) return;
|
||||
if (facing.sign() > 0) from[a] = to[a] - MIN_THICKNESS;
|
||||
else to[a] = from[a] + MIN_THICKNESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Snapshot of a wall decoration (painting or item frame). The world bounding box is captured directly: it already
|
||||
* encodes vanilla's multi-block painting offset, and its centre is the item frame's entity position.
|
||||
*/
|
||||
public record DecorationState(
|
||||
Kind kind,
|
||||
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
|
||||
ItemSpec item, // item-frame contents; null if empty
|
||||
int[][] mapPixels, // framed map's 128x128 ARGB colours (row-major [y][x]); null if not a map with data
|
||||
int rotation, // item-frame content rotation in 45° steps (0..7, vanilla ItemFrame#getRotation)
|
||||
boolean glow, // glow item frame
|
||||
boolean invisible, // invisible frame (setVisible(false)): no frame model, item sits on the wall
|
||||
List<MapMarker> mapMarkers // framed map's decorations shown on item frames (banners, frames, …); may be empty
|
||||
) {
|
||||
|
||||
/** Without map markers; used by the test harnesses. */
|
||||
public DecorationState(
|
||||
Kind kind,
|
||||
double minX,
|
||||
double minY,
|
||||
double minZ,
|
||||
double maxX,
|
||||
double maxY,
|
||||
double maxZ,
|
||||
Facing facing,
|
||||
String paintingArt,
|
||||
ItemSpec item,
|
||||
int[][] mapPixels,
|
||||
int rotation,
|
||||
boolean glow,
|
||||
boolean invisible) {
|
||||
this(
|
||||
kind,
|
||||
minX,
|
||||
minY,
|
||||
minZ,
|
||||
maxX,
|
||||
maxY,
|
||||
maxZ,
|
||||
facing,
|
||||
paintingArt,
|
||||
item,
|
||||
mapPixels,
|
||||
rotation,
|
||||
glow,
|
||||
invisible,
|
||||
List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* One map decoration (vanilla MapDecoration): sprite {@code map/decorations/<asset>}, position in half map
|
||||
* pixels from the centre ({@code x}, {@code y} in -128..127), rotation in 16ths of a full turn.
|
||||
*/
|
||||
public record MapMarker(String asset, int x, int y, int rot) {}
|
||||
|
||||
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,51 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Bounded, thread-safe LRU cache of derived (tinted/composited) textures, so identical mobs share one texture.
|
||||
*
|
||||
* <p>Keys ({@link #key}) list the inputs that determine the result; source grids compare by identity, which is exact
|
||||
* because no grid is mutated once built. Cached grids are shared: callers must not mutate them. The computation runs
|
||||
* outside the lock; concurrent misses may compute twice, the first stored result wins.
|
||||
*/
|
||||
public final class DerivedTextureCache {
|
||||
|
||||
private static final int[][] NONE = new int[0][0]; // cached "no texture"
|
||||
|
||||
private final Map<List<Object>, int[][]> map;
|
||||
|
||||
public DerivedTextureCache(int capacity) {
|
||||
this.map = new LinkedHashMap<>(64, 0.75f, true) {
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<List<Object>, int[][]> eldest) {
|
||||
return size() > capacity;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** A cache key from the inputs (nulls allowed; arrays compare by identity). */
|
||||
public static List<Object> key(Object... parts) {
|
||||
return Arrays.asList(parts);
|
||||
}
|
||||
|
||||
/** The cached texture for {@code key}, computing (and caching) it on a miss; null results are cached too. */
|
||||
public int[][] get(List<Object> key, Supplier<int[][]> compute) {
|
||||
int[][] v;
|
||||
synchronized (map) {
|
||||
v = map.get(key);
|
||||
}
|
||||
if (v == null) {
|
||||
int[][] computed = compute.get();
|
||||
synchronized (map) {
|
||||
v = map.putIfAbsent(key, computed == null ? NONE : computed);
|
||||
}
|
||||
if (v == null) return computed;
|
||||
}
|
||||
return v == NONE ? null : v;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||
|
||||
/** Bakes a captured state into a {@link RenderedEntity}, or null when it has no geometry. */
|
||||
public interface EntityBaker<S> {
|
||||
RenderedEntity bake(S state);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
|
||||
|
||||
/**
|
||||
* A baked entity cube: a local box, the affine map into the world, its six faces (by {@code Direction.ordinal()},
|
||||
* null = not drawn), the inverse map and the world-space AABB.
|
||||
*/
|
||||
public final class EntityCube {
|
||||
public final double[] from; // local corners; from > to on some axis = inverted (below)
|
||||
public final double[] to;
|
||||
public final Face[] faces;
|
||||
public final Affine toWorld;
|
||||
public final Affine toLocal;
|
||||
public final boolean additive; // added to what lies behind (energy swirl) instead of blended
|
||||
public final boolean unshaded; // no directional face shading (vanilla NO_CARDINAL_LIGHTING: eyes, breeze wind)
|
||||
// Block-model element with from > to on some axis (spawner/vault inner faces): its faces point INTO the box
|
||||
// and are back-face culled like vanilla quads (see EntityIntersector); mirrored = toWorld flips handedness.
|
||||
public final boolean inverted, mirrored;
|
||||
// Row-major map of the face normals: toWorld's linear part while it is a rotation with a uniform scale (same
|
||||
// direction), else its inverse-transpose (vanilla PoseStack normal matrix for non-uniform scales, e.g. block
|
||||
// displays scaled (2, 1, 0.5) and rotated).
|
||||
public final double[] normalMatrix;
|
||||
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, to, faces, toWorld, false);
|
||||
}
|
||||
|
||||
public EntityCube(double[] from, double[] to, Face[] faces, Affine toWorld, boolean additive) {
|
||||
this(from, to, faces, toWorld, additive, false);
|
||||
}
|
||||
|
||||
/** Whether the columns of {@code r} are orthogonal and of equal length (rotation, reflection, uniform scale). */
|
||||
private static boolean conformal(double[] r) {
|
||||
double xx = r[0] * r[0] + r[3] * r[3] + r[6] * r[6];
|
||||
double yy = r[1] * r[1] + r[4] * r[4] + r[7] * r[7];
|
||||
double zz = r[2] * r[2] + r[5] * r[5] + r[8] * r[8];
|
||||
double xy = r[0] * r[1] + r[3] * r[4] + r[6] * r[7];
|
||||
double xz = r[0] * r[2] + r[3] * r[5] + r[6] * r[8];
|
||||
double yz = r[1] * r[2] + r[4] * r[5] + r[7] * r[8];
|
||||
double tol = 1e-9 * Math.max(xx, Math.max(yy, zz));
|
||||
return Math.abs(xx - yy) <= tol
|
||||
&& Math.abs(xx - zz) <= tol
|
||||
&& Math.abs(xy) <= tol
|
||||
&& Math.abs(xz) <= tol
|
||||
&& Math.abs(yz) <= tol;
|
||||
}
|
||||
|
||||
/** This cube placed by {@code m} (applied after its own map), keeping its render flags. */
|
||||
public EntityCube transformed(Affine m) {
|
||||
return new EntityCube(from, to, faces, m.mul(toWorld), additive, unshaded);
|
||||
}
|
||||
|
||||
public EntityCube(double[] from, double[] to, Face[] faces, Affine toWorld, boolean additive, boolean unshaded) {
|
||||
this.additive = additive;
|
||||
this.unshaded = unshaded;
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
this.faces = faces;
|
||||
this.toWorld = toWorld;
|
||||
this.toLocal = toWorld.inverse();
|
||||
this.inverted = from[0] > to[0] || from[1] > to[1] || from[2] > to[2];
|
||||
this.mirrored = toWorld.determinant() < 0;
|
||||
double[] l = toLocal.r;
|
||||
this.normalMatrix =
|
||||
conformal(toWorld.r) ? toWorld.r : new double[] {l[0], l[3], l[6], l[1], l[4], l[7], l[2], l[5], l[8]};
|
||||
double[] r = toWorld.r, t = toWorld.t;
|
||||
for (int i = 0; i < 8; i++) {
|
||||
double x = (i & 1) == 0 ? from[0] : to[0];
|
||||
double y = (i & 2) == 0 ? from[1] : to[1];
|
||||
double z = (i & 4) == 0 ? from[2] : to[2];
|
||||
for (int a = 0; a < 3; a++) {
|
||||
double w = r[a * 3] * x + r[a * 3 + 1] * y + r[a * 3 + 2] * z + t[a];
|
||||
if (w < aabbMin[a]) aabbMin[a] = w;
|
||||
if (w > aabbMax[a]) aabbMax[a] = w;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
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}: the ray is mapped into the cube's local frame and
|
||||
* slab-tested (t stays the world-space one). Cut-out texels are holes; partially transparent ones keep their alpha
|
||||
* for the tracer to blend. The test writes into a caller-owned {@link Hit} and allocates nothing; {@link #toFaceHit}
|
||||
* builds the result only for the hit the caller keeps.
|
||||
*/
|
||||
public final class EntityIntersector {
|
||||
|
||||
private static final double EPS = 1e-7;
|
||||
private static final Direction[] DIRECTIONS = Direction.values();
|
||||
|
||||
private EntityIntersector() {}
|
||||
|
||||
/** Mutable per-thread result/scratch of {@link #intersect}; never shared between threads. */
|
||||
public static final class Hit {
|
||||
public double t;
|
||||
public int color;
|
||||
Direction dir;
|
||||
final double[] o = new double[3], d = new double[3];
|
||||
}
|
||||
|
||||
/**
|
||||
* Nearest drawn face with {@code t > minT} (entry face first, else the far face). Cubes entered beyond
|
||||
* {@code maxT} are rejected before any texture lookup; a hit at exactly {@code maxT} still counts.
|
||||
*/
|
||||
public static boolean intersect(
|
||||
EntityCube cube,
|
||||
double ox,
|
||||
double oy,
|
||||
double oz,
|
||||
double dx,
|
||||
double dy,
|
||||
double dz,
|
||||
double minT,
|
||||
double maxT,
|
||||
Hit out) {
|
||||
double[] r = cube.toLocal.r, tr = cube.toLocal.t;
|
||||
double[] o = out.o, d = out.d;
|
||||
o[0] = r[0] * ox + r[1] * oy + r[2] * oz + tr[0];
|
||||
o[1] = r[3] * ox + r[4] * oy + r[5] * oz + tr[1];
|
||||
o[2] = r[6] * ox + r[7] * oy + r[8] * oz + tr[2];
|
||||
d[0] = r[0] * dx + r[1] * dy + r[2] * dz;
|
||||
d[1] = r[3] * dx + r[4] * dy + r[5] * dz;
|
||||
d[2] = r[6] * dx + r[7] * dy + r[8] * dz;
|
||||
double lo = Math.max(EPS, minT);
|
||||
if (cube.inverted) return intersectInverted(cube, o, d, lo, maxT, out);
|
||||
|
||||
double[] from = cube.from, to = cube.to;
|
||||
double tmin = Double.NEGATIVE_INFINITY, tmax = Double.POSITIVE_INFINITY;
|
||||
int inAxis = -1, outAxis = -1;
|
||||
boolean inNeg = false, outNeg = false;
|
||||
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 false;
|
||||
continue;
|
||||
}
|
||||
double inv = 1.0 / d[a];
|
||||
// Entry side from the direction, not from t1 > t2: both are equal for zero-thickness boxes (wings, fins).
|
||||
boolean forward = inv > 0;
|
||||
double tNear = ((forward ? from[a] : to[a]) - o[a]) * inv;
|
||||
double tFar = ((forward ? to[a] : from[a]) - o[a]) * inv;
|
||||
if (tNear > tmin) {
|
||||
tmin = tNear;
|
||||
inAxis = a;
|
||||
inNeg = forward;
|
||||
}
|
||||
if (tFar < tmax) {
|
||||
tmax = tFar;
|
||||
outAxis = a;
|
||||
outNeg = !forward;
|
||||
}
|
||||
if (tmin > tmax + EPS) return false;
|
||||
}
|
||||
if (inAxis < 0 || tmax <= lo || tmin > maxT) return false;
|
||||
|
||||
// Vanilla draws entity models without back-face culling: through a transparent texel of the entry face the
|
||||
// ray sees the inside of the far face (and both sides of a plane).
|
||||
if (tmin > lo && sample(cube, o, d, tmin, Direction.of(inAxis, inNeg), out)) return true;
|
||||
return outAxis >= 0 && sample(cube, o, d, tmax, Direction.of(outAxis, outNeg), out);
|
||||
}
|
||||
|
||||
private static boolean sample(EntityCube cube, double[] o, double[] d, double t, Direction dir, Hit out) {
|
||||
Face face = cube.faces[dir.ordinal()];
|
||||
if (face == null) return false;
|
||||
double fx = ElementIntersector.frac(o[0] + d[0] * t, cube.from[0], cube.to[0]);
|
||||
double fy = ElementIntersector.frac(o[1] + d[1] * t, cube.from[1], cube.to[1]);
|
||||
double fz = ElementIntersector.frac(o[2] + d[2] * t, cube.from[2], cube.to[2]);
|
||||
int color = face.sample(ElementIntersector.faceS(dir, fx, fy, fz), ElementIntersector.faceT(dir, fy, fz));
|
||||
if (!ColorUtil.opaque(color)) return false;
|
||||
out.t = t;
|
||||
out.color = color;
|
||||
out.dir = dir;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A block-model element with {@code from > to} on some axis (spawner/vault inner faces) baked as an item or
|
||||
* display: back-face-culled inward quads like {@link ElementIntersector} (a mirroring {@code toWorld} flips their
|
||||
* winding too). The nearest opaque face in {@code (lo, maxT]} wins.
|
||||
*/
|
||||
private static boolean intersectInverted(EntityCube cube, double[] o, double[] d, double lo, double maxT, Hit out) {
|
||||
double[] from = cube.from, to = cube.to;
|
||||
double best = Double.POSITIVE_INFINITY;
|
||||
for (Direction dir : DIRECTIONS) {
|
||||
if (Math.abs(d[dir.axis]) < EPS) continue;
|
||||
double t = ElementIntersector.invertedFaceT(dir, from, to, o, d, cube.mirrored);
|
||||
if (!(t > lo) || t > maxT || t >= best) continue;
|
||||
if (sample(cube, o, d, t, dir, out)) best = t;
|
||||
}
|
||||
return best < Double.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
/** The result object for a hit of {@code cube} recorded by {@link #intersect} (world point + normal). */
|
||||
public static FaceHit toFaceHit(
|
||||
EntityCube cube, Hit h, double ox, double oy, double oz, double dx, double dy, double dz) {
|
||||
double t = h.t;
|
||||
Direction dir = h.dir;
|
||||
double[] r = cube.normalMatrix;
|
||||
Vector normal = new Vector(
|
||||
r[0] * dir.nx + r[1] * dir.ny + r[2] * dir.nz,
|
||||
r[3] * dir.nx + r[4] * dir.ny + r[5] * dir.nz,
|
||||
r[6] * dir.nx + r[7] * dir.ny + r[8] * dir.nz)
|
||||
.normalize();
|
||||
// A far/back face is seen from inside: shade it as facing the viewer.
|
||||
if (normal.getX() * dx + normal.getY() * dy + normal.getZ() * dz > 0) normal.multiply(-1);
|
||||
return new FaceHit(
|
||||
t,
|
||||
new Vector(ox + dx * t, oy + dy * t, oz + dz * t),
|
||||
normal,
|
||||
h.color,
|
||||
-1,
|
||||
cube.additive,
|
||||
cube.unshaded);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* Entity type -> CEM template model name (legacy fallback) and candidate texture paths. Most types use the type key
|
||||
* directly; small override maps handle the exceptions.
|
||||
*/
|
||||
public final class EntityModels {
|
||||
|
||||
private EntityModels() {}
|
||||
|
||||
// Exceptions to "model name = type key": reused models and renamed/version-suffixed CEM names.
|
||||
private static final Map<String, String> CEM_OVERRIDE = Map.ofEntries(
|
||||
Map.entry("husk", "zombie"),
|
||||
Map.entry("giant", "zombie"),
|
||||
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 (isBoat(typeKey)) 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.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 (isBoat(typeKey)) {
|
||||
String wood = typeKey.substring(0, typeKey.length() - 5);
|
||||
boolean chest = wood.endsWith("_chest");
|
||||
if (chest) wood = wood.substring(0, wood.length() - "_chest".length());
|
||||
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 (isBoat(typeKey)) return 1;
|
||||
return (variant != null ? variantPaths(typeKey, variant).size() : 0)
|
||||
+ (TEX_OVERRIDE.containsKey(typeKey) ? 1 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Texture paths of the ENUM variants vanilla hardcodes in its renderers ("entity/<folder>/<entity>_<variant>",
|
||||
* mismatches normalised by the maps below). Registry variants (cat, wolf, frog, cow, …) come from their
|
||||
* {@code asset_id} in the dump instead. Tried before the generic fallbacks.
|
||||
*/
|
||||
private static List<String> variantPaths(String typeKey, String v) {
|
||||
return switch (typeKey) {
|
||||
case "axolotl" -> List.of("entity/axolotl/axolotl_" + v);
|
||||
case "horse" -> List.of("entity/horse/horse_" + HORSE_COLOR.getOrDefault(v, v));
|
||||
case "llama" -> List.of("entity/llama/llama_" + 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 "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();
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean isBoat(String typeKey) {
|
||||
return typeKey.endsWith("_boat") || typeKey.endsWith("_raft");
|
||||
}
|
||||
|
||||
/** Vanilla tropical-fish patterns in id order: 0-5 use body shape A (small), 6-11 shape B (large). */
|
||||
private static final List<String> TROPICAL_PATTERNS = List.of(
|
||||
"kob",
|
||||
"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,470 @@
|
||||
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.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* All baked entities (mobs, block-entities, decorations, objects) of one render, as one bounding volume hierarchy
|
||||
* over the cubes' world AABBs (binned SAH, flat arrays), traversed front to back with the best hit as far bound.
|
||||
* Near-ties (coplanar faces of different cubes) are decided in bake order, the later cube winning (see
|
||||
* {@link #nearestHit}). Immutable after construction; the per-ray scratch is per thread.
|
||||
*/
|
||||
public final class EntityScene {
|
||||
|
||||
private static final double EPS = 1e-7;
|
||||
private static final int LEAF_SIZE = 4;
|
||||
private static final int SAH_BINS = 16;
|
||||
private static final int MAX_SAH_DEPTH = 40;
|
||||
|
||||
// Cubes in BVH leaf order, their bake-order rank (tie-break) and world AABBs (minX..maxZ, 6 per cube).
|
||||
private final EntityCube[] cubes;
|
||||
private final int[] cubeRank;
|
||||
private final double[] cubeBox;
|
||||
|
||||
// BVH nodes: box (6 per node); inner nodes have left child = node + 1 and right child = nodeRight,
|
||||
// leaves (nodeCount > 0) cover cubes[nodeStart .. nodeStart + nodeCount).
|
||||
private double[] nodeBox;
|
||||
private int[] nodeRight, nodeStart, nodeCount;
|
||||
private int nodes;
|
||||
private final int maxDepth;
|
||||
|
||||
// Hits within this distance of the nearest one are candidates for the exact tie-break (pass 2 of
|
||||
// nearestHit); far above double rounding at render distances, far below anything visible.
|
||||
private static final double TIE_SLACK = 1e-6;
|
||||
|
||||
/** Per-thread traversal scratch: stack and the candidate hits (reused, grown on demand). */
|
||||
private static final class Traversal {
|
||||
int[] stack = new int[64];
|
||||
double[] stackT = new double[64];
|
||||
EntityIntersector.Hit[] cand = new EntityIntersector.Hit[0];
|
||||
int[] candCube = new int[8];
|
||||
int[] order = new int[8];
|
||||
|
||||
EntityIntersector.Hit candidate(int k) {
|
||||
if (k == cand.length) {
|
||||
int len = Math.max(8, k * 2);
|
||||
EntityIntersector.Hit[] c = Arrays.copyOf(cand, len);
|
||||
for (int i = k; i < len; i++) c[i] = new EntityIntersector.Hit();
|
||||
cand = c;
|
||||
candCube = Arrays.copyOf(candCube, len);
|
||||
order = Arrays.copyOf(order, len);
|
||||
}
|
||||
return cand[k];
|
||||
}
|
||||
}
|
||||
|
||||
private static final ThreadLocal<Traversal> TRAVERSAL = ThreadLocal.withInitial(Traversal::new);
|
||||
|
||||
public EntityScene(
|
||||
List<EntityState> states,
|
||||
CemBaker baker,
|
||||
List<BlockEntityState> blockEntities,
|
||||
BlockEntityBaker beBaker,
|
||||
List<DecorationState> decorations,
|
||||
DecorationBaker decoBaker,
|
||||
List<ObjectState> objects,
|
||||
ObjectBaker objectBaker) {
|
||||
List<EntityCube> baked = new ArrayList<>();
|
||||
List<RuntimeException> failures = new ArrayList<>();
|
||||
addAll(states, baker, baked, failures);
|
||||
addAll(blockEntities, beBaker, baked, failures);
|
||||
addAll(decorations, decoBaker, baked, failures);
|
||||
addAll(objects, objectBaker, baked, failures);
|
||||
if (!failures.isEmpty()) {
|
||||
// Once per photo: one bad entity/asset must never fail the whole photo, but should not go unnoticed.
|
||||
Logger.getLogger("PixelPics")
|
||||
.warning("PixelPics: skipped " + failures.size() + " entities that failed to bake, first: "
|
||||
+ failures.getFirst());
|
||||
}
|
||||
EntityCube[] all = baked.toArray(new EntityCube[0]);
|
||||
int n = all.length;
|
||||
|
||||
cubes = new EntityCube[n];
|
||||
cubeRank = new int[n];
|
||||
cubeBox = new double[n * 6];
|
||||
maxDepth = build(all);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bakes each state into {@code out} (in bake order = tie-break rank); states that fail to bake are skipped and
|
||||
* collected in {@code failures}. A null baker (path disabled) is skipped.
|
||||
*/
|
||||
private static <S> void addAll(
|
||||
List<S> states, EntityBaker<S> baker, List<EntityCube> out, List<RuntimeException> failures) {
|
||||
if (baker == null) return;
|
||||
for (S s : states) {
|
||||
RenderedEntity e;
|
||||
try {
|
||||
e = baker.bake(s);
|
||||
} catch (RuntimeException ex) {
|
||||
failures.add(ex);
|
||||
continue;
|
||||
}
|
||||
if (e != null) out.addAll(e.cubes);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
return cubes.length == 0;
|
||||
}
|
||||
|
||||
/** Nearest entity hit with {@code minT < t < maxT}, or null. */
|
||||
public FaceHit nearestHit(
|
||||
double ox, double oy, double oz, double dx, double dy, double dz, double minT, double maxT) {
|
||||
if (nodes == 0) return null;
|
||||
Traversal tr = TRAVERSAL.get();
|
||||
if (tr.stack.length < maxDepth + 2) {
|
||||
tr.stack = new int[maxDepth + 2];
|
||||
tr.stackT = new double[maxDepth + 2];
|
||||
}
|
||||
int[] stack = tr.stack;
|
||||
double[] stackT = tr.stackT;
|
||||
|
||||
// Per-axis slab setup, the same arithmetic as a per-box test (inv = 1/d; |d| < EPS = parallel).
|
||||
boolean px = Math.abs(dx) < EPS, py = Math.abs(dy) < EPS, pz = Math.abs(dz) < EPS;
|
||||
double ix = px ? 0 : 1.0 / dx, iy = py ? 0 : 1.0 / dy, iz = pz ? 0 : 1.0 / dz;
|
||||
double t0 = Math.max(0, minT);
|
||||
|
||||
// Pass 1 (BVH, front to back): every cube hit within TIE_SLACK of the nearest one becomes a candidate.
|
||||
// The far bound stays 2·TIE_SLACK behind the nearest hit so rounding in the AABB tests can't drop one.
|
||||
double nearest = Double.POSITIVE_INFINITY, bound = maxT;
|
||||
int n = 0;
|
||||
double[] nb = nodeBox, cb = cubeBox;
|
||||
int sp = 0;
|
||||
double rootT = entry(nb, 0, ox, oy, oz, ix, iy, iz, px, py, pz, t0, bound);
|
||||
if (rootT != rootT) return null;
|
||||
stack[sp] = 0;
|
||||
stackT[sp++] = rootT;
|
||||
while (sp > 0) {
|
||||
int node = stack[--sp];
|
||||
if (stackT[sp] > bound) continue; // the nearest hit got closer since this node was queued
|
||||
int count = nodeCount[node];
|
||||
if (count > 0) {
|
||||
for (int i = nodeStart[node], end = i + count; i < end; i++) {
|
||||
double ct = entry(cb, i, ox, oy, oz, ix, iy, iz, px, py, pz, t0, bound);
|
||||
if (ct != ct) continue; // NaN: AABB missed
|
||||
EntityIntersector.Hit hit = tr.candidate(n);
|
||||
if (!EntityIntersector.intersect(cubes[i], ox, oy, oz, dx, dy, dz, minT, bound, hit)) continue;
|
||||
double t = hit.t;
|
||||
if (!(t < maxT) || t > nearest + TIE_SLACK) continue;
|
||||
tr.candCube[n++] = i;
|
||||
if (t < nearest) {
|
||||
nearest = t;
|
||||
bound = Math.min(maxT, t + 2 * TIE_SLACK);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
int l = node + 1, r = nodeRight[node];
|
||||
double tl = entry(nb, l, ox, oy, oz, ix, iy, iz, px, py, pz, t0, bound);
|
||||
double trr = entry(nb, r, ox, oy, oz, ix, iy, iz, px, py, pz, t0, bound);
|
||||
// Near child on top of the stack: visited first, so the far one is usually culled. NaN = missed.
|
||||
int near = l, far = r;
|
||||
double tn = tl, tf = trr;
|
||||
if (!(tl <= trr)) {
|
||||
near = r;
|
||||
far = l;
|
||||
tn = trr;
|
||||
tf = tl;
|
||||
}
|
||||
if (tf == tf) {
|
||||
stack[sp] = far;
|
||||
stackT[sp++] = tf;
|
||||
}
|
||||
if (tn == tn) {
|
||||
stack[sp] = near;
|
||||
stackT[sp++] = tn;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (n == 0) return null;
|
||||
|
||||
// Pass 2, the tie-break: the near candidates in bake order; a cube counts only if its AABB passes with the
|
||||
// best t so far as far bound and it is not farther. On equal t the later-baked cube wins, like vanilla's
|
||||
// LEQUAL depth test for later-drawn quads (block-model overlay elements in displays/items, layers on the
|
||||
// body mesh). Coplanar faces of different cubes depend on this order, so it must not change.
|
||||
int[] candCube = tr.candCube;
|
||||
int[] order = tr.order;
|
||||
int m = 0;
|
||||
for (int k = 0; k < n; k++) {
|
||||
if (tr.cand[k].t > nearest + TIE_SLACK) continue;
|
||||
// Insertion by bake rank (a handful of candidates at most).
|
||||
int rank = cubeRank[candCube[k]], j = m++;
|
||||
while (j > 0 && cubeRank[candCube[order[j - 1]]] > rank) {
|
||||
order[j] = order[j - 1];
|
||||
j--;
|
||||
}
|
||||
order[j] = k;
|
||||
}
|
||||
double bestT = maxT;
|
||||
int best = -1;
|
||||
for (int j = 0; j < m; j++) {
|
||||
int k = order[j];
|
||||
double t = tr.cand[k].t;
|
||||
double e = entry(cb, candCube[k], ox, oy, oz, ix, iy, iz, px, py, pz, t0, bestT);
|
||||
if (e == e && t <= bestT) {
|
||||
bestT = t;
|
||||
best = k;
|
||||
}
|
||||
}
|
||||
return EntityIntersector.toFaceHit(cubes[candCube[best]], tr.cand[best], ox, oy, oz, dx, dy, dz);
|
||||
}
|
||||
|
||||
/**
|
||||
* Slab test of box {@code i} of {@code boxes} (6 doubles per box) against the ray within {@code [t0, tMax]};
|
||||
* returns the entry t, or NaN on a miss (a box touching the interval at {@code tMax} still counts).
|
||||
*/
|
||||
private static double entry(
|
||||
double[] boxes,
|
||||
int i,
|
||||
double ox,
|
||||
double oy,
|
||||
double oz,
|
||||
double ix,
|
||||
double iy,
|
||||
double iz,
|
||||
boolean px,
|
||||
boolean py,
|
||||
boolean pz,
|
||||
double t0,
|
||||
double tMax) {
|
||||
int b = i * 6;
|
||||
double tmin = t0, tmax = tMax;
|
||||
if (px) {
|
||||
if (ox < boxes[b] || ox > boxes[b + 3]) return Double.NaN;
|
||||
} else {
|
||||
double t1 = (boxes[b] - ox) * ix, t2 = (boxes[b + 3] - ox) * ix;
|
||||
if (t1 > t2) {
|
||||
double tmp = t1;
|
||||
t1 = t2;
|
||||
t2 = tmp;
|
||||
}
|
||||
if (t1 > tmin) tmin = t1;
|
||||
if (t2 < tmax) tmax = t2;
|
||||
if (tmin > tmax) return Double.NaN;
|
||||
}
|
||||
if (py) {
|
||||
if (oy < boxes[b + 1] || oy > boxes[b + 4]) return Double.NaN;
|
||||
} else {
|
||||
double t1 = (boxes[b + 1] - oy) * iy, t2 = (boxes[b + 4] - oy) * iy;
|
||||
if (t1 > t2) {
|
||||
double tmp = t1;
|
||||
t1 = t2;
|
||||
t2 = tmp;
|
||||
}
|
||||
if (t1 > tmin) tmin = t1;
|
||||
if (t2 < tmax) tmax = t2;
|
||||
if (tmin > tmax) return Double.NaN;
|
||||
}
|
||||
if (pz) {
|
||||
if (oz < boxes[b + 2] || oz > boxes[b + 5]) return Double.NaN;
|
||||
} else {
|
||||
double t1 = (boxes[b + 2] - oz) * iz, t2 = (boxes[b + 5] - oz) * iz;
|
||||
if (t1 > t2) {
|
||||
double tmp = t1;
|
||||
t1 = t2;
|
||||
t2 = tmp;
|
||||
}
|
||||
if (t1 > tmin) tmin = t1;
|
||||
if (t2 < tmax) tmax = t2;
|
||||
if (tmin > tmax) return Double.NaN;
|
||||
}
|
||||
return tmin;
|
||||
}
|
||||
|
||||
private double[] centroid; // 3 per original cube
|
||||
private double[] srcBox; // 6 per original cube
|
||||
private int[] perm; // original cube indices, partitioned in place
|
||||
private int filled; // cubes written in leaf order
|
||||
|
||||
private int build(EntityCube[] all) {
|
||||
int n = all.length;
|
||||
if (n == 0) {
|
||||
nodeBox = new double[0];
|
||||
nodeRight = nodeStart = nodeCount = new int[0];
|
||||
return 0;
|
||||
}
|
||||
srcBox = new double[n * 6];
|
||||
centroid = new double[n * 3];
|
||||
perm = new int[n];
|
||||
for (int i = 0; i < n; i++) {
|
||||
EntityCube c = all[i];
|
||||
for (int a = 0; a < 3; a++) {
|
||||
srcBox[i * 6 + a] = c.aabbMin[a];
|
||||
srcBox[i * 6 + 3 + a] = c.aabbMax[a];
|
||||
centroid[i * 3 + a] = (c.aabbMin[a] + c.aabbMax[a]) * 0.5;
|
||||
}
|
||||
perm[i] = i;
|
||||
}
|
||||
int cap = 2 * n;
|
||||
nodeBox = new double[cap * 6];
|
||||
nodeRight = new int[cap];
|
||||
nodeStart = new int[cap];
|
||||
nodeCount = new int[cap];
|
||||
int depth = buildNode(all, 0, n, 0);
|
||||
srcBox = centroid = null;
|
||||
perm = null;
|
||||
return depth;
|
||||
}
|
||||
|
||||
/** Builds the subtree over perm[lo, hi); returns its depth. */
|
||||
private int buildNode(EntityCube[] all, int lo, int hi, int depth) {
|
||||
int node = nodes++;
|
||||
int nb = node * 6;
|
||||
nodeBox[nb] = nodeBox[nb + 1] = nodeBox[nb + 2] = Double.MAX_VALUE;
|
||||
nodeBox[nb + 3] = nodeBox[nb + 4] = nodeBox[nb + 5] = -Double.MAX_VALUE;
|
||||
double[] cmin = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE};
|
||||
double[] cmax = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE};
|
||||
for (int i = lo; i < hi; i++) {
|
||||
int c = perm[i];
|
||||
for (int a = 0; a < 3; a++) {
|
||||
nodeBox[nb + a] = Math.min(nodeBox[nb + a], srcBox[c * 6 + a]);
|
||||
nodeBox[nb + 3 + a] = Math.max(nodeBox[nb + 3 + a], srcBox[c * 6 + 3 + a]);
|
||||
cmin[a] = Math.min(cmin[a], centroid[c * 3 + a]);
|
||||
cmax[a] = Math.max(cmax[a], centroid[c * 3 + a]);
|
||||
}
|
||||
}
|
||||
int count = hi - lo;
|
||||
int axis = 0;
|
||||
for (int a = 1; a < 3; a++) if (cmax[a] - cmin[a] > cmax[axis] - cmin[axis]) axis = a;
|
||||
double extent = cmax[axis] - cmin[axis];
|
||||
int mid;
|
||||
if (count <= LEAF_SIZE || !(extent > 1e-9)) {
|
||||
mid = -1;
|
||||
} else if (depth >= MAX_SAH_DEPTH) {
|
||||
// Pathological centroid distributions can make SAH splits very lopsided: median keeps it bounded.
|
||||
sortByCentroid(lo, hi, axis);
|
||||
mid = lo + count / 2;
|
||||
} else {
|
||||
mid = sahSplit(nb, lo, hi, axis, cmin[axis], extent);
|
||||
}
|
||||
if (mid < 0) {
|
||||
// Leaf: cubes in bake order inside the leaf (ascending original index).
|
||||
Arrays.sort(perm, lo, hi);
|
||||
nodeStart[node] = filled;
|
||||
nodeCount[node] = count;
|
||||
for (int i = lo; i < hi; i++) {
|
||||
int c = perm[i];
|
||||
cubes[filled] = all[c];
|
||||
cubeRank[filled] = c;
|
||||
System.arraycopy(srcBox, c * 6, cubeBox, filled * 6, 6);
|
||||
filled++;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
nodeCount[node] = 0;
|
||||
int dl = buildNode(all, lo, mid, depth + 1);
|
||||
nodeRight[node] = nodes;
|
||||
int dr = buildNode(all, mid, hi, depth + 1);
|
||||
return Math.max(dl, dr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Partitions perm[lo, hi) along {@code axis} at the cheapest of {@link #SAH_BINS}-1 bin boundaries
|
||||
* (surface area heuristic); falls back to the median when every boundary leaves one side empty. Returns
|
||||
* the split index, or -1 when a leaf is cheaper.
|
||||
*/
|
||||
private int sahSplit(int nb, int lo, int hi, int axis, double cmin, double extent) {
|
||||
int[] binCount = new int[SAH_BINS];
|
||||
double[] binBox = new double[SAH_BINS * 6];
|
||||
for (int b = 0; b < SAH_BINS; b++) {
|
||||
for (int a = 0; a < 3; a++) {
|
||||
binBox[b * 6 + a] = Double.MAX_VALUE;
|
||||
binBox[b * 6 + 3 + a] = -Double.MAX_VALUE;
|
||||
}
|
||||
}
|
||||
double scale = SAH_BINS / extent;
|
||||
for (int i = lo; i < hi; i++) {
|
||||
int c = perm[i];
|
||||
int b = bin(centroid[c * 3 + axis], cmin, scale);
|
||||
binCount[b]++;
|
||||
for (int a = 0; a < 3; a++) {
|
||||
binBox[b * 6 + a] = Math.min(binBox[b * 6 + a], srcBox[c * 6 + a]);
|
||||
binBox[b * 6 + 3 + a] = Math.max(binBox[b * 6 + 3 + a], srcBox[c * 6 + 3 + a]);
|
||||
}
|
||||
}
|
||||
// Sweep: cost(split after bin s) = area(left)·nLeft + area(right)·nRight.
|
||||
double[] leftArea = new double[SAH_BINS];
|
||||
int[] leftCount = new int[SAH_BINS];
|
||||
double[] acc = {
|
||||
Double.MAX_VALUE,
|
||||
Double.MAX_VALUE,
|
||||
Double.MAX_VALUE,
|
||||
-Double.MAX_VALUE,
|
||||
-Double.MAX_VALUE,
|
||||
-Double.MAX_VALUE
|
||||
};
|
||||
int cnt = 0;
|
||||
for (int b = 0; b < SAH_BINS - 1; b++) {
|
||||
grow(acc, binBox, b);
|
||||
cnt += binCount[b];
|
||||
leftArea[b] = area(acc, 0);
|
||||
leftCount[b] = cnt;
|
||||
}
|
||||
acc[0] = acc[1] = acc[2] = Double.MAX_VALUE;
|
||||
acc[3] = acc[4] = acc[5] = -Double.MAX_VALUE;
|
||||
cnt = 0;
|
||||
double bestCost = Double.POSITIVE_INFINITY;
|
||||
int bestSplit = -1;
|
||||
for (int b = SAH_BINS - 1; b >= 1; b--) {
|
||||
grow(acc, binBox, b);
|
||||
cnt += binCount[b];
|
||||
int left = leftCount[b - 1];
|
||||
if (left == 0 || cnt == 0) continue;
|
||||
double cost = leftArea[b - 1] * left + area(acc, 0) * cnt;
|
||||
if (cost < bestCost) {
|
||||
bestCost = cost;
|
||||
bestSplit = b;
|
||||
}
|
||||
}
|
||||
int count = hi - lo;
|
||||
if (bestSplit < 0) {
|
||||
// All centroids in one bin (should not happen with extent > 0): median split.
|
||||
sortByCentroid(lo, hi, axis);
|
||||
return lo + count / 2;
|
||||
}
|
||||
// A leaf costs area(node)·count (one cube test per cube), a split one node visit more.
|
||||
double nodeArea = area(nodeBox, nb);
|
||||
if (count <= 16 && bestCost + nodeArea >= nodeArea * count) return -1;
|
||||
int i = lo, j = hi - 1;
|
||||
while (i <= j) {
|
||||
if (bin(centroid[perm[i] * 3 + axis], cmin, scale) < bestSplit) {
|
||||
i++;
|
||||
} else {
|
||||
int tmp = perm[i];
|
||||
perm[i] = perm[j];
|
||||
perm[j--] = tmp;
|
||||
}
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
private static int bin(double c, double cmin, double scale) {
|
||||
int b = (int) ((c - cmin) * scale);
|
||||
return b < 0 ? 0 : Math.min(b, SAH_BINS - 1);
|
||||
}
|
||||
|
||||
private void sortByCentroid(int lo, int hi, int axis) {
|
||||
Integer[] tmp = new Integer[hi - lo];
|
||||
for (int i = lo; i < hi; i++) tmp[i - lo] = perm[i];
|
||||
Arrays.sort(tmp, (a, b) -> Double.compare(centroid[a * 3 + axis], centroid[b * 3 + axis]));
|
||||
for (int i = lo; i < hi; i++) perm[i] = tmp[i - lo];
|
||||
}
|
||||
|
||||
private static void grow(double[] acc, double[] boxes, int b) {
|
||||
for (int a = 0; a < 3; a++) {
|
||||
acc[a] = Math.min(acc[a], boxes[b * 6 + a]);
|
||||
acc[3 + a] = Math.max(acc[3 + a], boxes[b * 6 + 3 + a]);
|
||||
}
|
||||
}
|
||||
|
||||
private static double area(double[] box, int o) {
|
||||
double x = box[o + 3] - box[o], y = box[o + 4] - box[o + 1], z = box[o + 5] - box[o + 2];
|
||||
if (!(x >= 0) || !(y >= 0) || !(z >= 0)) return 0;
|
||||
return 2 * (x * y + y * z + z * x);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/** Snapshot of one entity captured on the main thread, enough to bake it off-thread. Angles in degrees. */
|
||||
public record EntityState(
|
||||
String typeKey, // e.g. "cow", "zombie", "player"
|
||||
double x,
|
||||
double y,
|
||||
double z,
|
||||
float bodyYaw,
|
||||
boolean baby,
|
||||
double width,
|
||||
double height,
|
||||
boolean player,
|
||||
String skinUrl,
|
||||
boolean slim,
|
||||
String variant, // variant key ("ashen", "tabby"; "<ns>:<key>" outside minecraft; villagers: biome type)
|
||||
int tint, // ARGB multiplier for tintable layers (sheep wool); 0 = none
|
||||
double sizeScale, // extra model scale (slime/magma-cube size); 1.0 = default
|
||||
String profession, // villager profession key (e.g. "farmer", "librarian", "none"), or null
|
||||
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 (pose flag "chest")
|
||||
boolean arms, // armor stand shows its arms (pose flag "arms")
|
||||
String bodyEquip, // BODY-slot equipment asset (horse armor, llama carpet, harness, …) or "trader_llama"
|
||||
Equipment equipment, // worn humanoid armor (players, armor stands, zombies, …); null = none/not a wearer
|
||||
boolean invisible, // entity is invisible -> render only its equipment (like vanilla), not the body
|
||||
float pitch, // orients projectiles and pitched bodies
|
||||
int bodyDye, // ARGB dye of dyeable BODY equipment (leather horse armor, wolf armor); 0 = none
|
||||
int tint2, // second tint (tropical-fish pattern colour); 0 = none
|
||||
boolean passenger, // riding something (vanilla sitting pose)
|
||||
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
|
||||
int collar, // ARGB dye of a tamed cat's/wolf's collar; 0 = no collar (untamed)
|
||||
boolean powered, // energy-swirl layer: charged creeper, wither below half health
|
||||
ItemSpec mouthItem, // item carried in the mouth (fox/dolphin main hand); null = none
|
||||
String pose, // "+"-joined STATE_POSES flags of the dump (e.g. "lying+head_up"); null = idle
|
||||
Map<String, float[]> partPoses, // armor-stand part rotations by render-state field ("headPose", …), x/y/z deg
|
||||
String shoulderLeft, // parrot variant on a player's left shoulder (e.g. "red"); null = none
|
||||
String shoulderRight // parrot variant on the right shoulder; null = none
|
||||
) {
|
||||
|
||||
/** Whether the state pose flags contain {@code flag}. */
|
||||
public boolean hasPose(String flag) {
|
||||
if (pose == null) return false;
|
||||
for (String f : pose.split("\\+")) if (f.equals(flag)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 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,9 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||
|
||||
import org.bukkit.block.data.BlockData;
|
||||
|
||||
/**
|
||||
* An item to render (held, worn, displayed, dropped): item key, the block it places (null if none), that block's
|
||||
* block-entity state when it is block-entity rendered, and whether it is enchanted.
|
||||
*/
|
||||
public record ItemSpec(String id, BlockData block, BlockEntityState blockEntity, boolean glint) {}
|
||||
@@ -0,0 +1,17 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||
|
||||
/** What {@link BoxUv} needs of an entity-model box: size (model px), box-UV offset (texels), mirror, face UVs. */
|
||||
public final class ModelCube {
|
||||
public final double[] size;
|
||||
public final double[] uv;
|
||||
public final boolean mirror;
|
||||
// Per-face {u, v, w, h} texels by Direction.ordinal() (negative w/h flip); null = box-UV.
|
||||
public final double[][] faceUv;
|
||||
|
||||
public ModelCube(double[] size, double[] uv, boolean mirror, double[][] faceUv) {
|
||||
this.size = size;
|
||||
this.uv = uv;
|
||||
this.mirror = mirror;
|
||||
this.faceUv = faceUv;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
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.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Bakes {@link ObjectState}s (display entities, dropped items, falling blocks, primed TNT, minecart contents,
|
||||
* leashes): blocks and items through {@link ItemGeometry}, text rasterized with the pack's bitmap font.
|
||||
*/
|
||||
public final class ObjectBaker implements EntityBaker<ObjectState> {
|
||||
|
||||
// Vanilla LeashRenderer colours, alternating per segment.
|
||||
private static final Face[] LEASH_LIGHT = solidFaces(0xFF7F6650), LEASH_DARK = solidFaces(0xFF59473A);
|
||||
private static final int LEASH_SEGMENTS = 24;
|
||||
private static final double LEASH_HALF_WIDTH = 0.025;
|
||||
|
||||
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 -> {
|
||||
// Block model plus the block-entity renderer's parts (chest bodies, lectern book, bell, …).
|
||||
items.bakeBlock(s.block(), s.modelSeed(), s.toWorld(), cubes);
|
||||
items.bakeBlockEntity(s.blockEntity(), s.toWorld(), cubes);
|
||||
}
|
||||
case ITEM -> {
|
||||
Affine at = s.toWorld();
|
||||
if (s.dropped()) {
|
||||
// ItemEntityRenderer: T(0, bob + 0.0625 - modelBox.minY), bob = sin(age/10 + offset)·0.1 + 0.1
|
||||
// frozen at phase 0: the ground model's lowest point floats 0.1625 above the entity position.
|
||||
double[] box = items.modelBounds(s.item(), s.context());
|
||||
double lift = 0.1 + 0.0625 - (box == null ? 0 : box[1]);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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};
|
||||
EntityCube seg = segment(prev, cur, i % 2 == 0 ? LEASH_LIGHT : LEASH_DARK);
|
||||
if (seg != null) cubes.add(seg);
|
||||
prev = cur;
|
||||
}
|
||||
}
|
||||
|
||||
/** A thin square rope box from a to b. */
|
||||
private static EntityCube segment(double[] a, double[] b, Face[] faces) {
|
||||
double[] d = {b[0] - a[0], b[1] - a[1], b[2] - a[2]};
|
||||
double len = Math.sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]);
|
||||
if (len < 1e-6) return null;
|
||||
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]});
|
||||
double w = LEASH_HALF_WIDTH;
|
||||
return new EntityCube(new double[] {-w, -w, 0}, new double[] {w, w, len + w}, faces, frame);
|
||||
}
|
||||
|
||||
private static Face[] solidFaces(int argb) {
|
||||
Face[] f = new Face[6];
|
||||
Arrays.fill(f, new Face(new int[][] {{argb}}, 0, 0, 1, 1, 0, -1));
|
||||
return f;
|
||||
}
|
||||
|
||||
private static double[] cross(double[] a, double[] b) {
|
||||
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;
|
||||
}
|
||||
|
||||
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()));
|
||||
int w = 0, h = 0;
|
||||
if (!lines.stream().allMatch(String::isBlank)) {
|
||||
int[][] text = SignTextRasterizer.rasterize(lines, font, s.textArgb(), 0, false);
|
||||
w = text[0].length;
|
||||
h = text.length;
|
||||
if (s.shadow()) text = withShadow(text, s.textArgb());
|
||||
// Bottom edge on the origin (the shadow hangs below), horizontally centred, front facing +Z.
|
||||
double x0 = -w / 2.0;
|
||||
cubes.add(new EntityCube(
|
||||
new double[] {x0, h - text.length, 0.05},
|
||||
new double[] {x0 + text[0].length, h, 0.05},
|
||||
card(text),
|
||||
s.toWorld()));
|
||||
}
|
||||
int bg = s.backgroundArgb();
|
||||
// Translucent backgrounds (vanilla default: 25% black) keep their alpha; the tracer blends them.
|
||||
if (ColorUtil.opaque(bg) && !s.lines().isEmpty()) {
|
||||
int[][] solid = {{bg}};
|
||||
cubes.add(new EntityCube(
|
||||
new double[] {-w / 2.0 - 1, -1, 0}, new double[] {w / 2.0 + 1, h, 0}, card(solid), s.toWorld()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 (ColorUtil.alpha(text[y][x]) > 0) out[y + 1][x + 1] = shadow;
|
||||
for (int y = 0; y < h; y++)
|
||||
for (int x = 0; x < w; x++) if (ColorUtil.alpha(text[y][x]) > 0) out[y][x] = text[y][x];
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
|
||||
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, minecart blocks and leash ropes. The local→world
|
||||
* transform is resolved at capture (position, yaw/pitch or billboard, display transformation).
|
||||
*
|
||||
* <p>Local frames: BLOCK = the block cube {@code [0,1]³}; ITEM = an item centred on the origin
|
||||
* ({@code [-0.5,0.5]³}); TEXT = font pixels (x right, y up, text front facing +Z), already scaled.
|
||||
*/
|
||||
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 (lifted above the ground by the baker)
|
||||
double[] leash, // LEASH: rope points {fromX,fromY,fromZ, toX,toY,toZ} (entity -> holder)
|
||||
List<String> lines, // TEXT: explicit lines (wrapped by the baker to lineWidth)
|
||||
int lineWidth, // TEXT: wrap width in font px
|
||||
int textArgb, // TEXT: text colour (alpha = text opacity)
|
||||
int backgroundArgb, // TEXT: background colour (0 = none)
|
||||
boolean shadow, // TEXT: drop shadow
|
||||
long modelSeed) { // BLOCK: random seed of weighted blockstate variants
|
||||
|
||||
/** A block outside the world (display, minecart block): vanilla's fixed model seed. */
|
||||
public static ObjectState block(Affine toWorld, BlockData block, BlockEntityState blockEntity) {
|
||||
return block(toWorld, block, blockEntity, BlockModelRegistry.MODEL_SEED);
|
||||
}
|
||||
|
||||
/** A block with the model seed {@code modelSeed} (falling blocks: {@code getSeed} of their start position). */
|
||||
public static ObjectState block(Affine toWorld, BlockData block, BlockEntityState blockEntity, long modelSeed) {
|
||||
return new ObjectState(
|
||||
Kind.BLOCK, toWorld, block, blockEntity, null, null, false, null, List.of(), 0, 0, 0, false, modelSeed);
|
||||
}
|
||||
|
||||
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, 0);
|
||||
}
|
||||
|
||||
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,
|
||||
0);
|
||||
}
|
||||
|
||||
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, 0);
|
||||
}
|
||||
|
||||
public enum Kind {
|
||||
BLOCK,
|
||||
ITEM,
|
||||
TEXT,
|
||||
LEASH
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** A baked entity: its world-space cubes and their union AABB (item model bounds, harness framing). */
|
||||
public final class RenderedEntity {
|
||||
public final List<EntityCube> cubes;
|
||||
public final double[] aabbMin;
|
||||
public final double[] aabbMax;
|
||||
|
||||
private RenderedEntity(List<EntityCube> cubes, double[] aabbMin, double[] aabbMax) {
|
||||
this.cubes = cubes;
|
||||
this.aabbMin = aabbMin;
|
||||
this.aabbMax = aabbMax;
|
||||
}
|
||||
|
||||
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,109 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
|
||||
|
||||
/** In-place ARGB pixel-grid ({@code [y][x]}) compositing shared by the entity and block-entity bakers. */
|
||||
public final class TextureOps {
|
||||
|
||||
private TextureOps() {}
|
||||
|
||||
/** A row-by-row copy, so callers can tint/overlay without mutating a cached source texture. */
|
||||
public static int[][] deepCopy(int[][] src) {
|
||||
int[][] out = new int[src.length][];
|
||||
for (int y = 0; y < src.length; y++) out[y] = src[y].clone();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Multiplies the RGB of every non-transparent texel by {@code argb}, in place. */
|
||||
public static void tint(int[][] tex, int argb) {
|
||||
for (int[] row : tex) {
|
||||
for (int x = 0; x < row.length; x++) {
|
||||
if (ColorUtil.alpha(row[x]) != 0) row[x] = ColorUtil.multiply(row[x], argb);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static int[][] tinted(int[][] src, int argb) {
|
||||
int[][] out = deepCopy(src);
|
||||
tint(out, argb);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 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 = ColorUtil.alpha(sp);
|
||||
if (sa == 0) continue;
|
||||
if (sa == 255) {
|
||||
dst[y][x] = sp;
|
||||
continue;
|
||||
}
|
||||
int dp = dst[y][x];
|
||||
int da = ColorUtil.alpha(dp);
|
||||
int outA = sa + da * (255 - sa) / 255;
|
||||
int r = (ColorUtil.red(sp) * sa + ColorUtil.red(dp) * da * (255 - sa) / 255) / Math.max(1, outA);
|
||||
int g = (ColorUtil.green(sp) * sa + ColorUtil.green(dp) * da * (255 - sa) / 255) / Math.max(1, outA);
|
||||
int b = (ColorUtil.blue(sp) * sa + ColorUtil.blue(dp) * da * (255 - sa) / 255) / Math.max(1, outA);
|
||||
dst[y][x] = ColorUtil.argb(outA, r, g, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Armor-trim palette swap: each non-transparent texel is replaced by the {@code to} colour whose index is the
|
||||
* nearest (RGB distance) {@code from} key shade, keeping its alpha. Vanilla trim patterns use exactly those keys.
|
||||
*/
|
||||
public static void paletteSwap(int[][] tex, int[] from, int[] to) {
|
||||
int n = Math.min(from.length, to.length);
|
||||
if (n == 0) return;
|
||||
for (int[] row : tex) {
|
||||
for (int x = 0; x < row.length; x++) {
|
||||
int p = row[x];
|
||||
int a = ColorUtil.alpha(p);
|
||||
if (a == 0) continue;
|
||||
int best = 0, bestD = Integer.MAX_VALUE;
|
||||
for (int i = 0; i < n; i++) {
|
||||
int c = from[i];
|
||||
int dr = ColorUtil.red(p) - ColorUtil.red(c);
|
||||
int dg = ColorUtil.green(p) - ColorUtil.green(c);
|
||||
int db = ColorUtil.blue(p) - ColorUtil.blue(c);
|
||||
int d = dr * dr + dg * dg + db * db;
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
row[x] = (a << 24) | (to[best] & 0xFFFFFF);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Static approximation of the animated enchantment glint: one glint frame tiled over the texture and added
|
||||
* (tinted by {@code glintColor}, scaled by {@code strength}, frame alpha and luminance) to its opaque texels.
|
||||
*/
|
||||
public static void addGlint(int[][] tex, int[][] glint, int glintColor, double strength) {
|
||||
if (glint == null || glint.length == 0 || glint[0].length == 0) return;
|
||||
int gh = glint.length, gw = glint[0].length;
|
||||
int cr = ColorUtil.red(glintColor), cg = ColorUtil.green(glintColor), cb = ColorUtil.blue(glintColor);
|
||||
for (int y = 0; y < tex.length; y++) {
|
||||
for (int x = 0; x < tex[y].length; x++) {
|
||||
int p = tex[y][x];
|
||||
int a = ColorUtil.alpha(p);
|
||||
if (a == 0) continue;
|
||||
int gp = glint[y % gh][x % gw];
|
||||
int gl = Math.max(ColorUtil.red(gp), Math.max(ColorUtil.green(gp), ColorUtil.blue(gp)));
|
||||
double inten = strength * (ColorUtil.alpha(gp) / 255.0) * (gl / 255.0);
|
||||
if (inten <= 0) continue;
|
||||
int r = ColorUtil.clamp(ColorUtil.red(p) + (int) (cr * inten));
|
||||
int g = ColorUtil.clamp(ColorUtil.green(p) + (int) (cg * inten));
|
||||
int b = ColorUtil.clamp(ColorUtil.blue(p) + (int) (cb * inten));
|
||||
tex[y][x] = ColorUtil.argb(a, r, g, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
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.DerivedTextureCache;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.EntityBaker;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
|
||||
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.util.ColorUtil;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Bakes a {@link BlockEntityState} into world-space cubes: the dumped models its vanilla BlockEntityRenderer draws
|
||||
* ({@link BlockEntityModels#vanillaModel}) at {@code T(cell) · renderer pose}, plus sign text, contents (items,
|
||||
* spawner mob), beacon beam and end portal faces.
|
||||
*/
|
||||
public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
|
||||
|
||||
// Deliberate deviation for legibility on 128 px maps: sign text is scaled to fill the writable area (w×h in
|
||||
// text-frame px), capped so a single line isn't blown up. Vanilla: 1 font px per text px, lines 90 (hanging 60)
|
||||
// px wide, 10 (9) px apart.
|
||||
private record TextArea(double w, double h, double cap) {}
|
||||
|
||||
private static final TextArea SIGN_TEXT = new TextArea(88, 44, 2.0);
|
||||
private static final TextArea HANGING_TEXT = new TextArea(58, 40, 1.87);
|
||||
// Slab behind the text plane (text px) so the ray test never degenerates.
|
||||
private static final double TEXT_THICK = 0.2;
|
||||
|
||||
private final CemModelLoader models;
|
||||
private final TextureCache textures;
|
||||
private final SkinCache skins;
|
||||
private final BitmapFont font;
|
||||
// Banner/shield pattern composites, shared by identical banners.
|
||||
private final DerivedTextureCache derived = new DerivedTextureCache(256);
|
||||
// End portal/gateway faces (one per block face: the star field is not periodic) and beacon beam textures.
|
||||
private final DerivedTextureCache effects = new DerivedTextureCache(512);
|
||||
// Contents bakers (items, spawner mob), wired after construction since both depend on this baker; null = off.
|
||||
private volatile ItemGeometry items;
|
||||
private volatile CemBaker entities;
|
||||
|
||||
public void setItemGeometry(ItemGeometry items) {
|
||||
this.items = items;
|
||||
}
|
||||
|
||||
public void setEntityBaker(CemBaker entities) {
|
||||
this.entities = entities;
|
||||
}
|
||||
|
||||
/** The sign font, shared with text displays. */
|
||||
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;
|
||||
}
|
||||
|
||||
/** The placed block-entity in its cell, or null when nothing renders (unmapped/missing assets). */
|
||||
@Override
|
||||
public RenderedEntity bake(BlockEntityState s) {
|
||||
List<EntityCube> cubes = new ArrayList<>();
|
||||
Affine cell = Affine.translation(s.bx(), s.by(), s.bz());
|
||||
BlockEntityModels.VanillaModel vm = BlockEntityModels.vanillaModel(s);
|
||||
if (vm != null) bakeParts(s, vm, cell.mul(vm.pose()), cubes);
|
||||
addSignText(s, cell, cubes);
|
||||
addContents(s, cell, cubes);
|
||||
return cubes.isEmpty() ? null : RenderedEntity.of(cubes);
|
||||
}
|
||||
|
||||
/** The models WITHOUT the block pose, mapped by {@code toWorld} (special item renderers, worn skulls). */
|
||||
public void bakeModel(BlockEntityState s, Affine toWorld, List<EntityCube> out) {
|
||||
BlockEntityModels.VanillaModel vm = BlockEntityModels.vanillaModel(s);
|
||||
if (vm != null) bakeParts(s, vm, toWorld, out);
|
||||
}
|
||||
|
||||
private void bakeParts(BlockEntityState s, BlockEntityModels.VanillaModel vm, Affine pose, List<EntityCube> out) {
|
||||
Affine pre = CemGeometry.modelSpace(pose);
|
||||
for (BlockEntityModels.Part part : vm.parts()) {
|
||||
CemModelLoader.CemModel model = models.vanilla(part.model());
|
||||
int[][] tex = model == null ? null : texture(s, part);
|
||||
if (tex != null) CemGeometry.bakeCubes(model, tex, pre, part.hidden(), out);
|
||||
}
|
||||
}
|
||||
|
||||
private void addContents(BlockEntityState s, Affine cell, List<EntityCube> cubes) {
|
||||
ItemGeometry ig = items;
|
||||
switch (s.kind()) {
|
||||
case CAMPFIRE -> {
|
||||
if (ig != null) addCampfireItems(ig, s, cell, cubes);
|
||||
}
|
||||
case SHELF -> {
|
||||
if (ig != null) addShelfItems(ig, s, cell, cubes);
|
||||
}
|
||||
case VAULT -> {
|
||||
if (ig != null && slot(s, 0) != null) {
|
||||
// VaultRenderer: T(.5, .4, .5)·rotY(spin 0), ground context.
|
||||
ig.bake(slot(s, 0), "ground", false, cell.mul(Affine.translation(0.5, 0.4, 0.5)), cubes);
|
||||
}
|
||||
}
|
||||
case SPAWNER -> addSpawnerMob(s, cell, cubes);
|
||||
case BEACON -> addBeam(s, cell, cubes);
|
||||
case END_PORTAL, END_GATEWAY -> addPortal(s, cell, cubes);
|
||||
default -> {}
|
||||
}
|
||||
}
|
||||
|
||||
private static ItemSpec slot(BlockEntityState s, int i) {
|
||||
return i < s.items().size() ? s.items().get(i) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CampfireRenderer: slot i lies flat at {@code T(.5, 0.44921875, .5)·rotY(-dir.toYRot())·rotX(90°)·
|
||||
* T(-0.3125, -0.3125, 0)·S(0.375)}, {@code dir = from2DDataValue((i + facing.get2DDataValue()) % 4)}, in
|
||||
* the {@code fixed} display context.
|
||||
*/
|
||||
private static void addCampfireItems(ItemGeometry ig, BlockEntityState s, Affine cell, List<EntityCube> cubes) {
|
||||
int facing2d = Math.floorMod(Math.round(s.facingDeg() / 90), 4); // S0 W1 N2 E3 = yaw / 90
|
||||
for (int i = 0; i < 4; i++) {
|
||||
ItemSpec item = slot(s, i);
|
||||
if (item == null) continue;
|
||||
double yRot = ((i + facing2d) % 4) * 90;
|
||||
Affine m = cell.mul(Affine.translation(0.5, 0.44921875, 0.5))
|
||||
.mul(Affine.rotY(Math.toRadians(-yRot)))
|
||||
.mul(Affine.rotX(Math.toRadians(90)))
|
||||
.mul(Affine.translation(-0.3125, -0.3125, 0))
|
||||
.mul(Affine.scale(0.375));
|
||||
ig.bake(item, "fixed", false, m, cubes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ShelfRenderer (align_items_to_bottom off): slot i at {@code T(.5,.5,.5)·rotY(-facing.toYRot())·
|
||||
* T((i-1)·0.3125, 0, -0.25)·S(0.25)·T(0, -minY - (maxY-minY)/2, 0)} — the item's {@code on_shelf} model
|
||||
* bounding box vertically centred.
|
||||
*/
|
||||
private static void addShelfItems(ItemGeometry ig, BlockEntityState s, Affine cell, List<EntityCube> cubes) {
|
||||
for (int i = 0; i < 3; i++) {
|
||||
ItemSpec item = slot(s, i);
|
||||
if (item == null) continue;
|
||||
double[] box = ig.modelBounds(item, "on_shelf");
|
||||
if (box == null) continue;
|
||||
double offsetY = -box[1] - (box[4] - box[1]) / 2;
|
||||
Affine m = cell.mul(Affine.translation(0.5, 0.5, 0.5))
|
||||
.mul(Affine.rotY(Math.toRadians(-s.facingDeg())))
|
||||
.mul(Affine.translation((i - 1) * 0.3125, 0, -0.25))
|
||||
.mul(Affine.scale(0.25))
|
||||
.mul(Affine.translation(0, offsetY, 0));
|
||||
ig.bake(item, "on_shelf", false, m, cubes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SpawnerRenderer.submitEntityInSpawner: {@code T(.5,.4,.5)·rotY(spin)·T(0,-.2,0)·rotX(-30°)·S(scale)}
|
||||
* (spin 0, scale 0.53125 / max(width, height) when that exceeds 1), the mob at the origin (baked at its own
|
||||
* position, moved back by -pos).
|
||||
*/
|
||||
private void addSpawnerMob(BlockEntityState s, Affine cell, List<EntityCube> cubes) {
|
||||
CemBaker baker = entities;
|
||||
EntityState e = s.displayEntity();
|
||||
if (baker == null || e == null) return;
|
||||
RenderedEntity mob = baker.bake(e);
|
||||
if (mob == null) return;
|
||||
double scale = 0.53125;
|
||||
double max = Math.max(e.width(), e.height());
|
||||
if (max > 1) scale /= max;
|
||||
Affine m = cell.mul(Affine.translation(0.5, 0.4, 0.5))
|
||||
.mul(Affine.translation(0, -0.2, 0))
|
||||
.mul(Affine.rotX(Math.toRadians(-30)))
|
||||
.mul(Affine.scale(scale))
|
||||
.mul(Affine.translation(-e.x(), -e.y(), -e.z()));
|
||||
for (EntityCube c : mob.cubes) cubes.add(c.transformed(m));
|
||||
}
|
||||
|
||||
// BeaconRenderer: sections up to MAX_RENDER_Y (the last one), solid core radius 0.2 rotated by
|
||||
// animationTime·2.25 − 45° (time 0: an axis-aligned square of half-width 0.2/√2), texture v 2.5× per block;
|
||||
// glow radius 0.25 axis-aligned, alpha 32, v 1× per block. Both unlit, no caps.
|
||||
private static final int BEAM_MAX = 2048;
|
||||
private static final int BEAM_PIECE = 32; // blocks per cube; textures tiled to cover it
|
||||
private static final Direction[] SIDES = {Direction.NORTH, Direction.SOUTH, Direction.WEST, Direction.EAST};
|
||||
|
||||
private void addBeam(BlockEntityState s, Affine cell, List<EntityCube> cubes) {
|
||||
BlockEntityState.Beam beam = s.beam();
|
||||
if (beam == null || beam.sections().isEmpty()) return;
|
||||
int[][] tex = texture("entity/beacon/beacon_beam");
|
||||
if (tex == null) return;
|
||||
double core = 0.2 * beam.radiusScale() / Math.sqrt(2), glow = 0.25 * beam.radiusScale();
|
||||
int start = 0;
|
||||
List<BlockEntityState.BeamSection> secs = beam.sections();
|
||||
for (int i = 0; i < secs.size(); i++) {
|
||||
int height = i == secs.size() - 1 ? BEAM_MAX : secs.get(i).height();
|
||||
int argb = secs.get(i).argb();
|
||||
int[][] coreTex = beamTexture(tex, argb, false);
|
||||
int[][] glowTex = beamTexture(tex, argb, true);
|
||||
for (int y = 0; y < height; y += BEAM_PIECE) {
|
||||
int len = Math.min(BEAM_PIECE, height - y);
|
||||
double v = (double) len / BEAM_PIECE; // textures span BEAM_PIECE blocks; v grows upwards
|
||||
Affine at = cell.mul(Affine.translation(0.5, start + y, 0.5));
|
||||
cubes.add(beamCube(coreTex, core, len, v, at));
|
||||
cubes.add(beamCube(glowTex, glow, len, v, at));
|
||||
}
|
||||
start += height;
|
||||
}
|
||||
}
|
||||
|
||||
private static EntityCube beamCube(int[][] tex, double r, int len, double v, Affine at) {
|
||||
Face side = new Face(tex, 0, v, 1, 0, 0, -1);
|
||||
Face[] faces = new Face[6];
|
||||
for (Direction d : SIDES) faces[d.ordinal()] = side;
|
||||
return new EntityCube(new double[] {-r, 0, -r}, new double[] {r, len, r}, faces, at, false, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* The beam texture tiled over {@link #BEAM_PIECE} blocks (core 2.5 repeats per block, glow 1) and tinted
|
||||
* with the section colour: the core opaque (BEACON_BEAM_OPAQUE), the glow at alpha 32/255.
|
||||
*/
|
||||
private int[][] beamTexture(int[][] tex, int argb, boolean glowLayer) {
|
||||
return effects.get(DerivedTextureCache.key("beam", tex, argb, glowLayer), () -> {
|
||||
int reps = glowLayer ? BEAM_PIECE : BEAM_PIECE * 5 / 2;
|
||||
int h = tex.length, w = tex[0].length;
|
||||
int[][] out = new int[h * reps][w];
|
||||
for (int y = 0; y < out.length; y++) {
|
||||
for (int x = 0; x < w; x++) {
|
||||
int c = ColorUtil.multiply(tex[y % h][x], argb);
|
||||
int a = glowLayer ? ColorUtil.alpha(tex[y % h][x]) * 32 / 255 : 255;
|
||||
out[y][x] = (a << 24) | (c & 0xFFFFFF);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
// rendertype_end_portal.fsh: end_sky · COLORS[0] + Σ end_portal sampled through 15 (gateway 16) scaled,
|
||||
// rotated and shifted layers. Vanilla projects the textures in screen space; here they are mapped onto
|
||||
// the face plane in world space (one screen ≈ PORTAL_SPAN blocks) at GameTime 0.
|
||||
private static final double[][] PORTAL_COLORS = {
|
||||
{0.022087, 0.098399, 0.110818}, {0.011892, 0.095924, 0.089485}, {0.027636, 0.101689, 0.100326},
|
||||
{0.046564, 0.109883, 0.114838}, {0.064901, 0.117696, 0.097189}, {0.063761, 0.086895, 0.123646},
|
||||
{0.084817, 0.111994, 0.166380}, {0.097489, 0.154120, 0.091064}, {0.106152, 0.131144, 0.195191},
|
||||
{0.097721, 0.110188, 0.187229}, {0.133516, 0.138278, 0.148582}, {0.070006, 0.243332, 0.235792},
|
||||
{0.196766, 0.142899, 0.214696}, {0.047281, 0.315338, 0.321970}, {0.204675, 0.390010, 0.302066},
|
||||
{0.080955, 0.314821, 0.661491}
|
||||
};
|
||||
private static final double PORTAL_SPAN = 16;
|
||||
private static final int PORTAL_RES = 64; // texels per block face edge
|
||||
// end_portal_layer constants per layer i (layer = i + 1): scale, rotation and x shift 17/layer.
|
||||
private static final double[] LAYER_SCALE = new double[16], LAYER_COS = new double[16], LAYER_SIN = new double[16];
|
||||
private static final double[] LAYER_SHIFT = new double[16];
|
||||
|
||||
static {
|
||||
for (int i = 0; i < 16; i++) {
|
||||
double layer = i + 1;
|
||||
LAYER_SCALE[i] = (4.5 - layer / 4.0) * 2.0;
|
||||
double ang = Math.toRadians((layer * layer * 4321.0 + layer * 9.0) * 2.0);
|
||||
LAYER_COS[i] = Math.cos(ang);
|
||||
LAYER_SIN[i] = Math.sin(ang);
|
||||
LAYER_SHIFT[i] = 17.0 / layer;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TheEndPortalRenderer: the top and bottom face of {@code T(0,.375,0)·S(1,.375,1)} (y 0.375–0.75);
|
||||
* TheEndGatewayRenderer: the full cube's unoccluded faces. Unlit like the end_portal pipeline.
|
||||
*/
|
||||
private void addPortal(BlockEntityState s, Affine cell, List<EntityCube> cubes) {
|
||||
int[][] sky = texture("environment/end_sky");
|
||||
int[][] stars = texture("entity/end_portal/end_portal");
|
||||
if (sky == null || stars == null) return;
|
||||
boolean gateway = s.kind() == BlockEntityState.Kind.END_GATEWAY;
|
||||
double y0 = gateway ? 0 : 0.375, y1 = gateway ? 1 : 0.75;
|
||||
int layers = gateway ? 16 : 15;
|
||||
Set<Direction> shown = gateway ? gatewayFaces(s.direction()) : EnumSet.of(Direction.UP, Direction.DOWN);
|
||||
Face[] faces = new Face[6];
|
||||
for (Direction d : shown) {
|
||||
int[][] tex = effects.get(
|
||||
DerivedTextureCache.key("portal", sky, stars, layers, d, s.bx(), s.by(), s.bz(), y0, y1),
|
||||
() -> portalFace(sky, stars, layers, d, s.bx(), s.by(), s.bz(), y0, y1));
|
||||
faces[d.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, -1);
|
||||
}
|
||||
cubes.add(new EntityCube(new double[] {0, y0, 0}, new double[] {1, y1, 1}, faces, cell, false, true));
|
||||
}
|
||||
|
||||
/** The "+"-joined face names of a gateway state; null = all faces. */
|
||||
private static Set<Direction> gatewayFaces(String joined) {
|
||||
if (joined == null) return EnumSet.allOf(Direction.class);
|
||||
Set<Direction> out = EnumSet.noneOf(Direction.class);
|
||||
for (Direction d : Direction.values()) {
|
||||
if (("+" + joined + "+").contains("+" + d.name().toLowerCase(Locale.ROOT) + "+")) out.add(d);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* One face texture: each texel's world point on the face (inverting ElementIntersector.faceS/faceT), projected
|
||||
* onto the face plane.
|
||||
*/
|
||||
private static int[][] portalFace(
|
||||
int[][] sky, int[][] stars, int layers, Direction d, int bx, int by, int bz, double y0, double y1) {
|
||||
int n = PORTAL_RES;
|
||||
int[][] out = new int[n][n];
|
||||
for (int j = 0; j < n; j++) {
|
||||
for (int i = 0; i < n; i++) {
|
||||
double fs = (i + 0.5) / n, ft = (j + 0.5) / n;
|
||||
double a, b;
|
||||
switch (d) {
|
||||
case UP -> {
|
||||
a = bx + fs;
|
||||
b = bz + ft;
|
||||
}
|
||||
case DOWN -> {
|
||||
a = bx + fs;
|
||||
b = bz + (1 - ft);
|
||||
}
|
||||
case SOUTH -> {
|
||||
a = bx + fs;
|
||||
b = -(by + y0 + (1 - ft) * (y1 - y0));
|
||||
}
|
||||
case NORTH -> {
|
||||
a = bx + (1 - fs);
|
||||
b = -(by + y0 + (1 - ft) * (y1 - y0));
|
||||
}
|
||||
case WEST -> {
|
||||
a = bz + fs;
|
||||
b = -(by + y0 + (1 - ft) * (y1 - y0));
|
||||
}
|
||||
default -> {
|
||||
a = bz + (1 - fs);
|
||||
b = -(by + y0 + (1 - ft) * (y1 - y0));
|
||||
}
|
||||
}
|
||||
out[j][i] = portalColor(sky, stars, layers, a / PORTAL_SPAN, b / PORTAL_SPAN);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static int portalColor(int[][] sky, int[][] stars, int layers, double u, double v) {
|
||||
double[] c0 = PORTAL_COLORS[0];
|
||||
int skyTexel = sampleRepeat(sky, u, v);
|
||||
double r = ColorUtil.red(skyTexel) / 255.0 * c0[0];
|
||||
double g = ColorUtil.green(skyTexel) / 255.0 * c0[1];
|
||||
double b = ColorUtil.blue(skyTexel) / 255.0 * c0[2];
|
||||
for (int i = 0; i < layers; i++) {
|
||||
// end_portal_layer: (scale·rotate) then translate (17/layer, GameTime term) then ×0.5 + 0.25.
|
||||
double sc = LAYER_SCALE[i], cos = LAYER_COS[i], sin = LAYER_SIN[i];
|
||||
double x = sc * (u * cos - v * sin), y = sc * (u * sin + v * cos);
|
||||
x = (x + LAYER_SHIFT[i]) * 0.5 + 0.25;
|
||||
y = y * 0.5 + 0.25;
|
||||
int t = sampleRepeat(stars, x, y);
|
||||
double[] c = PORTAL_COLORS[i];
|
||||
r += ColorUtil.red(t) / 255.0 * c[0];
|
||||
g += ColorUtil.green(t) / 255.0 * c[1];
|
||||
b += ColorUtil.blue(t) / 255.0 * c[2];
|
||||
}
|
||||
return ColorUtil.argb(255, channel(r), channel(g), channel(b));
|
||||
}
|
||||
|
||||
private static int channel(double v) {
|
||||
return (int) Math.min(255, Math.round(v * 255));
|
||||
}
|
||||
|
||||
private static int sampleRepeat(int[][] tex, double u, double v) {
|
||||
int h = tex.length, w = tex[0].length;
|
||||
int x = Math.floorMod((int) Math.floor(u * w), w), y = Math.floorMod((int) Math.floor(v * h), h);
|
||||
return tex[y][x];
|
||||
}
|
||||
|
||||
private int[][] texture(String path) {
|
||||
return textures.get(ResourceLocation.parse(path)).orElse(null);
|
||||
}
|
||||
|
||||
private int[][] texture(BlockEntityState s, BlockEntityModels.Part part) {
|
||||
if (part.paint() == BlockEntityModels.Paint.SKIN && s.skinUrl() != null) {
|
||||
int[][] skin = skins.get(s.skinUrl()).orElse(null);
|
||||
if (skin != null) return skin;
|
||||
}
|
||||
for (String path : part.textures()) {
|
||||
int[][] t = texture(path);
|
||||
if (t == null) continue;
|
||||
return part.paint() == BlockEntityModels.Paint.BANNER_FLAG
|
||||
? patterned(t, "entity/banner/", s.baseColorArgb(), s.patterns())
|
||||
: t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* BannerRenderer.submitPatterns composited into one texture (one geometry, no coplanar layers): {@code base}
|
||||
* (banner_base / shield_base), the {@code <dir>base} mask dyed with {@code baseColor} (0 = none), then the
|
||||
* first 16 pattern masks ({@code <dir><key>}) dyed. {@code dir} = {@code entity/banner/} or
|
||||
* {@code entity/shield/} (ShieldSpecialRenderer). Cached.
|
||||
*/
|
||||
public int[][] patterned(int[][] base, String dir, int baseColor, List<BlockEntityState.BannerPattern> patterns) {
|
||||
List<BlockEntityState.BannerPattern> pats = List.copyOf(patterns.subList(0, Math.min(16, patterns.size())));
|
||||
return derived.get(
|
||||
DerivedTextureCache.key("banner", base, dir, baseColor, pats),
|
||||
() -> buildPatterned(base, dir, baseColor, pats));
|
||||
}
|
||||
|
||||
private int[][] buildPatterned(
|
||||
int[][] base, String dir, int baseColor, List<BlockEntityState.BannerPattern> patterns) {
|
||||
int[][] out = TextureOps.deepCopy(base);
|
||||
if (baseColor != 0) overlayMask(out, dir + "base", baseColor);
|
||||
for (BlockEntityState.BannerPattern pat : patterns) {
|
||||
overlayMask(out, dir + pat.patternKey(), pat.colorArgb());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void overlayMask(int[][] out, String path, int argb) {
|
||||
int[][] mask = texture(path);
|
||||
if (mask == null || mask.length != out.length || mask[0].length != out[0].length) return;
|
||||
TextureOps.overlay(out, TextureOps.tinted(mask, argb));
|
||||
}
|
||||
|
||||
/**
|
||||
* Front and back text as quads centred in the sign renderer's text frame ({@link BlockEntityModels#signTextFrame})
|
||||
* facing +Z, the reader; the +Z (SOUTH) face reads left to right ({@code s = fx}).
|
||||
*/
|
||||
private void addSignText(BlockEntityState s, Affine cell, List<EntityCube> cubes) {
|
||||
if (font == null) return;
|
||||
BlockEntityState.Kind k = s.kind();
|
||||
boolean hanging = k == BlockEntityState.Kind.HANGING_SIGN;
|
||||
if (!hanging && k != BlockEntityState.Kind.SIGN && k != BlockEntityState.Kind.WALL_SIGN) return;
|
||||
TextArea area = hanging ? HANGING_TEXT : SIGN_TEXT;
|
||||
addSide(s, s.frontText(), true, area, cell, cubes);
|
||||
addSide(s, s.backText(), false, area, cell, cubes);
|
||||
}
|
||||
|
||||
private void addSide(
|
||||
BlockEntityState s,
|
||||
BlockEntityState.SignText t,
|
||||
boolean front,
|
||||
TextArea area,
|
||||
Affine cell,
|
||||
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;
|
||||
double fpm = Math.min(area.cap, Math.min(area.w / bmp[0].length, area.h / bmp.length));
|
||||
double w = bmp[0].length * fpm, h = bmp.length * fpm;
|
||||
double[] px = new double[1];
|
||||
Affine frame = BlockEntityModels.signTextFrame(s, front, px);
|
||||
Face[] faces = new Face[6];
|
||||
faces[Direction.SOUTH.ordinal()] = new Face(bmp, 0, 0, 1, 1, 0, -1);
|
||||
double[] from = {-w / 2, -h / 2, -TEXT_THICK};
|
||||
double[] to = {w / 2, h / 2, 0};
|
||||
cubes.add(new EntityCube(from, to, faces, cell.mul(frame).mul(Affine.scale(px[0]))));
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,981 @@
|
||||
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.DerivedTextureCache;
|
||||
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.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/**
|
||||
* Bakes an {@link EntityState} into world-space cubes from the vanilla models dumped from the client jar
|
||||
* ({@code tools/VanillaModelDump.java}): posed by vanilla's {@code setupAnim}, with baby/variant/layer models,
|
||||
* default layers, armor wearers, pose variants and variant assets all taken from the dump (no per-type lists).
|
||||
* Placed like vanilla, feet at the entity position. Types missing from the dump fall back to the CEM template
|
||||
* set (rest pose, ground-snapped, no layers).
|
||||
*/
|
||||
public final class CemBaker implements EntityBaker<EntityState> {
|
||||
|
||||
// LivingEntityRenderer at yaw 0 (rotY(180)·scale(-1,-1,1)·translate(0,-1.501,0)) for types without a dumped
|
||||
// transform.
|
||||
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<>();
|
||||
// Tinted / composited textures (wool, villager overlays, armor, collars, …), shared by identical mobs.
|
||||
private final DerivedTextureCache derived = new DerivedTextureCache(512);
|
||||
|
||||
private volatile ItemGeometry items; // held/head items; null = not drawn
|
||||
|
||||
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 body model (vanilla dump if {@code model.absolute()}), its name, the type its layers live under. */
|
||||
private record Choice(CemModelLoader.CemModel model, String name, String base, boolean babyModel) {}
|
||||
|
||||
/** {@code pre} maps model px to entity space; {@code place} puts it in the world (null = ground snap later). */
|
||||
private record Placement(Affine t0, Affine pre, Affine place) {}
|
||||
|
||||
@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;
|
||||
boolean babyModel = ch != null && ch.babyModel;
|
||||
CemModelLoader.EntityInfo info = models.entityInfo(s.typeKey());
|
||||
if (info == null) info = models.entityInfo(base);
|
||||
|
||||
int[][] tex = bodyTexture(s, resolveTexture(s, info, babyModel), base, babyModel);
|
||||
boolean invisible = s.invisible();
|
||||
if (model == null) return invisible ? null : fallbackBox(s, tex);
|
||||
if (!invisible && tex == null) return fallbackBox(s, tex);
|
||||
|
||||
double extra = (s.baby() && !babyModel ? 0.5 : 1.0) * s.sizeScale(); // no baby model -> 0.5x adult
|
||||
SelectedPose sel = vanilla ? selectPose(s, info, ch) : SelectedPose.NONE;
|
||||
Placement at = vanilla
|
||||
? vanillaPlacement(s, info, sel.transform(), extra)
|
||||
: new Placement(null, Affine.scale(extra / 16.0), null);
|
||||
|
||||
// Body: baked even when invisible (not drawn, but it yields the part frames for items).
|
||||
Pose pose = new Pose(at.pre, vanilla ? withBindings(sel.parts(), s, info) : Map.of());
|
||||
Map<String, Affine> frames = vanilla && items != null && holdsItem(s) ? new HashMap<>() : null;
|
||||
List<CemGeometry.Baked> body = tex != null
|
||||
? CemGeometry.bakePosed(model, renderTexture(model, tex), at.pre, pose.parts, frames)
|
||||
: List.of();
|
||||
|
||||
Out out = new Out();
|
||||
List<EntityCube> itemCubes = new ArrayList<>();
|
||||
if (vanilla) {
|
||||
if (!invisible) {
|
||||
(unshaded(model) ? out.unshaded : out.lit).addAll(body);
|
||||
addVanillaLayers(s, ch, info, pose, tex, out);
|
||||
}
|
||||
// Drawn even when invisible (vanilla "floating armor").
|
||||
if (s.equipment() != null && info != null && info.humanoidArmor()) addArmorLayers(s, ch, pose, out);
|
||||
if (!invisible) addShoulderParrots(s, ch, at.t0, out);
|
||||
addEmissiveLayers(s, ch, info, pose, out);
|
||||
if (frames != null) addItems(s, frames, isIllager(info), itemCubes);
|
||||
} else if (!invisible) {
|
||||
out.lit.addAll(body);
|
||||
}
|
||||
if (out.lit.isEmpty() && out.unshaded.isEmpty() && out.glow.isEmpty() && itemCubes.isEmpty()) {
|
||||
return invisible ? null : fallbackBox(s, tex);
|
||||
}
|
||||
|
||||
Affine place = at.place != null ? at.place : groundSnap(s, out.lit);
|
||||
List<EntityCube> cubes = new ArrayList<>(out.lit.size());
|
||||
for (CemGeometry.Baked b : out.lit)
|
||||
cubes.add(new EntityCube(b.from(), b.to(), b.faces(), place.mul(b.world())));
|
||||
for (CemGeometry.Baked b : out.unshaded) {
|
||||
cubes.add(new EntityCube(b.from(), b.to(), b.faces(), place.mul(b.world()), false, true));
|
||||
}
|
||||
for (CemGeometry.Baked b : out.glow) {
|
||||
cubes.add(new EntityCube(b.from(), b.to(), b.faces(), place.mul(b.world()), true));
|
||||
}
|
||||
for (EntityCube c : itemCubes) cubes.add(c.transformed(place));
|
||||
return RenderedEntity.of(cubes);
|
||||
}
|
||||
|
||||
private static boolean holdsItem(EntityState s) {
|
||||
return s.rightHand() != null || s.leftHand() != null || s.headItem() != null || s.mouthItem() != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The renderer's captured pose (or the pose variant's own, e.g. sleeping in a bed, swimming) with yaw and pitch
|
||||
* applied the way that renderer applies them. Projectiles (checked against ArrowRenderer/ThrownTridentRenderer
|
||||
* 26.3): {@code rotY(yaw)·rotAxis(-X, pitch)·T0} == vanilla {@code rotY(yRot-90)·rotZ(xRot[+90])} with the raw
|
||||
* projectile rotation.
|
||||
*/
|
||||
private static Placement vanillaPlacement(
|
||||
EntityState s, CemModelLoader.EntityInfo info, CemModelLoader.PoseTransform pt, double extra) {
|
||||
boolean captured = info != null && info.transform() != null;
|
||||
double[] tr = pt != null && pt.transform() != null ? pt.transform() : captured ? info.transform() : null;
|
||||
Affine t0 = tr != null ? Affine.fromColumnMajor(tr) : DEFAULT_LIVING_TRANSFORM;
|
||||
Affine pre = CemGeometry.modelSpace(t0);
|
||||
int yawSign = pt != null ? pt.yawSign() : captured ? info.yawSign() : -1;
|
||||
double[] a = pt != null ? pt.pitchAxis() : info != null ? info.pitchAxis() : null;
|
||||
Affine place = Affine.translation(s.x(), s.y(), s.z())
|
||||
.mul(Affine.scale(extra))
|
||||
.mul(Affine.rotY(yawSign * Math.toRadians(s.bodyYaw())));
|
||||
if (a != null && s.pitch() != 0) place = place.mul(Affine.rotAxis(a[0], a[1], a[2], Math.toRadians(s.pitch())));
|
||||
return new Placement(t0, pre, place);
|
||||
}
|
||||
|
||||
/** Legacy CEM models: the lowest point snapped to the ground (their rest geometry has no reliable origin). */
|
||||
private static Affine groundSnap(EntityState s, List<CemGeometry.Baked> ref) {
|
||||
double minY = Double.MAX_VALUE;
|
||||
for (CemGeometry.Baked b : ref) minY = Math.min(minY, b.minWorldY());
|
||||
return Affine.translation(s.x(), s.y(), s.z())
|
||||
.mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw())))
|
||||
.mul(Affine.translation(0, -minY, 0));
|
||||
}
|
||||
|
||||
/** Same-UV overlays composited into the body texture (no extra geometry, no coplanar layers). */
|
||||
private int[][] bodyTexture(EntityState s, int[][] tex, String base, boolean babyModel) {
|
||||
if (base.equals("villager") || base.equals("zombie_villager")) {
|
||||
tex = compositeVillager(s, tex, base, babyModel);
|
||||
} else if (s.markings() != null && tex != null) {
|
||||
String m = "entity/horse/horse_markings_" + s.markings();
|
||||
String path = babyModel ? m + "_baby" : m;
|
||||
int[][] src = tex;
|
||||
tex = derived.get(DerivedTextureCache.key("markings", src, path), () -> {
|
||||
int[][] out = TextureOps.deepCopy(src);
|
||||
overlayIfPresent(out, path);
|
||||
return out;
|
||||
});
|
||||
}
|
||||
if (s.collar() != 0 && tex != null && layerModels(base).stream().noneMatch(n -> n.contains("CollarLayer."))) {
|
||||
// WolfCollarLayer draws the parent model (same UV).
|
||||
tex = withCollar(tex, base, babyModel, s.collar());
|
||||
}
|
||||
if (s.typeKey().equals("tropical_fish") && tex != null) tex = tinted(tex, s.tint());
|
||||
return tex;
|
||||
}
|
||||
|
||||
/** Pre-space transform and 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) {
|
||||
return CemGeometry.bakePosed(m, tex, pre, parts, null);
|
||||
}
|
||||
}
|
||||
|
||||
/** Baked boxes by how they are drawn: shaded, unshaded (NO_CARDINAL_LIGHTING) and additive (energy swirl). */
|
||||
private static final class Out {
|
||||
final List<CemGeometry.Baked> lit = new ArrayList<>();
|
||||
final List<CemGeometry.Baked> unshaded = new ArrayList<>();
|
||||
final List<CemGeometry.Baked> glow = new ArrayList<>();
|
||||
}
|
||||
|
||||
/** A vanilla model drawn the way its render type draws it. */
|
||||
private void add(Out out, Pose pose, CemModelLoader.CemModel m, int[][] tex) {
|
||||
(unshaded(m) ? out.unshaded : out.lit).addAll(pose.bake(m, renderTexture(m, tex)));
|
||||
}
|
||||
|
||||
// Overlay layers drawn on the body mesh itself (eyes, warden/copper golem/creaking emissive, sheep undercoat):
|
||||
// vanilla draws them later and the depth test passes on equal depth. Here their boxes grow by this much (model
|
||||
// px) to lie just in front (on coplanar ties the earlier-baked body would win); well above the tracer's layer
|
||||
// epsilon, far below a visible size.
|
||||
private static final double OVERLAY_INFLATE = 1 / 256.0;
|
||||
|
||||
private static void addOverlay(List<CemGeometry.Baked> target, List<CemGeometry.Baked> baked) {
|
||||
for (CemGeometry.Baked b : baked) {
|
||||
double[] from = b.from().clone(), to = b.to().clone();
|
||||
for (int i = 0; i < 3; i++) {
|
||||
from[i] -= OVERLAY_INFLATE;
|
||||
to[i] += OVERLAY_INFLATE;
|
||||
}
|
||||
target.add(new CemGeometry.Baked(from, to, b.faces(), b.world()));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean unshaded(CemModelLoader.CemModel m) {
|
||||
CemModelLoader.RenderType rt = m == null ? null : models.renderType(m.renderType());
|
||||
return rt != null && !rt.shaded();
|
||||
}
|
||||
|
||||
/**
|
||||
* A texture as the model's vanilla render type sees it: texels below the shader's ALPHA_CUTOUT (0.1 → alpha
|
||||
* < 26) are discarded; cutout and solid types write the rest opaque (partial alpha of e.g. llama or
|
||||
* tropical-fish patterns is NOT blended in vanilla), solid types even fully transparent texels; only blending
|
||||
* types (entity_translucent: slime, vex, allay, player skins, …) keep the alpha. Unknown or additive render
|
||||
* type: unchanged. Cached; {@code tex} itself when nothing changes.
|
||||
*/
|
||||
private int[][] renderTexture(CemModelLoader.CemModel m, int[][] tex) {
|
||||
return renderTexture(m == null ? null : models.renderType(m.renderType()), tex);
|
||||
}
|
||||
|
||||
private int[][] renderTexture(CemModelLoader.RenderType rt, int[][] tex) {
|
||||
if (rt == null || tex == null || "additive".equals(rt.blend())) return tex;
|
||||
boolean blend = rt.blend() != null;
|
||||
int min = rt.cutout() < 0 ? 0 : (int) Math.ceil(rt.cutout() * 255 - 1e-6);
|
||||
return derived.get(DerivedTextureCache.key("rendertype", tex, min, blend), () -> {
|
||||
int[][] out = null;
|
||||
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;
|
||||
int na = a < min ? 0 : blend ? a : 255;
|
||||
if (na == a) continue;
|
||||
if (out == null) out = TextureOps.deepCopy(tex);
|
||||
out[y][x] = (na << 24) | (p & 0xFFFFFF);
|
||||
}
|
||||
}
|
||||
return out == null ? tex : out;
|
||||
});
|
||||
}
|
||||
|
||||
/** A chosen pose variant: part overrides and (when it changes it) the renderer transform. */
|
||||
private record SelectedPose(Map<String, CemModelLoader.PartPose> parts, CemModelLoader.PoseTransform transform) {
|
||||
static final SelectedPose NONE = new SelectedPose(Map.of(), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The dumped pose variant (per body model: baby/small models have their own pivots) for the entity's flags:
|
||||
* {@code passenger}, {@code item_r}/{@code item_l}, {@code arms}, {@code chest} and the state poses of
|
||||
* {@link EntityState#pose()} the dump knows (sleeping, crouching, {@code arms_<armPose>}, …). Combinations
|
||||
* without an own variant equal a simpler pose: state-pose subsets from all to none, each with all other flags,
|
||||
* then without the held-item flags (e.g. zombies' arms ignore held items).
|
||||
*/
|
||||
private SelectedPose selectPose(EntityState s, CemModelLoader.EntityInfo info, Choice ch) {
|
||||
if (info == null) return SelectedPose.NONE;
|
||||
String sub = ch.name.equals(ch.base) || !ch.name.startsWith(ch.base + "/")
|
||||
? null
|
||||
: ch.name.substring(ch.base.length() + 1);
|
||||
CemModelLoader.PoseSet set = info.poseSet(sub);
|
||||
if (set.poses().isEmpty()) return SelectedPose.NONE;
|
||||
List<String> flags = new ArrayList<>();
|
||||
if (s.rightHand() != null) flags.add("item_r");
|
||||
if (s.leftHand() != null) flags.add("item_l");
|
||||
if (s.passenger()) flags.add("passenger");
|
||||
if (s.arms()) flags.add("arms");
|
||||
if (s.chest()) flags.add("chest");
|
||||
List<String> states = new ArrayList<>();
|
||||
if (s.pose() != null) {
|
||||
Set<String> known = knownPoseFlags(set);
|
||||
for (String f : s.pose().split("\\+")) if (known.contains(f) && !states.contains(f)) states.add(f);
|
||||
}
|
||||
List<List<String>> tries = new ArrayList<>();
|
||||
for (List<String> st : subsetsBySize(states)) {
|
||||
List<String> all = new ArrayList<>(st);
|
||||
all.addAll(flags);
|
||||
tries.add(all);
|
||||
List<String> noItems = new ArrayList<>(st);
|
||||
flags.stream().filter(f -> !f.startsWith("item_")).forEach(noItems::add);
|
||||
tries.add(noItems);
|
||||
}
|
||||
for (List<String> t : tries) {
|
||||
if (t.isEmpty()) continue;
|
||||
String name = String.join("+", t.stream().sorted().toList());
|
||||
Map<String, CemModelLoader.PartPose> p = set.poses().get(name);
|
||||
if (p != null) return new SelectedPose(p, set.transforms().get(name));
|
||||
}
|
||||
return SelectedPose.NONE;
|
||||
}
|
||||
|
||||
// Every flag occurring in a pose name, per loaded pose map.
|
||||
private final Map<Map<String, Map<String, CemModelLoader.PartPose>>, Set<String>> knownFlags =
|
||||
Collections.synchronizedMap(new IdentityHashMap<>());
|
||||
|
||||
private Set<String> knownPoseFlags(CemModelLoader.PoseSet set) {
|
||||
return knownFlags.computeIfAbsent(set.poses(), poses -> {
|
||||
Set<String> out = new HashSet<>();
|
||||
for (String n : poses.keySet()) out.addAll(List.of(n.split("\\+")));
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
/** All subsets of {@code l}, largest first (input order within a size); the empty set last. */
|
||||
private static List<List<String>> subsetsBySize(List<String> l) {
|
||||
List<List<String>> out = new ArrayList<>();
|
||||
int n = Math.min(l.size(), 6);
|
||||
for (int size = n; size >= 0; size--) {
|
||||
for (int mask = 0; mask < (1 << n); mask++) {
|
||||
if (Integer.bitCount(mask) != size) continue;
|
||||
List<String> sub = new ArrayList<>();
|
||||
for (int i = 0; i < n; i++) if ((mask & (1 << i)) != 0) sub.add(l.get(i));
|
||||
out.add(sub);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pose overrides plus the dump's rotation bindings: armor-stand part poses ({@code headPose}, …, from
|
||||
* {@link EntityState#partPoses()}) and body pitch ({@code xRot} = {@link EntityState#pitch()}: dolphins,
|
||||
* striders, nautili), each added as {@code coeff · (value − default)} to the bound parts' rotation.
|
||||
*/
|
||||
private static Map<String, CemModelLoader.PartPose> withBindings(
|
||||
Map<String, CemModelLoader.PartPose> parts, EntityState s, CemModelLoader.EntityInfo info) {
|
||||
if (info == null || info.rotationBindings().isEmpty()) return parts;
|
||||
Map<String, CemModelLoader.PartPose> out = null;
|
||||
for (Map.Entry<String, CemModelLoader.RotationBinding> b :
|
||||
info.rotationBindings().entrySet()) {
|
||||
double[] value;
|
||||
if (b.getKey().equals("xRot")) {
|
||||
value = new double[] {s.pitch()};
|
||||
} else {
|
||||
float[] v = s.partPoses() == null ? null : s.partPoses().get(b.getKey());
|
||||
if (v == null) continue;
|
||||
value = new double[] {v[0], v[1], v[2]};
|
||||
}
|
||||
double[] def = b.getValue().def();
|
||||
double[] delta = new double[3];
|
||||
boolean any = false;
|
||||
for (int j = 0; j < value.length && j < 3; j++) {
|
||||
delta[j] = value[j] - (def != null && j < def.length ? def[j] : 0);
|
||||
any |= Math.abs(delta[j]) > 1e-6;
|
||||
}
|
||||
if (!any) continue;
|
||||
if (out == null) out = new HashMap<>(parts);
|
||||
for (Map.Entry<String, double[]> pc : b.getValue().parts().entrySet()) {
|
||||
double[] c = pc.getValue();
|
||||
double[] add = new double[3];
|
||||
for (int i = 0; i < 3; i++)
|
||||
add[i] = c[i * 3] * delta[0] + c[i * 3 + 1] * delta[1] + c[i * 3 + 2] * delta[2];
|
||||
CemModelLoader.PartPose cur = out.get(pc.getKey());
|
||||
out.put(
|
||||
pc.getKey(),
|
||||
cur != null ? cur.plusRotation(add) : new CemModelLoader.PartPose(null, null, null, null, add));
|
||||
}
|
||||
}
|
||||
return out == null ? parts : out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Held items (ItemInHandLayer: hand frame · rotX(-90) · rotY(180) · T(±1/16, 0.125, -0.625), third-person
|
||||
* context), crossed-arms items (villagers/witches: root · T(0, 0.4, -0.4) · rotX(180), ground context; illagers
|
||||
* hide held items while their arms are crossed) and head items (CustomHeadLayer).
|
||||
*/
|
||||
private void addItems(EntityState s, Map<String, Affine> frames, boolean illager, List<EntityCube> out) {
|
||||
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"))) {
|
||||
// ItemInHandLayer.useBabyOffset (babies except armor stands).
|
||||
boolean babyOffset = s.baby() && !s.typeKey().equals("armor_stand");
|
||||
Affine x = arm.mul(CemGeometry.JAVA_LOCAL)
|
||||
.mul(Affine.rotX(Math.toRadians(-90)))
|
||||
.mul(Affine.rotY(Math.PI))
|
||||
.mul(
|
||||
babyOffset
|
||||
? Affine.translation(0, 1 / 16.0, -4.5 / 16)
|
||||
: 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);
|
||||
}
|
||||
}
|
||||
addMouthItem(s, frames, 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) {
|
||||
// CustomHeadLayer: head frame · S(1.1875) · the bare skull model.
|
||||
items.bakeBlockEntityModel(head.blockEntity(), hj.mul(Affine.scale(1.1875)), out);
|
||||
} else {
|
||||
// SnowGolemHeadLayer sits lower 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mouth item, "ground" context. FoxHeldItemLayer: head PIVOT (the layer turns by the head-look angles, not the
|
||||
* posed head part; PixelPics has no head look) · [baby S(0.75)] · offsets per sleeping/baby. DolphinCarrying-
|
||||
* ItemLayer: model root · offset by a = |pitch| / 60, up or down.
|
||||
*/
|
||||
private void addMouthItem(EntityState s, Map<String, Affine> frames, List<EntityCube> out) {
|
||||
ItemSpec item = s.mouthItem();
|
||||
if (item == null) return;
|
||||
boolean baby = s.baby();
|
||||
Affine x = null;
|
||||
String headPivot = CemGeometry.PIVOT + "head";
|
||||
if (s.typeKey().equals("fox") && frames.containsKey(headPivot)) {
|
||||
boolean asleep = s.hasPose("sleeping");
|
||||
x = frames.get(headPivot).mul(CemGeometry.JAVA_LOCAL);
|
||||
if (baby) x = x.mul(Affine.scale(0.75));
|
||||
if (asleep) {
|
||||
x = x.mul(baby ? Affine.translation(0.4, 0.26, 0.15) : Affine.translation(0.46, 0.26, 0.22))
|
||||
.mul(Affine.rotX(Math.PI / 2))
|
||||
.mul(Affine.rotZ(Math.PI / 2));
|
||||
} else {
|
||||
x = x.mul(Affine.translation(0.06, baby ? 0.26 : 0.27, -0.5)).mul(Affine.rotX(Math.PI / 2));
|
||||
}
|
||||
} else if (s.typeKey().equals("dolphin") && frames.containsKey("root")) {
|
||||
double a = Math.abs(s.pitch()) / 60.0;
|
||||
Affine off = s.pitch() < 0
|
||||
? Affine.translation(0, 1 - a * 0.5, -1 + a * 0.5)
|
||||
: Affine.translation(0, 1 + a * 0.8, -1 + a * 0.2);
|
||||
x = frames.get("root").mul(CemGeometry.JAVA_LOCAL).mul(off);
|
||||
}
|
||||
if (x != null) items.bake(item, "ground", false, x, out);
|
||||
}
|
||||
|
||||
/** ParrotOnShoulderLayer: the parrot in its on_shoulder pose at T(±0.4, -1.5 [crouching -1.3], 0). */
|
||||
private void addShoulderParrots(EntityState s, Choice ch, Affine t0, Out out) {
|
||||
if (s.shoulderLeft() == null && s.shoulderRight() == null) return;
|
||||
if (!ch.base.startsWith("player")) return;
|
||||
CemModelLoader.CemModel pm = models.vanilla("parrot");
|
||||
CemModelLoader.EntityInfo pi = models.entityInfo("parrot");
|
||||
if (pm == null || pi == null) return;
|
||||
Map<String, CemModelLoader.PartPose> parts = pi.poses().getOrDefault("on_shoulder", Map.of());
|
||||
double y = s.hasPose("crouching") ? -1.3 : -1.5;
|
||||
for (int side = 0; side < 2; side++) {
|
||||
String variant = side == 0 ? s.shoulderLeft() : s.shoulderRight();
|
||||
if (variant == null) continue;
|
||||
int[][] tex = null;
|
||||
for (ResourceLocation rl : EntityModels.textureCandidates("parrot", variant)) {
|
||||
tex = texture(rl.path());
|
||||
if (tex != null) break;
|
||||
}
|
||||
if (tex == null && pi.texture() != null) tex = texture(pi.texture());
|
||||
if (tex == null) continue;
|
||||
Affine pre = CemGeometry.modelSpace(t0.mul(Affine.translation(side == 0 ? 0.4 : -0.4, y, 0)));
|
||||
out.lit.addAll(CemGeometry.bakePosed(pm, renderTexture(pm, tex), pre, parts, null));
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isIllager(CemModelLoader.EntityInfo info) {
|
||||
return info != null && info.poses().keySet().stream().anyMatch(k -> k.startsWith("arms_"));
|
||||
}
|
||||
|
||||
/**
|
||||
* The body model: baby model for babies, variant model matched by name token (temperature variants, salmon
|
||||
* size, puff state, …), else the main model; else the legacy CEM template.
|
||||
*/
|
||||
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 without an own dump entry reuse another's (mannequin -> player).
|
||||
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);
|
||||
CemModelLoader.VariantAssets va = models.variant(type, s.variant());
|
||||
if (va != null) variant = va.model(); // "cold", …; null = normal
|
||||
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);
|
||||
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, 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;
|
||||
});
|
||||
}
|
||||
|
||||
private void addVanillaLayers(
|
||||
EntityState s, Choice ch, CemModelLoader.EntityInfo info, Pose pose, int[][] bodyTex, Out out) {
|
||||
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" -> {
|
||||
// Undercoat: only adults with non-white or jeb_ wool (snapshot flag), sheared or not.
|
||||
boolean undercoat = layerClass.equals("SheepWoolUndercoatLayer");
|
||||
if (undercoat ? !s.hasPose("undercoat") : "sheared".equals(s.variant())) continue;
|
||||
int[][] t = layerTexture(lm);
|
||||
if (t == null) continue;
|
||||
t = tinted(t, s.tint());
|
||||
if (undercoat || baby) {
|
||||
// The body's own mesh (coloredCutoutModelCopyLayerRender): an overlay on its surface.
|
||||
addOverlay(out.lit, pose.bake(lm, renderTexture(lm, t)));
|
||||
} else {
|
||||
add(out, pose, lm, t);
|
||||
}
|
||||
}
|
||||
case "TropicalFishPatternLayer" -> addTropicalPattern(s, n, lm, pose, out);
|
||||
case "CatCollarLayer" -> {
|
||||
int[][] t = s.collar() == 0 ? null : layerTexture(lm);
|
||||
if (t != null) add(out, pose, lm, tinted(t, s.collar()));
|
||||
}
|
||||
case "CreeperPowerLayer", "WitherArmorLayer" -> {
|
||||
// EnergySwirlLayer, drawn additively.
|
||||
if (!s.powered() || lm.texture() == null) continue;
|
||||
int[][] t = energySwirl(lm.texture(), layerClass.equals("WitherArmorLayer"));
|
||||
if (t != null) out.glow.addAll(pose.bake(lm, t));
|
||||
}
|
||||
case "SlimeOuterLayer" -> {
|
||||
int[][] t = lm.texture() != null ? texture(lm.texture()) : bodyTex;
|
||||
if (t != null) add(out, pose, lm, t);
|
||||
}
|
||||
case "LlamaDecorLayer", "WolfArmorLayer" -> {
|
||||
// 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 && base.contains("_")) {
|
||||
t = equipmentTexture(
|
||||
base.substring(base.indexOf('_') + 1) + "_body", s.bodyEquip(), baby, s.bodyDye());
|
||||
}
|
||||
if (t != null) add(out, pose, lm, t);
|
||||
}
|
||||
case "RopesLayer" -> {
|
||||
int[][] t = s.bodyEquip() == null ? null : layerTexture(lm); // only with a harness
|
||||
if (t != null) add(out, pose, lm, t);
|
||||
}
|
||||
default -> {
|
||||
// Other layers only if vanilla draws them for the default state (drowned outer, breeze wind, …).
|
||||
int[][] t = info == null || !info.layerDrawnByDefault(layerClass) ? null : layerTexture(lm);
|
||||
if (t != null) add(out, pose, lm, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Equipment layers <base>/equip.<layerType>.<field> (saddles, body armor, harness, carpets).
|
||||
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) add(out, pose, em, t);
|
||||
}
|
||||
}
|
||||
|
||||
private int[][] layerTexture(CemModelLoader.CemModel lm) {
|
||||
return lm.texture() == null ? null : texture(lm.texture());
|
||||
}
|
||||
|
||||
/** Tropical fish pattern texture, tinted with the pattern colour. */
|
||||
private void addTropicalPattern(EntityState s, String name, CemModelLoader.CemModel lm, Pose pose, Out out) {
|
||||
int idx = EntityModels.tropicalPattern(s.variant());
|
||||
if (idx < 0) return;
|
||||
boolean small = idx < 6; // patterns 0-5: shape A (small model), 6-11: shape B
|
||||
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;
|
||||
add(out, pose, lm, tinted(t, s.tint2()));
|
||||
}
|
||||
|
||||
/** {@code entity/equipment/<layerType>/<asset>} (baby, "_carpet"-stripped fallbacks; dye + overlay); cached. */
|
||||
private int[][] equipmentTexture(String layerType, String asset, boolean baby, int dye) {
|
||||
return derived.get(
|
||||
DerivedTextureCache.key("equip", layerType, asset, baby, dye),
|
||||
() -> buildEquipmentTexture(layerType, asset, baby, dye));
|
||||
}
|
||||
|
||||
private int[][] buildEquipmentTexture(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;
|
||||
}
|
||||
|
||||
// A photo has no time: the swirl scroll is frozen at this age (ticks), chosen so the sparse wither_armor pattern
|
||||
// covers every wither part (at e.g. 42 the side heads land on empty rows).
|
||||
private static final float SWIRL_AGE = 59f;
|
||||
|
||||
/**
|
||||
* EnergySwirlLayer texture: the texture matrix shifts the UV by ({@code xOffset(age) % 1}, {@code age·0.01 % 1})
|
||||
* (xOffset: creeper {@code age·0.01}, wither {@code cos(age·0.02)·3}), baked as a rolled copy; vertex colour
|
||||
* 0xFF808080 halves the RGB. Drawn additively, so alpha only cuts out.
|
||||
*/
|
||||
private int[][] energySwirl(String path, boolean wither) {
|
||||
return derived.get(DerivedTextureCache.key("swirl", path, wither), () -> buildEnergySwirl(path, wither));
|
||||
}
|
||||
|
||||
private int[][] buildEnergySwirl(String path, boolean wither) {
|
||||
int[][] src = texture(path);
|
||||
if (src == null) return null;
|
||||
double u = wither ? Math.cos(SWIRL_AGE * 0.02) * 3 : SWIRL_AGE * 0.01;
|
||||
double v = SWIRL_AGE * 0.01;
|
||||
int h = src.length, w = src[0].length;
|
||||
int du = Math.floorMod((int) Math.round((u % 1) * w), w);
|
||||
int dv = Math.floorMod((int) Math.round((v % 1) * h), h);
|
||||
int[][] out = new int[h][w];
|
||||
for (int y = 0; y < h; y++) {
|
||||
for (int x = 0; x < w; x++) {
|
||||
int p = src[(y + dv) % h][(x + du) % w];
|
||||
out[y][x] = (p & 0xFF000000) | ((p >>> 1) & 0x7F7F7F);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Body texture with the dyed {@code entity/<base>/<base>_collar[_baby]} over it. */
|
||||
private int[][] withCollar(int[][] tex, String base, boolean baby, int dye) {
|
||||
return derived.get(
|
||||
DerivedTextureCache.key("collar", tex, base, baby, dye), () -> buildWithCollar(tex, base, baby, dye));
|
||||
}
|
||||
|
||||
private int[][] buildWithCollar(int[][] tex, String base, boolean baby, int dye) {
|
||||
String path = "entity/" + base + "/" + base + "_collar" + (baby ? "_baby" : "");
|
||||
int[][] collar = texture(path);
|
||||
if (collar == null || collar.length != tex.length || collar[0].length != tex[0].length) return tex;
|
||||
int[][] out = TextureOps.deepCopy(tex);
|
||||
TextureOps.overlay(out, tinted(collar, dye));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** {@code src} multiplied by an ARGB tint (cached; {@code src} itself for no tint). */
|
||||
private int[][] tinted(int[][] src, int argb) {
|
||||
if (argb == 0) return src;
|
||||
return derived.get(DerivedTextureCache.key("tint", src, argb), () -> TextureOps.tinted(src, argb));
|
||||
}
|
||||
|
||||
/**
|
||||
* The dump's emissive layers (eyes, warden bioluminescence, …): texture per variant, processed by the layer's
|
||||
* render type, times its static alpha. Skipped: animated layers (a photo has no age), layers whose state flag is
|
||||
* off (creaking eyes), and on invisible entities those vanilla hides.
|
||||
*/
|
||||
private void addEmissiveLayers(EntityState s, Choice ch, CemModelLoader.EntityInfo info, Pose pose, Out out) {
|
||||
if (info == null) return;
|
||||
for (CemModelLoader.EmissiveLayer e : info.emissive()) {
|
||||
if (e.animated() || e.alpha() <= 1e-5) continue;
|
||||
if (e.when() != null && !s.hasPose(e.when())) continue;
|
||||
if (s.invisible() && !e.invisible()) continue;
|
||||
// A layer drawing the parent model follows the chosen body model (baby, variant).
|
||||
CemModelLoader.CemModel m = e.model().equals(ch.base) ? ch.model : models.vanilla(e.model());
|
||||
if (m == null) continue;
|
||||
String variantTex =
|
||||
s.variant() == null ? null : e.textures().get(s.variant().toLowerCase(Locale.ROOT));
|
||||
int[][] t = texture(variantTex != null ? variantTex : e.texture());
|
||||
if (t == null) continue;
|
||||
CemModelLoader.RenderType rt = models.renderType(e.renderType());
|
||||
t = renderTexture(rt, t);
|
||||
if (e.alpha() < 1) t = withAlpha(t, e.alpha());
|
||||
addOverlay(rt != null && !rt.shaded() ? out.unshaded : out.lit, pose.bake(m, t));
|
||||
}
|
||||
}
|
||||
|
||||
/** {@code src} with every texel's alpha multiplied by {@code alpha} (vertex alpha of a layer); cached. */
|
||||
private int[][] withAlpha(int[][] src, double alpha) {
|
||||
return derived.get(DerivedTextureCache.key("alpha", src, alpha), () -> {
|
||||
int[][] out = TextureOps.deepCopy(src);
|
||||
for (int[] row : out) {
|
||||
for (int x = 0; x < row.length; x++) {
|
||||
int a = (int) Math.round((row[x] >>> 24) * alpha);
|
||||
row[x] = (a << 24) | (row[x] & 0xFFFFFF);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
private int[][] texture(String path) {
|
||||
return textures.get(ResourceLocation.parse(path)).orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Player skin, else a dumped state texture whose flag is set (tipped arrow, invulnerable wither), else the
|
||||
* registry variant's asset, else the {@link EntityModels} candidates with the renderer's default texture (baby
|
||||
* model: the {@code _baby} counterparts first).
|
||||
*/
|
||||
private int[][] resolveTexture(EntityState s, CemModelLoader.EntityInfo info, boolean babyModel) {
|
||||
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;
|
||||
}
|
||||
if (info != null && s.pose() != null) {
|
||||
for (Map.Entry<String, String> st : info.stateTextures().entrySet()) {
|
||||
if (!s.hasPose(st.getKey())) continue;
|
||||
int[][] t = texture(st.getValue());
|
||||
if (t != null) return t;
|
||||
}
|
||||
}
|
||||
CemModelLoader.VariantAssets va = models.variant(s.typeKey(), s.variant());
|
||||
String asset = va == null ? null : va.texture(babyModel, s.collar() != 0, s.hasPose("angry"));
|
||||
if (asset != null) {
|
||||
int[][] t = texture(asset);
|
||||
if (t != null) return t;
|
||||
}
|
||||
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 (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;
|
||||
}
|
||||
|
||||
private static final String[] LEVEL_BADGE = {"stone", "iron", "gold", "emerald", "diamond"};
|
||||
|
||||
/** Villager texture: biome type, profession and level badge over the base body. */
|
||||
private int[][] compositeVillager(EntityState s, int[][] base, String folder, boolean babyModel) {
|
||||
if (base == null) return null;
|
||||
// Only the inputs the composite reads (babies: no profession).
|
||||
String prof = babyModel ? null : s.profession();
|
||||
int level = babyModel ? 0 : s.villagerLevel();
|
||||
return derived.get(
|
||||
DerivedTextureCache.key("villager", base, folder, babyModel, s.variant(), prof, level),
|
||||
() -> buildVillager(s, base, folder, babyModel));
|
||||
}
|
||||
|
||||
private int[][] buildVillager(EntityState s, int[][] base, String folder, boolean babyModel) {
|
||||
int[][] out = TextureOps.deepCopy(base);
|
||||
if (babyModel) {
|
||||
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;
|
||||
}
|
||||
|
||||
/** Overlays {@code path} onto {@code dst} if it exists and has the same size (HD-pack guard). */
|
||||
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;
|
||||
TextureOps.overlay(dst, o);
|
||||
}
|
||||
|
||||
private static final int GLINT_COLOR = 0xFF8040CC; // approximated enchantment-glint purple
|
||||
|
||||
/** The wearer's per-slot armor models {@code <base>/HumanoidArmorLayer.modelSet.<slot>}, posed like the body. */
|
||||
private void addArmorLayers(EntityState s, Choice ch, Pose pose, Out out) {
|
||||
EntityState.Equipment eq = s.equipment();
|
||||
String base = ch.base;
|
||||
boolean baby = ch.babyModel;
|
||||
String set = base + "/HumanoidArmorLayer." + (baby ? "babyModelSet" : "modelSet") + ".";
|
||||
// HumanoidArmorLayer: babies use humanoid_baby textures, except small armor stands (ARMOR_STAND_SMALL_ARMOR =
|
||||
// the adult layer scaled, adult UVs).
|
||||
boolean babyTextures = baby && !s.typeKey().equals("armor_stand");
|
||||
String folder = babyTextures ? "humanoid_baby" : "humanoid";
|
||||
// Vanilla draw order chest, legs, feet, head (coplanar ties: the later piece wins), then the WingsLayer.
|
||||
boolean elytra = eq.chest() != null && eq.chest().asset().equals("elytra");
|
||||
if (!elytra) bakeArmorPiece(eq.chest(), set + "chest", folder, pose, out);
|
||||
bakeArmorPiece(eq.legs(), set + "legs", babyTextures ? "humanoid_baby" : "humanoid_leggings", pose, out);
|
||||
bakeArmorPiece(eq.feet(), set + "feet", folder, pose, out);
|
||||
bakeArmorPiece(eq.head(), set + "head", folder, pose, out);
|
||||
if (elytra) bakeElytra(eq.chest(), base, baby, pose, out);
|
||||
}
|
||||
|
||||
private void bakeArmorPiece(
|
||||
EntityState.EquipPiece piece, String modelName, String layerFolder, Pose pose, Out out) {
|
||||
if (piece == null) return;
|
||||
CemModelLoader.CemModel model = models.vanilla(modelName);
|
||||
if (model == null) return;
|
||||
int[][] tex = derived.get(
|
||||
DerivedTextureCache.key("armor", piece, layerFolder), () -> buildArmorTexture(piece, layerFolder));
|
||||
if (tex != null) add(out, pose, model, tex);
|
||||
}
|
||||
|
||||
/** Material texture (+ leather dye and 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: the grayscale pattern recoloured by the material palette, overlaid (same UV). */
|
||||
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 WingsLayer). */
|
||||
private void bakeElytra(EntityState.EquipPiece piece, String base, boolean baby, Pose pose, Out out) {
|
||||
CemModelLoader.CemModel model =
|
||||
models.vanilla(base + "/WingsLayer." + (baby ? "elytraBabyModel" : "elytraModel"));
|
||||
if (model == null) return; // renderer without a WingsLayer
|
||||
int[][] tex = texture("entity/equipment/wings/elytra");
|
||||
if (tex == null) return;
|
||||
if (piece.glint()) {
|
||||
int[][] src = tex;
|
||||
tex = derived.get(DerivedTextureCache.key("elytra_glint", src), () -> {
|
||||
int[][] glinted = TextureOps.deepCopy(src);
|
||||
applyGlint(glinted);
|
||||
return glinted;
|
||||
});
|
||||
}
|
||||
add(out, pose, model, tex);
|
||||
}
|
||||
|
||||
/** A textured (or grey) box of the entity's size when no model renders. */
|
||||
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();
|
||||
int[][] t = tex != null ? tex : new int[][] {{0xFF8C8C8C}};
|
||||
ModelCube box = new ModelCube(new double[] {w, h, w}, new double[] {0, 0}, false, null);
|
||||
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));
|
||||
return RenderedEntity.of(List.of(
|
||||
new EntityCube(new double[] {-w / 2, 0, -w / 2}, new double[] {w / 2, h, w / 2}, faces, place)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity.cem;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.BoxUv;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.ModelCube;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Turns a {@link CemModelLoader.CemModel} into {@link Baked} boxes (local box + model→pre-space transform) for
|
||||
* mobs, block entities and items; placing them in the world is up to the caller.
|
||||
*/
|
||||
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});
|
||||
|
||||
/** Model px (CEM frame) → the Java-model block space {@code x} maps. */
|
||||
static Affine modelSpace(Affine x) {
|
||||
return x.mul(Affine.scale(1 / 16.0)).mul(CEM_TO_JAVA);
|
||||
}
|
||||
|
||||
/** {@link #bakeModel} as cubes. */
|
||||
static void bakeCubes(
|
||||
CemModelLoader.CemModel model, int[][] tex, Affine pre, Set<String> hidden, List<EntityCube> out) {
|
||||
for (Baked b : bakeModel(model, tex, pre, hidden)) {
|
||||
out.add(new EntityCube(b.from(), b.to(), b.faces(), b.world()));
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Rest pose; parts in {@code hidden} are skipped. */
|
||||
static List<Baked> bakeModel(CemModelLoader.CemModel model, int[][] tex, Affine pre, Set<String> hidden) {
|
||||
return bake(model, tex, pre, hidden, Map.of(), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vanilla models with pose {@code overrides} by part name (pivot/rotation/visibility/scale; boxes follow an
|
||||
* overridden pivot). {@code frames}, when non-null, receives each part's frame {@code parent·T(P)·R·S} (also
|
||||
* {@link #PIVOT} and {@link #DRAWN} entries), used to attach items to hands and heads.
|
||||
*/
|
||||
static List<Baked> bakePosed(
|
||||
CemModelLoader.CemModel model,
|
||||
int[][] tex,
|
||||
Affine pre,
|
||||
Map<String, CemModelLoader.PartPose> overrides,
|
||||
Map<String, Affine> frames) {
|
||||
return bake(model, tex, pre, Set.of(), overrides, frames);
|
||||
}
|
||||
|
||||
private static List<Baked> bake(
|
||||
CemModelLoader.CemModel model,
|
||||
int[][] tex,
|
||||
Affine pre,
|
||||
Set<String> hidden,
|
||||
Map<String, CemModelLoader.PartPose> overrides,
|
||||
Map<String, Affine> frames) {
|
||||
Ctx ctx = new Ctx(hidden, model.texW(), model.texH(), tex, new ArrayList<>(), overrides, frames);
|
||||
for (CemModelLoader.CemPart p : model.parts()) {
|
||||
if (model.absolute()) {
|
||||
bakeAbsolute(p, pre, ctx, true);
|
||||
} else {
|
||||
double[] o = {-p.translate()[0], -p.translate()[1], -p.translate()[2]};
|
||||
bakePart(p, pre, o, 0, ctx);
|
||||
}
|
||||
}
|
||||
return ctx.out;
|
||||
}
|
||||
|
||||
// Frame-map key prefixes: parts that were drawn, and a part's pivot frame parent·T(P) (without its rotation).
|
||||
static final String DRAWN = "drawn:";
|
||||
static final String PIVOT = "pivot:";
|
||||
|
||||
private record Ctx(
|
||||
Set<String> hidden,
|
||||
int texW,
|
||||
int texH,
|
||||
int[][] tex,
|
||||
List<Baked> out,
|
||||
Map<String, CemModelLoader.PartPose> overrides,
|
||||
Map<String, Affine> frames) {}
|
||||
|
||||
/**
|
||||
* Vanilla ("absolute") part: {@code parent · T(P) · Rz·Ry·Rx · S · T(-P)} like
|
||||
* {@code ModelPart.translateAndRotate}. Invisible parts hide their subtree, {@code skipDraw} only their own
|
||||
* boxes. With frames requested, a hidden subtree is still walked ({@code draw=false}): vanilla attaches held
|
||||
* items to an arm even when it 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() != null ? !ov.visible() : part.invisible();
|
||||
if (invisible) {
|
||||
if (ctx.frames == null) return;
|
||||
draw = false;
|
||||
}
|
||||
double[] scale = ov != null && ov.scale() != null ? ov.scale() : part.scale();
|
||||
double[] add = ov == null ? null : ov.rotateAdd();
|
||||
boolean moved = ov != null && ov.pivot() != null;
|
||||
double[] o = moved ? ov.pivot() : part.pivot();
|
||||
double[] rot = moved ? ov.rotate() : part.rotate();
|
||||
if (add != null) rot = new double[] {rot[0] + add[0], rot[1] + add[1], rot[2] + add[2]};
|
||||
Affine pivotFrame = parentWorld.mul(Affine.translation(o[0], o[1], o[2]));
|
||||
Affine world = pivotFrame
|
||||
.mul(Affine.rotZ(Math.toRadians(rot[2])))
|
||||
.mul(Affine.rotY(Math.toRadians(rot[1])))
|
||||
.mul(Affine.rotX(Math.toRadians(rot[0])));
|
||||
if (scale != null) {
|
||||
// A collapsed (zero) scale is vanilla's way to hide a part; it would also be non-invertible.
|
||||
for (double v : scale) if (Math.abs(v) < 1e-6) return;
|
||||
world = world.mul(Affine.scale(scale[0], scale[1], scale[2]));
|
||||
}
|
||||
if (ctx.frames != null) {
|
||||
ctx.frames.putIfAbsent(part.name(), world);
|
||||
ctx.frames.putIfAbsent(PIVOT + part.name(), pivotFrame);
|
||||
if (draw) ctx.frames.putIfAbsent(DRAWN + part.name(), world);
|
||||
}
|
||||
world = world.mul(Affine.translation(-o[0], -o[1], -o[2]));
|
||||
Affine boxWorld = world;
|
||||
if (moved) {
|
||||
// Boxes follow the overridden pivot; children carry their own absolute pivots.
|
||||
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]};
|
||||
ModelCube mc = new ModelCube(b.size(), b.uv(), b.mirror(), b.faceUv());
|
||||
// Vanilla texScale: UVs normalised by 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* CEM template part (OptiFine/Blockbench import): {@code parent · T(O) · R · T(-O)} with origin {@code O} =
|
||||
* {@code -translate} at the top level, else {@code translate}, accumulated with the parent origin from the
|
||||
* 2nd nesting level on. Top-level boxes are absolute, nested ones offset by their group origin.
|
||||
*/
|
||||
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();
|
||||
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,509 @@
|
||||
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 eu.mhsl.minecraft.pixelpics.assets.model.Direction;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Loads CEM ({@code .jem} layout) entity models: the vanilla client dump ({@code tools/VanillaModelDump.java},
|
||||
* "absolute" models plus per-type renderer info, render types and registry variants) and the CEM template set
|
||||
* (legacy entity fallback; part {@code translate} = negated pivot, box {@code coordinates} absolute px).
|
||||
*/
|
||||
public final class CemModelLoader {
|
||||
|
||||
/** A box in px: min corner, size, per-axis inflation, box-UV offset (texels) and mirror. */
|
||||
public record CemBox(
|
||||
double[] origin,
|
||||
double[] size,
|
||||
double[] inflate,
|
||||
double[] uv,
|
||||
boolean mirror,
|
||||
double[][] faceUv, // per-face {x, y, w, h} texels by Direction ordinal; null = box-UV
|
||||
double[] texScale, // vanilla CubeDefinition texScale (UV normalised by texSize*scale); null = 1
|
||||
boolean[] hiddenFaces) {} // by Direction ordinal; null = all faces drawn
|
||||
|
||||
/** A model part: CEM translate (legacy pivot rule, see {@link CemGeometry}), rotation (deg), boxes, children. */
|
||||
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 model: declared texture size, top-level parts; {@code absolute} = vanilla dump (absolute pivots and boxes,
|
||||
* {@code renderType} = the vanilla render type it is drawn with, null = unknown).
|
||||
*/
|
||||
public record CemModel(
|
||||
int texW, int texH, List<CemPart> parts, boolean absolute, String texture, String renderType) {}
|
||||
|
||||
/**
|
||||
* How a vanilla render type's pipeline draws texels: {@code cutout} = shader ALPHA_CUTOUT threshold (0..1,
|
||||
* texels below it are discarded; -1 = none), {@code blend} = alpha-blended ("translucent") / added
|
||||
* ("additive") / null = written opaque (cutout and solid types: alpha ignored), {@code shaded} = directional
|
||||
* face shading (false with NO_CARDINAL_LIGHTING: eyes, breeze wind).
|
||||
*/
|
||||
public record RenderType(double cutout, String blend, boolean shaded) {}
|
||||
|
||||
/**
|
||||
* An emissive render layer (EyesLayer, LivingEntityEmissiveLayer, BreezeEyesLayer): the dumped model it
|
||||
* draws (the body model's name for layers that draw the parent model), its texture (per render-state enum
|
||||
* constant in {@code textures}, e.g. copper-golem weathering), render type, the constant vertex alpha of
|
||||
* its alpha function, the state flag it needs ({@code when}, null = always), whether it is still drawn on an
|
||||
* invisible entity, and whether its alpha is animated (not drawn: a photo has no age).
|
||||
*/
|
||||
public record EmissiveLayer(
|
||||
String model,
|
||||
String texture,
|
||||
Map<String, String> textures,
|
||||
String renderType,
|
||||
double alpha,
|
||||
String when,
|
||||
boolean invisible,
|
||||
boolean animated) {}
|
||||
|
||||
/** Per-entity-type renderer info from the vanilla dump. */
|
||||
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, // pose ("passenger+item_r", "arms", "chest", …) -> overrides
|
||||
Map<String, Boolean> layers, // render layer class -> drawn for the default (idle, unequipped) state
|
||||
boolean humanoidArmor, // the renderer has a HumanoidArmorLayer (wears humanoid armor)
|
||||
Map<String, PoseTransform> poseTransforms, // pose -> renderer transform when it differs (sleeping, …)
|
||||
Map<String, PoseSet> modelPoses, // body sub-model ("baby", "smallModel", …) -> its own poses
|
||||
Map<String, RotationBinding> rotationBindings, // state field ("headPose", "xRot", …) -> part rotations
|
||||
List<EmissiveLayer> emissive, // emissive layers (eyes, glow), in vanilla layer order
|
||||
Map<String, String> stateTextures) { // state flag ("tipped", "invulnerable") -> body texture
|
||||
|
||||
/** Whether the vanilla renderer draws this layer for an entity in its default state. */
|
||||
public boolean layerDrawnByDefault(String layerClass) {
|
||||
return layers.getOrDefault(layerClass, false);
|
||||
}
|
||||
|
||||
/** The poses of a body model: its own set when the dump has one, else the main model's. */
|
||||
public PoseSet poseSet(String subModel) {
|
||||
PoseSet own = subModel == null ? null : modelPoses.get(subModel);
|
||||
return own != null ? own : new PoseSet(poses, poseTransforms);
|
||||
}
|
||||
}
|
||||
|
||||
/** Pose overrides + renderer transforms of one body model (see {@link EntityInfo#poseSet}). */
|
||||
public record PoseSet(Map<String, Map<String, PartPose>> poses, Map<String, PoseTransform> transforms) {}
|
||||
|
||||
/**
|
||||
* The renderer transform of a pose (like {@link EntityInfo#transform}/{@code yawSign}/{@code pitchAxis}):
|
||||
* e.g. sleeping turns the body by the bed direction (yawSign +1), swimming pitches it.
|
||||
*/
|
||||
public record PoseTransform(double[] transform, int yawSign, double[] pitchAxis) {}
|
||||
|
||||
/**
|
||||
* A continuous state input bound linearly to part rotations: {@code part rotate += coeff · (value − def)},
|
||||
* {@code coeff} row-major 3×3 (row = CEM rotate axis, column = input component). Armor-stand part poses,
|
||||
* body pitch.
|
||||
*/
|
||||
public record RotationBinding(double[] def, Map<String, double[]> parts) {}
|
||||
|
||||
/**
|
||||
* A posed part override: absolute CEM pivot and CEM rotation (deg) — both null for a pure visibility
|
||||
* toggle (the part keeps the pivot of whichever model it is in) — visibility (null = the part's own), scale
|
||||
* (null = the part's own) and an extra rotation (deg, added to whichever rotation applies; null = none).
|
||||
*/
|
||||
public record PartPose(double[] pivot, double[] rotate, Boolean visible, double[] scale, double[] rotateAdd) {
|
||||
|
||||
/** This override plus an extra rotation (rotation bindings). */
|
||||
public PartPose plusRotation(double[] add) {
|
||||
double[] r = rotateAdd == null
|
||||
? add
|
||||
: new double[] {rotateAdd[0] + add[0], rotateAdd[1] + add[1], rotateAdd[2] + add[2]};
|
||||
return new PartPose(pivot, rotate, visible, scale, r);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client assets of a data-driven mob variant ({@code data/<ns>/<type>_variant/<key>.json}): the texture
|
||||
* ({@code asset_id}), its baby texture, the per-state textures of wolves ({@code assets.wild/tame/angry},
|
||||
* {@code baby_assets}) and the model kind ({@code model}: "cold", "warm"; null = normal).
|
||||
*/
|
||||
public record VariantAssets(
|
||||
String asset, String babyAsset, Map<String, String> assets, Map<String, String> babyAssets, String model) {
|
||||
|
||||
static VariantAssets parse(JsonObject o) {
|
||||
return new VariantAssets(
|
||||
str(o, "asset_id"),
|
||||
str(o, "baby_asset_id"),
|
||||
strMap(o, "assets"),
|
||||
strMap(o, "baby_assets"),
|
||||
str(o, "model"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Texture id for the entity's state: wolves by tame / angry / wild (vanilla Wolf.getTexture: a tame wolf
|
||||
* keeps its tame texture when angry), others by asset id; baby first if asked.
|
||||
*/
|
||||
public String texture(boolean baby, boolean tame, boolean angry) {
|
||||
String state = tame ? "tame" : angry ? "angry" : "wild";
|
||||
if (baby && babyAssets.containsKey(state)) return babyAssets.get(state);
|
||||
if (baby && babyAsset != null) return babyAsset;
|
||||
if (assets.containsKey(state)) return assets.get(state);
|
||||
return asset;
|
||||
}
|
||||
|
||||
private static Map<String, String> strMap(JsonObject o, String k) {
|
||||
Map<String, String> out = new HashMap<>();
|
||||
if (o.has(k) && o.get(k).isJsonObject()) {
|
||||
for (Map.Entry<String, JsonElement> e : o.getAsJsonObject(k).entrySet()) {
|
||||
if (e.getValue().isJsonPrimitive())
|
||||
out.put(e.getKey(), e.getValue().getAsString());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
private final Map<String, CemModel> models = new HashMap<>();
|
||||
// Own namespace: the dump and the CEM template set use the same names for different models.
|
||||
private final Map<String, CemModel> vanilla = new HashMap<>();
|
||||
private final Map<String, EntityInfo> entities = new HashMap<>();
|
||||
private final Map<String, RenderType> renderTypes = new HashMap<>();
|
||||
// entity type -> variant key ("tabby", or "<ns>:<key>" outside minecraft) -> assets
|
||||
private final Map<String, Map<String, VariantAssets>> variants = new ConcurrentHashMap<>();
|
||||
|
||||
/** Assets of a data-driven variant of {@code typeKey} (registry {@code <typeKey>_variant}), or null. */
|
||||
public VariantAssets variant(String typeKey, String key) {
|
||||
Map<String, VariantAssets> m = key == null ? null : variants.get(typeKey);
|
||||
return m == null ? null : m.get(key);
|
||||
}
|
||||
|
||||
/** Registers a (datapack) variant definition — the raw {@code <type>_variant} JSON; replaces the vanilla one. */
|
||||
public void addVariant(String typeKey, String key, JsonObject definition) {
|
||||
variants.computeIfAbsent(typeKey, k -> new ConcurrentHashMap<>()).put(key, VariantAssets.parse(definition));
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
public Set<String> vanillaNames() {
|
||||
return vanilla.keySet();
|
||||
}
|
||||
|
||||
/** Pipeline properties of a vanilla render type by name (see {@link CemModel#renderType()}), or null. */
|
||||
public RenderType renderType(String name) {
|
||||
return name == null ? null : renderTypes.get(name);
|
||||
}
|
||||
|
||||
/** Vanilla renderer info for an entity type key, or null (not in the dump). */
|
||||
public EntityInfo entityInfo(String typeKey) {
|
||||
return entities.get(typeKey);
|
||||
}
|
||||
|
||||
/** A model of the CEM template set (legacy fallback), or null. */
|
||||
public CemModel get(String name) {
|
||||
return models.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a CEM template set or a vanilla dump (recognised by its {@code entities}); later loads override
|
||||
* models of the same name. Returns the total number of models loaded so far.
|
||||
*/
|
||||
public int load(InputStream in, Logger logger) {
|
||||
JsonObject root = JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8))
|
||||
.getAsJsonObject();
|
||||
boolean isVanillaDump = root.has("entities");
|
||||
if (isVanillaDump) loadDumpInfo(root);
|
||||
Map<String, CemModel> target = isVanillaDump ? vanilla : models;
|
||||
for (Map.Entry<String, JsonElement> e : root.getAsJsonObject("models").entrySet()) {
|
||||
try {
|
||||
JsonObject entry = e.getValue().getAsJsonObject();
|
||||
if (!entry.has("model")) continue;
|
||||
// The dump nests the jem as an object; the CEM template set embeds a JSON string.
|
||||
JsonElement m = entry.get("model");
|
||||
JsonObject model = m.isJsonObject()
|
||||
? m.getAsJsonObject()
|
||||
: JsonParser.parseString(m.getAsString()).getAsJsonObject();
|
||||
JsonArray size = model.getAsJsonArray("textureSize");
|
||||
List<CemPart> parts = new ArrayList<>();
|
||||
for (JsonElement pe : model.getAsJsonArray("models")) parts.add(parsePart(pe.getAsJsonObject()));
|
||||
target.put(
|
||||
e.getKey(),
|
||||
new CemModel(
|
||||
size.get(0).getAsInt(),
|
||||
size.get(1).getAsInt(),
|
||||
parts,
|
||||
bool(model, "absolute"),
|
||||
str(model, "texture"),
|
||||
str(model, "renderType")));
|
||||
} catch (Exception ex) {
|
||||
if (logger != null) logger.warning("Failed to parse CEM model " + e.getKey() + ": " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
return models.size() + vanilla.size();
|
||||
}
|
||||
|
||||
private void loadDumpInfo(JsonObject root) {
|
||||
for (Map.Entry<String, JsonElement> e : root.getAsJsonObject("entities").entrySet()) {
|
||||
JsonObject o = e.getValue().getAsJsonObject();
|
||||
entities.put(
|
||||
e.getKey(),
|
||||
new EntityInfo(
|
||||
str(o, "texture"),
|
||||
str(o, "babyTexture"),
|
||||
doubles(o, "transform"),
|
||||
o.has("yawSign") ? o.get("yawSign").getAsInt() : -1,
|
||||
doubles(o, "pitchAxis"),
|
||||
poses(o.getAsJsonObject("poses")),
|
||||
layers(o.getAsJsonObject("layers")),
|
||||
bool(o, "humanoidArmor"),
|
||||
poseTransforms(o.getAsJsonObject("poseTransforms")),
|
||||
modelPoses(o.getAsJsonObject("modelPoses")),
|
||||
rotationBindings(o.getAsJsonObject("rotationBindings")),
|
||||
emissive(o.getAsJsonArray("emissive")),
|
||||
strings(o.getAsJsonObject("stateTextures"))));
|
||||
}
|
||||
if (root.has("renderTypes")) {
|
||||
for (Map.Entry<String, JsonElement> t :
|
||||
root.getAsJsonObject("renderTypes").entrySet()) {
|
||||
JsonObject o = t.getValue().getAsJsonObject();
|
||||
renderTypes.put(
|
||||
t.getKey(),
|
||||
new RenderType(
|
||||
o.has("cutout") ? o.get("cutout").getAsDouble() : -1,
|
||||
str(o, "blend"),
|
||||
!o.has("shaded") || o.get("shaded").getAsBoolean()));
|
||||
}
|
||||
}
|
||||
if (root.has("variants")) {
|
||||
for (Map.Entry<String, JsonElement> t :
|
||||
root.getAsJsonObject("variants").entrySet()) {
|
||||
Map<String, VariantAssets> m = new ConcurrentHashMap<>();
|
||||
for (Map.Entry<String, JsonElement> v :
|
||||
t.getValue().getAsJsonObject().entrySet()) {
|
||||
m.put(v.getKey(), VariantAssets.parse(v.getValue().getAsJsonObject()));
|
||||
}
|
||||
variants.put(t.getKey(), m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private CemPart parsePart(JsonObject p) {
|
||||
double[] translate = arr3(p, "translate");
|
||||
double[] rotate = arr3(p, "rotate");
|
||||
boolean partMirror = mirrorsU(p); // a part's mirrorTexture applies to all its 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[Direction.fromName(hf.getAsString()).ordinal()] = true;
|
||||
}
|
||||
}
|
||||
JsonArray to = b.has("textureOffset") ? b.getAsJsonArray("textureOffset") : null;
|
||||
double[] uv = to == null
|
||||
? new double[] {0, 0}
|
||||
: new double[] {to.get(0).getAsDouble(), to.get(1).getAsDouble()};
|
||||
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;
|
||||
return new CemPart(
|
||||
name, translate, rotate, boxes, children, pivot, scale, bool(p, "invisible"), bool(p, "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"};
|
||||
|
||||
/** Per-face UV ({@code uvNorth} etc., each {@code [u1,v1,u2,v2]}) as {@code {x,y,w,h}}, or null. */
|
||||
private static double[][] parseFaceUv(JsonObject b) {
|
||||
if (Arrays.stream(FACE_UV_KEYS).noneMatch(b::has)) 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 poses) {
|
||||
Map<String, Map<String, PartPose>> out = new HashMap<>();
|
||||
if (poses == null) return out;
|
||||
for (Map.Entry<String, JsonElement> v : 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(
|
||||
po.has("pivot") ? arr3(po, "pivot") : null,
|
||||
po.has("rotate") ? arr3(po, "rotate") : null,
|
||||
po.has("visible") ? po.get("visible").getAsBoolean() : null,
|
||||
po.has("scale") ? arr3(po, "scale") : null,
|
||||
null));
|
||||
}
|
||||
out.put(v.getKey(), parts);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Map<String, PoseTransform> poseTransforms(JsonObject o) {
|
||||
Map<String, PoseTransform> out = new HashMap<>();
|
||||
if (o == null) return out;
|
||||
for (Map.Entry<String, JsonElement> e : o.entrySet()) {
|
||||
JsonObject t = e.getValue().getAsJsonObject();
|
||||
out.put(
|
||||
e.getKey(),
|
||||
new PoseTransform(
|
||||
doubles(t, "transform"),
|
||||
t.has("yawSign") ? t.get("yawSign").getAsInt() : -1,
|
||||
doubles(t, "pitchAxis")));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Map<String, PoseSet> modelPoses(JsonObject o) {
|
||||
Map<String, PoseSet> out = new HashMap<>();
|
||||
if (o == null) return out;
|
||||
for (Map.Entry<String, JsonElement> e : o.entrySet()) {
|
||||
JsonObject m = e.getValue().getAsJsonObject();
|
||||
out.put(
|
||||
e.getKey(),
|
||||
new PoseSet(
|
||||
poses(m.getAsJsonObject("poses")), poseTransforms(m.getAsJsonObject("poseTransforms"))));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Map<String, RotationBinding> rotationBindings(JsonObject o) {
|
||||
Map<String, RotationBinding> out = new HashMap<>();
|
||||
if (o == null) return out;
|
||||
for (Map.Entry<String, JsonElement> e : o.entrySet()) {
|
||||
JsonObject b = e.getValue().getAsJsonObject();
|
||||
Map<String, double[]> parts = new HashMap<>();
|
||||
for (Map.Entry<String, JsonElement> p : b.getAsJsonObject("parts").entrySet()) {
|
||||
JsonArray a = p.getValue().getAsJsonArray();
|
||||
double[] c = new double[9];
|
||||
for (int i = 0; i < 9 && i < a.size(); i++) c[i] = a.get(i).getAsDouble();
|
||||
parts.put(p.getKey(), c);
|
||||
}
|
||||
out.put(e.getKey(), new RotationBinding(doubles(b, "default"), parts));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static List<EmissiveLayer> emissive(JsonArray a) {
|
||||
List<EmissiveLayer> out = new ArrayList<>();
|
||||
if (a == null) return out;
|
||||
for (JsonElement el : a) {
|
||||
JsonObject o = el.getAsJsonObject();
|
||||
out.add(new EmissiveLayer(
|
||||
o.get("model").getAsString(),
|
||||
o.get("texture").getAsString(),
|
||||
strings(o.getAsJsonObject("textures")),
|
||||
str(o, "renderType"),
|
||||
o.has("alpha") ? o.get("alpha").getAsDouble() : 1,
|
||||
str(o, "when"),
|
||||
bool(o, "invisible"),
|
||||
bool(o, "animated")));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Map<String, String> strings(JsonObject o) {
|
||||
Map<String, String> out = new HashMap<>();
|
||||
if (o == null) return out;
|
||||
for (Map.Entry<String, JsonElement> e : o.entrySet())
|
||||
out.put(e.getKey(), e.getValue().getAsString());
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Map<String, Boolean> layers(JsonObject o) {
|
||||
Map<String, Boolean> out = new HashMap<>();
|
||||
if (o == null) return out;
|
||||
for (Map.Entry<String, JsonElement> e : o.entrySet())
|
||||
out.put(e.getKey(), e.getValue().getAsBoolean());
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String str(JsonObject o, String k) {
|
||||
return o.has(k) && o.get(k).isJsonPrimitive() ? o.get(k).getAsString() : null;
|
||||
}
|
||||
|
||||
private static boolean bool(JsonObject o, String k) {
|
||||
return o.has(k) && o.get(k).getAsBoolean();
|
||||
}
|
||||
|
||||
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,112 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity.cem;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
/**
|
||||
* Registers the mob variants of the worlds' datapacks ({@code data/<ns>/<type>_variant/<key>.json}, folder or
|
||||
* zip packs) with the model loader, overriding the dumped vanilla ones (textures must be in the resource pack).
|
||||
*/
|
||||
public final class DatapackVariants {
|
||||
|
||||
private static final Pattern VARIANT =
|
||||
Pattern.compile("data/([a-z0-9_.-]+)/([a-z0-9_]+)_variant/([a-z0-9_/.-]+)\\.json");
|
||||
|
||||
private DatapackVariants() {}
|
||||
|
||||
/**
|
||||
* Scans {@code <level>/datapacks/*} for every world folder: the folder itself or the nearest of up to 4 parents
|
||||
* that has one (26.x keeps dimensions under {@code <level>/dimensions/<ns>/<name>}); returns the number of
|
||||
* variants registered.
|
||||
*/
|
||||
public static int load(CemModelLoader models, Iterable<File> worldFolders, Logger log) {
|
||||
int n = 0;
|
||||
Set<File> seen = new HashSet<>();
|
||||
for (File world : worldFolders) {
|
||||
File dir = world.getAbsoluteFile();
|
||||
for (int up = 0; dir != null && up < 4 && !new File(dir, "datapacks").isDirectory(); up++) {
|
||||
dir = dir.getParentFile();
|
||||
}
|
||||
if (dir == null || !seen.add(dir)) continue;
|
||||
File[] packs = new File(dir, "datapacks").listFiles();
|
||||
if (packs == null) continue;
|
||||
for (File pack : packs) {
|
||||
try {
|
||||
n += pack.isDirectory() ? scanDir(models, pack.toPath(), log) : scanZip(models, pack, log);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
log.warning("Skipping datapack " + pack + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
private static int scanDir(CemModelLoader models, Path root, Logger log) throws IOException {
|
||||
int n = 0;
|
||||
try (Stream<Path> files = Files.walk(root)) {
|
||||
for (Path f : (Iterable<Path>) files.filter(Files::isRegularFile)::iterator) {
|
||||
String rel = root.relativize(f).toString().replace(File.separatorChar, '/');
|
||||
Matcher m = variant(rel);
|
||||
if (m == null) continue;
|
||||
try (InputStream in = Files.newInputStream(f)) {
|
||||
register(models, m, in);
|
||||
n++;
|
||||
} catch (IOException | RuntimeException e) {
|
||||
log.warning("Skipping variant " + f + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
private static int scanZip(CemModelLoader models, File zip, Logger log) throws IOException {
|
||||
if (!zip.getName().endsWith(".zip")) return 0;
|
||||
int n = 0;
|
||||
try (ZipFile zf = new ZipFile(zip)) {
|
||||
for (ZipEntry ze : Collections.list(zf.entries())) {
|
||||
if (ze.isDirectory()) continue;
|
||||
Matcher m = variant(ze.getName());
|
||||
if (m == null) continue;
|
||||
try (InputStream in = zf.getInputStream(ze)) {
|
||||
register(models, m, in);
|
||||
n++;
|
||||
} catch (IOException | RuntimeException e) {
|
||||
log.warning("Skipping variant " + ze.getName() + " in " + zip + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** The match of a mob variant definition path, or null (other files are not even opened). */
|
||||
private static Matcher variant(String path) {
|
||||
Matcher m = VARIANT.matcher(path);
|
||||
if (!m.matches()) return null;
|
||||
String type = m.group(2);
|
||||
if (type.endsWith("_sound") || type.equals("painting")) return null; // no mob texture
|
||||
return m;
|
||||
}
|
||||
|
||||
private static void register(CemModelLoader models, Matcher m, InputStream in) {
|
||||
JsonObject def = JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8))
|
||||
.getAsJsonObject();
|
||||
String key = m.group(1).equals("minecraft") ? m.group(3) : m.group(1) + ":" + m.group(3);
|
||||
models.addVariant(m.group(2), key, def);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
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 eu.mhsl.minecraft.pixelpics.render.util.SpecialDates;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TimeZone;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.bukkit.DyeColor;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
|
||||
/**
|
||||
* Item (and block) geometry like vanilla's ItemRenderer, from the resource pack: the 26.x item definition
|
||||
* ({@code items/<id>.json}) picks the model for the display context, its parent chain supplies the {@code display}
|
||||
* transform and {@code layerN} textures. Generated items are extruded sprites (1 px thick), block items their block
|
||||
* model, special models (chest, heads, banners, shield, trident, …) entity/block-entity models. The caller's
|
||||
* {@code toWorld} maps the ItemRenderer origin; this class applies the display transform and {@code T(-0.5)}.
|
||||
*/
|
||||
public final class ItemGeometry {
|
||||
|
||||
private static final Info EMPTY = new Info(null, List.of(), List.of(), null, null, null);
|
||||
private static final double Z0 = 7.5 / 16, Z1 = 8.5 / 16; // extruded sprite depth
|
||||
private final AssetReader reader;
|
||||
private final BlockModelRegistry registry;
|
||||
private final TextureCache textures;
|
||||
private final CemModelLoader models;
|
||||
private final BlockEntityBaker blockEntities;
|
||||
// Per "id#context"; date-dependent definitions (local_time selects) remember the day they were resolved for.
|
||||
private final Map<String, DatedInfo> 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
|
||||
JsonObject specialModel, // the special model's own fields (chest texture/chest_type, …) or null
|
||||
Affine specialTransform) {} // item-model transformation for special models (T·Rl·S·Rr) or null
|
||||
|
||||
/** 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) {
|
||||
// An item, not a placed block: the first (unrotated) variant like its item model, no random pick.
|
||||
bakeModel(registry.firstVariants(item.block()), item.block(), m, out);
|
||||
}
|
||||
// Placed block-entity fallback only for block items (a shield carries its patterns as a banner state).
|
||||
if (out.size() == before && item.block() != null && item.blockEntity() != null) {
|
||||
bakeBlockEntity(item.blockEntity(), m, out);
|
||||
}
|
||||
if (out.size() == before) {
|
||||
int[][] sprite = sprite(item.id());
|
||||
if (sprite != null) addExtruded(sprite, m, out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ItemStackRenderState.getModelBoundingBox: the model's extents through {@code display·T(-0.5)} as
|
||||
* {minX, minY, minZ, maxX, maxY, maxZ}; null when nothing renders. Generated items span their whole sprite
|
||||
* square, not just the opaque texels.
|
||||
*/
|
||||
public double[] modelBounds(ItemSpec item, String context) {
|
||||
if (item == null) return null;
|
||||
Info info = info(item.id(), context);
|
||||
List<EntityCube> cubes = new ArrayList<>();
|
||||
if (info.special == null && !info.layers.isEmpty()) {
|
||||
Affine m = displayTransform(info.display, false).mul(Affine.translation(-0.5, -0.5, -0.5));
|
||||
cubes.add(new EntityCube(new double[] {0, 0, Z0}, new double[] {1, 1, Z1}, new Face[6], m));
|
||||
} else {
|
||||
bake(item, context, false, Affine.identity(), cubes);
|
||||
}
|
||||
if (cubes.isEmpty()) return null;
|
||||
RenderedEntity r = RenderedEntity.of(cubes);
|
||||
return new double[] {r.aabbMin[0], r.aabbMin[1], r.aabbMin[2], r.aabbMax[0], r.aabbMax[1], r.aabbMax[2]};
|
||||
}
|
||||
|
||||
/**
|
||||
* A block's model elements in the block cube [0,1]³, weighted variants picked by {@code modelSeed}
|
||||
* ({@link BlockModelRegistry#MODEL_SEED} for displays/minecart blocks).
|
||||
*/
|
||||
public void bakeBlock(BlockData data, long modelSeed, Affine toWorld, List<EntityCube> out) {
|
||||
if (data == null) return;
|
||||
bakeModel(registry.get(data, modelSeed), data, toWorld, out);
|
||||
}
|
||||
|
||||
/** A block model's elements in the block cube [0,1]³; {@code data} = tint context (plains colours) or null. */
|
||||
public void bakeModel(ResolvedModel model, BlockData data, Affine toWorld, List<EntityCube> out) {
|
||||
if (model == null || !model.hasGeometry) return;
|
||||
for (Element world : model.elements) {
|
||||
// Blockstate-rotated: the model-space element turned as a whole, so textures stay bound to the model.
|
||||
Element e = world;
|
||||
Affine variant = Affine.identity();
|
||||
if (world.unrotated != null) {
|
||||
e = world.unrotated;
|
||||
variant = variantRotation(world.xSteps, world.ySteps);
|
||||
}
|
||||
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 placed as in the world at cell (0,0,0), in the block cube [0,1]³ (block displays, …). */
|
||||
public void bakeBlockEntity(BlockEntityState state, Affine toWorld, List<EntityCube> out) {
|
||||
if (state == null || blockEntities == null) return;
|
||||
RenderedEntity r = blockEntities.bake(state);
|
||||
if (r == null) return;
|
||||
for (EntityCube c : r.cubes) out.add(c.transformed(toWorld));
|
||||
}
|
||||
|
||||
/** A block entity's bare models (no block pose) mapped by {@code toWorld}. */
|
||||
public void bakeBlockEntityModel(BlockEntityState state, Affine toWorld, List<EntityCube> out) {
|
||||
if (state != null && blockEntities != null) blockEntities.bakeModel(state, toWorld, out);
|
||||
}
|
||||
|
||||
/** A resolved item definition; {@code day} is the date it holds for (local_time selects) or null (any day). */
|
||||
private record DatedInfo(Info info, LocalDate day) {}
|
||||
|
||||
private Info info(String id, String context) {
|
||||
ZonedDateTime now = SpecialDates.now();
|
||||
LocalDate today = now.toLocalDate();
|
||||
String key = id + "#" + context;
|
||||
DatedInfo cached = infos.get(key);
|
||||
if (cached != null && (cached.day == null || cached.day.equals(today))) return cached.info;
|
||||
// minecraft:local_time selects (christmas chest items) change with the day: such entries are replaced on a
|
||||
// new day instead of adding one per day.
|
||||
boolean[] dated = {false};
|
||||
Info info;
|
||||
try {
|
||||
info = resolve(id, context, now, dated);
|
||||
} catch (RuntimeException e) {
|
||||
// Unexpected item-definition format (newer version?): the plain sprite.
|
||||
info = EMPTY;
|
||||
}
|
||||
infos.put(key, new DatedInfo(info, dated[0] ? today : null));
|
||||
return info;
|
||||
}
|
||||
|
||||
private Info resolve(String id, String context, ZonedDateTime now, boolean[] dated) {
|
||||
String modelRef = null, special = null;
|
||||
JsonObject specialModel = 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")) {
|
||||
Affine[] outer = {null};
|
||||
JsonObject node = pick(def.get().getAsJsonObject("model"), context, now, outer, dated);
|
||||
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:", "");
|
||||
specialModel = sm;
|
||||
specialXf = outer[0];
|
||||
} 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: the nearest "display" entry for the context wins.
|
||||
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, specialModel, specialXf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks an item-model node to the model/special for a display context. {@code outer[0]} accumulates the
|
||||
* {@code transformation}s of the nodes passed down to a special (ItemModel.Unbaked.bake: shield/trident flips,
|
||||
* statue placement; null = none). {@code dated[0]} is set when a {@code local_time} select was evaluated.
|
||||
*/
|
||||
private static JsonObject pick(
|
||||
JsonObject node, String context, ZonedDateTime now, Affine[] outer, boolean[] dated) {
|
||||
for (int depth = 0; node != null && depth < 16; depth++) {
|
||||
String type = str(node, "type").replace("minecraft:", "");
|
||||
if (!type.equals("model") && node.has("transformation")) {
|
||||
Affine t = transformation(node.getAsJsonObject("transformation"));
|
||||
outer[0] = outer[0] == null ? t : outer[0].mul(t);
|
||||
}
|
||||
switch (type) {
|
||||
case "model", "special" -> {
|
||||
return node;
|
||||
}
|
||||
case "condition" -> node = obj(node, "on_false");
|
||||
case "select" -> {
|
||||
JsonObject chosen = null;
|
||||
String property = str(node, "property");
|
||||
if (property.endsWith("local_time")) dated[0] = true;
|
||||
String value = property.endsWith("display_context")
|
||||
? context
|
||||
: property.endsWith("local_time") ? localTime(node, now) : null;
|
||||
if (value != null && node.has("cases")) {
|
||||
for (JsonElement c : node.getAsJsonArray("cases")) {
|
||||
JsonObject co = c.getAsJsonObject();
|
||||
if (matches(co.get("when"), value)) {
|
||||
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;
|
||||
}
|
||||
|
||||
/** The {@code minecraft:local_time} select value (SimpleDateFormat) for {@code now}; null when invalid. */
|
||||
private static String localTime(JsonObject node, ZonedDateTime now) {
|
||||
try {
|
||||
String locale = str(node, "locale");
|
||||
SimpleDateFormat f = new SimpleDateFormat(
|
||||
str(node, "pattern"), locale.isEmpty() ? Locale.ROOT : Locale.forLanguageTag(locale));
|
||||
String tz = str(node, "time_zone");
|
||||
f.setTimeZone(tz.isEmpty() ? TimeZone.getTimeZone(now.getZone()) : TimeZone.getTimeZone(tz));
|
||||
return f.format(Date.from(now.toInstant()));
|
||||
} catch (RuntimeException e) {
|
||||
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;
|
||||
}
|
||||
|
||||
private void bakeSpecial(ItemSpec item, Info info, Affine m, List<EntityCube> out) {
|
||||
Affine xf = info.specialTransform != null ? m.mul(info.specialTransform) : m;
|
||||
switch (info.special) {
|
||||
case "shield" -> {
|
||||
CemModelLoader.CemModel model = models.vanilla("layer/shield#main");
|
||||
int[][] tex = shieldTexture(item.blockEntity());
|
||||
if (model != null && tex != null) bakeEntityModel(model, tex, xf, out);
|
||||
}
|
||||
case "trident" -> {
|
||||
CemModelLoader.CemModel model = models.vanilla("trident");
|
||||
if (model == null) model = models.vanilla("layer/trident#main");
|
||||
int[][] tex = texture("entity/trident/trident");
|
||||
if (model != null && tex != null) bakeEntityModel(model, tex, xf, out);
|
||||
}
|
||||
case "chest" -> {
|
||||
if (!bakeChest(info.specialModel, xf, out)) bakeBlockEntityModel(item.blockEntity(), xf, out);
|
||||
}
|
||||
// Block-entity specials (head, banner, shulker box, conduit, pot, …): the bare model.
|
||||
default -> bakeBlockEntityModel(item.blockEntity(), xf, out);
|
||||
}
|
||||
}
|
||||
|
||||
private static void bakeEntityModel(CemModelLoader.CemModel model, int[][] tex, Affine xf, List<EntityCube> out) {
|
||||
CemGeometry.bakeCubes(model, tex, CemGeometry.modelSpace(xf), Set.of(), out);
|
||||
}
|
||||
|
||||
/**
|
||||
* ChestSpecialRenderer: the chest model of the {@code chest_type} with the definition's texture
|
||||
* ({@code entity/chest/<texture>}); false when unresolvable.
|
||||
*/
|
||||
private boolean bakeChest(JsonObject sm, Affine xf, List<EntityCube> out) {
|
||||
if (sm == null || str(sm, "texture").isEmpty()) return false;
|
||||
String layer =
|
||||
switch (str(sm, "chest_type").replace("minecraft:", "")) {
|
||||
case "left" -> "layer/double_chest_left#main";
|
||||
case "right" -> "layer/double_chest_right#main";
|
||||
default -> "layer/chest#main";
|
||||
};
|
||||
CemModelLoader.CemModel model = models.vanilla(layer);
|
||||
ResourceLocation id = ResourceLocation.parse(str(sm, "texture"));
|
||||
int[][] tex = texture(id.namespace() + ":entity/chest/" + id.path());
|
||||
if (model == null || tex == null) return false;
|
||||
bakeEntityModel(model, tex, xf, out);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* ShieldSpecialRenderer: shield_base_nopattern, or with patterns/base colour (a {@code BANNER} state from the
|
||||
* item meta) shield_base + the dyed shield masks (base colour default white).
|
||||
*/
|
||||
private int[][] shieldTexture(BlockEntityState patterns) {
|
||||
boolean patterned = patterns != null
|
||||
&& (patterns.baseColorArgb() != 0 || !patterns.patterns().isEmpty());
|
||||
int[][] base = texture(patterned ? "entity/shield/shield_base" : "entity/shield/shield_base_nopattern");
|
||||
if (!patterned || base == null || blockEntities == null) return base;
|
||||
int baseColor = patterns.baseColorArgb() != 0 ? patterns.baseColorArgb() : ColorUtil.dyeArgb(DyeColor.WHITE, 0);
|
||||
return blockEntities.patterned(base, "entity/shield/", baseColor, patterns.patterns());
|
||||
}
|
||||
|
||||
private void bakeGenerated(Info info, Affine m, List<EntityCube> out) {
|
||||
// Cached per Info: a stable texture identity keeps the extrusion cache from growing per bake.
|
||||
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 = texture(info.layers.get(i));
|
||||
if (t == null) t = TextureCache.MISSINGNO; // vanilla: the missing texture, not a hole
|
||||
int tint = i < info.tints.size() ? info.tints.get(i) : 0;
|
||||
if (composed == null) {
|
||||
composed = tint != 0 ? TextureOps.tinted(t, tint) : TextureOps.deepCopy(t);
|
||||
} else if (t.length == composed.length && t[0].length == composed[0].length) {
|
||||
TextureOps.overlay(composed, tint != 0 ? TextureOps.tinted(t, tint) : t);
|
||||
}
|
||||
}
|
||||
return composed;
|
||||
}
|
||||
|
||||
private int[][] sprite(String id) {
|
||||
int[][] t = texture("item/" + id);
|
||||
return t != null ? t : texture("block/" + id);
|
||||
}
|
||||
|
||||
private int[][] texture(String path) {
|
||||
return textures.get(ResourceLocation.parse(path)).orElse(null);
|
||||
}
|
||||
|
||||
private record Box(double[] from, double[] to, Face[] faces) {}
|
||||
|
||||
/** Extruded sprite: one box per horizontal run of opaque texels; edges show the run's end texels. */
|
||||
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<>();
|
||||
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();
|
||||
Affine r =
|
||||
switch (e.rotAxis) {
|
||||
case 0 -> Affine.rotX(e.rotAngleRad);
|
||||
case 1 -> Affine.rotY(e.rotAngleRad);
|
||||
case 2 -> Affine.rotZ(e.rotAngleRad);
|
||||
default -> new Affine(e.linearRotation(), new double[] {0, 0, 0});
|
||||
};
|
||||
if (e.invScale != null) {
|
||||
// Vanilla CuboidRotation rescale: R·S, S = 1 / max|component of R·axis| per local axis.
|
||||
double[] f = e.rescaleFactors();
|
||||
r = r.mul(Affine.scale(f[0], f[1], f[2]));
|
||||
}
|
||||
return Affine.rotationAround(r, e.rotOrigin[0], e.rotOrigin[1], e.rotOrigin[2]);
|
||||
}
|
||||
|
||||
/** The face with its tint baked into a texture copy (the entity path has no per-hit tinting). */
|
||||
private Face tint(Face f, BlockData data) {
|
||||
if (f == null || f.tintIndex < 0 || data == null) return f;
|
||||
int color = TintResolver.resolve(data, f.tintIndex, BiomeTint.PLAINS);
|
||||
if (color == -1) return f;
|
||||
int[][] t;
|
||||
synchronized (tinted) {
|
||||
t = tinted.computeIfAbsent(f.texture, k -> new HashMap<>())
|
||||
.computeIfAbsent(color, c -> TextureOps.tinted(f.texture, c));
|
||||
}
|
||||
return f.withTexture(t, -1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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 text lines into a transparent ARGB grid (top-left origin) at the font's native pixel size: lines
|
||||
* centred horizontally, blank lines keep their slot, glyphs of all providers on a common baseline, glowing text
|
||||
* with an 8-directional outline.
|
||||
*/
|
||||
public final class SignTextRasterizer {
|
||||
|
||||
private SignTextRasterizer() {}
|
||||
|
||||
/** The text panel, or null when nothing is drawn (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();
|
||||
int margin = glow ? 1 : 0; // room for the outline
|
||||
|
||||
int[] widths = new int[lines.size()];
|
||||
int contentW = 1;
|
||||
for (int i = 0; i < widths.length; i++)
|
||||
contentW = Math.max(contentW, widths[i] = lineWidth(lines.get(i), 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];
|
||||
|
||||
boolean any = false;
|
||||
for (int li = 0; li < lines.size(); li++) {
|
||||
String line = lines.get(li);
|
||||
int penX = margin + (contentW - widths[li]) / 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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Stamps the glyph's opaque pixels in {@code fillArgb}, its baseline on {@code baseline}; false if none landed. */
|
||||
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) == 0) continue;
|
||||
out[oy][ox] = fillArgb;
|
||||
covered[oy][ox] = true;
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
return any;
|
||||
}
|
||||
|
||||
/** Width of a line in font px (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,70 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.raytrace;
|
||||
|
||||
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.util.ColorUtil;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.bukkit.block.Biome;
|
||||
|
||||
/**
|
||||
* Biome tints of one photo: the vanilla biome blend (radius 2) over a {@link WorldSnapshot}, cached per block column
|
||||
* and biome cell height ({@code y >> 2}: snapshots store biomes per 4³ cell). Lives as long as its photo, so the
|
||||
* cache never outlives the snapshot it was computed from (other worlds, later biome changes, chunks loaded since).
|
||||
*/
|
||||
public final class BiomeBlend {
|
||||
|
||||
private static final int RADIUS = 2;
|
||||
private static final BiomeTint NONE = new BiomeTint(0, 0, 0, 0); // cached "no biome around"
|
||||
|
||||
private final WorldSnapshot snapshot;
|
||||
private final BiomeTintProvider provider;
|
||||
private final Map<Long, BiomeTint> cache = new ConcurrentHashMap<>();
|
||||
|
||||
BiomeBlend(WorldSnapshot snapshot, BiomeTintProvider provider) {
|
||||
this.snapshot = snapshot;
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
/** Biome tint averaged over the (2r+1)² columns around the block at its height; null without any biome. */
|
||||
public BiomeTint at(int bx, int by, int bz) {
|
||||
// x/z: 26 bits each (world border ±30M), y cell: 12 bits.
|
||||
long key = ((bx & 0x3FFFFFFL) << 38) | ((bz & 0x3FFFFFFL) << 12) | ((by >> 2) & 0xFFFL);
|
||||
BiomeTint cached = cache.get(key);
|
||||
if (cached == null) {
|
||||
cached = blend(bx, by, bz);
|
||||
cache.put(key, cached);
|
||||
}
|
||||
return cached == NONE ? null : cached;
|
||||
}
|
||||
|
||||
private BiomeTint blend(int bx, int by, int bz) {
|
||||
long[] g = new long[3], f = new long[3], d = new long[3], w = new long[3];
|
||||
int n = 0;
|
||||
for (int dx = -RADIUS; dx <= RADIUS; dx++) {
|
||||
for (int dz = -RADIUS; dz <= RADIUS; dz++) {
|
||||
Biome biome = snapshot.getBiome(bx + dx, by, bz + dz);
|
||||
if (biome == null) continue;
|
||||
BiomeTint t = provider.forBiome(biome);
|
||||
accumulate(g, t.grass());
|
||||
accumulate(f, t.foliage());
|
||||
accumulate(d, t.dryFoliage());
|
||||
accumulate(w, t.water());
|
||||
n++;
|
||||
}
|
||||
}
|
||||
if (n == 0) return NONE;
|
||||
return new BiomeTint(average(g, n), average(f, n), average(d, n), average(w, n));
|
||||
}
|
||||
|
||||
private static void accumulate(long[] acc, int argb) {
|
||||
acc[0] += ColorUtil.red(argb);
|
||||
acc[1] += ColorUtil.green(argb);
|
||||
acc[2] += ColorUtil.blue(argb);
|
||||
}
|
||||
|
||||
private static int average(long[] acc, int n) {
|
||||
return ColorUtil.argb(0xFF, (int) (acc[0] / n), (int) (acc[1] / n), (int) (acc[2] / n));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.raytrace;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
|
||||
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
|
||||
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeClimate;
|
||||
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
|
||||
import org.bukkit.block.Biome;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
/**
|
||||
* What the camera looks through and its distance fog. In water/lava like vanilla 26.3 {@code FogRenderer} with
|
||||
* {@code WaterFogEnvironment} / {@code LavaFogEnvironment}: linear fog from {@code fogStart} to {@code fogEnd}
|
||||
* (spherical distance) towards {@code fogColor}; the sky is the fogged sky disc. In air: PixelPics' own
|
||||
* atmospheric fog towards the sky colour (not vanilla).
|
||||
*
|
||||
* <p>Water: start/end/colour are the biome attributes {@code water_fog_start_distance} (-8),
|
||||
* {@code water_fog_end_distance} (96, swamps x0.85) and {@code water_fog_color} (default #050533), blended over
|
||||
* the biomes around the camera with vanilla's {@code GaussianSampler} (6x6x6 quart cells, kernel 1-4-6-4-1).
|
||||
* Water vision ({@code LocalPlayer.getWaterVision}) is taken as fully adapted (1), which keeps the end distance and
|
||||
* brightens the colour to a maximum channel of 1. Lava: #991A00 from 0.25 to 1 block (no fire resistance).
|
||||
*
|
||||
* <p>Air outside the overworld: the Nether fogs like vanilla's environmental fog towards the camera biome's
|
||||
* {@code fog_color}; the End uses the overworld's atmospheric fog towards its fog colour instead of the sky.
|
||||
*
|
||||
* @param fogColor in air: the fixed colour distant surfaces fog towards, or -1 for the sky colour in their direction
|
||||
* @param fogMax the strongest fog (1 = opaque)
|
||||
*/
|
||||
public record CameraMedium(int fluid, int fogColor, double fogStart, double fogEnd, double fogMax) {
|
||||
|
||||
/** Overworld air: PixelPics' atmospheric fog towards the sky. */
|
||||
public static final CameraMedium AIR = new CameraMedium(
|
||||
FluidRenderer.NONE, -1, CameraMedium.AIR_FOG_START, CameraMedium.AIR_FOG_END, CameraMedium.AIR_FOG_MAX);
|
||||
|
||||
private static final double[] KERNEL = {0, 1, 4, 6, 4, 1, 0};
|
||||
|
||||
// Atmospheric fog in air.
|
||||
private static final double AIR_FOG_START = 128;
|
||||
private static final double AIR_FOG_END = 256;
|
||||
private static final double AIR_FOG_MAX = 0.75;
|
||||
// Nether: dimension type attributes minecraft:visual/fog_start_distance / fog_end_distance (vanilla 26.3).
|
||||
private static final double NETHER_FOG_START = 10;
|
||||
private static final double NETHER_FOG_END = 96;
|
||||
|
||||
// Vanilla sky disc (SkyRenderer.buildSkyDisc): a fan at height 16 around the camera, rim octagon at 512.
|
||||
private static final double DISC_HEIGHT = 16;
|
||||
private static final double DISC_RADIUS = 512;
|
||||
private static final double DISC_APOTHEM = DISC_RADIUS * Math.cos(Math.PI / 8);
|
||||
private static final double DISC_RIM_DISTANCE = Math.hypot(DISC_RADIUS, DISC_HEIGHT);
|
||||
|
||||
public boolean inFluid() {
|
||||
return fluid != FluidRenderer.NONE;
|
||||
}
|
||||
|
||||
static CameraMedium at(WorldSnapshot snapshot, Vector eye, int fluid, BiomeTintProvider tints, SkyContext sky) {
|
||||
double x = eye.getX(), y = eye.getY(), z = eye.getZ();
|
||||
if (fluid == FluidRenderer.LAVA) return new CameraMedium(fluid, 0x991A00, 0.25, 1.0, 1);
|
||||
if (fluid != FluidRenderer.WATER) {
|
||||
return switch (sky.skybox()) {
|
||||
case OVERWORLD -> AIR;
|
||||
case NONE -> new CameraMedium(fluid, sky.fogColor(), NETHER_FOG_START, NETHER_FOG_END, 1);
|
||||
case END -> new CameraMedium(fluid, sky.fogColor(), AIR_FOG_START, AIR_FOG_END, AIR_FOG_MAX);
|
||||
};
|
||||
}
|
||||
|
||||
// GaussianSampler.sample(position / 4) over the noise biomes (quart positions).
|
||||
double px = x * 0.25 - 0.5, py = y * 0.25 - 0.5, pz = z * 0.25 - 0.5;
|
||||
int ix = (int) Math.floor(px), iy = (int) Math.floor(py), iz = (int) Math.floor(pz);
|
||||
double rx = px - ix, ry = py - iy, rz = pz - iz;
|
||||
double r = 0, g = 0, b = 0, start = 0, end = 0, total = 0;
|
||||
for (int k = 0; k < 6; k++) {
|
||||
double wz = lerp(rz, KERNEL[k + 1], KERNEL[k]);
|
||||
for (int i = 0; i < 6; i++) {
|
||||
double wx = lerp(rx, KERNEL[i + 1], KERNEL[i]);
|
||||
for (int j = 0; j < 6; j++) {
|
||||
double wy = lerp(ry, KERNEL[j + 1], KERNEL[j]);
|
||||
double w = wx * wy * wz;
|
||||
if (w == 0) continue;
|
||||
// Quart y clamped into the captured height range (vanilla clamps to the world height).
|
||||
int by = Math.clamp((iy - 2 + j) * 4, snapshot.minY(), snapshot.maxY() - 1);
|
||||
Biome biome = snapshot.getBiome((ix - 2 + i) * 4, by, (iz - 2 + k) * 4);
|
||||
BiomeClimate.Climate c = biome == null ? BiomeClimate.DEFAULT : tints.climate(biome);
|
||||
int fog = c.waterFogColor();
|
||||
r += w * ColorUtil.red(fog) / 255.0;
|
||||
g += w * ColorUtil.green(fog) / 255.0;
|
||||
b += w * ColorUtil.blue(fog) / 255.0;
|
||||
start += w * c.waterFogStart();
|
||||
end += w * c.waterFogEnd();
|
||||
total += w;
|
||||
}
|
||||
}
|
||||
}
|
||||
r /= total;
|
||||
g /= total;
|
||||
b /= total;
|
||||
start /= total;
|
||||
end /= total;
|
||||
// FogRenderer.computeFogColor with water vision 1: brightened to a maximum channel of 1.
|
||||
if (r != 0 && g != 0 && b != 0) {
|
||||
double scale = 1 / Math.max(r, Math.max(g, b));
|
||||
r = r + (r * scale - r);
|
||||
g = g + (g * scale - g);
|
||||
b = b + (b * scale - b);
|
||||
}
|
||||
int color = ColorUtil.argb(255, channel(r), channel(g), channel(b)) & 0xFFFFFF;
|
||||
return new CameraMedium(fluid, color, start, end, 1);
|
||||
}
|
||||
|
||||
private static double lerp(double a, double from, double to) {
|
||||
return from + a * (to - from);
|
||||
}
|
||||
|
||||
private static int channel(double v) {
|
||||
return Math.clamp((int) Math.round(v * 255), 0, 255);
|
||||
}
|
||||
|
||||
/** Fog fraction at distance {@code d} (in fluids fog.glsl {@code linear_fog_value}). */
|
||||
double fogValue(double d) {
|
||||
if (d <= fogStart) return 0;
|
||||
if (d >= fogEnd) return fogMax;
|
||||
return Math.min((d - fogStart) / (fogEnd - fogStart), fogMax);
|
||||
}
|
||||
|
||||
/** {@code color} fogged at distance {@code d}; overworld air fogs towards {@code skyColor}. */
|
||||
int fog(int color, double d, int skyColor) {
|
||||
double f = fogValue(d);
|
||||
return f > 0 ? ColorUtil.mix(color, fogColor >= 0 ? fogColor : skyColor, 1 - f, f) : color;
|
||||
}
|
||||
|
||||
/** Whether {@link #fog}/{@link #sky} of a surface at {@code t} (infinite = sky) read the sky colour. */
|
||||
boolean needsSkyColor(double t) {
|
||||
return Double.isInfinite(t) || (fogColor < 0 && t > fogStart);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sky seen in direction {@code (dx, dy, dz)} (unit). From inside a fluid vanilla draws the sky disc with fog
|
||||
* over {@code [0, skyEnd = fogEnd]} using the vertex distances interpolated across the fan triangles (centre 16,
|
||||
* rim 512, plus the cylindrical distance beyond skyEnd = fully fogged); below the horizon and beyond the disc
|
||||
* only the clear colour (= fog colour) remains.
|
||||
*/
|
||||
int sky(int skyColor, double dx, double dy, double dz) {
|
||||
if (!inFluid()) return skyColor;
|
||||
if (fluid == FluidRenderer.LAVA || dy <= 1e-9) return fogColor;
|
||||
double horizontal = Math.sqrt(dx * dx + dz * dz);
|
||||
double radius = DISC_HEIGHT * horizontal / dy;
|
||||
double lambda = 0;
|
||||
if (radius > 0) {
|
||||
double phi = Math.atan2(dz, dx);
|
||||
double sector = Math.PI / 4;
|
||||
double rel = phi - Math.floor(phi / sector) * sector; // 0..45°
|
||||
lambda = radius * Math.cos(rel - sector / 2) / DISC_APOTHEM;
|
||||
}
|
||||
if (lambda >= 1) return fogColor;
|
||||
double spherical = DISC_HEIGHT + (DISC_RIM_DISTANCE - DISC_HEIGHT) * lambda;
|
||||
double cylindrical = DISC_HEIGHT + (DISC_RADIUS - DISC_HEIGHT) * lambda;
|
||||
double f = cylindrical > fogEnd ? 1 : Math.clamp(spherical / fogEnd, 0, 1);
|
||||
return ColorUtil.mix(skyColor, fogColor, 1 - f, f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
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.Arrays;
|
||||
import java.util.Comparator;
|
||||
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 drawn
|
||||
* face hit. Rotated elements are slab-tested in their local frame. Cut-out texels are holes the ray passes through;
|
||||
* partially transparent texels are returned with their alpha for the caller to blend.
|
||||
*/
|
||||
public final class ElementIntersector {
|
||||
|
||||
private static final double EPS = 1e-7;
|
||||
private static final Direction[] DIRECTIONS = Direction.values();
|
||||
|
||||
private ElementIntersector() {}
|
||||
|
||||
/** Per-thread scratch of one {@link #intersect} call: candidate faces as parallel arrays, so nothing is allocated. */
|
||||
private static final class Scratch {
|
||||
int n;
|
||||
Element[] element = new Element[16];
|
||||
double[] t = new double[16];
|
||||
Direction[] dir = new Direction[16];
|
||||
double[] s = new double[16];
|
||||
double[] t2 = new double[16];
|
||||
double[] nx = new double[16], ny = new double[16], nz = new double[16];
|
||||
int[] order = new int[16];
|
||||
// Candidate indices to sort; pre-boxed (cached Integers) so the sort allocates nothing.
|
||||
Integer[] idx = new Integer[16];
|
||||
final double[] o = new double[3], d = new double[3], v = new double[3];
|
||||
// Result of the last slab test.
|
||||
double hitT, hitS, hitT2, hitNx, hitNy, hitNz;
|
||||
Direction hitDir;
|
||||
|
||||
// Depth; coplanar within 1e-4: later elements first, like vanilla's draw order (overlays such as the grass
|
||||
// side tint). Not transitive, so the tie order depends on the sort algorithm: keep Arrays.sort over objects
|
||||
// (TimSort).
|
||||
final Comparator<Integer> byDepth = (a, b) -> {
|
||||
if (Math.abs(t[a] - t[b]) > 1e-4) return Double.compare(t[a], t[b]);
|
||||
return Integer.compare(order[b], order[a]);
|
||||
};
|
||||
|
||||
void grow() {
|
||||
int len = element.length * 2;
|
||||
element = Arrays.copyOf(element, len);
|
||||
t = Arrays.copyOf(t, len);
|
||||
dir = Arrays.copyOf(dir, len);
|
||||
s = Arrays.copyOf(s, len);
|
||||
t2 = Arrays.copyOf(t2, len);
|
||||
nx = Arrays.copyOf(nx, len);
|
||||
ny = Arrays.copyOf(ny, len);
|
||||
nz = Arrays.copyOf(nz, len);
|
||||
order = Arrays.copyOf(order, len);
|
||||
idx = new Integer[len];
|
||||
}
|
||||
}
|
||||
|
||||
private static final ThreadLocal<Scratch> SCRATCH = ThreadLocal.withInitial(Scratch::new);
|
||||
|
||||
/**
|
||||
* @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)
|
||||
* @param minT only faces strictly beyond this ray parameter count (continuing past a translucent hit)
|
||||
*/
|
||||
public static FaceHit intersect(
|
||||
ResolvedModel model,
|
||||
double ox,
|
||||
double oy,
|
||||
double oz,
|
||||
double dx,
|
||||
double dy,
|
||||
double dz,
|
||||
int bx,
|
||||
int by,
|
||||
int bz,
|
||||
double minT) {
|
||||
List<Element> elements = model.elements;
|
||||
int count = elements.size();
|
||||
if (count == 0) return null;
|
||||
Scratch sc = SCRATCH.get();
|
||||
sc.n = 0;
|
||||
double[] v = sc.v;
|
||||
for (int i = 0; i < count; i++) {
|
||||
Element element = elements.get(i);
|
||||
Element e = element;
|
||||
double eox = ox, eoy = oy, eoz = oz, edx = dx, edy = dy, edz = dz;
|
||||
if (element.unrotated != null) {
|
||||
// Blockstate rotation: hit the model-space element with the inverse-rotated ray (same t: rigid
|
||||
// transform), so faces and UVs stay bound to the model like vanilla's FaceBakery.
|
||||
e = element.unrotated;
|
||||
Element.unrotate(v, ox, oy, oz, element.xSteps, element.ySteps, true);
|
||||
eox = v[0];
|
||||
eoy = v[1];
|
||||
eoz = v[2];
|
||||
Element.unrotate(v, dx, dy, dz, element.xSteps, element.ySteps, false);
|
||||
edx = v[0];
|
||||
edy = v[1];
|
||||
edz = v[2];
|
||||
}
|
||||
boolean hit = e.isAxisAligned()
|
||||
? slab(sc, eox, eoy, eoz, edx, edy, edz, e.from, e.to, null)
|
||||
: intersectObb(sc, e, eox, eoy, eoz, edx, edy, edz);
|
||||
if (hit && element.unrotated != null) {
|
||||
Element.rotate(v, sc.hitNx, sc.hitNy, sc.hitNz, element.xSteps, element.ySteps, false);
|
||||
sc.hitNx = v[0];
|
||||
sc.hitNy = v[1];
|
||||
sc.hitNz = v[2];
|
||||
}
|
||||
if (!hit || !(sc.hitT > minT)) continue;
|
||||
int k = sc.n;
|
||||
if (k == sc.element.length) sc.grow();
|
||||
sc.element[k] = e;
|
||||
sc.t[k] = sc.hitT;
|
||||
sc.dir[k] = sc.hitDir;
|
||||
sc.s[k] = sc.hitS;
|
||||
sc.t2[k] = sc.hitT2;
|
||||
sc.nx[k] = sc.hitNx;
|
||||
sc.ny[k] = sc.hitNy;
|
||||
sc.nz[k] = sc.hitNz;
|
||||
sc.order[k] = i;
|
||||
sc.n = k + 1;
|
||||
}
|
||||
int n = sc.n;
|
||||
if (n == 0) return null;
|
||||
Integer[] idx = sc.idx;
|
||||
for (int k = 0; k < n; k++) idx[k] = k; // Integer cache for k < 128
|
||||
if (n > 1) Arrays.sort(idx, 0, n, sc.byDepth);
|
||||
|
||||
for (int j = 0; j < n; j++) {
|
||||
int k = idx[j];
|
||||
Face face = sc.element[k].faces[sc.dir[k].ordinal()];
|
||||
if (face == null) continue;
|
||||
int color = face.sample(sc.s[k], sc.t2[k]);
|
||||
if (!ColorUtil.opaque(color)) continue;
|
||||
|
||||
double t = sc.t[k];
|
||||
Vector world = new Vector(bx + ox + dx * t, by + oy + dy * t, bz + oz + dz * t);
|
||||
Vector normal = new Vector(sc.nx[k], sc.ny[k], sc.nz[k]);
|
||||
return new FaceHit(t, world, normal, color, face.tintIndex, false, false);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotated element: slab test in the element's local (unrotated, unscaled) frame. Vanilla bakes
|
||||
* {@code origin + R·S·(p - origin)} (S = rescale); the inverse is affine, so t stays the block-space one.
|
||||
*/
|
||||
private static boolean intersectObb(
|
||||
Scratch sc, Element e, double ox, double oy, double oz, double dx, double dy, double dz) {
|
||||
double[] v = sc.v;
|
||||
double[] o = e.rotOrigin;
|
||||
unrotateElement(v, e, ox - o[0], oy - o[1], oz - o[2]);
|
||||
double lox = v[0], loy = v[1], loz = v[2];
|
||||
unrotateElement(v, e, dx, dy, dz);
|
||||
double ldx = v[0], ldy = v[1], ldz = v[2];
|
||||
double[] is = e.invScale;
|
||||
if (is != null) {
|
||||
lox *= is[0];
|
||||
loy *= is[1];
|
||||
loz *= is[2];
|
||||
ldx *= is[0];
|
||||
ldy *= is[1];
|
||||
ldz *= is[2];
|
||||
}
|
||||
return slab(sc, lox + o[0], loy + o[1], loz + o[2], ldx, ldy, ldz, e.from, e.to, e);
|
||||
}
|
||||
|
||||
/** Inverse element rotation (block -> local) of a vector into out, without the rescale. */
|
||||
private static void unrotateElement(double[] out, Element e, double x, double y, double z) {
|
||||
double[] m = e.invRotMatrix;
|
||||
if (m != null) {
|
||||
out[0] = m[0] * x + m[1] * y + m[2] * z;
|
||||
out[1] = m[3] * x + m[4] * y + m[5] * z;
|
||||
out[2] = m[6] * x + m[7] * y + m[8] * z;
|
||||
} else {
|
||||
rotate(out, x, y, z, e.rotAxis, e.invRotCos, e.invRotSin);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Slab test; on a hit the entry (or, from inside, exit) face is left in the scratch's hit fields. {@code obb}:
|
||||
* the rotated element whose local frame the ray is in (normal rotated back), else null.
|
||||
*/
|
||||
private static boolean slab(
|
||||
Scratch sc,
|
||||
double ox,
|
||||
double oy,
|
||||
double oz,
|
||||
double dx,
|
||||
double dy,
|
||||
double dz,
|
||||
double[] from,
|
||||
double[] to,
|
||||
Element obb) {
|
||||
double[] o = sc.o, d = sc.d;
|
||||
o[0] = ox;
|
||||
o[1] = oy;
|
||||
o[2] = oz;
|
||||
d[0] = dx;
|
||||
d[1] = dy;
|
||||
d[2] = dz;
|
||||
if (from[0] > to[0] || from[1] > to[1] || from[2] > to[2]) return invertedBox(sc, from, to, obb);
|
||||
double tmin = Double.NEGATIVE_INFINITY;
|
||||
double tmax = Double.POSITIVE_INFINITY;
|
||||
int axis = -1;
|
||||
boolean negFace = false; // entered through the low-coordinate face
|
||||
|
||||
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 false;
|
||||
continue;
|
||||
}
|
||||
double inv = 1.0 / d[a];
|
||||
double t1 = (from[a] - o[a]) * inv;
|
||||
double t2 = (to[a] - o[a]) * inv;
|
||||
// Entered through the low face when travelling +axis. Decided by the direction, not t1 > t2, so
|
||||
// zero-thickness elements (vines, lichen: from == to) show the face turned towards the ray.
|
||||
boolean neg = inv > 0;
|
||||
if (t1 > t2) {
|
||||
double tmp = t1;
|
||||
t1 = t2;
|
||||
t2 = tmp;
|
||||
}
|
||||
if (t1 > tmin) {
|
||||
tmin = t1;
|
||||
axis = a;
|
||||
negFace = neg;
|
||||
}
|
||||
if (t2 < tmax) tmax = t2;
|
||||
if (tmin > tmax) return false;
|
||||
}
|
||||
if (axis < 0) return false;
|
||||
|
||||
double tEntry = tmin;
|
||||
if (tEntry < EPS) {
|
||||
// Origin inside the box (camera within a block): the exit face.
|
||||
tEntry = tmax;
|
||||
if (tEntry < EPS) return false;
|
||||
}
|
||||
|
||||
double px = o[0] + d[0] * tEntry;
|
||||
double py = o[1] + d[1] * tEntry;
|
||||
double pz = o[2] + d[2] * tEntry;
|
||||
|
||||
Direction dir = Direction.of(axis, negFace);
|
||||
setNormal(sc, obb, dir.nx, dir.ny, dir.nz);
|
||||
double fracX = frac(px, from[0], to[0]);
|
||||
double fracY = frac(py, from[1], to[1]);
|
||||
double fracZ = frac(pz, from[2], to[2]);
|
||||
sc.hitT = tEntry;
|
||||
sc.hitDir = dir;
|
||||
sc.hitS = faceS(dir, fracX, fracY, fracZ);
|
||||
sc.hitT2 = faceT(dir, fracY, fracZ);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Element with {@code from > to} on some axis (spawner/vault/trial-spawner inner faces, snow layer 2's inner
|
||||
* bottom): vanilla places the WEST/DOWN/NORTH face at {@code from} and EAST/UP/SOUTH at {@code to} of its axis,
|
||||
* and every inverted OTHER axis mirrors the quad, flipping its winding. So these faces point into the box and
|
||||
* are seen from inside (through the cage: the far wall). Each face is tested as its own back-face-culled quad;
|
||||
* the nearest visible one wins. Texture coordinates follow the mirrored corners (signed span). The ray is in
|
||||
* the scratch's {@code o}/{@code d}.
|
||||
*/
|
||||
private static boolean invertedBox(Scratch sc, double[] from, double[] to, Element obb) {
|
||||
double[] o = sc.o, d = sc.d;
|
||||
double best = Double.POSITIVE_INFINITY;
|
||||
Direction bestDir = null;
|
||||
for (Direction dir : DIRECTIONS) {
|
||||
double t = invertedFaceT(dir, from, to, o, d, false);
|
||||
if (!(t >= EPS) || t >= best) continue;
|
||||
best = t;
|
||||
bestDir = dir;
|
||||
}
|
||||
if (bestDir == null) return false;
|
||||
int a = bestDir.axis;
|
||||
double sign = invertedFaceSign(bestDir, from, to, false);
|
||||
setNormal(sc, obb, a == 0 ? sign : 0, a == 1 ? sign : 0, a == 2 ? sign : 0);
|
||||
double fx = frac(o[0] + d[0] * best, from[0], to[0]);
|
||||
double fy = frac(o[1] + d[1] * best, from[1], to[1]);
|
||||
double fz = frac(o[2] + d[2] * best, from[2], to[2]);
|
||||
sc.hitT = best;
|
||||
sc.hitDir = bestDir;
|
||||
sc.hitS = faceS(bestDir, fx, fy, fz);
|
||||
sc.hitT2 = faceT(bestDir, fy, fz);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outward normal sign of face {@code dir} of an inverted box along its axis: each inverted OTHER axis flips the
|
||||
* winding, as does a mirroring transform ({@code mirrored}).
|
||||
*/
|
||||
private static double invertedFaceSign(Direction dir, double[] from, double[] to, boolean mirrored) {
|
||||
int b = (dir.axis + 1) % 3, c = (dir.axis + 2) % 3;
|
||||
int flips = (from[b] > to[b] ? 1 : 0) + (from[c] > to[c] ? 1 : 0);
|
||||
boolean neg = dir.nx + dir.ny + dir.nz < 0;
|
||||
return (neg ? -1 : 1) * (flips % 2 == 0 ? 1 : -1) * (mirrored ? -1 : 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ray parameter where the ray {@code o + t·d} meets face {@code dir} of an inverted box (see
|
||||
* {@link #invertedBox}) from its visible side inside the face rectangle; NaN for a back face, a parallel ray or
|
||||
* a miss. Shared with the entity intersector (inverted elements baked as items/displays).
|
||||
*/
|
||||
public static double invertedFaceT(
|
||||
Direction dir, double[] from, double[] to, double[] o, double[] d, boolean mirrored) {
|
||||
int a = dir.axis, b = (a + 1) % 3, c = (a + 2) % 3;
|
||||
if (invertedFaceSign(dir, from, to, mirrored) * d[a] >= 0) return Double.NaN;
|
||||
boolean neg = dir.nx + dir.ny + dir.nz < 0;
|
||||
double t = ((neg ? from[a] : to[a]) - o[a]) / d[a];
|
||||
double pb = o[b] + d[b] * t, pc = o[c] + d[c] * t;
|
||||
if (pb < Math.min(from[b], to[b]) - EPS || pb > Math.max(from[b], to[b]) + EPS) return Double.NaN;
|
||||
if (pc < Math.min(from[c], to[c]) - EPS || pc > Math.max(from[c], to[c]) + EPS) return Double.NaN;
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* The hit normal (local axis normal of the element's frame) in block space: rotated back for {@code obb}. The
|
||||
* rescale is diagonal in the local frame, so an axis normal keeps its direction (n = R·n_local).
|
||||
*/
|
||||
private static void setNormal(Scratch sc, Element obb, double nx, double ny, double nz) {
|
||||
if (obb == null) {
|
||||
sc.hitNx = nx;
|
||||
sc.hitNy = ny;
|
||||
sc.hitNz = nz;
|
||||
return;
|
||||
}
|
||||
double[] m = obb.rotMatrix;
|
||||
if (m != null) {
|
||||
sc.hitNx = m[0] * nx + m[1] * ny + m[2] * nz;
|
||||
sc.hitNy = m[3] * nx + m[4] * ny + m[5] * nz;
|
||||
sc.hitNz = m[6] * nx + m[7] * ny + m[8] * nz;
|
||||
} else {
|
||||
double[] v = sc.v;
|
||||
rotate(v, nx, ny, nz, obb.rotAxis, obb.rotCos, obb.rotSin);
|
||||
sc.hitNx = v[0];
|
||||
sc.hitNy = v[1];
|
||||
sc.hitNz = v[2];
|
||||
}
|
||||
}
|
||||
|
||||
/** Position between {@code from} (0) and {@code to} (1), also when {@code from > to}; clamped to 0..1. */
|
||||
public static double frac(double v, double from, double to) {
|
||||
double span = to - from;
|
||||
if (Math.abs(span) < 1e-6) return 0;
|
||||
double f = (v - from) / span;
|
||||
return f < 0 ? 0 : Math.min(f, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Face-local horizontal coordinate s in 0..1 for a hit at box fractions (fx, fy, fz), like vanilla's
|
||||
* {@code BlockElement.uvsByFace}: north/east faces run towards -x/-z (left to right seen from outside), down runs
|
||||
* v towards north. Shared with the entity intersector (box UVs have the same orientation).
|
||||
*/
|
||||
public static double faceS(Direction dir, double fx, double fy, double fz) {
|
||||
return switch (dir) {
|
||||
case UP, DOWN, SOUTH -> fx;
|
||||
case NORTH -> 1 - fx;
|
||||
case WEST -> fz;
|
||||
case EAST -> 1 - fz;
|
||||
};
|
||||
}
|
||||
|
||||
/** Face-local vertical coordinate t in 0..1 (see {@link #faceS}); t does not depend on fx. */
|
||||
public static double faceT(Direction dir, double fy, double fz) {
|
||||
return switch (dir) {
|
||||
case UP -> fz;
|
||||
case DOWN -> 1 - fz;
|
||||
default -> 1 - fy;
|
||||
};
|
||||
}
|
||||
|
||||
/** Rotates (x, y, z) about axis 0/1/2 (x/y/z) by the angle with the given cos/sin into out. */
|
||||
private static void rotate(double[] out, double x, double y, double z, int axis, double cos, double sin) {
|
||||
switch (axis) {
|
||||
case 0 -> {
|
||||
out[0] = x;
|
||||
out[1] = y * cos - z * sin;
|
||||
out[2] = y * sin + z * cos;
|
||||
}
|
||||
case 1 -> {
|
||||
out[0] = x * cos + z * sin;
|
||||
out[1] = y;
|
||||
out[2] = -x * sin + z * cos;
|
||||
}
|
||||
default -> {
|
||||
out[0] = x * cos - y * sin;
|
||||
out[1] = x * sin + y * cos;
|
||||
out[2] = z;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.raytrace;
|
||||
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
/**
|
||||
* A ray hit on block or entity geometry: world-space point and normal, sampled ARGB texel (before shading/tint),
|
||||
* tint index (-1 = none), additive (emissive energy swirl, added to what lies behind) and unshaded (no cardinal
|
||||
* lighting: eyes, breeze wind).
|
||||
*/
|
||||
public record FaceHit(
|
||||
double t, Vector point, Vector normal, int color, int tintIndex, boolean additive, boolean unshaded) {}
|
||||
@@ -0,0 +1,571 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.raytrace;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
|
||||
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 java.util.EnumSet;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.block.BlockSupport;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.block.data.Levelled;
|
||||
import org.bukkit.block.data.Waterlogged;
|
||||
|
||||
/**
|
||||
* Water and lava like vanilla 26.3 {@code FluidRenderer} (decompiled client): not block models but polygons built
|
||||
* per cell from the fluid state and its neighbours. The top surface has four corner heights (amount / 9, full
|
||||
* under the same fluid, averaged with the neighbours like {@code calculateAverageHeight}) split into the triangles
|
||||
* NW-SW-SE / NW-SE-NE; side faces against every neighbour that is not the same fluid and does not cover the face;
|
||||
* a bottom face. Top and sides are double-sided (the top only if {@code shouldRenderBackwardUpFace}), except side
|
||||
* water faces against {@code HalfTransparentBlock}/{@code LeavesBlock}, which use the overlay texture one-sided; the
|
||||
* bottom is seen from below only. Waterlogged blocks and seagrass/kelp/bubble columns hold a water source.
|
||||
*/
|
||||
public final class FluidRenderer {
|
||||
|
||||
public static final int NONE = 0;
|
||||
static final int WATER = 1;
|
||||
static final int LAVA = 2;
|
||||
|
||||
// Vanilla insets: faces 0.001 inside the cell, the top 0.001 below the computed height.
|
||||
private static final float OFFS = 0.001F;
|
||||
private static final double EPS = 1e-9;
|
||||
|
||||
/** Block access by world position: a {@code WorldSnapshot} or the live world (main thread). */
|
||||
@FunctionalInterface
|
||||
public interface BlockLookup {
|
||||
BlockData at(int x, int y, int z);
|
||||
}
|
||||
|
||||
// LiquidBlockContainers without a waterlogged property: always a water source.
|
||||
private static final EnumSet<Material> ALWAYS_WATER =
|
||||
materials("SEAGRASS", "TALL_SEAGRASS", "KELP", "KELP_PLANT", "BUBBLE_COLUMN");
|
||||
|
||||
// Vanilla HalfTransparentBlock / LeavesBlock (FluidRenderer isOverlay). Blue ice too, but it occludes anyway.
|
||||
private static final EnumSet<Material> OVERLAY_NEIGHBOURS = EnumSet.noneOf(Material.class);
|
||||
// Vanilla tag blocks_fluid_flow = #blocks_motion (Material.isSolid) + #all_signs.
|
||||
private static final EnumSet<Material> SIGNS = EnumSet.noneOf(Material.class);
|
||||
|
||||
static {
|
||||
for (Material m : Material.values()) {
|
||||
if (m.isLegacy()) continue;
|
||||
String n = m.name();
|
||||
if (n.equals("GLASS")
|
||||
|| n.equals("TINTED_GLASS")
|
||||
|| n.endsWith("_STAINED_GLASS")
|
||||
|| n.equals("ICE")
|
||||
|| n.equals("FROSTED_ICE")
|
||||
|| n.equals("BLUE_ICE")
|
||||
|| n.equals("SLIME_BLOCK")
|
||||
|| n.equals("HONEY_BLOCK")
|
||||
|| n.endsWith("_LEAVES")) OVERLAY_NEIGHBOURS.add(m);
|
||||
if (n.endsWith("_SIGN")) SIGNS.add(m);
|
||||
}
|
||||
}
|
||||
|
||||
private static EnumSet<Material> materials(String... names) {
|
||||
EnumSet<Material> set = EnumSet.noneOf(Material.class);
|
||||
for (String n : names) {
|
||||
try {
|
||||
set.add(Material.valueOf(n));
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Older/newer server
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
// Packed fluid state: type (bits 0-1), amount 1..8 (bits 2-5), falling (bit 6), source (bit 7).
|
||||
|
||||
private static final int WATER_SOURCE = WATER | 8 << 2 | 1 << 7;
|
||||
|
||||
/** The fluid state of a block (vanilla {@code BlockState.getFluidState}), packed; 0 = no fluid. */
|
||||
static int state(BlockData data) {
|
||||
Material m = data.getMaterial();
|
||||
if (m == Material.AIR) return 0;
|
||||
if (m == Material.WATER || m == Material.LAVA) {
|
||||
int type = m == Material.WATER ? WATER : LAVA;
|
||||
// LiquidBlock: level 0 = source, 1..7 = flowing amount 8 - level, 8..15 = falling amount 8.
|
||||
int level = data instanceof Levelled l ? l.getLevel() : 0;
|
||||
if (level == 0) return type | 8 << 2 | 1 << 7;
|
||||
if (level >= 8) return type | 8 << 2 | 1 << 6;
|
||||
return type | (8 - level) << 2;
|
||||
}
|
||||
if (ALWAYS_WATER.contains(m)) return WATER_SOURCE;
|
||||
if (data instanceof Waterlogged w && w.isWaterlogged()) return WATER_SOURCE;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int type(int state) {
|
||||
return state & 3;
|
||||
}
|
||||
|
||||
static int amount(int state) {
|
||||
return (state >> 2) & 15;
|
||||
}
|
||||
|
||||
static boolean source(int state) {
|
||||
return (state & 1 << 7) != 0;
|
||||
}
|
||||
|
||||
/** FlowingFluid.getOwnHeight: amount / 9 (0 without fluid). */
|
||||
static float ownHeight(int state) {
|
||||
return amount(state) / 9.0F;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which fluid the camera at {@code (x, y, z)} is in (vanilla {@code Camera.getFluidInCamera}): the eye block's
|
||||
* fluid when the eye is below its {@code FluidState.getHeightForCamera} (1 under the same fluid or for a source
|
||||
* under a sturdy bottom face, else amount / 9).
|
||||
*/
|
||||
public static int cameraFluid(BlockLookup level, double x, double y, double z) {
|
||||
int bx = (int) Math.floor(x), by = (int) Math.floor(y), bz = (int) Math.floor(z);
|
||||
int s = state(level.at(bx, by, bz));
|
||||
int type = type(s);
|
||||
if (type == NONE) return NONE;
|
||||
BlockData above = level.at(bx, by + 1, bz);
|
||||
float h = type(state(above)) == type || (source(s) && sturdyBottom(above)) ? 1 : ownHeight(s);
|
||||
double rel = y - by;
|
||||
// Water: strictly below the surface; lava (near-plane check): at or below it.
|
||||
return type == WATER ? (rel < h ? WATER : NONE) : (rel <= h ? LAVA : NONE);
|
||||
}
|
||||
|
||||
private static boolean sturdyBottom(BlockData data) {
|
||||
if (data.getMaterial().isAir()) return false;
|
||||
try {
|
||||
return data.isFaceSturdy(BlockFace.DOWN, BlockSupport.FULL);
|
||||
} catch (RuntimeException e) {
|
||||
return data.getMaterial().isOccluding();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vanilla sky light level of the cell (x, y, z) under water: 15 minus one per block from this cell up to the
|
||||
* topmost water block of the column (water and non-occluding blocks below the surface cost one level each).
|
||||
* 15 without water in the next 15 blocks.
|
||||
*/
|
||||
static int skyLevel(BlockLookup level, int x, int y, int z) {
|
||||
int water = 0;
|
||||
for (int i = 0; i < 15; i++) {
|
||||
BlockData d = level.at(x, y + i, z);
|
||||
if (type(state(d)) == WATER) {
|
||||
water = i + 1;
|
||||
continue;
|
||||
}
|
||||
Material m = d.getMaterial();
|
||||
if (m.isAir() || m.isOccluding()) break;
|
||||
}
|
||||
return 15 - water;
|
||||
}
|
||||
|
||||
private final BlockModelRegistry registry;
|
||||
private final int[][] waterStill, waterFlow, waterOverlay, lavaStill, lavaFlow;
|
||||
// Bit per Direction: the model covers that whole cell face with opaque texels (approximates vanilla's face
|
||||
// occlusion shape). Identity keys: ResolvedModel is cached per BlockData.
|
||||
private final Map<ResolvedModel, Integer> fullFaces = new ConcurrentHashMap<>();
|
||||
|
||||
FluidRenderer(BlockModelRegistry registry) {
|
||||
this.registry = registry;
|
||||
TextureCache textures = registry.textures();
|
||||
this.waterStill = texture(textures, "block/water_still", 0xB4B1B1B1);
|
||||
this.waterFlow = texture(textures, "block/water_flow", 0xB4AAAAAA);
|
||||
this.waterOverlay = texture(textures, "block/water_overlay", 0xB4A5A5A5);
|
||||
this.lavaStill = texture(textures, "block/lava_still", 0xFFD9691A);
|
||||
this.lavaFlow = texture(textures, "block/lava_flow", 0xFFCF5C15);
|
||||
}
|
||||
|
||||
private static int[][] texture(TextureCache textures, String path, int fallback) {
|
||||
int[][] tex = textures.get(ResourceLocation.parse(path)).orElse(null);
|
||||
return tex != null && tex.length > 0 && tex[0].length > 0 ? tex : new int[][] {{fallback}};
|
||||
}
|
||||
|
||||
/** Samples a sprite at (u, v) in 0..1 of the sprite (vanilla getU/getV), nearest texel. */
|
||||
private static int sample(int[][] tex, double u, double v) {
|
||||
int h = tex.length, w = tex[0].length;
|
||||
int x = Math.clamp((int) Math.floor(u * w), 0, w - 1);
|
||||
int y = Math.clamp((int) Math.floor(v * h), 0, h - 1);
|
||||
return tex[y][x];
|
||||
}
|
||||
|
||||
private int fullFaces(BlockData data) {
|
||||
if (data.getMaterial().isAir()) return 0;
|
||||
// Any pick of weighted variants: vanilla occludes by the state's occlusion shape, not by the drawn variant.
|
||||
return fullFaces(registry.get(data));
|
||||
}
|
||||
|
||||
private int fullFaces(ResolvedModel model) {
|
||||
return fullFaces.computeIfAbsent(model, FluidRenderer::computeFullFaces);
|
||||
}
|
||||
|
||||
/** Faces fully covered by opaque, unrotated element faces on the cell boundary (8x8 sample grid). */
|
||||
private static int computeFullFaces(ResolvedModel model) {
|
||||
int mask = 0;
|
||||
if (model.elements.isEmpty()) return 0;
|
||||
for (Direction d : Direction.values()) {
|
||||
int axis = d.axis;
|
||||
int a1 = axis == 0 ? 1 : 0, a2 = axis == 2 ? 1 : 2;
|
||||
boolean positive = d.nx + d.ny + d.nz > 0;
|
||||
boolean covered = true;
|
||||
for (int i = 0; i < 8 && covered; i++) {
|
||||
for (int j = 0; j < 8 && covered; j++) {
|
||||
double p1 = (i + 0.5) / 8, p2 = (j + 0.5) / 8;
|
||||
boolean any = false;
|
||||
for (Element e : model.elements) {
|
||||
if (e.rotAxis != -1) continue;
|
||||
Face f = e.faces[d.ordinal()];
|
||||
if (f == null || !opaqueTexture(f)) continue;
|
||||
double bound = positive ? e.to[axis] : e.from[axis];
|
||||
if (Math.abs(bound - (positive ? 1 : 0)) > 1e-6) continue;
|
||||
if (p1 < Math.min(e.from[a1], e.to[a1]) || p1 > Math.max(e.from[a1], e.to[a1])) continue;
|
||||
if (p2 < Math.min(e.from[a2], e.to[a2]) || p2 > Math.max(e.from[a2], e.to[a2])) continue;
|
||||
any = true;
|
||||
break;
|
||||
}
|
||||
covered = any;
|
||||
}
|
||||
}
|
||||
if (covered) mask |= 1 << d.ordinal();
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
private static boolean opaqueTexture(Face f) {
|
||||
if (f.texture == null) return false;
|
||||
for (int[] row : f.texture) for (int px : row) if ((px >>> 24) != 0xFF) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** FluidRenderer.isFaceOccludedByState for a neighbour block whose face points at the fluid. */
|
||||
private boolean occludes(BlockData block, Direction face) {
|
||||
return covers(fullFaces(block), face);
|
||||
}
|
||||
|
||||
private static boolean covers(int fullFaces, Direction face) {
|
||||
return (fullFaces & 1 << face.ordinal()) != 0;
|
||||
}
|
||||
|
||||
/** FluidRenderer.getHeight: 1 under the same fluid, own height in it, 0 for non-solid blocks, -1 for solids. */
|
||||
private static float height(BlockLookup level, int type, int x, int y, int z, BlockData data, int state) {
|
||||
if (type(state) == type) {
|
||||
return type(state(level.at(x, y + 1, z))) == type ? 1.0F : ownHeight(state);
|
||||
}
|
||||
return !data.getMaterial().isSolid() ? 0.0F : -1.0F;
|
||||
}
|
||||
|
||||
private static float height(BlockLookup level, int type, int x, int y, int z) {
|
||||
BlockData d = level.at(x, y, z);
|
||||
return height(level, type, x, y, z, d, state(d));
|
||||
}
|
||||
|
||||
/** FluidRenderer.calculateAverageHeight (weights: 10 for heights >= 0.8, 1 below, solids (-1) none). */
|
||||
private static float averageHeight(
|
||||
BlockLookup level, int type, float self, float h2, float h1, int cx, int cy, int cz) {
|
||||
if (h1 >= 1.0F || h2 >= 1.0F) return 1.0F;
|
||||
float corner = -1.0F;
|
||||
if (h1 > 0.0F || h2 > 0.0F) {
|
||||
corner = height(level, type, cx, cy, cz);
|
||||
if (corner >= 1.0F) return 1.0F;
|
||||
}
|
||||
float wc = weight(corner), ws = weight(self), w1 = weight(h1), w2 = weight(h2);
|
||||
// Same summation order as vanilla's running sums.
|
||||
return (0.0F + corner * wc + self * ws + h1 * w1 + h2 * w2) / (0.0F + wc + ws + w1 + w2);
|
||||
}
|
||||
|
||||
private static float weight(float h) {
|
||||
return h >= 0.8F ? 10.0F : h >= 0.0F ? 1.0F : 0.0F;
|
||||
}
|
||||
|
||||
private static final int[][] HORIZONTAL = {{0, -1}, {1, 0}, {0, 1}, {-1, 0}};
|
||||
|
||||
/**
|
||||
* Texture angle of the flowing top sprite from FlowingFluid.getFlow (horizontal part; the falling tweak only adds
|
||||
* y), or NaN for the still texture.
|
||||
*/
|
||||
private static float flowAngle(BlockLookup level, int type, int x, int y, int z, int state) {
|
||||
double fx = 0, fz = 0;
|
||||
float own = ownHeight(state);
|
||||
for (int[] d : HORIZONTAL) {
|
||||
BlockData nd = level.at(x + d[0], y, z + d[1]);
|
||||
int ns = state(nd);
|
||||
if (!affectsFlow(type, ns)) continue;
|
||||
float nh = ownHeight(ns);
|
||||
float distance = 0.0F;
|
||||
if (nh == 0.0F) {
|
||||
if (!blocksFluidFlow(nd)) {
|
||||
int below = state(level.at(x + d[0], y - 1, z + d[1]));
|
||||
if (affectsFlow(type, below)) {
|
||||
nh = ownHeight(below);
|
||||
if (nh > 0.0F) distance = own - (nh - 0.8888889F);
|
||||
}
|
||||
}
|
||||
} else if (nh > 0.0F) {
|
||||
distance = own - nh;
|
||||
}
|
||||
if (distance != 0.0F) {
|
||||
fx += d[0] * distance;
|
||||
fz += d[1] * distance;
|
||||
}
|
||||
}
|
||||
// Vec3.normalize: below 1e-5 the vector is zero (still texture).
|
||||
if (Math.sqrt(fx * fx + fz * fz) < 1.0E-5) return Float.NaN;
|
||||
return (float) Math.atan2(fz, fx) - (float) (Math.PI / 2);
|
||||
}
|
||||
|
||||
private static boolean affectsFlow(int type, int neighbourState) {
|
||||
int t = type(neighbourState);
|
||||
return t == NONE || t == type;
|
||||
}
|
||||
|
||||
private static boolean blocksFluidFlow(BlockData data) {
|
||||
Material m = data.getMaterial();
|
||||
return m.isSolid() || SIGNS.contains(m);
|
||||
}
|
||||
|
||||
/** FluidState.shouldRenderBackwardUpFace: some block of the 3x3 above is neither the fluid nor solid-render. */
|
||||
private static boolean backwardUpFace(BlockLookup level, int type, int x, int y, int z) {
|
||||
for (int ox = -1; ox <= 1; ox++) {
|
||||
for (int oz = -1; oz <= 1; oz++) {
|
||||
BlockData d = level.at(x + ox, y, z + oz);
|
||||
if (type(state(d)) != type && !d.getMaterial().isOccluding()) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A drawn fluid polygon crossed by a ray. */
|
||||
static final class Crossing {
|
||||
double t;
|
||||
int texel;
|
||||
boolean entering; // the ray comes from outside the fluid (front side)
|
||||
double nx, ny, nz; // outward unit normal
|
||||
boolean bottom; // the bottom face, lit by the cell below
|
||||
}
|
||||
|
||||
/**
|
||||
* The nearest drawn fluid polygon of cell (bx, by, bz) (block model {@code model}, fluid {@code state}) crossed
|
||||
* by the ray {@code o + t·d} with {@code t > minT}, or null.
|
||||
*/
|
||||
Crossing nearest(
|
||||
BlockLookup level,
|
||||
int bx,
|
||||
int by,
|
||||
int bz,
|
||||
ResolvedModel model,
|
||||
int state,
|
||||
double ox,
|
||||
double oy,
|
||||
double oz,
|
||||
double dx,
|
||||
double dy,
|
||||
double dz,
|
||||
double minT) {
|
||||
int type = type(state);
|
||||
BlockData down = level.at(bx, by - 1, bz), up = level.at(bx, by + 1, bz);
|
||||
BlockData north = level.at(bx, by, bz - 1), south = level.at(bx, by, bz + 1);
|
||||
BlockData west = level.at(bx - 1, by, bz), east = level.at(bx + 1, by, bz);
|
||||
int sNorth = state(north), sSouth = state(south), sWest = state(west), sEast = state(east);
|
||||
boolean sameUp = type(state(up)) == type, sameDown = type(state(down)) == type;
|
||||
boolean sameN = type(sNorth) == type, sameS = type(sSouth) == type;
|
||||
boolean sameW = type(sWest) == type, sameE = type(sEast) == type;
|
||||
// Own faces covering the cell faces; only looked up when a face other than the top may be drawn.
|
||||
int selfFaces = sameDown && sameN && sameS && sameW && sameE ? 0 : fullFaces(model);
|
||||
|
||||
boolean renderUp = !sameUp;
|
||||
boolean renderDown = !sameDown && !covers(selfFaces, Direction.DOWN) && !occludes(down, Direction.UP);
|
||||
boolean renderN = !sameN && !covers(selfFaces, Direction.NORTH);
|
||||
boolean renderS = !sameS && !covers(selfFaces, Direction.SOUTH);
|
||||
boolean renderW = !sameW && !covers(selfFaces, Direction.WEST);
|
||||
boolean renderE = !sameE && !covers(selfFaces, Direction.EAST);
|
||||
if (!(renderUp || renderDown || renderN || renderS || renderW || renderE)) return null;
|
||||
|
||||
float self = sameUp ? 1.0F : ownHeight(state);
|
||||
float hNE, hNW, hSE, hSW;
|
||||
if (self >= 1.0F) {
|
||||
hNE = hNW = hSE = hSW = 1.0F;
|
||||
} else {
|
||||
float hN = height(level, type, bx, by, bz - 1, north, sNorth);
|
||||
float hS = height(level, type, bx, by, bz + 1, south, sSouth);
|
||||
float hE = height(level, type, bx + 1, by, bz, east, sEast);
|
||||
float hW = height(level, type, bx - 1, by, bz, west, sWest);
|
||||
hNE = averageHeight(level, type, self, hN, hE, bx + 1, by, bz - 1);
|
||||
hNW = averageHeight(level, type, self, hN, hW, bx - 1, by, bz - 1);
|
||||
hSE = averageHeight(level, type, self, hS, hE, bx + 1, by, bz + 1);
|
||||
hSW = averageHeight(level, type, self, hS, hW, bx - 1, by, bz + 1);
|
||||
}
|
||||
float bottom = renderDown ? OFFS : 0.0F;
|
||||
// The top is not drawn under a block covering it when every corner is full.
|
||||
boolean drawUp =
|
||||
renderUp && !(Math.min(Math.min(hNW, hSW), Math.min(hSE, hNE)) >= 1.0F && occludes(up, Direction.DOWN));
|
||||
if (drawUp) {
|
||||
hNW -= OFFS;
|
||||
hSW -= OFFS;
|
||||
hSE -= OFFS;
|
||||
hNE -= OFFS;
|
||||
}
|
||||
|
||||
double lx = ox - bx, ly = oy - by, lz = oz - bz;
|
||||
double bestT = Double.POSITIVE_INFINITY;
|
||||
int bestFace = -1; // 0 top, 1 bottom, 2 N, 3 S, 4 W, 5 E
|
||||
double bestNx = 0, bestNy = 0, bestNz = 0;
|
||||
boolean bestEntering = false;
|
||||
double hitX = 0, hitY = 0, hitZ = 0;
|
||||
boolean sideOverlay = false;
|
||||
double sideS = 0;
|
||||
|
||||
if (drawUp) {
|
||||
Boolean backUp = null;
|
||||
// Triangle 1: NW(0,0) SW(0,1) SE(1,1) (z >= x); triangle 2: NW, SE, NE(1,0) (z < x).
|
||||
for (int tri = 0; tri < 2; tri++) {
|
||||
double a = hNW, b, c;
|
||||
if (tri == 0) {
|
||||
b = hSE - hSW;
|
||||
c = hSW - hNW;
|
||||
} else {
|
||||
b = hNE - hNW;
|
||||
c = hSE - hNE;
|
||||
}
|
||||
// Plane y = a + b·x + c·z, outward normal (-b, 1, -c).
|
||||
double denom = dy - b * dx - c * dz;
|
||||
if (Math.abs(denom) < 1e-12) continue;
|
||||
double t = (a + b * lx + c * lz - ly) / denom;
|
||||
if (!(t > minT) || t >= bestT) continue;
|
||||
double x = lx + dx * t, z = lz + dz * t;
|
||||
if (x < -EPS || x > 1 + EPS || z < -EPS || z > 1 + EPS) continue;
|
||||
if (tri == 0 ? z < x - EPS : z >= x + EPS) continue;
|
||||
boolean entering = denom < 0;
|
||||
if (!entering) {
|
||||
if (backUp == null) backUp = backwardUpFace(level, type, bx, by + 1, bz);
|
||||
if (!backUp) continue;
|
||||
}
|
||||
double len = Math.sqrt(b * b + 1 + c * c);
|
||||
bestT = t;
|
||||
bestFace = 0;
|
||||
bestNx = -b / len;
|
||||
bestNy = 1 / len;
|
||||
bestNz = -c / len;
|
||||
bestEntering = entering;
|
||||
hitX = x;
|
||||
hitY = ly + dy * t;
|
||||
hitZ = z;
|
||||
}
|
||||
}
|
||||
if (renderDown && dy > 0) {
|
||||
double t = (bottom - ly) / dy;
|
||||
if (t > minT && t < bestT) {
|
||||
double x = lx + dx * t, z = lz + dz * t;
|
||||
if (x >= -EPS && x <= 1 + EPS && z >= -EPS && z <= 1 + EPS) {
|
||||
bestT = t;
|
||||
bestFace = 1;
|
||||
bestNx = 0;
|
||||
bestNy = -1;
|
||||
bestNz = 0;
|
||||
bestEntering = true;
|
||||
hitX = x;
|
||||
hitY = bottom;
|
||||
hitZ = z;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Side faces: the neighbour's face towards the fluid, plane, outward sign and the edge heights from vertex 0
|
||||
// to vertex 1 in vanilla's order (N: west->east, S: east->west, W: south->north, E: north->south).
|
||||
for (int face = 2; face <= 5; face++) {
|
||||
boolean render;
|
||||
BlockData neighbour;
|
||||
Direction neighbourFace;
|
||||
float h0, h1;
|
||||
switch (face) {
|
||||
case 2 -> {
|
||||
render = renderN;
|
||||
neighbour = north;
|
||||
neighbourFace = Direction.SOUTH;
|
||||
h0 = hNW;
|
||||
h1 = hNE;
|
||||
}
|
||||
case 3 -> {
|
||||
render = renderS;
|
||||
neighbour = south;
|
||||
neighbourFace = Direction.NORTH;
|
||||
h0 = hSE;
|
||||
h1 = hSW;
|
||||
}
|
||||
case 4 -> {
|
||||
render = renderW;
|
||||
neighbour = west;
|
||||
neighbourFace = Direction.EAST;
|
||||
h0 = hSW;
|
||||
h1 = hNW;
|
||||
}
|
||||
default -> {
|
||||
render = renderE;
|
||||
neighbour = east;
|
||||
neighbourFace = Direction.WEST;
|
||||
h0 = hNE;
|
||||
h1 = hSE;
|
||||
}
|
||||
}
|
||||
if (!render) continue;
|
||||
boolean alongX = face == 2 || face == 3;
|
||||
boolean negative = face == 2 || face == 4;
|
||||
double plane = negative ? OFFS : 1 - OFFS;
|
||||
double dAxis = alongX ? dz : dx;
|
||||
if (Math.abs(dAxis) < 1e-12) continue;
|
||||
double t = (plane - (alongX ? lz : lx)) / dAxis;
|
||||
if (!(t > minT) || t >= bestT) continue;
|
||||
double outward = negative ? -1 : 1;
|
||||
boolean entering = dAxis * outward < 0;
|
||||
if (occludes(neighbour, neighbourFace)) continue;
|
||||
boolean overlay = type == WATER && OVERLAY_NEIGHBOURS.contains(neighbour.getMaterial());
|
||||
if (!entering && overlay) continue;
|
||||
double x = lx + dx * t, y = ly + dy * t, z = lz + dz * t;
|
||||
double along = alongX ? x : z;
|
||||
if (along < -EPS || along > 1 + EPS) continue;
|
||||
double s = alongX ? (negative ? x : 1 - x) : (negative ? 1 - z : z);
|
||||
double top = h0 + (h1 - h0) * s;
|
||||
if (y < bottom - EPS || y > top + EPS) continue;
|
||||
bestT = t;
|
||||
bestFace = face;
|
||||
bestNx = alongX ? 0 : outward;
|
||||
bestNy = 0;
|
||||
bestNz = alongX ? outward : 0;
|
||||
bestEntering = entering;
|
||||
hitX = x;
|
||||
hitY = y;
|
||||
hitZ = z;
|
||||
sideOverlay = overlay;
|
||||
sideS = s;
|
||||
}
|
||||
if (bestFace < 0) return null;
|
||||
|
||||
Crossing best = new Crossing();
|
||||
best.t = bestT;
|
||||
best.entering = bestEntering;
|
||||
best.nx = bestNx;
|
||||
best.ny = bestNy;
|
||||
best.nz = bestNz;
|
||||
best.bottom = bestFace == 1;
|
||||
boolean water = type == WATER;
|
||||
int[][] still = water ? waterStill : lavaStill, flow = water ? waterFlow : lavaFlow;
|
||||
if (bestFace == 0) {
|
||||
float angle = flowAngle(level, type, bx, by, bz, state);
|
||||
if (Float.isNaN(angle)) {
|
||||
best.texel = sample(still, hitX, hitZ);
|
||||
} else {
|
||||
// Flowing sprite turned to the flow (vanilla UVs at the corners; affine over the cell).
|
||||
double sn = Math.sin(angle) * 0.25F, cs = Math.cos(angle) * 0.25F;
|
||||
double u = 0.5 - cs - sn + 2 * cs * hitX + 2 * sn * hitZ;
|
||||
double v = 0.5 - cs + sn - 2 * sn * hitX + 2 * cs * hitZ;
|
||||
best.texel = sample(flow, u, v);
|
||||
}
|
||||
} else if (bestFace == 1) {
|
||||
best.texel = sample(still, hitX, hitZ);
|
||||
} else {
|
||||
best.texel = sample(sideOverlay ? waterOverlay : flow, sideS * 0.5, (1 - hitY) * 0.5);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.raytrace;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.EntityScene;
|
||||
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
|
||||
import eu.mhsl.minecraft.pixelpics.render.sky.SkyRenderer;
|
||||
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
|
||||
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTint;
|
||||
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
|
||||
import eu.mhsl.minecraft.pixelpics.render.tint.TintResolver;
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
/**
|
||||
* Traces a single ray against a {@link WorldSnapshot}: block models ({@link ElementIntersector}), fluid polygons
|
||||
* ({@link FluidRenderer}) and entities, with biome tint, face shading, ambient occlusion, alpha blending of
|
||||
* translucent surfaces, fog and water reflection. No Bukkit world access, so it runs on worker threads.
|
||||
*/
|
||||
public final class SnapshotRaytracer {
|
||||
|
||||
// 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};
|
||||
|
||||
// Texels with alpha >= OPAQUE_ALPHA end the ray; translucent ones are blended over what lies behind them.
|
||||
// A surface after which less than MIN_TRANSMITTANCE (one 8-bit step) of the background would show through ends
|
||||
// the ray too (error <= 1 colour level): water faces (alpha 180/255) stay see-through up to the 5th one, e.g.
|
||||
// through two water blocks with air between them (4 faces) or a waterfall in front of a pool. Bounded so a stack
|
||||
// of glass costs at most a few extra intersection rounds per ray; only rays through that many faces pay.
|
||||
private static final int OPAQUE_ALPHA = 250;
|
||||
private static final int MAX_TRANSLUCENT_LAYERS = 8;
|
||||
private static final double MIN_TRANSMITTANCE = 1 / 255.0;
|
||||
private static final double LAYER_EPS = 1e-5;
|
||||
// layerAlpha marker of an additive (emissive) layer: its colour is added to what lies behind.
|
||||
private static final double ADDITIVE = -1;
|
||||
|
||||
// Water reflection (not vanilla): weight of the reflected ray on the first water surface entered from the air.
|
||||
private static final double WATER_REFLECTION = 0.10;
|
||||
|
||||
// Brightness by sky light level: vanilla lightmap.fsh (level / (4 - 3·level), mixed halfway with notGamma at the
|
||||
// default brightness 0.5, overworld ambient 0). PixelPics has no other light model, so it only matters under
|
||||
// water, where sky light drops by vertical water depth (FluidRenderer.skyLevel). SKY_LIGHT[15] == 1.
|
||||
private static final double[] SKY_LIGHT = new double[16];
|
||||
|
||||
static {
|
||||
for (int level = 0; level < 16; level++) {
|
||||
double l = level / 15.0;
|
||||
double b = l / (4 - 3 * l);
|
||||
double inv = 1 - b;
|
||||
SKY_LIGHT[level] = (b + (1 - inv * inv * inv * inv)) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
private final BlockModelRegistry registry;
|
||||
private final FluidRenderer fluids;
|
||||
private final BiomeTintProvider tintProvider;
|
||||
private final SkyRenderer skyRenderer;
|
||||
private final double maxDistance;
|
||||
private final int reflectionDepth;
|
||||
private final int maxSteps;
|
||||
|
||||
public SnapshotRaytracer(
|
||||
BlockModelRegistry registry,
|
||||
BiomeTintProvider tintProvider,
|
||||
SkyRenderer skyRenderer,
|
||||
double maxDistance,
|
||||
int reflectionDepth) {
|
||||
this.registry = registry;
|
||||
this.fluids = new FluidRenderer(registry);
|
||||
this.tintProvider = tintProvider;
|
||||
this.skyRenderer = skyRenderer;
|
||||
this.maxDistance = maxDistance;
|
||||
this.reflectionDepth = reflectionDepth;
|
||||
this.maxSteps = (int) (maxDistance * 3) + 3;
|
||||
}
|
||||
|
||||
/** The per-photo biome tint blend over {@code snapshot}; once per photo. */
|
||||
public BiomeBlend biomeBlend(WorldSnapshot snapshot) {
|
||||
return new BiomeBlend(snapshot, tintProvider);
|
||||
}
|
||||
|
||||
/**
|
||||
* The medium (air or {@code fluid} with its fog) of the camera at {@code origin}; once per photo. {@code fluid} =
|
||||
* {@link FluidRenderer#cameraFluid} (determined once per photo, also for the FOV).
|
||||
*/
|
||||
public CameraMedium cameraMedium(WorldSnapshot snapshot, Vector origin, int fluid, SkyContext sky) {
|
||||
return CameraMedium.at(snapshot, origin, fluid, tintProvider, sky);
|
||||
}
|
||||
|
||||
public int trace(
|
||||
WorldSnapshot snapshot,
|
||||
Vector origin,
|
||||
Vector direction,
|
||||
SkyContext sky,
|
||||
EntityScene scene,
|
||||
CameraMedium medium,
|
||||
BiomeBlend tints) {
|
||||
return trace(snapshot, origin, direction, sky, scene, reflectionDepth, medium, tints);
|
||||
}
|
||||
|
||||
private int trace(
|
||||
WorldSnapshot snapshot,
|
||||
Vector origin,
|
||||
Vector direction,
|
||||
SkyContext sky,
|
||||
EntityScene scene,
|
||||
int depth,
|
||||
CameraMedium medium,
|
||||
BiomeBlend tints) {
|
||||
double ox = origin.getX(), oy = origin.getY(), oz = origin.getZ();
|
||||
double dx = direction.getX(), dy = direction.getY(), dz = direction.getZ();
|
||||
|
||||
BlockCursor blocks = new BlockCursor(snapshot, origin, direction, sky, scene, depth, medium, tints);
|
||||
boolean entities = scene != null && !scene.isEmpty();
|
||||
|
||||
// Translucent surfaces in front of the final one, front to back (shaded colour, alpha 0..1 or ADDITIVE,
|
||||
// ray t, already lit).
|
||||
int[] layerColor = null;
|
||||
double[] layerAlpha = null, layerT = null;
|
||||
boolean[] layerLit = null;
|
||||
int layers = 0;
|
||||
double transmittance = 1;
|
||||
|
||||
int finalColor = 0;
|
||||
double finalT = Double.POSITIVE_INFINITY; // sky
|
||||
boolean finalLit = false;
|
||||
double minT = 0;
|
||||
BlockHit pending = null; // nearest block surface not consumed yet
|
||||
|
||||
// Surfaces in depth order (blocks, fluid faces and entities interleaved): translucent ones become layers,
|
||||
// the first opaque one ends the ray.
|
||||
while (true) {
|
||||
if (pending == null) pending = blocks.next(minT);
|
||||
double limit = pending != null ? pending.t : maxDistance;
|
||||
FaceHit eh = entities ? scene.nearestHit(ox, oy, oz, dx, dy, dz, minT, limit) : null;
|
||||
int color;
|
||||
double t;
|
||||
boolean additive = false;
|
||||
boolean lit = false;
|
||||
if (eh != null) {
|
||||
// Vanilla NO_CARDINAL_LIGHTING: additive energy swirl, eyes, breeze wind.
|
||||
additive = eh.additive();
|
||||
boolean flat = additive || eh.unshaded();
|
||||
color = flat ? eh.color() : ColorUtil.shade(eh.color(), shadeFactor(eh.normal()));
|
||||
t = eh.t();
|
||||
} else if (pending != null) {
|
||||
color = pending.color;
|
||||
t = pending.t;
|
||||
lit = pending.lit;
|
||||
pending = null;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
int alpha = ColorUtil.alpha(color);
|
||||
double a = alpha / 255.0;
|
||||
if (additive) {
|
||||
// Additive light neither ends nor dims the ray; beyond the layer budget it is dropped.
|
||||
if (layers == MAX_TRANSLUCENT_LAYERS) {
|
||||
minT = t + LAYER_EPS;
|
||||
continue;
|
||||
}
|
||||
} else if (alpha >= OPAQUE_ALPHA
|
||||
|| layers == MAX_TRANSLUCENT_LAYERS
|
||||
|| transmittance * (1 - a) < MIN_TRANSMITTANCE) {
|
||||
finalColor = color;
|
||||
finalT = t;
|
||||
finalLit = lit;
|
||||
break;
|
||||
}
|
||||
if (layerColor == null) {
|
||||
layerColor = new int[MAX_TRANSLUCENT_LAYERS];
|
||||
layerAlpha = new double[MAX_TRANSLUCENT_LAYERS];
|
||||
layerT = new double[MAX_TRANSLUCENT_LAYERS];
|
||||
layerLit = new boolean[MAX_TRANSLUCENT_LAYERS];
|
||||
}
|
||||
layerColor[layers] = color;
|
||||
layerAlpha[layers] = additive ? ADDITIVE : a;
|
||||
layerT[layers] = t;
|
||||
layerLit[layers] = lit;
|
||||
layers++;
|
||||
if (!additive) transmittance *= 1 - a;
|
||||
minT = t + LAYER_EPS;
|
||||
}
|
||||
|
||||
// Compose back to front, each surface fogged; surfaces under water dimmed by their sky light. The sky
|
||||
// colour is only computed when something reads it (most rays end on a near surface).
|
||||
int skyColor = medium.needsSkyColor(finalT) ? skyRenderer.colorFor(direction, origin, sky) : 0;
|
||||
int color;
|
||||
if (Double.isInfinite(finalT)) {
|
||||
color = medium.sky(skyColor, dx, dy, dz);
|
||||
} else {
|
||||
int lit = finalLit ? finalColor : blocks.underwaterLight(finalColor, finalT);
|
||||
color = medium.fog(lit, finalT, skyColor);
|
||||
}
|
||||
for (int i = layers - 1; i >= 0; i--) {
|
||||
if (layerAlpha[i] == ADDITIVE) {
|
||||
double f = medium.fogValue(layerT[i]);
|
||||
color = ColorUtil.add(color, ColorUtil.shade(layerColor[i], 1 - f));
|
||||
} else {
|
||||
int layer = layerLit[i] ? layerColor[i] : blocks.underwaterLight(layerColor[i], layerT[i]);
|
||||
color = ColorUtil.mix(medium.fog(layer, layerT[i], skyColor), color, layerAlpha[i], 1 - layerAlpha[i]);
|
||||
}
|
||||
}
|
||||
return color & 0xFFFFFF;
|
||||
}
|
||||
|
||||
/** A surface found by the {@link BlockCursor}; {@code lit}: carries its own sky light (fluid faces). */
|
||||
private record BlockHit(int color, double t, boolean lit) {}
|
||||
|
||||
/**
|
||||
* Walks the voxel grid along one ray and hands out block surfaces in depth order. It stays on the voxel of the
|
||||
* last returned hit, so a later call (with {@code minT} behind that hit) also finds surfaces further inside the
|
||||
* same block (slime/honey cores, the far side of a pane, the water polygons of a waterlogged block).
|
||||
*/
|
||||
private final class BlockCursor {
|
||||
private final WorldSnapshot snapshot;
|
||||
private final Vector direction;
|
||||
private final SkyContext sky;
|
||||
private final EntityScene scene;
|
||||
private final int depth;
|
||||
private final CameraMedium medium;
|
||||
private final BiomeBlend tints;
|
||||
private final double ox, oy, oz, dx, dy, dz;
|
||||
private final FluidRenderer.BlockLookup blocks;
|
||||
private final VoxelDDA dda;
|
||||
private int steps;
|
||||
private boolean done;
|
||||
|
||||
// Ray parameter from which on the ray is in water (camera in water: -inf; never: +inf): surfaces behind it
|
||||
// may need the underwater light, surfaces in front of it (also entities and layers composed after the whole
|
||||
// walk) never do. Set by the first water cell the walk enters: the water face it enters through, else the
|
||||
// cell entry.
|
||||
private double waterT;
|
||||
private boolean reflected;
|
||||
|
||||
BlockCursor(
|
||||
WorldSnapshot snapshot,
|
||||
Vector origin,
|
||||
Vector direction,
|
||||
SkyContext sky,
|
||||
EntityScene scene,
|
||||
int depth,
|
||||
CameraMedium medium,
|
||||
BiomeBlend tints) {
|
||||
this.snapshot = snapshot;
|
||||
this.tints = tints;
|
||||
this.direction = direction;
|
||||
this.sky = sky;
|
||||
this.scene = scene;
|
||||
this.depth = depth;
|
||||
this.medium = medium;
|
||||
this.ox = origin.getX();
|
||||
this.oy = origin.getY();
|
||||
this.oz = origin.getZ();
|
||||
this.dx = direction.getX();
|
||||
this.dy = direction.getY();
|
||||
this.dz = direction.getZ();
|
||||
this.blocks = snapshot::getBlockData;
|
||||
this.dda = new VoxelDDA(ox, oy, oz, dx, dy, dz);
|
||||
this.waterT = medium.fluid() == FluidRenderer.WATER ? Double.NEGATIVE_INFINITY : Double.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
/** The next block surface with {@code t > minT}, or null when the ray leaves the render distance. */
|
||||
BlockHit next(double minT) {
|
||||
while (!done) {
|
||||
if (steps >= maxSteps || dda.tCurrent > maxDistance) {
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
int bx = dda.x, by = dda.y, bz = dda.z;
|
||||
BlockData data = snapshot.getBlockData(bx, by, bz);
|
||||
Material material = data.getMaterial();
|
||||
if (material == Material.AIR) {
|
||||
advance();
|
||||
continue;
|
||||
}
|
||||
ResolvedModel model = registry.get(data, bx, by, bz);
|
||||
FaceHit hit =
|
||||
ElementIntersector.intersect(model, ox - bx, oy - by, oz - bz, dx, dy, dz, bx, by, bz, minT);
|
||||
int fluidState = FluidRenderer.state(data);
|
||||
if (fluidState != 0) {
|
||||
int type = FluidRenderer.type(fluidState);
|
||||
FluidRenderer.Crossing c =
|
||||
fluids.nearest(blocks, bx, by, bz, model, fluidState, ox, oy, oz, dx, dy, dz, minT);
|
||||
if (type == FluidRenderer.WATER && waterT == Double.POSITIVE_INFINITY) {
|
||||
// An exit face first: the ray was in the water from the cell entry on.
|
||||
waterT = c != null && c.entering ? c.t : dda.tCurrent;
|
||||
}
|
||||
if (c != null && (hit == null || c.t < hit.t())) return fluidHit(c, type, bx, by, bz);
|
||||
}
|
||||
if (hit == null) {
|
||||
advance();
|
||||
continue;
|
||||
}
|
||||
// Shading and tint keep the texel alpha, so the culling test can run first.
|
||||
if (ColorUtil.alpha(hit.color()) < OPAQUE_ALPHA && culledByNeighbour(hit, data, bx, by, bz)) {
|
||||
minT = hit.t();
|
||||
continue;
|
||||
}
|
||||
return new BlockHit(shadeAndTint(hit, data, snapshot, tints, bx, by, bz), hit.t(), false);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void advance() {
|
||||
dda.advance();
|
||||
steps++;
|
||||
}
|
||||
|
||||
/**
|
||||
* A crossed fluid polygon as a surface: texel × biome water colour × face shade × sky light of the face's
|
||||
* cell (the one below for the bottom face; max with the cell above, like vanilla's light coords). Lava is
|
||||
* neither tinted nor dimmed. The first water surface entered from the air also reflects.
|
||||
*/
|
||||
private BlockHit fluidHit(FluidRenderer.Crossing c, int type, int bx, int by, int bz) {
|
||||
int color = c.texel;
|
||||
boolean water = type == FluidRenderer.WATER;
|
||||
double light = 1;
|
||||
if (water) {
|
||||
BiomeTint tint = tints.at(bx, by, bz);
|
||||
if (tint != null) color = ColorUtil.multiply(color, tint.water());
|
||||
int y = c.bottom ? by - 1 : by;
|
||||
int level = Math.max(
|
||||
FluidRenderer.skyLevel(blocks, bx, y, bz), FluidRenderer.skyLevel(blocks, bx, y + 1, bz));
|
||||
if (level < 15) light = SKY_LIGHT[level];
|
||||
}
|
||||
color = ColorUtil.shade(color, shadeFactor(c.nx, c.ny, c.nz) * light);
|
||||
if (water && c.entering && !reflected && depth > 0 && !medium.inFluid()) {
|
||||
reflected = true;
|
||||
double px = ox + dx * c.t, py = oy + dy * c.t, pz = oz + dz * c.t;
|
||||
Vector normal = new Vector(c.nx, c.ny, c.nz);
|
||||
Vector reflectDir = MathUtil.reflectVector(direction, normal);
|
||||
Vector reflectStart = new Vector(px, py, pz).add(normal.clone().multiply(1e-3));
|
||||
int reflection =
|
||||
trace(snapshot, reflectStart, reflectDir, sky, scene, depth - 1, CameraMedium.AIR, tints);
|
||||
// mix(layer over behind, reflection) as one layer with alpha a' = 1 - (1-a)(1-r).
|
||||
double a = ColorUtil.alpha(color) / 255.0, r = WATER_REFLECTION;
|
||||
double a2 = 1 - (1 - a) * (1 - r);
|
||||
double wl = a * (1 - r) / a2, wr = r / a2;
|
||||
int mixed = ColorUtil.mix(color, reflection, wl, wr);
|
||||
color = ColorUtil.argb(
|
||||
(int) Math.round(a2 * 255),
|
||||
ColorUtil.red(mixed),
|
||||
ColorUtil.green(mixed),
|
||||
ColorUtil.blue(mixed));
|
||||
}
|
||||
return new BlockHit(color, c.t, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code color} of a surface at ray parameter {@code t} dimmed by the sky light of the block in front of it
|
||||
* (back along the ray), when the ray is in water there.
|
||||
*/
|
||||
int underwaterLight(int color, double t) {
|
||||
if (t < waterT) return color;
|
||||
double back = 1e-4 / Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
int bx = (int) Math.floor(ox + dx * (t - back));
|
||||
int by = (int) Math.floor(oy + dy * (t - back));
|
||||
int bz = (int) Math.floor(oz + dz * (t - back));
|
||||
int level = FluidRenderer.skyLevel(blocks, bx, by, bz);
|
||||
return level >= 15 ? color : ColorUtil.shade(color, SKY_LIGHT[level]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vanilla skips the faces between two translucent blocks of the same kind (HalfTransparentBlock: stained
|
||||
* glass walls, ice, connected panes): the hit lies on the block boundary and the neighbour is the same type.
|
||||
*/
|
||||
private boolean culledByNeighbour(FaceHit hit, BlockData data, int bx, int by, int bz) {
|
||||
Vector n = hit.normal();
|
||||
double nx = n.getX(), ny = n.getY(), nz = n.getZ();
|
||||
int sx = 0, sy = 0, sz = 0;
|
||||
double local;
|
||||
if (Math.abs(nx) > 0.99) {
|
||||
sx = nx > 0 ? 1 : -1;
|
||||
local = hit.point().getX() - bx;
|
||||
} else if (Math.abs(ny) > 0.99) {
|
||||
sy = ny > 0 ? 1 : -1;
|
||||
local = hit.point().getY() - by;
|
||||
} else if (Math.abs(nz) > 0.99) {
|
||||
sz = nz > 0 ? 1 : -1;
|
||||
local = hit.point().getZ() - bz;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
double boundary = (sx + sy + sz) > 0 ? 1 : 0;
|
||||
if (Math.abs(local - boundary) > 1e-4) return false;
|
||||
return snapshot.getBlockData(bx + sx, by + sy, bz + sz).getMaterial() == data.getMaterial();
|
||||
}
|
||||
}
|
||||
|
||||
private int shadeAndTint(
|
||||
FaceHit hit, BlockData data, WorldSnapshot snapshot, BiomeBlend tints, int bx, int by, int bz) {
|
||||
int color = hit.color();
|
||||
if (hit.tintIndex() >= 0) {
|
||||
BiomeTint tint = tints.at(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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smooth ambient occlusion like vanilla: each face corner is darkened by how many of the three blocks touching
|
||||
* it (in the layer just outside the face) are solid, bilinearly interpolated across the face. Axis-aligned
|
||||
* faces only.
|
||||
*/
|
||||
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;
|
||||
|
||||
double lx = hit.point().getX() - bx;
|
||||
double ly = hit.point().getY() - by;
|
||||
double lz = hit.point().getZ() - bz;
|
||||
|
||||
// In-plane axes u = (ux, 0, uz), v = (0, vy, vz) and the face coordinates along them.
|
||||
int ux, uz, vy, vz;
|
||||
double su, sv;
|
||||
if (ay > 0.5) { // up/down
|
||||
ux = 1;
|
||||
uz = 0;
|
||||
vy = 0;
|
||||
vz = 1;
|
||||
su = lx;
|
||||
sv = lz;
|
||||
} else if (ax > 0.5) { // east/west
|
||||
ux = 0;
|
||||
uz = 1;
|
||||
vy = 1;
|
||||
vz = 0;
|
||||
su = lz;
|
||||
sv = ly;
|
||||
} else { // north/south
|
||||
ux = 1;
|
||||
uz = 0;
|
||||
vy = 1;
|
||||
vz = 0;
|
||||
su = lx;
|
||||
sv = ly;
|
||||
}
|
||||
// The cell just outside the face.
|
||||
int x = bx + (int) Math.round(nx), y = by + (int) Math.round(ny), z = bz + (int) Math.round(nz);
|
||||
boolean uMinus = solid(snapshot, x - ux, y, z - uz), uPlus = solid(snapshot, x + ux, y, z + uz);
|
||||
boolean vMinus = solid(snapshot, x, y - vy, z - vz), vPlus = solid(snapshot, x, y + vy, z + vz);
|
||||
double b00 = aoBrightness(uMinus, vMinus, solid(snapshot, x - ux, y - vy, z - uz - vz));
|
||||
double b10 = aoBrightness(uPlus, vMinus, solid(snapshot, x + ux, y - vy, z + uz - vz));
|
||||
double b01 = aoBrightness(uMinus, vPlus, solid(snapshot, x - ux, y + vy, z - uz + vz));
|
||||
double b11 = aoBrightness(uPlus, vPlus, solid(snapshot, x + ux, y + vy, z + uz + vz));
|
||||
|
||||
double top = b00 + (b10 - b00) * su;
|
||||
double bottom = b01 + (b11 - b01) * su;
|
||||
return top + (bottom - top) * sv;
|
||||
}
|
||||
|
||||
private static double aoBrightness(boolean side1, boolean side2, boolean corner) {
|
||||
int level = (side1 && side2) ? 0 : 3 - (side1 ? 1 : 0) - (side2 ? 1 : 0) - (corner ? 1 : 0);
|
||||
return AO_BRIGHTNESS[level];
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
private static double shadeFactor(Vector normal) {
|
||||
return shadeFactor(normal.getX(), normal.getY(), normal.getZ());
|
||||
}
|
||||
|
||||
/** Vanilla directional shading by the dominant normal axis: up 1.0, down 0.5, north/south 0.8, east/west 0.6. */
|
||||
private static double shadeFactor(double nx, double ny, double nz) {
|
||||
double ax = Math.abs(nx), ay = Math.abs(ny), az = Math.abs(nz);
|
||||
if (ay >= ax && ay >= az) return ny >= 0 ? 1.0 : 0.5;
|
||||
if (az >= ax) return 0.8;
|
||||
return 0.6;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.raytrace;
|
||||
|
||||
/** Amanatides-Woo voxel traversal: the integer block cells a ray passes through, in order. */
|
||||
final class VoxelDDA {
|
||||
|
||||
int x, y, z;
|
||||
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;
|
||||
|
||||
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.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;
|
||||
}
|
||||
|
||||
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,269 @@
|
||||
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.BiomeBlend;
|
||||
import eu.mhsl.minecraft.pixelpics.render.raytrace.CameraMedium;
|
||||
import eu.mhsl.minecraft.pixelpics.render.raytrace.FluidRenderer;
|
||||
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.BiomeClimate;
|
||||
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.SpecialDates;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.DataBufferInt;
|
||||
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.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
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 {
|
||||
|
||||
/** Vertical field of view in degrees (vanilla's default FOV option is vertical too). */
|
||||
public static final double V_FOV_DEG = 70;
|
||||
// Camera.getFov in water/lava: fov · lerp(fovEffectScale = 1, 1, 6/7).
|
||||
private static final double FLUID_FOV_SCALE = 6.0 / 7.0;
|
||||
|
||||
private static final double MAX_DISTANCE = 256;
|
||||
// Fog colours of the vanilla 26.3 dimension types / nether_wastes (the default of a nether biome without one).
|
||||
private static final int NETHER_FOG_COLOR = 0x330808;
|
||||
private static final int END_FOG_COLOR = 0x181318;
|
||||
private static final int REFLECTION_DEPTH = 4;
|
||||
|
||||
/** 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;
|
||||
ItemGeometry items = new ItemGeometry(
|
||||
registry.reader(),
|
||||
registry,
|
||||
textures,
|
||||
entityBaker == null ? null : entityBaker.models(),
|
||||
blockEntityBaker);
|
||||
if (entityBaker != null) entityBaker.setItemGeometry(items);
|
||||
if (blockEntityBaker != null) {
|
||||
blockEntityBaker.setItemGeometry(items); // campfire/shelf/vault contents
|
||||
blockEntityBaker.setEntityBaker(entityBaker); // spawner mobs
|
||||
}
|
||||
this.decorationBaker = new DecorationBaker(textures, registry, items);
|
||||
this.objectBaker = new ObjectBaker(items, blockEntityBaker == null ? null : blockEntityBaker.font());
|
||||
this.logger = logger;
|
||||
this.tracePool = tracePool;
|
||||
}
|
||||
|
||||
/** 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.width() * SSAA;
|
||||
int superH = resolution.height() * SSAA;
|
||||
World world = eyeLocation.getWorld();
|
||||
// Determined once, here: the FOV needs it before the snapshot exists, and execute's fog uses the same value.
|
||||
int cameraFluid = cameraFluid(eyeLocation);
|
||||
RayMap rayMap = rayMap(eyeLocation, superW, superH, cameraFluid);
|
||||
FrustumBounds bounds = FrustumBounds.of(eyeLocation, rayMap, MAX_DISTANCE);
|
||||
WorldSnapshot snapshot = SnapshotBuilder.build(world, eyeLocation, bounds, logger);
|
||||
Collection<Entity> nearby = bounds.nearbyEntities(world);
|
||||
List<EntityState> entities = EntitySnapshotBuilder.build(nearby, shooter);
|
||||
List<BlockEntityState> blockEntities = BlockEntitySnapshotBuilder.build(eyeLocation, bounds);
|
||||
List<DecorationState> decorations = DecorationSnapshotBuilder.build(nearby);
|
||||
List<ObjectState> objects = ObjectSnapshotBuilder.build(nearby, eyeLocation);
|
||||
|
||||
long dayTime = world.getTime();
|
||||
long fullTime = world.getFullTime();
|
||||
int moonPhase = (int) (fullTime / 24000L % 8L);
|
||||
SkyContext sky =
|
||||
switch (world.getEnvironment()) {
|
||||
case NETHER -> new SkyContext(
|
||||
dayTime,
|
||||
moonPhase,
|
||||
fullTime,
|
||||
SkyContext.Skybox.NONE,
|
||||
BiomeClimate.fogColor(
|
||||
world.getBiome(eyeLocation).getKey().getKey(), NETHER_FOG_COLOR));
|
||||
case THE_END -> new SkyContext(dayTime, moonPhase, fullTime, SkyContext.Skybox.END, END_FOG_COLOR);
|
||||
default -> new SkyContext(dayTime, moonPhase, fullTime);
|
||||
};
|
||||
|
||||
return new RenderJob(
|
||||
snapshot,
|
||||
rayMap,
|
||||
eyeLocation.toVector(),
|
||||
cameraFluid,
|
||||
resolution.width(),
|
||||
resolution.height(),
|
||||
sky,
|
||||
entities,
|
||||
blockEntities,
|
||||
decorations,
|
||||
objects,
|
||||
SpecialDates.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* The chunks {@link #prepare} will capture for this view (with a block of slack: computed from one ray per output
|
||||
* pixel instead of the supersampled ones). Main thread.
|
||||
*/
|
||||
public SnapshotBuilder.ChunkArea chunkArea(Location eyeLocation, Resolution resolution) {
|
||||
RayMap rayMap = rayMap(eyeLocation, resolution.width(), resolution.height(), cameraFluid(eyeLocation));
|
||||
return SnapshotBuilder.chunkArea(eyeLocation, FrustumBounds.of(eyeLocation, rayMap, MAX_DISTANCE), 2);
|
||||
}
|
||||
|
||||
/** The fluid at the eye; an unloaded chunk counts as air (never loads one, see {@link SnapshotBuilder}). */
|
||||
private static int cameraFluid(Location eye) {
|
||||
World world = eye.getWorld();
|
||||
BlockData air = Material.AIR.createBlockData();
|
||||
return FluidRenderer.cameraFluid(
|
||||
(x, y, z) -> world.isChunkLoaded(x >> 4, z >> 4) ? world.getBlockData(x, y, z) : air,
|
||||
eye.getX(),
|
||||
eye.getY(),
|
||||
eye.getZ());
|
||||
}
|
||||
|
||||
private static RayMap rayMap(Location eye, int width, int height, int cameraFluid) {
|
||||
return RayMap.of(
|
||||
eye, width, height, cameraFluid != FluidRenderer.NONE ? V_FOV_DEG * FLUID_FOV_SCALE : V_FOV_DEG);
|
||||
}
|
||||
|
||||
/** 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 abortable: once {@code cancelled} is set (or the tracing thread is
|
||||
* interrupted) the remaining work is skipped and the returned image is partial (to be discarded).
|
||||
*/
|
||||
public BufferedImage execute(RenderJob job, AtomicBoolean cancelled) {
|
||||
int finalW = job.width();
|
||||
int finalH = job.height();
|
||||
int superW = finalW * SSAA;
|
||||
RayMap rayMap = job.rayMap();
|
||||
WorldSnapshot snapshot = job.snapshot();
|
||||
Vector origin = job.origin();
|
||||
SkyContext sky = job.sky();
|
||||
// Date-dependent assets (christmas chests, local_time item models) use the photo's time throughout.
|
||||
EntityScene scene = SpecialDates.at(
|
||||
job.time(),
|
||||
() -> new EntityScene(
|
||||
job.entities(),
|
||||
entityBaker,
|
||||
job.blockEntities(),
|
||||
blockEntityBaker,
|
||||
job.decorations(),
|
||||
decorationBaker,
|
||||
job.objects(),
|
||||
objectBaker));
|
||||
|
||||
CameraMedium medium = raytracer.cameraMedium(snapshot, origin, job.cameraFluid(), sky);
|
||||
BiomeBlend tints = raytracer.biomeBlend(snapshot);
|
||||
int[] superBuf = new int[rayMap.size()];
|
||||
runParallel(() -> IntStream.range(0, rayMap.size()).parallel().forEach(i -> {
|
||||
if (!stopped(cancelled))
|
||||
superBuf[i] = raytracer.trace(snapshot, origin, rayMap.get(i), sky, scene, medium, tints);
|
||||
}));
|
||||
|
||||
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 (stopped(cancelled)) 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;
|
||||
}
|
||||
|
||||
/** Cancelled, or the worker was interrupted (pool shut down), which then cancels the whole job. */
|
||||
private static boolean stopped(AtomicBoolean cancelled) {
|
||||
if (cancelled.get()) return true;
|
||||
if (!Thread.currentThread().isInterrupted()) return false;
|
||||
cancelled.set(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a parallel-stream task in the {@link #tracePool} (parallel streams adopt the pool of the running fork-join
|
||||
* worker), or on the common pool without one (offline render tools).
|
||||
*/
|
||||
private void runParallel(Runnable task) {
|
||||
if (tracePool == null) {
|
||||
task.run();
|
||||
} else {
|
||||
tracePool.submit(task).join();
|
||||
}
|
||||
}
|
||||
|
||||
/** {@link RayMap#of} with the default vertical FOV. */
|
||||
public static RayMap buildRayMap(Location eye, int width, int height) {
|
||||
return RayMap.of(eye, width, height, V_FOV_DEG);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.render;
|
||||
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
/**
|
||||
* The camera rays of a photo, computed on demand instead of stored (a stored ray map of a 384×384 photo held 147k
|
||||
* {@link Vector}s, ~6 MB per queued photo). Pinhole camera like vanilla's projection: {@code ray = forward +
|
||||
* u·tan(fovX/2)·right + v·tan(fovY/2)·up} with u, v ∈ [-1, 1] at the pixel centres, row 0 = top; {@code tan(fovX/2)
|
||||
* = tan(fovY/2)·w/h}. Right/up come from the yaw of the eye (no roll), so looking straight up or down keeps the yaw
|
||||
* orientation. Immutable and thread-safe.
|
||||
*/
|
||||
public final class RayMap {
|
||||
|
||||
private final int width, height;
|
||||
private final double tanX, tanY;
|
||||
private final double fx, fy, fz, rx, ry, rz, ux, uy, uz;
|
||||
|
||||
private RayMap(int width, int height, double tanX, double tanY, Vector forward, Vector right, Vector up) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.tanX = tanX;
|
||||
this.tanY = tanY;
|
||||
this.fx = forward.getX();
|
||||
this.fy = forward.getY();
|
||||
this.fz = forward.getZ();
|
||||
this.rx = right.getX();
|
||||
this.ry = right.getY();
|
||||
this.rz = right.getZ();
|
||||
this.ux = up.getX();
|
||||
this.uy = up.getY();
|
||||
this.uz = up.getZ();
|
||||
}
|
||||
|
||||
/** {@code width × height} rays from {@code eye}'s view with the vertical FOV {@code vFovDeg}. */
|
||||
public static RayMap of(Location eye, int width, int height, double vFovDeg) {
|
||||
double yaw = Math.toRadians(eye.getYaw());
|
||||
Vector forward = eye.getDirection();
|
||||
// Player's right in Minecraft's coordinates (yaw 0 = south/+Z → right = west/−X).
|
||||
Vector right = new Vector(-Math.cos(yaw), 0, -Math.sin(yaw));
|
||||
Vector up = right.getCrossProduct(forward).normalize();
|
||||
|
||||
double tanY = Math.tan(Math.toRadians(vFovDeg) / 2);
|
||||
double tanX = tanY * width / height;
|
||||
return new RayMap(width, height, tanX, tanY, forward, right, up);
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return width * height;
|
||||
}
|
||||
|
||||
/** The normalized direction of ray {@code i} (row-major, row 0 = top); a new vector per call. */
|
||||
public Vector get(int i) {
|
||||
int row = i / width, col = i % width;
|
||||
double v = (1 - 2 * (row + 0.5) / height) * tanY;
|
||||
double u = (2 * (col + 0.5) / width - 1) * tanX;
|
||||
return new Vector(fx + u * rx + v * ux, fy + u * ry + v * uy, fz + u * rz + v * uz).normalize();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.render;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.ObjectState;
|
||||
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
|
||||
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.List;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
/**
|
||||
* A prepared render: everything captured on the main thread (world snapshot, ray map, camera origin and the fluid
|
||||
* it is in, sky state, entity/block-entity/decoration/object states, the time for date-dependent assets).
|
||||
* {@link DefaultScreenRenderer#execute} runs it off the main thread.
|
||||
*
|
||||
* @param cameraFluid {@code FluidRenderer.cameraFluid} at the origin (NONE/WATER/LAVA), determined once in
|
||||
* {@code prepare}: it sets the FOV of the ray map and the fog
|
||||
*/
|
||||
public record RenderJob(
|
||||
WorldSnapshot snapshot,
|
||||
RayMap rayMap,
|
||||
Vector origin,
|
||||
int cameraFluid,
|
||||
int width,
|
||||
int height,
|
||||
SkyContext sky,
|
||||
List<EntityState> entities,
|
||||
List<BlockEntityState> blockEntities,
|
||||
List<DecorationState> decorations,
|
||||
List<ObjectState> objects,
|
||||
ZonedDateTime time) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.render;
|
||||
|
||||
import com.google.common.base.Preconditions;
|
||||
|
||||
public record Resolution(int width, int height) {
|
||||
|
||||
public Resolution {
|
||||
Preconditions.checkArgument(width > 0);
|
||||
Preconditions.checkArgument(height > 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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), the absolute world time (for continuous cloud drift), the dimension's sky box and its fog colour (Nether:
|
||||
* the camera biome's {@code fog_color}, End: the dimension's). Immutable so it can be read from worker threads.
|
||||
*/
|
||||
public record SkyContext(long dayTime, int moonPhase, long fullTime, Skybox skybox, int fogColor) {
|
||||
|
||||
/** Like vanilla's {@code DimensionType.skybox}; {@code NONE} (the Nether) shows only the fog colour. */
|
||||
public enum Skybox {
|
||||
OVERWORLD,
|
||||
NONE,
|
||||
END
|
||||
}
|
||||
|
||||
/** An overworld sky. */
|
||||
public SkyContext(long dayTime, int moonPhase, long fullTime) {
|
||||
this(dayTime, moonPhase, fullTime, Skybox.OVERWORLD, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* Sky colour of rays that escape the world. Overworld (not vanilla's sky renderer): day/night gradient with twilight
|
||||
* glow, sun and moon (with phase), stars and vanilla's cloud layer. The Nether has no sky (fog colour) and the End
|
||||
* vanilla's textured sky box ({@code SkyRenderer.buildEndSky}). Immutable, so 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
|
||||
|
||||
// sin of the angular half-size.
|
||||
private static final double SUN_SIN_HALF = Math.sin(0.085);
|
||||
private static final double MOON_SIN_HALF = Math.sin(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);
|
||||
|
||||
// End sky box (vanilla SkyRenderer.buildEndSky): 6 quads at distance 100, the texture repeated 16 times per face,
|
||||
// vertex colour #282828.
|
||||
private static final double END_SKY_REPEAT = 16;
|
||||
private static final int END_SKY_TINT = 0x28;
|
||||
// Face poses of the end sky quads: identity, rotX(90°), rotX(-90°), rotX(180°), rotZ(90°), rotZ(-90°), as
|
||||
// {axis (0 none, 1 x, 2 z), cos, sin}.
|
||||
private static final double[][] END_FACES = {
|
||||
endFace(0, 0),
|
||||
endFace(1, Math.PI / 2),
|
||||
endFace(1, -Math.PI / 2),
|
||||
endFace(1, Math.PI),
|
||||
endFace(2, Math.PI / 2),
|
||||
endFace(2, -Math.PI / 2)
|
||||
};
|
||||
|
||||
private static double[] endFace(int axis, double angle) {
|
||||
return new double[] {axis, Math.cos(angle), Math.sin(angle)};
|
||||
}
|
||||
|
||||
private final int[][] moonTexture;
|
||||
private final int[][] cloudTexture;
|
||||
private final int[][] endSkyTexture;
|
||||
|
||||
public SkyRenderer(TextureCache textures) {
|
||||
this.moonTexture =
|
||||
textures.get(ResourceLocation.parse("environment/moon_phases")).orElse(null);
|
||||
this.cloudTexture =
|
||||
textures.get(ResourceLocation.parse("environment/clouds")).orElse(null);
|
||||
this.endSkyTexture =
|
||||
textures.get(ResourceLocation.parse("environment/end_sky")).orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The end sky in a direction: the quad of vanilla's sky box hit by the ray, its texel (nearest, repeating) times
|
||||
* the vertex colour.
|
||||
*/
|
||||
private int endSky(double dx, double dy, double dz) {
|
||||
int[][] tex = endSkyTexture;
|
||||
if (tex == null || tex.length == 0) return rgb(END_SKY_TINT, END_SKY_TINT, END_SKY_TINT);
|
||||
// The quad whose pose maps its plane y = -100 onto the ray: q = pose^T d with the most negative q.y.
|
||||
double bestY = 0, qx = 0, qz = 0;
|
||||
for (double[] face : END_FACES) {
|
||||
double c = face[1], sn = face[2];
|
||||
double x, y, z;
|
||||
if (face[0] == 1) { // rotX(a)^T
|
||||
x = dx;
|
||||
y = dy * c + dz * sn;
|
||||
z = -dy * sn + dz * c;
|
||||
} else if (face[0] == 2) { // rotZ(a)^T
|
||||
x = dx * c + dy * sn;
|
||||
y = -dx * sn + dy * c;
|
||||
z = dz;
|
||||
} else {
|
||||
x = dx;
|
||||
y = dy;
|
||||
z = dz;
|
||||
}
|
||||
if (y < bestY) {
|
||||
bestY = y;
|
||||
qx = x;
|
||||
qz = z;
|
||||
}
|
||||
}
|
||||
if (bestY >= 0) return rgb(END_SKY_TINT, END_SKY_TINT, END_SKY_TINT);
|
||||
double t = -100 / bestY;
|
||||
double u = (qx * t + 100) / 200 * END_SKY_REPEAT;
|
||||
double v = (qz * t + 100) / 200 * END_SKY_REPEAT;
|
||||
int h = tex.length, w = tex[0].length;
|
||||
int px = Math.floorMod((int) Math.floor(u * w), w);
|
||||
int py = Math.floorMod((int) Math.floor(v * h), h);
|
||||
int texel = tex[py][px];
|
||||
return rgb(
|
||||
ColorUtil.red(texel) * END_SKY_TINT / 255,
|
||||
ColorUtil.green(texel) * END_SKY_TINT / 255,
|
||||
ColorUtil.blue(texel) * END_SKY_TINT / 255);
|
||||
}
|
||||
|
||||
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;
|
||||
switch (ctx.skybox()) {
|
||||
case NONE -> {
|
||||
return ctx.fogColor() & 0xFFFFFF;
|
||||
}
|
||||
case END -> {
|
||||
return endSky(dx, dy, dz);
|
||||
}
|
||||
case OVERWORLD -> {}
|
||||
}
|
||||
|
||||
// Vanilla sun direction: rotated by celestialAngle·360° about X (after -90° about Y)
|
||||
// = (-sin(2π·ca), cos(2π·ca), 0).
|
||||
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.
|
||||
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 = ColorUtil.add(color, rgb(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);
|
||||
}
|
||||
}
|
||||
|
||||
if (sunY > -0.15) {
|
||||
color = overlayDisc(color, dx, dy, dz, sunX, sunY, 0, SUN_SIN_HALF, rgb(255, 244, 214), -1);
|
||||
}
|
||||
if (sunY < 0.15) {
|
||||
color = overlayDisc(color, dx, dy, dz, -sunX, -sunY, 0, MOON_SIN_HALF, rgb(228, 228, 238), ctx.moonPhase());
|
||||
}
|
||||
|
||||
// Cloud layer at y = CLOUD_HEIGHT, drifting along +X with the world time.
|
||||
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 the sun or moon (a square billboard like vanilla) in colour {@code solid} around the body direction
|
||||
* {@code c}. The sun ({@code moonPhase < 0}) is a solid square, the moon takes its phase shape from the alpha of
|
||||
* the moon texture's tile (only a mask, so its dark transparent texels never bleed in as a rim).
|
||||
*/
|
||||
private int overlayDisc(
|
||||
int base,
|
||||
double dx,
|
||||
double dy,
|
||||
double dz,
|
||||
double cx,
|
||||
double cy,
|
||||
double cz,
|
||||
double sinHalf,
|
||||
int solid,
|
||||
int moonPhase) {
|
||||
double cos = dx * cx + dy * cy + dz * cz;
|
||||
if (cos <= 0) return base;
|
||||
// Disc coordinates: 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;
|
||||
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;
|
||||
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;
|
||||
double alpha;
|
||||
if (moonPhase >= 0 && moonTexture != null && moonTexture.length > 0) {
|
||||
alpha = bodyAlpha(moonTexture, su, sv, moonPhase) > 80 ? 1.0 : 0.0;
|
||||
} 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 texture at the disc coordinate in the moon phase's tile of 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;
|
||||
u = (col + u) / 4.0;
|
||||
v = (row + v) / 2.0;
|
||||
int px = Math.clamp((int) (u * w), 0, w - 1);
|
||||
int py = Math.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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vanilla cloud coverage: {@code clouds.png} tiled over the world, one texel per {@link #CLOUD_CELL} block square
|
||||
* (U along X, V along Z); a texel with alpha is a cloud. Value noise only when the pack lacks the texture.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
+710
@@ -0,0 +1,710 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.snapshot;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.assets.BlockEntityBlocks;
|
||||
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.ChestKind;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.Kind;
|
||||
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 java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.DyeColor;
|
||||
import org.bukkit.HeightMap;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Banner;
|
||||
import org.bukkit.block.Beacon;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.block.BlockState;
|
||||
import org.bukkit.block.Campfire;
|
||||
import org.bukkit.block.Conduit;
|
||||
import org.bukkit.block.CreatureSpawner;
|
||||
import org.bukkit.block.DecoratedPot;
|
||||
import org.bukkit.block.Shelf;
|
||||
import org.bukkit.block.Sign;
|
||||
import org.bukkit.block.Skull;
|
||||
import org.bukkit.block.TrialSpawner;
|
||||
import org.bukkit.block.Vault;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.block.data.Directional;
|
||||
import org.bukkit.block.data.Rotatable;
|
||||
import org.bukkit.block.data.type.Chest;
|
||||
import org.bukkit.block.data.type.CopperGolemStatue;
|
||||
import org.bukkit.block.data.type.Lectern;
|
||||
import org.bukkit.block.sign.Side;
|
||||
import org.bukkit.block.sign.SignSide;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.EntitySnapshot;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.BannerMeta;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
import org.bukkit.inventory.meta.ShieldMeta;
|
||||
import org.bukkit.inventory.meta.SkullMeta;
|
||||
import org.bukkit.profile.PlayerProfile;
|
||||
import org.bukkit.spawner.BaseSpawner;
|
||||
|
||||
/**
|
||||
* Captures block-entities (chests, signs, banners, heads, pots, lecterns, …) inside the frustum bounds into
|
||||
* {@link BlockEntityState}s. MUST run on the main thread (live {@link BlockState} access). Reads each loaded
|
||||
* chunk's tile entities instead of scanning every block.
|
||||
*/
|
||||
public final class BlockEntitySnapshotBuilder {
|
||||
|
||||
private BlockEntitySnapshotBuilder() {}
|
||||
|
||||
/** {@code eye}: camera position for the camera-dependent parts (enchanting-table book, beacon beam width). */
|
||||
public static List<BlockEntityState> build(Location eye, FrustumBounds bounds) {
|
||||
World world = eye.getWorld();
|
||||
// 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();
|
||||
// A beacon's beam rises from below: keep beacons under the view box too.
|
||||
if (bx < bMinX
|
||||
|| bx > bMaxX
|
||||
|| (by < bMinY && !(ts instanceof Beacon))
|
||||
|| by > bMaxY
|
||||
|| bz < bMinZ
|
||||
|| bz > bMaxZ) continue;
|
||||
try {
|
||||
BlockEntityState s = classify(ts.getBlockData(), bx, by, bz, ts, eye);
|
||||
if (s != null) out.add(s);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A state from bare block data (no tile) at cell 0 for block displays, minecarts and special items; without
|
||||
* tile-only details (patterns, sign text, skins). Null for blocks that aren't block-entity rendered.
|
||||
*/
|
||||
static BlockEntityState fromBlockData(BlockData data) {
|
||||
try {
|
||||
return classify(data, 0, 0, 0, null, null);
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the stack details a placed tile would carry to a {@link #fromBlockData} state: a player head's skin
|
||||
* ({@code SkullMeta}) and banner patterns ({@code BannerMeta}).
|
||||
*/
|
||||
static BlockEntityState withItemMeta(BlockEntityState s, ItemMeta meta) {
|
||||
if (s == null || meta == null) return s;
|
||||
try {
|
||||
if (meta instanceof SkullMeta skull && "player".equals(s.headType())) {
|
||||
String skin = skinUrl(skull.getOwnerProfile());
|
||||
return skin == null ? s : copy(s, skin, s.patterns());
|
||||
}
|
||||
if (meta instanceof BannerMeta banner && !banner.getPatterns().isEmpty()) {
|
||||
return copy(s, s.skinUrl(), patterns(banner.getPatterns()));
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** A shield's base colour + patterns as a BANNER state for the shield special model; null for a plain shield. */
|
||||
static BlockEntityState shieldPatterns(ItemMeta meta) {
|
||||
try {
|
||||
if (!(meta instanceof ShieldMeta shield)) return null;
|
||||
DyeColor baseColor = shield.getBaseColor();
|
||||
List<BannerPattern> pats = patterns(shield.getPatterns());
|
||||
if (baseColor == null && pats.isEmpty()) return null;
|
||||
return base(Kind.BANNER, 0, 0, 0, 0)
|
||||
.baseColorArgb(ColorUtil.dyeArgb(baseColor, 0))
|
||||
.patterns(pats)
|
||||
.build();
|
||||
} catch (Throwable ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static BlockEntityState copy(BlockEntityState s, String skinUrl, List<BannerPattern> patterns) {
|
||||
return new BlockEntityState(
|
||||
s.kind(),
|
||||
s.bx(),
|
||||
s.by(),
|
||||
s.bz(),
|
||||
s.facingDeg(),
|
||||
s.direction(),
|
||||
s.active(),
|
||||
s.chestKind(),
|
||||
s.baseColorArgb(),
|
||||
s.colorName(),
|
||||
s.headType(),
|
||||
skinUrl,
|
||||
patterns,
|
||||
s.sherds(),
|
||||
s.frontText(),
|
||||
s.backText(),
|
||||
s.items(),
|
||||
s.displayEntity(),
|
||||
s.beam(),
|
||||
s.pose());
|
||||
}
|
||||
|
||||
/** {@code ts} = the live tile (null for bare block data), {@code eye} = camera position or null. */
|
||||
private static BlockEntityState classify(BlockData data, int bx, int by, int bz, BlockState ts, Location eye) {
|
||||
Material mat = data.getMaterial();
|
||||
String n = mat.name();
|
||||
|
||||
Kind chest = BlockEntityBlocks.isCopperChest(mat)
|
||||
? Kind.COPPER_CHEST
|
||||
: switch (mat) {
|
||||
case CHEST -> Kind.CHEST;
|
||||
case TRAPPED_CHEST -> Kind.TRAPPED_CHEST;
|
||||
case ENDER_CHEST -> Kind.ENDER_CHEST;
|
||||
default -> null;
|
||||
};
|
||||
if (chest != null) {
|
||||
ChestKind ck =
|
||||
data instanceof Chest cd ? ChestKind.valueOf(cd.getType().name()) : ChestKind.SINGLE;
|
||||
Builder b = base(chest, bx, by, bz, facingYaw(data)).chestKind(ck);
|
||||
if (chest == Kind.COPPER_CHEST) b.colorName(copperStage(n, "COPPER_CHEST"));
|
||||
return b.build();
|
||||
}
|
||||
|
||||
if (BlockEntityBlocks.isShulkerBox(mat)) {
|
||||
String color =
|
||||
n.equals("SHULKER_BOX") ? null : prefix(n, "_SHULKER_BOX").toLowerCase(Locale.ROOT);
|
||||
String dir = data instanceof Directional d ? d.getFacing().name().toLowerCase(Locale.ROOT) : "up";
|
||||
return base(Kind.SHULKER_BOX, bx, by, bz, 0)
|
||||
.direction(dir)
|
||||
.colorName(color)
|
||||
.build();
|
||||
}
|
||||
|
||||
if (BlockEntityBlocks.isBanner(mat)) {
|
||||
boolean wall = BlockEntityBlocks.isWallBanner(mat);
|
||||
Builder b = base(wall ? Kind.WALL_BANNER : Kind.BANNER, bx, by, bz, facingYaw(data));
|
||||
if (ts instanceof Banner banner) {
|
||||
b.baseColorArgb(ColorUtil.dyeArgb(banner.getBaseColor(), 0xFFFFFFFF));
|
||||
b.patterns(patterns(banner.getPatterns()));
|
||||
} else { // no tile: the base colour is in the block name
|
||||
DyeColor dye = dye(prefix(n, wall ? "_WALL_BANNER" : "_BANNER"));
|
||||
if (dye != null) b.baseColorArgb(ColorUtil.dyeArgb(dye, 0xFFFFFFFF));
|
||||
}
|
||||
return b.build();
|
||||
}
|
||||
|
||||
if (BlockEntityBlocks.isSign(mat)) {
|
||||
Kind kind = BlockEntityBlocks.isWallSign(mat)
|
||||
? Kind.WALL_SIGN
|
||||
: BlockEntityBlocks.isHangingSign(mat) ? Kind.HANGING_SIGN : Kind.SIGN;
|
||||
Builder b = base(kind, bx, by, bz, facingYaw(data));
|
||||
if (ts instanceof Sign sign) {
|
||||
b.frontText(signText(sign.getSide(Side.FRONT)));
|
||||
b.backText(signText(sign.getSide(Side.BACK)));
|
||||
}
|
||||
return b.build();
|
||||
}
|
||||
|
||||
if (BlockEntityBlocks.isHead(mat)) {
|
||||
boolean wall = BlockEntityBlocks.isWallHead(mat);
|
||||
String headType = headType(n);
|
||||
Builder b = base(wall ? Kind.WALL_HEAD : Kind.HEAD, bx, by, bz, facingYaw(data))
|
||||
.headType(headType);
|
||||
if ("player".equals(headType) && ts instanceof Skull skull) {
|
||||
try {
|
||||
b.skinUrl(skinUrl(skull.getOwnerProfile()));
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
return b.build();
|
||||
}
|
||||
|
||||
if (mat == Material.CONDUIT) {
|
||||
return base(Kind.CONDUIT, bx, by, bz, 0)
|
||||
.active(ts instanceof Conduit c && c.isActive())
|
||||
.build();
|
||||
}
|
||||
|
||||
// Lectern and bell frames are block models; the renderers add only the book / the bell body.
|
||||
if (data instanceof Lectern lectern) {
|
||||
return base(Kind.LECTERN, bx, by, bz, facingYaw(data))
|
||||
.active(lectern.hasBook())
|
||||
.build();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
if (mat == Material.BELL) {
|
||||
return base(Kind.BELL, bx, by, bz, 0).build();
|
||||
}
|
||||
|
||||
if (mat == Material.ENCHANTING_TABLE) {
|
||||
return enchantingTable(bx, by, bz, eye);
|
||||
}
|
||||
|
||||
if (data instanceof org.bukkit.block.data.type.Campfire) {
|
||||
Builder b = base(Kind.CAMPFIRE, bx, by, bz, facingYaw(data));
|
||||
if (ts instanceof Campfire c) {
|
||||
List<ItemSpec> items = new ArrayList<>(c.getSize());
|
||||
for (int i = 0; i < c.getSize(); i++) items.add(ItemSpecs.of(c.getItem(i)));
|
||||
b.items(items);
|
||||
}
|
||||
return b.build();
|
||||
}
|
||||
|
||||
// ShelfRenderer's align_items_to_bottom has no API (false).
|
||||
if (data instanceof org.bukkit.block.data.type.Shelf) {
|
||||
Builder b = base(Kind.SHELF, bx, by, bz, facingYaw(data));
|
||||
if (ts instanceof Shelf shelf) {
|
||||
ItemStack[] content = shelf.getSnapshotInventory().getContents();
|
||||
List<ItemSpec> items = new ArrayList<>(content.length);
|
||||
for (ItemStack it : content) items.add(ItemSpecs.of(it));
|
||||
b.items(items);
|
||||
}
|
||||
return b.build();
|
||||
}
|
||||
|
||||
if (data instanceof CopperGolemStatue statue) {
|
||||
return base(Kind.COPPER_GOLEM_STATUE, bx, by, bz, facingYaw(data))
|
||||
.colorName(copperStage(n, "COPPER_GOLEM_STATUE"))
|
||||
.pose(statue.getCopperGolemPose().name().toLowerCase(Locale.ROOT))
|
||||
.build();
|
||||
}
|
||||
|
||||
if (mat == Material.SPAWNER || n.equals("TRIAL_SPAWNER")) {
|
||||
return base(Kind.SPAWNER, bx, by, bz, 0)
|
||||
.displayEntity(spawnerEntity(ts, data))
|
||||
.build();
|
||||
}
|
||||
|
||||
// VaultRenderer shows the displayed item only while a player is connected.
|
||||
if (n.equals("VAULT")) {
|
||||
Builder b = base(Kind.VAULT, bx, by, bz, facingYaw(data));
|
||||
if (ts instanceof Vault vault && !vault.getConnectedPlayers().isEmpty()) {
|
||||
ItemSpec item = ItemSpecs.of(vault.getDisplayedItem());
|
||||
if (item != null) b.items(List.of(item));
|
||||
}
|
||||
return b.build();
|
||||
}
|
||||
|
||||
// No beam without a pyramid (getBeamSections is empty at level 0).
|
||||
if (mat == Material.BEACON) {
|
||||
Builder b = base(Kind.BEACON, bx, by, bz, 0);
|
||||
if (ts instanceof Beacon beacon && beacon.getTier() > 0) b.beam(beam(ts.getWorld(), bx, by, bz, eye));
|
||||
return b.build();
|
||||
}
|
||||
|
||||
if (mat == Material.END_PORTAL) {
|
||||
return base(Kind.END_PORTAL, bx, by, bz, 0).build();
|
||||
}
|
||||
if (mat == Material.END_GATEWAY) {
|
||||
return base(Kind.END_GATEWAY, bx, by, bz, 0)
|
||||
.direction(ts == null ? null : gatewayFaces(ts))
|
||||
.build();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* EnchantingTableBlockEntity.bookAnimationTick: with a player within 3 blocks of the table centre the book
|
||||
* opens and turns to {@code atan2(dz, dx)}; otherwise it is closed (its idle yaw drifts — static 0 here).
|
||||
* The photographer's feet ({@code eye − 1.62}) stand in for the nearest player.
|
||||
*/
|
||||
private static BlockEntityState enchantingTable(int bx, int by, int bz, Location eye) {
|
||||
boolean open = false;
|
||||
float yaw = 0;
|
||||
if (eye != null) {
|
||||
double dx = eye.getX() - (bx + 0.5), dy = eye.getY() - 1.62 - (by + 0.5), dz = eye.getZ() - (bz + 0.5);
|
||||
if (dx * dx + dy * dy + dz * dz <= 9) {
|
||||
open = true;
|
||||
yaw = (float) Math.toDegrees(Math.atan2(dz, dx));
|
||||
}
|
||||
}
|
||||
return base(Kind.ENCHANTING_TABLE, bx, by, bz, yaw).active(open).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* The spawner's display mob (vanilla BaseSpawner/TrialSpawnerStateData.getOrCreateDisplayEntity loads the
|
||||
* same NBT): the spawn data's entity snapshot, else a bare entity of the spawned type. Trial spawners show
|
||||
* it only while waiting/active, from the ominous configuration when ominous. Null when empty or no tile.
|
||||
*/
|
||||
private static EntityState spawnerEntity(BlockState ts, BlockData data) {
|
||||
try {
|
||||
BaseSpawner spawner;
|
||||
if (ts instanceof CreatureSpawner cs) {
|
||||
spawner = cs;
|
||||
} else if (ts instanceof TrialSpawner trial && data instanceof org.bukkit.block.data.type.TrialSpawner td) {
|
||||
String st = td.getTrialSpawnerState().name();
|
||||
if (!st.equals("WAITING_FOR_PLAYERS") && !st.equals("ACTIVE")) return null;
|
||||
spawner = trial.isOminous() ? trial.getOminousConfiguration() : trial.getNormalConfiguration();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
EntitySnapshot snap = spawner.getSpawnedEntity();
|
||||
if (snap == null && !spawner.getPotentialSpawns().isEmpty()) {
|
||||
snap = spawner.getPotentialSpawns().getFirst().getSnapshot();
|
||||
}
|
||||
if (snap == null && spawner.getSpawnedType() != null) {
|
||||
snap = Bukkit.getEntityFactory()
|
||||
.createEntitySnapshot(
|
||||
"{id:\"" + spawner.getSpawnedType().getKey() + "\"}");
|
||||
}
|
||||
if (snap == null) return null;
|
||||
// createEntity(World), NOT createEntity(Location): the Location variant ADDS the entity to the world
|
||||
// (createEntity(World) also skips finalizeSpawn randomisation). The baker moves the state to the cage.
|
||||
Entity e = snap.createEntity(ts.getWorld());
|
||||
return e == null ? null : EntitySnapshotBuilder.toState(e);
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BeaconBlockEntity.tick's beam scan from the beacon up to the WORLD_SURFACE height: the beacon starts a
|
||||
* white section, stained glass (panes) starts a section of its dye colour (the first one over the beacon
|
||||
* is not blended with white, later ones average with the previous colour, equal colours just extend it), other
|
||||
* blocks extend the current section, and a light-blocking block (except bedrock) stops the beam.
|
||||
*/
|
||||
private static BlockEntityState.Beam beam(World world, int bx, int by, int bz, Location eye) {
|
||||
int top = world.getHighestBlockYAt(bx, bz, HeightMap.WORLD_SURFACE);
|
||||
List<int[]> sections = new ArrayList<>(); // {argb, height}
|
||||
for (int y = by; y <= top; y++) {
|
||||
Material m = y == by ? Material.BEACON : world.getType(bx, y, bz);
|
||||
int color = beamColor(m);
|
||||
if (color != 0) {
|
||||
if (sections.size() <= 1) {
|
||||
sections.add(new int[] {color, 1});
|
||||
} else {
|
||||
int[] last = sections.getLast();
|
||||
if (color == last[0]) last[1]++;
|
||||
else sections.add(new int[] {average(last[0], color), 1});
|
||||
}
|
||||
} else {
|
||||
if (sections.isEmpty() || (blocksBeam(m) && m != Material.BEDROCK)) return null;
|
||||
sections.getLast()[1]++;
|
||||
}
|
||||
}
|
||||
List<BlockEntityState.BeamSection> out = new ArrayList<>(sections.size());
|
||||
for (int[] sec : sections) out.add(new BlockEntityState.BeamSection(sec[0], sec[1]));
|
||||
float scale = 1;
|
||||
if (eye != null) {
|
||||
double dx = eye.getX() - (bx + 0.5), dz = eye.getZ() - (bz + 0.5);
|
||||
scale = (float) Math.max(1, Math.sqrt(dx * dx + dz * dz) / 96);
|
||||
}
|
||||
return new BlockEntityState.Beam(out, scale);
|
||||
}
|
||||
|
||||
/** BeaconBeamBlock colour (DyeColor texture-diffuse colour) of a beacon/stained glass (pane); 0 = none. */
|
||||
private static int beamColor(Material m) {
|
||||
if (m == Material.BEACON) return ColorUtil.dyeArgb(DyeColor.WHITE, 0);
|
||||
String n = m.name();
|
||||
int i = n.indexOf("_STAINED_GLASS");
|
||||
return i < 0 ? 0 : ColorUtil.dyeArgb(dye(n.substring(0, i)), 0);
|
||||
}
|
||||
|
||||
/** Light dampening 15 (full opaque cubes, tinted glass) — the beam stops there. */
|
||||
private static boolean blocksBeam(Material m) {
|
||||
return m.isOccluding() || m == Material.TINTED_GLASS;
|
||||
}
|
||||
|
||||
/** ARGB.average: per-channel integer mean. */
|
||||
private static int average(int a, int b) {
|
||||
return ColorUtil.argb(
|
||||
(ColorUtil.alpha(a) + ColorUtil.alpha(b)) / 2,
|
||||
(ColorUtil.red(a) + ColorUtil.red(b)) / 2,
|
||||
(ColorUtil.green(a) + ColorUtil.green(b)) / 2,
|
||||
(ColorUtil.blue(a) + ColorUtil.blue(b)) / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* TheEndGatewayBlockEntity.shouldRenderFace ≈ the neighbour is not a full opaque cube; "+"-joined. A neighbour in
|
||||
* an unloaded chunk counts as open (like the client's empty chunk) and is not loaded.
|
||||
*/
|
||||
private static String gatewayFaces(BlockState ts) {
|
||||
World world = ts.getWorld();
|
||||
int x = ts.getX(), y = ts.getY(), z = ts.getZ();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (BlockFace f : GATEWAY_FACES) {
|
||||
int nx = x + f.getModX(), nz = z + f.getModZ();
|
||||
if (world.isChunkLoaded(nx >> 4, nz >> 4)
|
||||
&& world.getType(nx, y + f.getModY(), nz).isOccluding()) continue;
|
||||
if (!sb.isEmpty()) sb.append('+');
|
||||
sb.append(f.name().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static final BlockFace[] GATEWAY_FACES = {
|
||||
BlockFace.DOWN, BlockFace.UP, BlockFace.NORTH, BlockFace.SOUTH, BlockFace.WEST, BlockFace.EAST
|
||||
};
|
||||
|
||||
/**
|
||||
* Yaw in degrees (0 = south, clockwise, vanilla rotation convention) of a wall block's {@link Directional}
|
||||
* facing or a standing block's {@link Rotatable} rotation (no block data implements both).
|
||||
*/
|
||||
private static float facingYaw(BlockData data) {
|
||||
BlockFace face =
|
||||
data instanceof Directional d ? d.getFacing() : data instanceof Rotatable r ? r.getRotation() : null;
|
||||
Float y = face == null ? null : 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;
|
||||
}
|
||||
|
||||
private static String prefix(String name, String suffix) {
|
||||
return name.substring(0, name.length() - suffix.length());
|
||||
}
|
||||
|
||||
/** The dye of an upper-case colour name, or null. */
|
||||
private static DyeColor dye(String name) {
|
||||
try {
|
||||
return DyeColor.valueOf(name);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Weathering stage from {@code [WAXED_][EXPOSED_|WEATHERED_|OXIDIZED_]<suffix>}; null when unweathered. */
|
||||
private static String copperStage(String name, String suffix) {
|
||||
String stage = name.replace("WAXED_", "").replace(suffix, "").replace("_", "");
|
||||
return stage.isEmpty() ? null : stage.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static List<BannerPattern> patterns(List<org.bukkit.block.banner.Pattern> patterns) {
|
||||
List<BannerPattern> out = new ArrayList<>(patterns.size());
|
||||
for (org.bukkit.block.banner.Pattern p : patterns) {
|
||||
out.add(new BannerPattern(patternKey(p), ColorUtil.dyeArgb(p.getColor(), 0xFFFFFFFF)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static final Pattern LEGACY_FORMAT = Pattern.compile("§.");
|
||||
|
||||
/** 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 ? "" : LEGACY_FORMAT.matcher(l).replaceAll("");
|
||||
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 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";
|
||||
};
|
||||
}
|
||||
|
||||
/** Order of the DecoratedPotRenderer side parts (BlockEntityModels.potParts). */
|
||||
private static final DecoratedPot.Side[] SHERD_SIDES = {
|
||||
DecoratedPot.Side.FRONT, DecoratedPot.Side.LEFT, DecoratedPot.Side.RIGHT, DecoratedPot.Side.BACK
|
||||
};
|
||||
|
||||
private static List<String> sherds(DecoratedPot pot) {
|
||||
List<String> out = new ArrayList<>(4);
|
||||
for (DecoratedPot.Side side : SHERD_SIDES)
|
||||
out.add(pot.getSherd(side).name().toLowerCase(Locale.ROOT));
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String skinUrl(PlayerProfile profile) {
|
||||
return profile == null || profile.getTextures().getSkin() == null
|
||||
? null
|
||||
: profile.getTextures().getSkin().toString();
|
||||
}
|
||||
|
||||
/** Every PatternType key accessor is marked for removal with no stable replacement. */
|
||||
@SuppressWarnings("removal")
|
||||
private static String patternKey(org.bukkit.block.banner.Pattern p) {
|
||||
return p.getPattern().getKey().getKey();
|
||||
}
|
||||
|
||||
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 String direction;
|
||||
private boolean active;
|
||||
private ChestKind chestKind;
|
||||
private int baseColorArgb;
|
||||
private String colorName;
|
||||
private String headType;
|
||||
private String skinUrl;
|
||||
private List<BannerPattern> patterns = List.of();
|
||||
private List<String> sherds = List.of();
|
||||
private BlockEntityState.SignText frontText;
|
||||
private BlockEntityState.SignText backText;
|
||||
private List<ItemSpec> items = List.of();
|
||||
private EntityState displayEntity;
|
||||
private BlockEntityState.Beam beam;
|
||||
private String pose;
|
||||
|
||||
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 direction(String v) {
|
||||
this.direction = v;
|
||||
return this;
|
||||
}
|
||||
|
||||
Builder active(boolean v) {
|
||||
this.active = 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 frontText(BlockEntityState.SignText v) {
|
||||
this.frontText = v;
|
||||
return this;
|
||||
}
|
||||
|
||||
Builder backText(BlockEntityState.SignText v) {
|
||||
this.backText = v;
|
||||
return this;
|
||||
}
|
||||
|
||||
Builder items(List<ItemSpec> v) {
|
||||
this.items = v;
|
||||
return this;
|
||||
}
|
||||
|
||||
Builder displayEntity(EntityState v) {
|
||||
this.displayEntity = v;
|
||||
return this;
|
||||
}
|
||||
|
||||
Builder beam(BlockEntityState.Beam v) {
|
||||
this.beam = v;
|
||||
return this;
|
||||
}
|
||||
|
||||
Builder pose(String v) {
|
||||
this.pose = v;
|
||||
return this;
|
||||
}
|
||||
|
||||
BlockEntityState build() {
|
||||
return new BlockEntityState(
|
||||
kind,
|
||||
bx,
|
||||
by,
|
||||
bz,
|
||||
yaw,
|
||||
direction,
|
||||
active,
|
||||
chestKind,
|
||||
baseColorArgb,
|
||||
colorName,
|
||||
headType,
|
||||
skinUrl,
|
||||
patterns,
|
||||
sherds,
|
||||
frontText,
|
||||
backText,
|
||||
items,
|
||||
displayEntity,
|
||||
beam,
|
||||
pose);
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.snapshot;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.MapManager;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import org.bukkit.Material;
|
||||
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.map.MapView;
|
||||
import org.bukkit.util.BoundingBox;
|
||||
|
||||
/**
|
||||
* Captures paintings and item frames into {@link DecorationState}s. MUST run on the main thread. The entity's
|
||||
* world bounding box already encodes vanilla's painting placement offsets.
|
||||
*/
|
||||
public final class DecorationSnapshotBuilder {
|
||||
|
||||
private DecorationSnapshotBuilder() {}
|
||||
|
||||
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) {
|
||||
DecorationState.Kind kind;
|
||||
String art = null;
|
||||
ItemSpec item = null;
|
||||
int[][] map = null;
|
||||
List<DecorationState.MapMarker> markers = List.of();
|
||||
int rotation = 0;
|
||||
boolean glow = false, invisible = false;
|
||||
if (e instanceof Painting painting) {
|
||||
kind = DecorationState.Kind.PAINTING;
|
||||
art = painting.getArt().assetId().value();
|
||||
} else if (e instanceof ItemFrame frame) {
|
||||
kind = DecorationState.Kind.ITEM_FRAME;
|
||||
ItemStack stack = frame.getItem();
|
||||
item = ItemSpecs.of(stack);
|
||||
if (stack.getType() == Material.FILLED_MAP) {
|
||||
int id = MapPixels.mapId(stack);
|
||||
// the attached renderer first: spares the file checks for loaded photos
|
||||
if (id >= 0 && (MapManager.renderer(id) != null || MapManager.isManaged(id))) {
|
||||
map = MapPixels.photo(id); // photos have no markers
|
||||
} else {
|
||||
MapView view = MapPixels.view(stack);
|
||||
map = MapPixels.of(view);
|
||||
if (map != null) markers = MapPixels.markers(view);
|
||||
}
|
||||
}
|
||||
rotation = frame.getRotation().ordinal();
|
||||
glow = e instanceof GlowItemFrame;
|
||||
invisible = !frame.isVisible();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
BoundingBox bb = e.getBoundingBox();
|
||||
return new DecorationState(
|
||||
kind,
|
||||
bb.getMinX(),
|
||||
bb.getMinY(),
|
||||
bb.getMinZ(),
|
||||
bb.getMaxX(),
|
||||
bb.getMaxY(),
|
||||
bb.getMaxZ(),
|
||||
facing(e.getFacing()),
|
||||
art,
|
||||
item,
|
||||
map,
|
||||
rotation,
|
||||
glow,
|
||||
invisible,
|
||||
markers);
|
||||
}
|
||||
|
||||
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,766 @@
|
||||
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.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.UUID;
|
||||
import java.util.function.DoubleSupplier;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
||||
import org.bukkit.Color;
|
||||
import org.bukkit.DyeColor;
|
||||
import org.bukkit.Keyed;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Tag;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.attribute.AttributeInstance;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.block.data.type.Bed;
|
||||
import org.bukkit.entity.AbstractArrow;
|
||||
import org.bukkit.entity.Ageable;
|
||||
import org.bukkit.entity.ArmorStand;
|
||||
import org.bukkit.entity.Arrow;
|
||||
import org.bukkit.entity.Axolotl;
|
||||
import org.bukkit.entity.Bat;
|
||||
import org.bukkit.entity.Cat;
|
||||
import org.bukkit.entity.ChestedHorse;
|
||||
import org.bukkit.entity.Creaking;
|
||||
import org.bukkit.entity.Creeper;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Fox;
|
||||
import org.bukkit.entity.Horse;
|
||||
import org.bukkit.entity.HumanEntity;
|
||||
import org.bukkit.entity.Illusioner;
|
||||
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.Piglin;
|
||||
import org.bukkit.entity.PiglinAbstract;
|
||||
import org.bukkit.entity.Pillager;
|
||||
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.Sittable;
|
||||
import org.bukkit.entity.Slime;
|
||||
import org.bukkit.entity.Sniffer;
|
||||
import org.bukkit.entity.Snowman;
|
||||
import org.bukkit.entity.Spellcaster;
|
||||
import org.bukkit.entity.TraderLlama;
|
||||
import org.bukkit.entity.Trident;
|
||||
import org.bukkit.entity.TropicalFish;
|
||||
import org.bukkit.entity.Villager;
|
||||
import org.bukkit.entity.Vindicator;
|
||||
import org.bukkit.entity.Wither;
|
||||
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.CrossbowMeta;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
import org.bukkit.inventory.meta.LeatherArmorMeta;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.bukkit.util.BoundingBox;
|
||||
import org.bukkit.util.EulerAngle;
|
||||
|
||||
/** Captures entities into {@link EntityState}s. MUST run on the main thread (live entity access). */
|
||||
public final class EntitySnapshotBuilder {
|
||||
|
||||
private EntitySnapshotBuilder() {}
|
||||
|
||||
// Types without a mob model (technical entities, or drawn by the decoration/object builders); they would
|
||||
// otherwise render as fallback boxes.
|
||||
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");
|
||||
|
||||
// Mobs whose main-hand item is drawn in the mouth (FoxHeldItemLayer, DolphinCarryingItemLayer).
|
||||
private static final Set<String> MOUTH_CARRIERS = Set.of("fox", "dolphin");
|
||||
|
||||
/** {@code shooter}: the photographer, skipped. */
|
||||
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;
|
||||
}
|
||||
|
||||
/** The entity's state, or null for non-renderable types. Also used for unspawned spawner display mobs. */
|
||||
static EntityState toState(Entity e) {
|
||||
String type = e.getType().getKey().getKey();
|
||||
if (NON_RENDERABLE.contains(type)) return null;
|
||||
Location loc = e.getLocation();
|
||||
float bodyYaw = e instanceof LivingEntity le ? le.getBodyYaw() : loc.getYaw();
|
||||
|
||||
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 (e instanceof Player p) {
|
||||
String[] skin = resolveSkin(p);
|
||||
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;
|
||||
boolean arms = false;
|
||||
String bodyEquip = null;
|
||||
int bodyDye = 0;
|
||||
int tint2 = 0;
|
||||
int collar = 0;
|
||||
boolean powered = false;
|
||||
try {
|
||||
if (e instanceof Slime sl) sizeScale = sl.getSize(); // incl. magma cubes
|
||||
// Order matters for subtypes (Llama extends ChestedHorse extends AbstractHorse).
|
||||
if (e instanceof Sheep sh) {
|
||||
tint = isJebSheep(sh) ? jebWoolColor(sh.getTicksLived()) : sheepWoolColor(sh.getColor());
|
||||
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());
|
||||
if (c.isTamed()) collar = ColorUtil.dyeArgb(c.getCollarColor(), 0);
|
||||
} else if (e instanceof Wolf w) {
|
||||
variant = keyOf(w.getVariant());
|
||||
if (w.isTamed()) collar = ColorUtil.dyeArgb(w.getCollarColor(), 0);
|
||||
} else if (e instanceof Creeper cr) {
|
||||
powered = cr.isPowered();
|
||||
} else if (e instanceof Wither wi) {
|
||||
// WitherBoss.isPowered(): health <= max health / 2 (wither armor layer).
|
||||
AttributeInstance max = wi.getAttribute(Attribute.MAX_HEALTH);
|
||||
powered = max != null && wi.getHealth() <= max.getValue() / 2;
|
||||
// WitherBossRenderer.scale: 2 - invulnerableTicks / 220 · 0.5 (the dumped transform holds the 2).
|
||||
int inv = wi.getInvulnerableTicks();
|
||||
if (inv > 0) sizeScale = (2 - inv / 220f * 0.5f) / 2;
|
||||
} 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());
|
||||
} else if (e instanceof Llama l) {
|
||||
variant = keyOf(l.getColor());
|
||||
chest = l.isCarryingChest();
|
||||
} else if (e instanceof ChestedHorse ch) {
|
||||
chest = ch.isCarryingChest();
|
||||
} 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 Shulker s) {
|
||||
variant = keyOf(s.getColor());
|
||||
} else if (e instanceof ZombieVillager zv) {
|
||||
variant = keyOf(zv.getVillagerType());
|
||||
} else if (e instanceof Villager vi) {
|
||||
variant = keyOf(vi.getVillagerType());
|
||||
profession = keyOf(vi.getProfession());
|
||||
villagerLevel = vi.getVillagerLevel();
|
||||
}
|
||||
if (e instanceof ArmorStand st) arms = st.hasArms();
|
||||
// Any other mob with a variant/weathering getter (frog, cow, pig, copper golem, new mobs, …).
|
||||
// Reflective on purpose: the compile-time declaring interface of getVariant() (AbstractCow) doesn't
|
||||
// exist at runtime on every server build (NoSuchMethodError).
|
||||
if (variant == null) variant = genericVariant(e);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
// Saddle + BODY slot for every mount (horse armor, carpet, harness, wolf armor, …), held items by hand
|
||||
// side (a left-handed mob holds its main-hand item left) and a non-armor head item.
|
||||
ItemSpec rightHand = null, leftHand = null, headItem = null, mouthItem = 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 = asset;
|
||||
bodyDye = dyeOf(body);
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
// slot not supported by this entity
|
||||
}
|
||||
// LlamaDecorLayer: the trader llama's own decor unless it wears a carpet.
|
||||
if (bodyEquip == null && e instanceof TraderLlama) bodyEquip = "trader_llama";
|
||||
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());
|
||||
if (MOUTH_CARRIERS.contains(type)) {
|
||||
mouthItem = main;
|
||||
} else {
|
||||
rightHand = leftHanded ? off : main;
|
||||
leftHand = leftHanded ? main : off;
|
||||
}
|
||||
ItemStack helmet = eq.getHelmet();
|
||||
if (helmet != null && !helmet.getType().isAir() && armorAsset(helmet, EquipmentSlot.HEAD) == 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) {
|
||||
}
|
||||
}
|
||||
|
||||
// Worn armor of every living entity; the baker draws it only for renderers with a HumanoidArmorLayer.
|
||||
EntityState.Equipment equipment = eq == null ? null : captureEquipment(eq);
|
||||
|
||||
String pose = poseFlags(e);
|
||||
double x = loc.getX(), z = loc.getZ();
|
||||
if (pose != null && pose.contains("sleeping") && e instanceof LivingEntity sleeper && !(e instanceof Fox)) {
|
||||
// LivingEntityRenderer: a sleeper in a bed lies along the bed (yaw from the bed direction instead of
|
||||
// the body yaw) and is shifted by its standing eye height - 0.1 towards the foot end.
|
||||
BlockFace bed = bedFacing(e);
|
||||
if (bed != null) {
|
||||
bodyYaw = switch (bed) {
|
||||
case SOUTH -> 90;
|
||||
case NORTH -> 270;
|
||||
case EAST -> 180;
|
||||
default -> 0;};
|
||||
double off = standingEyeHeight(sleeper) - 0.1;
|
||||
x -= bed.getModX() * off;
|
||||
z -= bed.getModZ() * off;
|
||||
}
|
||||
}
|
||||
Map<String, float[]> partPoses = e instanceof ArmorStand as ? armorStandPoses(as) : null;
|
||||
String shoulderLeft = null, shoulderRight = null;
|
||||
if (e instanceof HumanEntity he) {
|
||||
shoulderLeft = shoulderParrot(he, true);
|
||||
shoulderRight = shoulderParrot(he, false);
|
||||
}
|
||||
|
||||
return new EntityState(
|
||||
type,
|
||||
x,
|
||||
loc.getY(),
|
||||
z,
|
||||
bodyYaw,
|
||||
baby,
|
||||
width,
|
||||
height,
|
||||
player,
|
||||
skinUrl,
|
||||
slim,
|
||||
variant,
|
||||
tint,
|
||||
sizeScale,
|
||||
profession,
|
||||
villagerLevel,
|
||||
markings,
|
||||
saddle,
|
||||
chest,
|
||||
arms,
|
||||
bodyEquip,
|
||||
equipment,
|
||||
invisible,
|
||||
loc.getPitch() + arrowShake(e),
|
||||
bodyDye,
|
||||
tint2,
|
||||
e.isInsideVehicle(),
|
||||
rightHand,
|
||||
leftHand,
|
||||
headItem,
|
||||
collar,
|
||||
powered,
|
||||
mouthItem,
|
||||
pose,
|
||||
partPoses,
|
||||
shoulderLeft,
|
||||
shoulderRight);
|
||||
}
|
||||
|
||||
/**
|
||||
* State-pose flags (names of the vanilla dump's STATE_POSES, sorted, "+"-joined; null = idle): the generic
|
||||
* pose plus the per-mob flags vanilla's renderers read from the entity. The baker ignores unknown flags.
|
||||
*/
|
||||
private static String poseFlags(Entity e) {
|
||||
TreeSet<String> f = new TreeSet<>();
|
||||
try {
|
||||
switch (e.getPose()) {
|
||||
case SLEEPING -> f.add("sleeping");
|
||||
case SNEAKING -> f.add("crouching");
|
||||
case SWIMMING -> f.add(e.isInWater() ? "swimming" : "crawling");
|
||||
case FALL_FLYING -> f.add("fall_flying");
|
||||
case SITTING -> f.add("sitting");
|
||||
case DIGGING -> f.add("digging");
|
||||
default -> {}
|
||||
}
|
||||
if (e instanceof LivingEntity le && le.isGliding()) f.add("fall_flying");
|
||||
if (e.isInWater()) f.add("in_water");
|
||||
if (e instanceof Sittable st && st.isSitting()) f.add("sitting");
|
||||
if (e instanceof Fox fox) {
|
||||
if (fox.isSleeping()) f.add("sleeping");
|
||||
if (fox.isCrouching()) f.add("crouching");
|
||||
if (fox.isLeaping()) f.add("pouncing");
|
||||
if (fox.isFaceplanted()) f.add("faceplanted");
|
||||
} else if (e instanceof Cat cat) {
|
||||
if (cat.isLyingDown()) {
|
||||
f.add("lying");
|
||||
if (sleepingPlayerNear(cat)) f.add("on_player");
|
||||
}
|
||||
if (cat.isHeadUp()) f.add("head_up");
|
||||
} else if (e instanceof Panda pa) {
|
||||
if (pa.isOnBack()) f.add("on_back");
|
||||
} else if (e instanceof Bat bat) {
|
||||
if (!bat.isAwake()) f.add("resting");
|
||||
} else if (e instanceof Parrot pa) {
|
||||
// ParrotModel.getPose: party > sitting > flying (= not on the ground) > standing.
|
||||
if (pa.isDancing()) f.add("party");
|
||||
else if (!pa.isSitting() && pa.isOnGround()) f.add("standing");
|
||||
} else if (e instanceof Sniffer sn && sn.getState() == Sniffer.State.DIGGING) {
|
||||
f.add("digging");
|
||||
}
|
||||
renderFlags(e, f);
|
||||
} catch (Throwable ignored) {
|
||||
// keep the flags found so far
|
||||
}
|
||||
return f.isEmpty() ? null : String.join("+", f);
|
||||
}
|
||||
|
||||
/** Render-state flags beyond poses: arm poses ({@code arms_<pose>}), textures and layers. */
|
||||
private static void renderFlags(Entity e, Set<String> f) {
|
||||
String arms = armPose(e);
|
||||
if (arms != null) f.add("arms_" + arms);
|
||||
if (e instanceof Wolf w && w.isAngry()) f.add("angry");
|
||||
// TippableArrowRenderer: isTipped = Arrow.getColor() > 0 — the raw ARGB int, so only colours without the
|
||||
// alpha bit (custom RGB colours) count; potion colours are opaque ARGB (negative) and keep the plain arrow.
|
||||
if (e instanceof Arrow ar && ar.getColor() != null && ar.getColor().asARGB() > 0) f.add("tipped");
|
||||
if (e instanceof Wither wi) {
|
||||
// WitherBossRenderer.getTextureLocation: invulnerable texture above 80 ticks, then blinking every 5.
|
||||
int t = wi.getInvulnerableTicks();
|
||||
if (t > 0 && (t > 80 || t / 5 % 2 != 1)) f.add("invulnerable");
|
||||
}
|
||||
if (e instanceof Creaking cr && cr.isActive()) f.add("eyes_glowing");
|
||||
// SheepWoolUndercoatLayer: jeb_ or non-white wool (adults only, decided by the baker), sheared or not.
|
||||
if (e instanceof Sheep sh && (isJebSheep(sh) || sh.getColor() != DyeColor.WHITE)) f.add("undercoat");
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm pose (lower-case IllagerArmPose / PiglinArmPose constant; null = idle) like Piglin/PiglinBrute.getArmPose
|
||||
* and the illagers' getArmPose.
|
||||
*/
|
||||
private static String armPose(Entity e) {
|
||||
boolean aggressive = e instanceof Mob m && m.isAggressive();
|
||||
if (e instanceof PiglinAbstract pa) {
|
||||
EntityEquipment eq = pa.getEquipment();
|
||||
ItemStack main = eq == null ? null : eq.getItemInMainHand();
|
||||
ItemStack off = eq == null ? null : eq.getItemInOffHand();
|
||||
boolean melee = main != null && !main.getType().isAir() && main.hasData(DataComponentTypes.TOOL);
|
||||
if (e instanceof Piglin p) {
|
||||
if (p.isDancing()) return "dancing";
|
||||
if (off != null && Tag.ITEMS_PIGLIN_LOVED.isTagged(off.getType())) return "admiring_item";
|
||||
if (aggressive && melee) return "attacking_with_melee_weapon";
|
||||
if (p.isChargingCrossbow()) return "crossbow_charge";
|
||||
boolean holding = (main != null && main.getType() == Material.CROSSBOW)
|
||||
|| (off != null && off.getType() == Material.CROSSBOW);
|
||||
return holding
|
||||
&& main != null
|
||||
&& main.getItemMeta() instanceof CrossbowMeta cm
|
||||
&& cm.hasChargedProjectiles()
|
||||
? "crossbow_hold"
|
||||
: null;
|
||||
}
|
||||
return aggressive && melee ? "attacking_with_melee_weapon" : null;
|
||||
}
|
||||
if (e instanceof Pillager pi) {
|
||||
if (pi.isChargingAttack()) return "crossbow_charge";
|
||||
EntityEquipment eq = pi.getEquipment();
|
||||
boolean crossbow = eq != null
|
||||
&& (eq.getItemInMainHand().getType() == Material.CROSSBOW
|
||||
|| eq.getItemInOffHand().getType() == Material.CROSSBOW);
|
||||
return crossbow ? "crossbow_hold" : aggressive ? "attacking" : "neutral";
|
||||
}
|
||||
if (e instanceof Vindicator v) {
|
||||
return aggressive ? "attacking" : v.isCelebrating() ? "celebrating" : null;
|
||||
}
|
||||
if (e instanceof Spellcaster sc) {
|
||||
if (sc.getSpell() != Spellcaster.Spell.NONE) return "spellcasting";
|
||||
if (e instanceof Illusioner) return aggressive ? "bow_and_arrow" : null;
|
||||
return sc.isCelebrating() ? "celebrating" : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** SheepRenderer: a sheep named "jeb_" (checkMagicName) cycles through all wool colours. */
|
||||
private static boolean isJebSheep(Sheep sh) {
|
||||
Component name = sh.customName();
|
||||
return name != null
|
||||
&& "jeb_".equals(PlainTextComponentSerializer.plainText().serialize(name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Vanilla wool colour (ColorLerper.Type.SHEEP): the dye's texture-diffuse colour × 0.75, white 0xE6E6E6.
|
||||
*/
|
||||
private static int sheepWoolColor(DyeColor dye) {
|
||||
if (dye == null || dye == DyeColor.WHITE) return 0xFFE6E6E6;
|
||||
Color c = dye.getColor();
|
||||
return ColorUtil.argb(
|
||||
0xFF, (int) (c.getRed() * 0.75f), (int) (c.getGreen() * 0.75f), (int) (c.getBlue() * 0.75f));
|
||||
}
|
||||
|
||||
/**
|
||||
* jeb_ wool (ColorLerper.getLerpedColor(SHEEP, ageInTicks)) at the entity's age: 25 ticks per dye colour, linearly
|
||||
* blended to the next one.
|
||||
*/
|
||||
private static int jebWoolColor(int ticks) {
|
||||
DyeColor[] dyes = DyeColor.values();
|
||||
int value = ticks / 25;
|
||||
int c1 = sheepWoolColor(dyes[value % dyes.length]);
|
||||
int c2 = sheepWoolColor(dyes[(value + 1) % dyes.length]);
|
||||
float step = (ticks % 25) / 25f;
|
||||
int out = 0xFF000000;
|
||||
for (int sh = 0; sh <= 16; sh += 8) {
|
||||
int a = (c1 >> sh) & 0xFF, b = (c2 >> sh) & 0xFF;
|
||||
out |= (a + (int) Math.floor(step * (b - a))) << sh;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Cat.isLyingOnTopOfSleepingPlayer: a sleeping player within the cat's block box inflated by 2. */
|
||||
private static boolean sleepingPlayerNear(Entity cat) {
|
||||
Location l = cat.getLocation();
|
||||
BoundingBox box = new BoundingBox(
|
||||
l.getBlockX() - 2,
|
||||
l.getBlockY() - 2,
|
||||
l.getBlockZ() - 2,
|
||||
l.getBlockX() + 3,
|
||||
l.getBlockY() + 3,
|
||||
l.getBlockZ() + 3);
|
||||
for (Entity p : cat.getWorld().getNearbyEntities(box, en -> en instanceof Player)) {
|
||||
if (((Player) p).isSleeping()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eye height in the STANDING pose (vanilla state.eyeHeight of a sleeper): {@code getEyeHeight(true)} returns the
|
||||
* sleeping pose's 0.2 on Paper 26.x, so ask the server entity ({@code getEyeHeight(Pose.STANDING)}).
|
||||
*/
|
||||
private static double standingEyeHeight(LivingEntity le) {
|
||||
try {
|
||||
Object handle = handle(le);
|
||||
StandingEyeHeight m = STANDING_EYE_HEIGHT.get(handle.getClass());
|
||||
if (m != null) return ((Number) m.method.invoke(handle, m.standing)).doubleValue();
|
||||
} catch (ReflectiveOperationException | RuntimeException ignored) {
|
||||
}
|
||||
return le.getEyeHeight(true);
|
||||
}
|
||||
|
||||
private record StandingEyeHeight(Method method, Object standing) {}
|
||||
|
||||
/** Per NMS entity class: its {@code getEyeHeight(Pose)} with the STANDING constant, or null. */
|
||||
private static final ClassValue<StandingEyeHeight> STANDING_EYE_HEIGHT = new ClassValue<>() {
|
||||
@Override
|
||||
protected StandingEyeHeight computeValue(Class<?> c) {
|
||||
for (Method m : c.getMethods()) {
|
||||
if (!m.getName().equals("getEyeHeight") || m.getParameterCount() != 1) continue;
|
||||
Class<?> pose = m.getParameterTypes()[0];
|
||||
if (!pose.isEnum()) continue;
|
||||
for (Object p : pose.getEnumConstants()) {
|
||||
if (((Enum<?>) p).name().equals("STANDING")) return new StandingEyeHeight(m, p);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
private static final ClassValue<Method> GET_HANDLE = Reflection.method("getHandle"); // Craft entity
|
||||
|
||||
private static Object handle(Entity e) throws ReflectiveOperationException {
|
||||
Method m = GET_HANDLE.get(e.getClass());
|
||||
if (m == null) throw new NoSuchMethodException("getHandle");
|
||||
return m.invoke(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Facing of the bed the entity sleeps in (LivingEntity.getBedOrientation = the bed block's FACING), or null. Never
|
||||
* loads a chunk: spawner display mobs are not in a world (their location is the origin), and an unloaded chunk
|
||||
* has no bed to read.
|
||||
*/
|
||||
private static BlockFace bedFacing(Entity e) {
|
||||
if (!e.isInWorld()) return null;
|
||||
Location l = e.getLocation();
|
||||
World w = l.getWorld();
|
||||
int x = l.getBlockX(), y = l.getBlockY(), z = l.getBlockZ();
|
||||
if (w == null || !w.isChunkLoaded(x >> 4, z >> 4)) return null;
|
||||
return w.getBlockData(x, y, z) instanceof Bed bed ? bed.getFacing() : null;
|
||||
}
|
||||
|
||||
/** Armor-stand part poses in degrees, keyed by the vanilla render-state field (dump rotation bindings). */
|
||||
private static Map<String, float[]> armorStandPoses(ArmorStand as) {
|
||||
Map<String, float[]> out = new HashMap<>();
|
||||
out.put("headPose", deg(as.getHeadPose()));
|
||||
out.put("bodyPose", deg(as.getBodyPose()));
|
||||
out.put("leftArmPose", deg(as.getLeftArmPose()));
|
||||
out.put("rightArmPose", deg(as.getRightArmPose()));
|
||||
out.put("leftLegPose", deg(as.getLeftLegPose()));
|
||||
out.put("rightLegPose", deg(as.getRightLegPose()));
|
||||
return out;
|
||||
}
|
||||
|
||||
private static float[] deg(EulerAngle a) {
|
||||
return new float[] {
|
||||
(float) Math.toDegrees(a.getX()), (float) Math.toDegrees(a.getY()), (float) Math.toDegrees(a.getZ())
|
||||
};
|
||||
}
|
||||
|
||||
/** Variant key of a parrot sitting on the player's left/right shoulder, or null. */
|
||||
@SuppressWarnings("deprecation")
|
||||
private static String shoulderParrot(HumanEntity he, boolean left) {
|
||||
try {
|
||||
Entity p = left ? he.getShoulderEntityLeft() : he.getShoulderEntityRight();
|
||||
return p instanceof Parrot parrot ? keyOf(parrot.getVariant()) : null;
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Worn armor; null when nothing is equipped. */
|
||||
private static EntityState.Equipment captureEquipment(EntityEquipment eq) {
|
||||
try {
|
||||
EntityState.Equipment equip = new EntityState.Equipment(
|
||||
armorPiece(eq.getHelmet(), EquipmentSlot.HEAD), armorPiece(eq.getChestplate(), EquipmentSlot.CHEST),
|
||||
armorPiece(eq.getLeggings(), EquipmentSlot.LEGS), armorPiece(eq.getBoots(), EquipmentSlot.FEET));
|
||||
return equip.isEmpty() ? null : equip;
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Null for empty / non-armor items. */
|
||||
private static EntityState.EquipPiece armorPiece(ItemStack it, EquipmentSlot slot) {
|
||||
if (it == null || it.getType().isAir()) return null;
|
||||
String asset = armorAsset(it, slot);
|
||||
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);
|
||||
}
|
||||
|
||||
private static final String[] ARMOR_SUFFIXES = {"_helmet", "_chestplate", "_leggings", "_boots"};
|
||||
|
||||
/**
|
||||
* Equipment asset id (= texture name) a humanoid armor layer draws for the item worn in {@code slot}: the
|
||||
* EQUIPPABLE asset when its slot matches (vanilla HumanoidArmorLayer.shouldRender, custom armor included);
|
||||
* from the item name without the component. Null if none.
|
||||
*/
|
||||
private static String armorAsset(ItemStack it, EquipmentSlot slot) {
|
||||
try {
|
||||
var comp = it.getData(DataComponentTypes.EQUIPPABLE);
|
||||
if (comp != null)
|
||||
return comp.slot() == slot && comp.assetId() != null
|
||||
? comp.assetId().value()
|
||||
: null;
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
return armorAssetByName(it.getType());
|
||||
}
|
||||
|
||||
/** Item Material -> equipment asset id: strips the slot suffix; null if not armor. */
|
||||
private static String armorAssetByName(Material m) {
|
||||
String key = m.getKey().getKey();
|
||||
if (key.equals("elytra")) return "elytra";
|
||||
if (key.equals("turtle_helmet")) return "turtle_scute";
|
||||
for (String suf : ARMOR_SUFFIXES) {
|
||||
if (key.endsWith(suf)) return assetMaterial(key.substring(0, key.length() - suf.length()));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Equipment assets name the gold material "gold", items "golden". */
|
||||
private static String assetMaterial(String itemMaterial) {
|
||||
return itemMaterial.equals("golden") ? "gold" : itemMaterial;
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
|
||||
private static final ClassValue<Field> SHAKE_FIELD = Reflection.field("shakeTime"); // NMS AbstractArrow
|
||||
|
||||
/**
|
||||
* Vanilla impact wobble of a freshly stuck arrow (ArrowModel#setupAnim, partialTick 0): the model root turns
|
||||
* by {@code -sin(3·shake)·shake} degrees about its z axis — the same axis as the pitch, so it is added to it.
|
||||
* {@code shakeTime} (7 → 0 ticks after hitting a block) has no API; read from the server entity. Tridents
|
||||
* ignore it (ThrownTridentRenderer has no shake).
|
||||
*/
|
||||
private static float arrowShake(Entity e) {
|
||||
if (!(e instanceof AbstractArrow) || e instanceof Trident) return 0;
|
||||
try {
|
||||
Object handle = handle(e);
|
||||
Field f = SHAKE_FIELD.get(handle.getClass());
|
||||
int shake = f == null ? 0 : f.getInt(handle);
|
||||
return shake <= 0 ? 0 : (float) (-Math.sin(shake * 3.0) * shake);
|
||||
} catch (ReflectiveOperationException | RuntimeException ex) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static final ClassValue<Method> VARIANT_GETTER = Reflection.method("getVariant", "getWeatheringState");
|
||||
|
||||
private static String genericVariant(Entity e) {
|
||||
Method m = VARIANT_GETTER.get(e.getClass());
|
||||
if (m == null) return null;
|
||||
try {
|
||||
return keyOf(m.invoke(e));
|
||||
} catch (ReflectiveOperationException ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry/Keyed values yield their key path ({@code "<ns>:<path>"} outside the minecraft namespace, e.g.
|
||||
* datapack variants); plain enums yield their lower-case name.
|
||||
*/
|
||||
private static String keyOf(Object o) {
|
||||
return switch (o) {
|
||||
case null -> null;
|
||||
case Keyed k -> k.getKey().getNamespace().equals("minecraft")
|
||||
? k.getKey().getKey()
|
||||
: k.getKey().toString();
|
||||
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,58 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.snapshot;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.render.RayMap;
|
||||
import java.util.Collection;
|
||||
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 (origin plus every ray end at {@code maxDistance}),
|
||||
* computed once per photo and shared by all snapshot builders.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
public static FrustumBounds of(Location eye, RayMap rayMap, double maxDistance) {
|
||||
double ox = eye.getX(), oy = eye.getY(), oz = eye.getZ();
|
||||
double minX = ox, minY = oy, minZ = oz;
|
||||
double maxX = ox, maxY = oy, maxZ = oz;
|
||||
for (int i = 0, n = rayMap.size(); i < n; i++) {
|
||||
Vector ray = rayMap.get(i);
|
||||
double fx = ox + ray.getX() * maxDistance;
|
||||
double fy = oy + ray.getY() * maxDistance;
|
||||
double fz = oz + 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entities near the frustum (2-block margin), queried once per photo and shared by the entity, decoration
|
||||
* and object builders. MUST run on the main thread.
|
||||
*/
|
||||
public Collection<Entity> nearbyEntities(World world) {
|
||||
Location center = new Location(world, (minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2);
|
||||
double hx = (maxX - minX) / 2 + 2;
|
||||
double hy = (maxY - minY) / 2 + 2;
|
||||
double hz = (maxZ - minZ) / 2 + 2;
|
||||
return world.getNearbyEntities(center, hx, hy, hz);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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. */
|
||||
final class ItemSpecs {
|
||||
|
||||
private ItemSpecs() {}
|
||||
|
||||
/** Block data + block-entity state depend only on the 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. */
|
||||
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;
|
||||
}
|
||||
BlockEntityState be = b.blockEntity;
|
||||
// Head skins / banner patterns live on the stack: only those kinds pay for the meta copy.
|
||||
if (be != null && ("player".equals(be.headType()) || be.kind() == BlockEntityState.Kind.BANNER)) {
|
||||
be = BlockEntitySnapshotBuilder.withItemMeta(be, stack.getItemMeta());
|
||||
} else if (m == Material.SHIELD) {
|
||||
be = BlockEntitySnapshotBuilder.shieldPatterns(stack.getItemMeta());
|
||||
}
|
||||
return new ItemSpec(m.getKey().getKey(), b.data, be, 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,143 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.snapshot;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.MapColorPalette;
|
||||
import eu.mhsl.minecraft.pixelpics.utils.MapManager;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.MapMeta;
|
||||
import org.bukkit.map.MapRenderer;
|
||||
import org.bukkit.map.MapView;
|
||||
|
||||
/**
|
||||
* Captures a filled map's 128x128 colours (and vanilla frame markers) for rendering it in an item frame.
|
||||
* PixelPics photo maps come from their {@link ImageMapRenderer} (or the on-disk index cache), other maps from
|
||||
* the server's {@code MapItemSavedData} via reflection (Bukkit has no API to read it). MUST run on the main
|
||||
* thread.
|
||||
*/
|
||||
final class MapPixels {
|
||||
|
||||
private static final int SIZE = ImageMapRenderer.MAP_SIZE;
|
||||
|
||||
private MapPixels() {}
|
||||
|
||||
/** The stack's map view, or null when it has none. */
|
||||
static MapView view(ItemStack stack) {
|
||||
try {
|
||||
return stack.getItemMeta() instanceof MapMeta meta && meta.hasMapView() ? meta.getMapView() : null;
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The stack's map id without loading its view, or -1. */
|
||||
static int mapId(ItemStack stack) {
|
||||
try {
|
||||
return stack.getItemMeta() instanceof MapMeta meta && meta.hasMapId() ? meta.getMapId() : -1;
|
||||
} catch (Throwable t) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A PixelPics photo without loading the server's map data (expensive on the main thread for a wall of photos): its
|
||||
* renderer's indices, else the stored index cache, else blank film like its map shows meanwhile.
|
||||
*/
|
||||
static int[][] photo(int mapId) {
|
||||
ImageMapRenderer renderer = MapManager.renderer(mapId);
|
||||
byte[] colors = renderer != null ? renderer.getIndices() : MapManager.readIndices(mapId);
|
||||
return argb(colors != null ? colors : film());
|
||||
}
|
||||
|
||||
/** ARGB {@code [y][x]}, or null without map data (vanilla then draws the item). */
|
||||
static int[][] of(MapView view) {
|
||||
if (view == null) return null;
|
||||
try {
|
||||
return argb(colors(view));
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static int[][] argb(byte[] colors) {
|
||||
if (colors == null || colors.length != SIZE * SIZE) return null;
|
||||
int[][] out = new int[SIZE][SIZE];
|
||||
for (int y = 0; y < SIZE; y++) {
|
||||
for (int x = 0; x < SIZE; x++) out[y][x] = MapColorPalette.argb(colors[y * SIZE + x]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static byte[] colors(MapView view) throws ReflectiveOperationException {
|
||||
for (MapRenderer r : view.getRenderers()) {
|
||||
if (r instanceof ImageMapRenderer image) {
|
||||
byte[] indices = image.getIndices();
|
||||
return indices != null ? indices : film();
|
||||
}
|
||||
}
|
||||
if (MapManager.isManaged(view.getId())) {
|
||||
byte[] cached = MapManager.readIndices(view.getId());
|
||||
if (cached != null) return cached;
|
||||
}
|
||||
Object data = worldMap(view);
|
||||
Field colors = data == null ? null : COLORS.get(data.getClass());
|
||||
return colors == null ? null : (byte[]) colors.get(data);
|
||||
}
|
||||
|
||||
/** A photo still being rendered shows blank film, as its map does. */
|
||||
private static byte[] film() {
|
||||
byte[] b = new byte[SIZE * SIZE];
|
||||
Arrays.fill(b, ImageMapRenderer.filmColor());
|
||||
return b;
|
||||
}
|
||||
|
||||
/**
|
||||
* The decorations an item frame shows (MapDecorationType.showOnItemFrame: banners, frame/structure/target
|
||||
* markers; never players), in the map data's order. Only for maps drawn by the vanilla CraftMapRenderer,
|
||||
* which sends {@code MapItemSavedData.decorations} as cursors; plugin-rendered maps (photos) get none.
|
||||
*/
|
||||
static List<DecorationState.MapMarker> markers(MapView view) {
|
||||
try {
|
||||
if (view == null || MapManager.isManaged(view.getId())) return List.of();
|
||||
boolean vanilla = false;
|
||||
for (MapRenderer r : view.getRenderers()) {
|
||||
if (r instanceof ImageMapRenderer) return List.of();
|
||||
if (r.getClass().getSimpleName().equals("CraftMapRenderer")) vanilla = true;
|
||||
}
|
||||
if (!vanilla) return List.of();
|
||||
Object data = worldMap(view);
|
||||
Field f = data == null ? null : DECORATIONS.get(data.getClass());
|
||||
if (f == null || !(f.get(data) instanceof Map<?, ?> decorations) || decorations.isEmpty()) return List.of();
|
||||
List<DecorationState.MapMarker> out = new ArrayList<>();
|
||||
for (Object d : decorations.values()) {
|
||||
Method[] m = DECORATION_METHODS.get(d.getClass());
|
||||
if (m == null) return List.of();
|
||||
if (!(Boolean) m[0].invoke(d)) continue;
|
||||
String sprite = String.valueOf(m[1].invoke(d));
|
||||
out.add(new DecorationState.MapMarker(
|
||||
sprite.substring(sprite.indexOf(':') + 1), (Byte) m[2].invoke(d), (Byte) m[3].invoke(d), (Byte)
|
||||
m[4].invoke(d)));
|
||||
}
|
||||
return out;
|
||||
} catch (Throwable t) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static Object worldMap(MapView view) throws ReflectiveOperationException {
|
||||
Field f = WORLD_MAP.get(view.getClass());
|
||||
return f == null ? null : f.get(view);
|
||||
}
|
||||
|
||||
private static final ClassValue<Field> WORLD_MAP = Reflection.field("worldMap"); // CraftMapView
|
||||
private static final ClassValue<Field> DECORATIONS = Reflection.field("decorations"); // MapItemSavedData
|
||||
private static final ClassValue<Field> COLORS = Reflection.field("colors"); // MapItemSavedData
|
||||
private static final ClassValue<Method[]> DECORATION_METHODS =
|
||||
Reflection.methods("renderOnFrame", "getSpriteLocation", "x", "y", "rot"); // MapDecoration
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.snapshot;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.ObjectState;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
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 display entities (transformation, billboard resolved against the camera), dropped items, falling
|
||||
* blocks, primed TNT, minecart blocks and leash ropes into {@link ObjectState}s. MUST run on the main thread.
|
||||
*/
|
||||
public final class ObjectSnapshotBuilder {
|
||||
|
||||
private ObjectSnapshotBuilder() {}
|
||||
|
||||
/** {@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) {
|
||||
}
|
||||
try {
|
||||
ObjectState leash = leash(e);
|
||||
if (leash != null) out.add(leash);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
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, l, 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(Locale.ROOT);
|
||||
// ItemDisplayRenderer.submitInner turns the item by 180° about Y before submitting it.
|
||||
return item(base.mul(Affine.rotY(Math.PI)), id.getItemStack(), ctx, false);
|
||||
}
|
||||
if (d instanceof TextDisplay td) {
|
||||
return text(base, td);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (e instanceof FallingBlock fb) {
|
||||
// FallingBlockRenderer: weighted variants seeded by getSeed(startPos) (the block it fell from; Paper's
|
||||
// origin = the spawn position at the block centre).
|
||||
BlockData data = fb.getBlockData();
|
||||
if (data == null || data.getMaterial().isAir()) return null;
|
||||
Location start = fb.getOrigin() != null ? fb.getOrigin() : fb.getLocation();
|
||||
long seed = BlockModelRegistry.seed(data, start.getBlockX(), start.getBlockY(), start.getBlockZ());
|
||||
return ObjectState.block(
|
||||
at.mul(Affine.translation(-0.5, 0, -0.5)),
|
||||
data,
|
||||
BlockEntitySnapshotBuilder.fromBlockData(data),
|
||||
seed);
|
||||
}
|
||||
if (e instanceof TNTPrimed) {
|
||||
return block(at.mul(Affine.translation(-0.5, 0, -0.5)), Material.TNT.createBlockData());
|
||||
}
|
||||
if (e instanceof Item it) {
|
||||
// GROUND transform, lifted by the baker. The spin (vanilla: age/20 + random bobOffset) is frozen from
|
||||
// the yaw, which ItemEntity also randomises at creation (tests fix it with setRotation).
|
||||
double spin = Math.toRadians(l.getYaw());
|
||||
return item(at.mul(Affine.rotY(spin)), it.getItemStack(), "ground", true);
|
||||
}
|
||||
if (e instanceof Minecart mc) {
|
||||
// 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, mc.getDisplayBlockData());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ObjectState block(Affine toWorld, BlockData data) {
|
||||
if (data == null || data.getMaterial().isAir()) return null;
|
||||
return ObjectState.block(toWorld, data, BlockEntitySnapshotBuilder.fromBlockData(data));
|
||||
}
|
||||
|
||||
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<Method[]> LEASH_METHODS = Reflection.methods("isLeashed", "getLeashHolder");
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private static ObjectState leash(Entity e) throws ReflectiveOperationException {
|
||||
Entity holder;
|
||||
if (e instanceof LivingEntity le) {
|
||||
if (!le.isLeashed()) return null;
|
||||
holder = le.getLeashHolder();
|
||||
} else {
|
||||
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(bodyYaw(e, l));
|
||||
double fwd = e.getWidth() * 0.4;
|
||||
double[] p = new double[6];
|
||||
p[0] = l.getX() - Math.sin(yaw) * fwd;
|
||||
p[1] = l.getY() + eyeHeight(e);
|
||||
p[2] = l.getZ() + Math.cos(yaw) * fwd;
|
||||
Location h = holder.getLocation();
|
||||
p[3] = h.getX();
|
||||
p[5] = h.getZ();
|
||||
if (holder instanceof LeashHitch) {
|
||||
p[4] = h.getY() + 0.2;
|
||||
} else if (holder instanceof HumanEntity
|
||||
|| holder.getType().getKey().getKey().equals("mannequin")) {
|
||||
double hy = Math.toRadians(bodyYaw(holder, h));
|
||||
double side = 0.35, f = 0.1;
|
||||
p[3] = h.getX() - Math.cos(hy) * side - Math.sin(hy) * f;
|
||||
p[4] = h.getY() + holder.getHeight() * 0.42;
|
||||
p[5] = h.getZ() - Math.sin(hy) * side + Math.cos(hy) * f;
|
||||
} else {
|
||||
p[4] = h.getY() + eyeHeight(holder) * 0.7;
|
||||
}
|
||||
return ObjectState.leash(p);
|
||||
}
|
||||
|
||||
private static double bodyYaw(Entity e, Location l) {
|
||||
return e instanceof LivingEntity le ? le.getBodyYaw() : l.getYaw();
|
||||
}
|
||||
|
||||
private static double eyeHeight(Entity e) {
|
||||
return e instanceof LivingEntity le ? le.getEyeHeight() : e.getHeight() * 0.5;
|
||||
}
|
||||
|
||||
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 < 26) opacity = 255; // vanilla: -1 (default) and very low values render opaque
|
||||
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.
|
||||
return ObjectState.text(
|
||||
base.mul(Affine.scale(0.025)),
|
||||
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 l, Location eye) {
|
||||
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,65 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.snapshot;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
/**
|
||||
* Per-class cached reflection handles for server internals Bukkit has no API for (null when absent, so callers
|
||||
* fall back). Looked up once per runtime class, not per entity/photo.
|
||||
*/
|
||||
final class Reflection {
|
||||
|
||||
private Reflection() {}
|
||||
|
||||
/** The first of the public no-arg methods {@code names} the class has, or null. */
|
||||
static ClassValue<Method> method(String... names) {
|
||||
return new ClassValue<>() {
|
||||
@Override
|
||||
protected Method computeValue(Class<?> c) {
|
||||
for (String n : names) {
|
||||
try {
|
||||
return c.getMethod(n);
|
||||
} catch (NoSuchMethodException ignored) {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** All the public no-arg methods {@code names}, or null unless the class has every one. */
|
||||
static ClassValue<Method[]> methods(String... names) {
|
||||
return new ClassValue<>() {
|
||||
@Override
|
||||
protected Method[] computeValue(Class<?> c) {
|
||||
Method[] m = new Method[names.length];
|
||||
try {
|
||||
for (int i = 0; i < names.length; i++) m[i] = c.getMethod(names[i]);
|
||||
} catch (NoSuchMethodException e) {
|
||||
return null;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** A public (possibly inherited) field, else a field declared by the class itself (made accessible), or null. */
|
||||
static ClassValue<Field> field(String name) {
|
||||
return new ClassValue<>() {
|
||||
@Override
|
||||
protected Field computeValue(Class<?> c) {
|
||||
try {
|
||||
return c.getField(name);
|
||||
} catch (NoSuchFieldException ignored) {
|
||||
}
|
||||
try {
|
||||
Field f = c.getDeclaredField(name);
|
||||
f.setAccessible(true);
|
||||
return f;
|
||||
} catch (ReflectiveOperationException | RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.snapshot;
|
||||
|
||||
import java.util.HashMap;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Captures the world region covered by the camera frustum into a {@link WorldSnapshot}. MUST run on the main
|
||||
* thread. Only already-loaded chunks are captured (no forced loading), rays into unloaded areas hit sky.
|
||||
*
|
||||
* <p>Chunk snapshots always hold the full world height, so the snapshot keeps all of it: the sky light under water
|
||||
* counts the water column up to its surface ({@code FluidRenderer.skyLevel}), which may lie above the frustum. The
|
||||
* chunks around the camera ({@link #CAMERA_MARGIN}) are captured too, also those behind it: the underwater fog
|
||||
* blends the biomes around the eye.
|
||||
*/
|
||||
public final class SnapshotBuilder {
|
||||
|
||||
/** Safety cap on captured chunks (memory/latency). */
|
||||
private static final int MAX_CHUNKS = 4096;
|
||||
|
||||
/**
|
||||
* Blocks around the eye always captured: the water fog's Gaussian biome blend reads quart cells up to 12 blocks
|
||||
* (+3 within the cell) from the eye ({@code CameraMedium}).
|
||||
*/
|
||||
private static final int CAMERA_MARGIN = 16;
|
||||
|
||||
private SnapshotBuilder() {}
|
||||
|
||||
public static WorldSnapshot build(World world, Location eye, FrustumBounds bounds, Logger logger) {
|
||||
int minY = world.getMinHeight();
|
||||
int maxY = world.getMaxHeight();
|
||||
|
||||
ChunkArea area = chunkArea(eye, bounds, 0);
|
||||
|
||||
Map<Long, ChunkSnapshot> chunks = new HashMap<>();
|
||||
int skipped = 0;
|
||||
for (int cx = area.minCX(); cx <= area.maxCX(); cx++) {
|
||||
for (int cz = area.minCZ(); cz <= area.maxCZ(); cz++) {
|
||||
if (chunks.size() >= MAX_CHUNKS || !world.isChunkLoaded(cx, cz)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
chunks.put(
|
||||
WorldSnapshot.chunkKey(cx, cz), world.getChunkAt(cx, cz).getChunkSnapshot(false, true, false));
|
||||
}
|
||||
}
|
||||
if (skipped > 0) {
|
||||
logger.fine(String.format(
|
||||
"Snapshot captured %d chunks, skipped %d (unloaded or over cap)", chunks.size(), skipped));
|
||||
}
|
||||
return new WorldSnapshot(chunks, minY, maxY, Material.AIR.createBlockData());
|
||||
}
|
||||
|
||||
/** Chunk coordinates (inclusive) of a capture area. */
|
||||
public record ChunkArea(int minCX, int maxCX, int minCZ, int maxCZ) {}
|
||||
|
||||
/** The chunks {@link #build} captures for these bounds, widened by {@code margin} blocks on every side. */
|
||||
public static ChunkArea chunkArea(Location eye, FrustumBounds bounds, double margin) {
|
||||
double r = CAMERA_MARGIN + margin;
|
||||
return new ChunkArea(
|
||||
(int) Math.floor(Math.min(bounds.minX - margin, eye.getX() - r)) >> 4,
|
||||
(int) Math.floor(Math.max(bounds.maxX + margin, eye.getX() + r)) >> 4,
|
||||
(int) Math.floor(Math.min(bounds.minZ - margin, eye.getZ() - r)) >> 4,
|
||||
(int) Math.floor(Math.max(bounds.maxZ + margin, eye.getZ() + r)) >> 4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* Immutable, thread-safe view of the captured chunks; lookups outside them return air/null.
|
||||
*
|
||||
* <p>Performance: the chunks live in a primitive open-addressing table (linear probing, Fibonacci hash of the
|
||||
* full {@link #chunkKey}), not a {@code HashMap<Long, …>}: {@code Long.hashCode} is {@code cx ^ cz}, so every
|
||||
* diagonal would collide into treeified bins and each block lookup would pay for boxing.
|
||||
*/
|
||||
public final class WorldSnapshot {
|
||||
|
||||
private static final long GOLDEN = 0x9E3779B97F4A7C15L;
|
||||
|
||||
private final long[] keys;
|
||||
private final ChunkSnapshot[] values; // null = empty slot
|
||||
private final int mask;
|
||||
private final int shift;
|
||||
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) {
|
||||
// Power-of-two capacity >= 2·size (load factor <= 0.5), at least 2 so the shift stays below 64.
|
||||
int cap = Math.max(2, Integer.highestOneBit(Math.max(1, chunks.size()) * 2 - 1) << 1);
|
||||
this.keys = new long[cap];
|
||||
this.values = new ChunkSnapshot[cap];
|
||||
this.mask = cap - 1;
|
||||
this.shift = 64 - Integer.numberOfTrailingZeros(cap);
|
||||
for (Map.Entry<Long, ChunkSnapshot> e : chunks.entrySet()) {
|
||||
long key = e.getKey();
|
||||
int i = slot(key);
|
||||
while (values[i] != null) i = (i + 1) & mask;
|
||||
keys[i] = key;
|
||||
values[i] = e.getValue();
|
||||
}
|
||||
this.minY = minY;
|
||||
this.maxY = maxY;
|
||||
this.air = air;
|
||||
}
|
||||
|
||||
/** Unique key of a chunk position (x in the high, z in the low 32 bits). */
|
||||
public static long chunkKey(int chunkX, int chunkZ) {
|
||||
return ((long) chunkX << 32) ^ (chunkZ & 0xFFFFFFFFL);
|
||||
}
|
||||
|
||||
private int slot(long key) {
|
||||
return (int) ((key * GOLDEN) >>> shift);
|
||||
}
|
||||
|
||||
private ChunkSnapshot chunk(int chunkX, int chunkZ) {
|
||||
long key = chunkKey(chunkX, chunkZ);
|
||||
int i = slot(key);
|
||||
ChunkSnapshot cs;
|
||||
while ((cs = values[i]) != null) {
|
||||
if (keys[i] == key) return cs;
|
||||
i = (i + 1) & mask;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public BlockData getBlockData(int x, int y, int z) {
|
||||
if (y < minY || y >= maxY) return air;
|
||||
ChunkSnapshot cs = chunk(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 = chunk(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,404 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.tint;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
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 RGB water colour (no alpha)
|
||||
* @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
|
||||
* @param waterFogColor RGB underwater fog colour (attribute {@code minecraft:visual/water_fog_color})
|
||||
* @param waterFogStart underwater fog start distance ({@code minecraft:visual/water_fog_start_distance})
|
||||
* @param waterFogEnd underwater fog end distance ({@code minecraft:visual/water_fog_end_distance})
|
||||
*/
|
||||
public record Climate(
|
||||
double temperature,
|
||||
double downfall,
|
||||
int water,
|
||||
int grass,
|
||||
int foliage,
|
||||
int dryFoliage,
|
||||
String grassModifier,
|
||||
int waterFogColor,
|
||||
double waterFogStart,
|
||||
double waterFogEnd) {
|
||||
|
||||
Climate(
|
||||
double temperature,
|
||||
double downfall,
|
||||
int water,
|
||||
int grass,
|
||||
int foliage,
|
||||
int dryFoliage,
|
||||
String grassModifier) {
|
||||
this(
|
||||
temperature,
|
||||
downfall,
|
||||
water,
|
||||
grass,
|
||||
foliage,
|
||||
dryFoliage,
|
||||
grassModifier,
|
||||
DEFAULT_WATER_FOG_COLOR,
|
||||
DEFAULT_WATER_FOG_START,
|
||||
DEFAULT_WATER_FOG_END);
|
||||
}
|
||||
|
||||
Climate withWaterFog(int color, double endFactor) {
|
||||
return new Climate(
|
||||
temperature,
|
||||
downfall,
|
||||
water,
|
||||
grass,
|
||||
foliage,
|
||||
dryFoliage,
|
||||
grassModifier,
|
||||
color,
|
||||
waterFogStart,
|
||||
waterFogEnd * endFactor);
|
||||
}
|
||||
}
|
||||
|
||||
private static final int DEFAULT_WATER = 0x3F76E4;
|
||||
// Vanilla 26.3 EnvironmentAttributes defaults (no dimension type sets them).
|
||||
private static final int DEFAULT_WATER_FOG_COLOR = 0x050533;
|
||||
private static final double DEFAULT_WATER_FOG_START = -8;
|
||||
private static final double DEFAULT_WATER_FOG_END = 96;
|
||||
public static final Climate DEFAULT = new Climate(0.8, 0.4, DEFAULT_WATER, NONE, NONE, NONE, null);
|
||||
|
||||
private static final String DATA_PATH = "data/minecraft/worldgen/biome/";
|
||||
|
||||
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);
|
||||
// Biome attributes minecraft:visual/water_fog_color (+ water_fog_end_distance multiply modifier).
|
||||
fog("cherry_grove", 0x5DB7EF, 1);
|
||||
fog("dappled_forest", 0x375154, 1);
|
||||
fog("deep_lukewarm_ocean", 0x041633, 1);
|
||||
fog("lukewarm_ocean", 0x041633, 1);
|
||||
fog("mangrove_swamp", 0x4D7A60, 0.85);
|
||||
fog("pale_garden", 0x556980, 1);
|
||||
fog("sulfur_caves", 0x17543C, 1);
|
||||
fog("swamp", 0x232317, 0.85);
|
||||
fog("warm_ocean", 0x041F33, 1);
|
||||
}
|
||||
|
||||
private static void fog(String key, int color, double endFactor) {
|
||||
FALLBACK.put(key, FALLBACK.get(key).withWaterFog(color, endFactor));
|
||||
}
|
||||
|
||||
private BiomeClimate() {}
|
||||
|
||||
// Biome attribute minecraft:visual/fog_color of the vanilla 26.3 nether biomes (fallback without server data).
|
||||
private static final Map<String, Integer> FOG_FALLBACK = Map.of(
|
||||
"nether_wastes", 0x330808,
|
||||
"basalt_deltas", 0x685F70,
|
||||
"crimson_forest", 0x330303,
|
||||
"warped_forest", 0x1A051A,
|
||||
"soul_sand_valley", 0x1B4745);
|
||||
private static final Map<String, Integer> FOG_LOADED = new ConcurrentHashMap<>();
|
||||
|
||||
/** The biome's {@code minecraft:visual/fog_color} attribute applied to {@code base}. */
|
||||
public static int fogColor(String biomePath, int base) {
|
||||
int loaded = FOG_LOADED.computeIfAbsent(biomePath, BiomeClimate::loadFogColor);
|
||||
if (loaded != NONE) return loaded == Integer.MIN_VALUE ? base : loaded;
|
||||
return FOG_FALLBACK.getOrDefault(biomePath, base);
|
||||
}
|
||||
|
||||
/** The fog colour of the biome file (MIN_VALUE: file without one), NONE without server data. */
|
||||
private static int loadFogColor(String biomePath) {
|
||||
if (!biomePath.matches("[a-z0-9_./-]+")) return NONE;
|
||||
ClassLoader loader = BiomeClimate.class.getClassLoader();
|
||||
if (loader == null) return NONE;
|
||||
try (InputStream in = loader.getResourceAsStream(DATA_PATH + biomePath + ".json")) {
|
||||
if (in == null) return NONE;
|
||||
JsonObject root = JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8))
|
||||
.getAsJsonObject();
|
||||
JsonObject attributes = root.has("attributes") ? root.getAsJsonObject("attributes") : new JsonObject();
|
||||
if (!attributes.has("minecraft:visual/fog_color")) return Integer.MIN_VALUE;
|
||||
return rgbAttribute(attributes, "minecraft:visual/fog_color", 0);
|
||||
} catch (Exception e) {
|
||||
return NONE;
|
||||
}
|
||||
}
|
||||
|
||||
public static Climate forKey(String biomePath) {
|
||||
Optional<Climate> loaded = LOADED.computeIfAbsent(biomePath, BiomeClimate::loadFromServerData);
|
||||
return loaded.isPresent() ? loaded.get() : 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");
|
||||
JsonObject attributes = root.has("attributes") ? root.getAsJsonObject("attributes") : new JsonObject();
|
||||
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,
|
||||
rgbAttribute(attributes, "minecraft:visual/water_fog_color", DEFAULT_WATER_FOG_COLOR),
|
||||
floatAttribute(attributes, "minecraft:visual/water_fog_start_distance", DEFAULT_WATER_FOG_START),
|
||||
floatAttribute(attributes, "minecraft:visual/water_fog_end_distance", DEFAULT_WATER_FOG_END)));
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A float environment attribute of a biome applied to the default: a plain value overrides it, an object
|
||||
* {@code {"argument": a, "modifier": op}} applies vanilla's float modifier (override/alpha_blend/add/subtract/
|
||||
* multiply/minimum/maximum).
|
||||
*/
|
||||
private static double floatAttribute(JsonObject attributes, String key, double base) {
|
||||
JsonElement el = attributes.get(key);
|
||||
if (el == null) return base;
|
||||
try {
|
||||
if (el.isJsonPrimitive()) return el.getAsDouble();
|
||||
if (!el.isJsonObject()) return base;
|
||||
JsonObject o = el.getAsJsonObject();
|
||||
String op = o.has("modifier") ? o.get("modifier").getAsString() : "override";
|
||||
JsonElement arg = o.get("argument");
|
||||
if (op.equals("alpha_blend")) {
|
||||
// FloatWithAlpha: a plain value (alpha 1) or {"value", "alpha"}; lerp(alpha, base, value).
|
||||
if (!arg.isJsonObject()) return arg.getAsDouble();
|
||||
JsonObject v = arg.getAsJsonObject();
|
||||
double alpha = v.has("alpha") ? v.get("alpha").getAsDouble() : 1;
|
||||
return base + alpha * (v.get("value").getAsDouble() - base);
|
||||
}
|
||||
double a = arg.getAsDouble();
|
||||
return switch (op) {
|
||||
case "add" -> base + a;
|
||||
case "subtract" -> base - a;
|
||||
case "multiply" -> base * a;
|
||||
case "minimum" -> Math.min(base, a);
|
||||
case "maximum" -> Math.max(base, a);
|
||||
default -> a;
|
||||
};
|
||||
} catch (RuntimeException e) {
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An RGB colour environment attribute of a biome applied to the default {@code base}: a plain colour overrides
|
||||
* it, an object {@code {"argument": a, "modifier": op}} applies vanilla's {@code ColorModifier} (26.3
|
||||
* {@code AttributeModifier.RGB_COLOR_LIBRARY}): override, alpha_blend (argument ARGB), add, subtract, multiply
|
||||
* (argument RGB), blend_to_gray ({@code {brightness, factor}}). Channels as floats 0..1 like vanilla.
|
||||
*/
|
||||
static int rgbAttribute(JsonObject attributes, String key, int base) {
|
||||
JsonElement el = attributes.get(key);
|
||||
if (el == null) return base;
|
||||
try {
|
||||
if (!el.isJsonObject()) return toRgb(rgb(el));
|
||||
JsonObject o = el.getAsJsonObject();
|
||||
JsonElement arg = o.get("argument");
|
||||
String op = o.has("modifier") ? o.get("modifier").getAsString() : "override";
|
||||
float[] c = rgb(new JsonPrimitive(base));
|
||||
float[] r =
|
||||
switch (op) {
|
||||
case "override" -> rgb(arg);
|
||||
case "alpha_blend" -> {
|
||||
float[] a = argb(arg); // ARGB.alphaBlend: lerp towards the argument by its alpha
|
||||
yield a[3] == 0 ? c : lerp(a[3], c, new float[] {a[0], a[1], a[2]});
|
||||
}
|
||||
case "add" -> {
|
||||
float[] a = rgb(arg);
|
||||
yield new float[] {
|
||||
Math.min(c[0] + a[0], 1), Math.min(c[1] + a[1], 1), Math.min(c[2] + a[2], 1)
|
||||
};
|
||||
}
|
||||
case "subtract" -> {
|
||||
float[] a = rgb(arg);
|
||||
yield new float[] {
|
||||
Math.max(c[0] - a[0], 0), Math.max(c[1] - a[1], 0), Math.max(c[2] - a[2], 0)
|
||||
};
|
||||
}
|
||||
case "multiply" -> {
|
||||
float[] a = rgb(arg);
|
||||
yield new float[] {c[0] * a[0], c[1] * a[1], c[2] * a[2]};
|
||||
}
|
||||
case "blend_to_gray" -> {
|
||||
JsonObject a = arg.getAsJsonObject();
|
||||
float brightness = a.get("brightness").getAsFloat();
|
||||
float factor = a.get("factor").getAsFloat();
|
||||
float g = Math.clamp((c[0] * 0.3F + c[1] * 0.59F + c[2] * 0.11F) * brightness, 0, 1);
|
||||
yield lerp(factor, c, new float[] {g, g, g});
|
||||
}
|
||||
// Unknown operation: vanilla rejects the biome; keep the default.
|
||||
default -> c;
|
||||
};
|
||||
return toRgb(r);
|
||||
} catch (RuntimeException e) {
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
private static float[] lerp(float t, float[] from, float[] to) {
|
||||
return new float[] {
|
||||
from[0] + t * (to[0] - from[0]), from[1] + t * (to[1] - from[1]), from[2] + t * (to[2] - from[2])
|
||||
};
|
||||
}
|
||||
|
||||
/** ExtraCodecs.STRING_RGB_VEC3_COLOR: "#rrggbb", a packed int or [r, g, b] floats. */
|
||||
private static float[] rgb(JsonElement el) {
|
||||
if (el.isJsonArray()) {
|
||||
var a = el.getAsJsonArray();
|
||||
return new float[] {
|
||||
a.get(0).getAsFloat(), a.get(1).getAsFloat(), a.get(2).getAsFloat()
|
||||
};
|
||||
}
|
||||
int v = el.getAsJsonPrimitive().isNumber() ? el.getAsInt() : hex(el.getAsString(), 6);
|
||||
return new float[] {(v >> 16 & 0xFF) / 255F, (v >> 8 & 0xFF) / 255F, (v & 0xFF) / 255F};
|
||||
}
|
||||
|
||||
/** ExtraCodecs.STRING_ARGB_VEC4_COLOR as {r, g, b, a}: "#aarrggbb", a packed int or [r, g, b, a] floats. */
|
||||
private static float[] argb(JsonElement el) {
|
||||
if (el.isJsonArray()) {
|
||||
var a = el.getAsJsonArray();
|
||||
return new float[] {
|
||||
a.get(0).getAsFloat(),
|
||||
a.get(1).getAsFloat(),
|
||||
a.get(2).getAsFloat(),
|
||||
a.get(3).getAsFloat()
|
||||
};
|
||||
}
|
||||
int v = el.getAsJsonPrimitive().isNumber() ? el.getAsInt() : hex(el.getAsString(), 8);
|
||||
return new float[] {(v >> 16 & 0xFF) / 255F, (v >> 8 & 0xFF) / 255F, (v & 0xFF) / 255F, (v >>> 24) / 255F};
|
||||
}
|
||||
|
||||
private static int hex(String s, int digits) {
|
||||
if (!s.startsWith("#") || s.length() != digits + 1) throw new IllegalArgumentException(s);
|
||||
return (int) Long.parseLong(s.substring(1), 16);
|
||||
}
|
||||
|
||||
private static int toRgb(float[] c) {
|
||||
return channel(c[0]) << 16 | channel(c[1]) << 8 | channel(c[2]);
|
||||
}
|
||||
|
||||
private static int channel(float v) {
|
||||
return Math.clamp(Math.round(v * 255), 0, 255);
|
||||
}
|
||||
|
||||
/** Colors are either "#rrggbb" strings or packed integers, depending on the data version. */
|
||||
private static int color(JsonObject obj, String key, int fallback) {
|
||||
JsonElement el = obj.get(key);
|
||||
if (el == null || !el.isJsonPrimitive()) return fallback;
|
||||
if (el.getAsJsonPrimitive().isNumber()) return el.getAsInt() & 0xFFFFFF;
|
||||
String s = el.getAsString().trim();
|
||||
if (s.startsWith("#")) s = s.substring(1);
|
||||
try {
|
||||
return Integer.parseInt(s, 16) & 0xFFFFFF;
|
||||
} catch (NumberFormatException e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.tint;
|
||||
|
||||
/** Biome tint colours (opaque ARGB) of the colormap-driven channels. */
|
||||
public record BiomeTint(int grass, int foliage, int dryFoliage, int water) {
|
||||
/** Plains colours: the fallback without a colormap, and the tint of blocks without biome context. */
|
||||
public static final BiomeTint PLAINS = new BiomeTint(0xFF91BD59, 0xFF77AB2F, 0xFFA9A05B, 0xFF3F76E4);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/** Per-biome grass/foliage/water tints: explicit biome colours or the pack's colormaps, cached per biome. */
|
||||
public final class BiomeTintProvider {
|
||||
|
||||
private final int[][] grassMap;
|
||||
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);
|
||||
}
|
||||
|
||||
/** The biome's climate/effects/attribute data (water fog etc.). */
|
||||
public BiomeClimate.Climate climate(Biome biome) {
|
||||
return BiomeClimate.forKey(keyOf(biome));
|
||||
}
|
||||
|
||||
private String keyOf(Biome biome) {
|
||||
try {
|
||||
return biome.getKey().getKey();
|
||||
} catch (Throwable t) {
|
||||
return "plains";
|
||||
}
|
||||
}
|
||||
|
||||
private BiomeTint compute(String key) {
|
||||
BiomeClimate.Climate climate = BiomeClimate.forKey(key);
|
||||
int grass = channel(climate.grass(), grassMap, climate, BiomeTint.PLAINS.grass());
|
||||
int foliage = channel(climate.foliage(), foliageMap, climate, BiomeTint.PLAINS.foliage());
|
||||
int dry = channel(climate.dryFoliage(), dryFoliageMap, climate, BiomeTint.PLAINS.dryFoliage());
|
||||
|
||||
// Vanilla grass_color_modifier (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());
|
||||
}
|
||||
|
||||
/** An explicit biome colour wins over the colormap, like in vanilla. */
|
||||
private static int channel(int explicit, int[][] colormap, BiomeClimate.Climate climate, int fallback) {
|
||||
return explicit != BiomeClimate.NONE ? 0xFF000000 | explicit : sample(colormap, climate, fallback);
|
||||
}
|
||||
|
||||
/** Vanilla colormap lookup: x = (1-temp)*255, y = (1-downfall*temp)*255. */
|
||||
private static int sample(int[][] colormap, BiomeClimate.Climate climate, int fallback) {
|
||||
if (colormap == null || colormap.length == 0) return fallback;
|
||||
double temp = Math.clamp(climate.temperature(), 0, 1);
|
||||
double down = Math.clamp(climate.downfall(), 0, 1) * temp;
|
||||
int x = (int) ((1.0 - temp) * 255.0);
|
||||
int y = (int) ((1.0 - down) * 255.0);
|
||||
int h = colormap.length;
|
||||
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,76 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.tint;
|
||||
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.data.Ageable;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.block.data.type.RedstoneWire;
|
||||
|
||||
/**
|
||||
* Maps a tinted face (block + tintindex) to its in-world tint colour like vanilla BlockColors.createDefault (26.3):
|
||||
* a biome channel or a constant. Blocks without a registered tint source (bamboo, cherry and pale oak leaves, the
|
||||
* lava/powder-snow cauldron, the stonecutter, …) and tint layers beyond the registered ones are not tinted.
|
||||
*/
|
||||
public final class TintResolver {
|
||||
|
||||
private static final int SPRUCE = 0xFF619961;
|
||||
private static final int BIRCH = 0xFF80A755;
|
||||
private static final int LILY_PAD_IN_WORLD = 0xFF208030;
|
||||
private static final int ATTACHED_STEM = 0xFFE0C71C;
|
||||
|
||||
/** RedstoneWireBlock.COLORS: the wire colour per power level, with vanilla's float arithmetic. */
|
||||
private static final int[] REDSTONE = new int[16];
|
||||
|
||||
static {
|
||||
for (int i = 0; i <= 15; i++) {
|
||||
float power = i / 15.0F;
|
||||
float red = power * 0.6F + (power > 0.0F ? 0.4F : 0.3F);
|
||||
float green = clamp(power * power * 0.7F - 0.5F);
|
||||
float blue = clamp(power * power * 0.6F - 0.7F);
|
||||
REDSTONE[i] = 0xFF000000 | channel8(red) << 16 | channel8(green) << 8 | channel8(blue);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
Material m = data.getMaterial();
|
||||
// Pink petals and wildflowers: layer 0 (the flowers) is blank, layer 1 (the stems) grass.
|
||||
if (m == Material.PINK_PETALS || m == Material.WILDFLOWERS) return tintIndex == 1 ? biomeTint.grass() : -1;
|
||||
if (tintIndex != 0) return -1; // every other source has one layer
|
||||
return switch (m) {
|
||||
case GRASS_BLOCK, SHORT_GRASS, FERN, POTTED_FERN -> biomeTint.grass();
|
||||
case BUSH, TALL_GRASS, LARGE_FERN, SUGAR_CANE -> biomeTint.grass();
|
||||
case OAK_LEAVES, JUNGLE_LEAVES, ACACIA_LEAVES -> biomeTint.foliage();
|
||||
case DARK_OAK_LEAVES, MANGROVE_LEAVES, VINE -> biomeTint.foliage();
|
||||
case SPRUCE_LEAVES -> SPRUCE;
|
||||
case BIRCH_LEAVES -> BIRCH;
|
||||
case LEAF_LITTER -> biomeTint.dryFoliage();
|
||||
case WATER_CAULDRON -> biomeTint.water();
|
||||
case REDSTONE_WIRE -> redstone(data);
|
||||
case MELON_STEM, PUMPKIN_STEM -> stem(data);
|
||||
case ATTACHED_MELON_STEM, ATTACHED_PUMPKIN_STEM -> ATTACHED_STEM;
|
||||
case LILY_PAD -> LILY_PAD_IN_WORLD;
|
||||
default -> -1;
|
||||
};
|
||||
}
|
||||
|
||||
private static int redstone(BlockData data) {
|
||||
return REDSTONE[data instanceof RedstoneWire wire ? Math.clamp(wire.getPower(), 0, 15) : 0];
|
||||
}
|
||||
|
||||
/** BlockTintSources.stem: {@code ARGB.color(age·32, 255 - age·8, age·4)}. */
|
||||
private static int stem(BlockData data) {
|
||||
int age = data instanceof Ageable a ? a.getAge() : 0;
|
||||
return 0xFF000000 | age * 32 << 16 | (255 - age * 8) << 8 | age * 4;
|
||||
}
|
||||
|
||||
private static float clamp(float v) {
|
||||
return v < 0.0F ? 0.0F : Math.min(v, 1.0F);
|
||||
}
|
||||
|
||||
/** ARGB.as8BitChannel: {@code floor(v·255)}. */
|
||||
private static int channel8(float v) {
|
||||
return (int) Math.floor(v * 255.0F);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.util;
|
||||
|
||||
import org.bukkit.Color;
|
||||
import org.bukkit.DyeColor;
|
||||
|
||||
/** Helpers for packed ARGB colours. */
|
||||
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());
|
||||
}
|
||||
|
||||
/** Vanilla {@code DyeColor.getTextColor()} (not the cloth colour) as opaque ARGB; null = black (default ink). */
|
||||
private static int signTextColor(DyeColor dye) {
|
||||
int rgb =
|
||||
switch (dye == null ? DyeColor.BLACK : dye) {
|
||||
case WHITE -> 0xF9FFFE;
|
||||
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;
|
||||
}
|
||||
|
||||
/** Sign text fill (vanilla SignRenderer): the dye's text colour, darkened to 40 % unless glowing. */
|
||||
public static int signFillArgb(DyeColor dye, boolean glowing) {
|
||||
int base = signTextColor(dye);
|
||||
return glowing ? base : shade(base, 0.4);
|
||||
}
|
||||
|
||||
/** Outline of glowing sign text (vanilla {@code getDarkColor}): text colour at 40 %, black ink a light cream. */
|
||||
public static int signOutlineArgb(DyeColor dye) {
|
||||
if (dye == null || dye == DyeColor.BLACK) return 0xFFF0EBCC;
|
||||
return shade(signTextColor(dye), 0.4);
|
||||
}
|
||||
|
||||
/** Multiplies the RGB channels of {@code base} by {@code tint} (per-channel, 0..255), keeping base alpha. */
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** Scales the RGB channels by {@code factor}, keeping alpha. */
|
||||
public static int shade(int argb, double factor) {
|
||||
int a = alpha(argb);
|
||||
int r = clamp((int) (red(argb) * factor));
|
||||
int g = clamp((int) (green(argb) * factor));
|
||||
int b = clamp((int) (blue(argb) * factor));
|
||||
return argb(a, r, g, b);
|
||||
}
|
||||
|
||||
/** Additive blend (vanilla BlendFunction.ADDITIVE, ONE/ONE): RGB of {@code src} added to {@code dst}, clamped. */
|
||||
public static int add(int dst, int src) {
|
||||
return (clamp(red(dst) + red(src)) << 16)
|
||||
| (clamp(green(dst) + green(src)) << 8)
|
||||
| clamp(blue(dst) + blue(src));
|
||||
}
|
||||
|
||||
public static int clamp(int v) {
|
||||
return v < 0 ? 0 : Math.min(v, 255);
|
||||
}
|
||||
|
||||
/** Weighted average of two RGB colours (weights normalised by their sum); alpha ignored, result RGB only. */
|
||||
public static int mix(int rgbA, int rgbB, double weightA, double weightB) {
|
||||
double total = weightA + weightB;
|
||||
int r = (int) ((red(rgbA) * weightA + red(rgbB) * weightB) / total);
|
||||
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;
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
private static int toSrgb(double linear) {
|
||||
double c = linear <= 0.0031308 ? linear * 12.92 : 1.055 * Math.pow(linear, 1 / 2.4) - 0.055;
|
||||
return clamp((int) Math.round(c * 255.0));
|
||||
}
|
||||
|
||||
/** 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,13 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.util;
|
||||
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
public final class MathUtil {
|
||||
|
||||
private MathUtil() {}
|
||||
|
||||
/** 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)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.util;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.MonthDay;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Vanilla {@code net.minecraft.util.SpecialDates} (decompiled 26.3): the server's local date decides the
|
||||
* christmas chest textures (ChestRenderer.xmasTextures = {@code isExtendedChristmas()}, 24–26 December) and the
|
||||
* {@code minecraft:local_time} item-model property (chest / trapped chest items).
|
||||
*
|
||||
* <p>Testing: the JVM system property {@value #TEST_DATE_PROPERTY} ({@code yyyy-MM-dd}, e.g.
|
||||
* {@code -Dpixelpics.testDate=2026-12-25}) replaces today's date (time of day stays the current one). Never set
|
||||
* it in production.
|
||||
*/
|
||||
public final class SpecialDates {
|
||||
|
||||
public static final String TEST_DATE_PROPERTY = "pixelpics.testDate";
|
||||
|
||||
private static final List<MonthDay> CHRISTMAS_RANGE =
|
||||
List.of(MonthDay.of(12, 24), MonthDay.of(12, 25), MonthDay.of(12, 26));
|
||||
|
||||
// The time of the photo being baked (set around a scene bake), so one photo never mixes two dates.
|
||||
private static final ScopedValue<ZonedDateTime> PHOTO_TIME = ScopedValue.newInstance();
|
||||
|
||||
private SpecialDates() {}
|
||||
|
||||
/** Runs {@code task} with {@link #now()} fixed to {@code time} on this thread. */
|
||||
public static <T> T at(ZonedDateTime time, Supplier<T> task) {
|
||||
return ScopedValue.where(PHOTO_TIME, time).call(task::get);
|
||||
}
|
||||
|
||||
/**
|
||||
* The photo's time inside {@link #at}, else the current local date-time ({@code ZonedDateTime.now()}), or the
|
||||
* test date at the current time of day.
|
||||
*/
|
||||
public static ZonedDateTime now() {
|
||||
if (PHOTO_TIME.isBound()) return PHOTO_TIME.get();
|
||||
ZonedDateTime now = ZonedDateTime.now();
|
||||
String test = System.getProperty(TEST_DATE_PROPERTY);
|
||||
if (test == null || test.isBlank()) return now;
|
||||
try {
|
||||
return ZonedDateTime.of(LocalDate.parse(test.trim()), LocalTime.from(now), ZoneId.systemDefault());
|
||||
} catch (RuntimeException e) {
|
||||
return now;
|
||||
}
|
||||
}
|
||||
|
||||
/** Vanilla {@code SpecialDates.isExtendedChristmas()}: today is 24, 25 or 26 December. */
|
||||
public static boolean isExtendedChristmas() {
|
||||
return CHRISTMAS_RANGE.contains(MonthDay.from(now()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package eu.mhsl.minecraft.pixelpics.setup;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonParser;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.ResourcePackLoader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
/**
|
||||
* The Minecraft version of the vanilla packs in {@code resourcepack/}: from the {@value #STAMP} written by the
|
||||
* auto-download and {@code tools/build-vanilla-pack.sh}, else from a file name {@code vanilla-<version>.zip} (packs
|
||||
* built by older versions of the script). Packs without either have no known version and are never replaced.
|
||||
*/
|
||||
public final class PackVersions {
|
||||
|
||||
/** Version stamp in the pack root: {@code {"version": "26.3", ...}}. */
|
||||
public static final String STAMP = "pixelpics-pack.json";
|
||||
|
||||
private static final Pattern FILE_NAME = Pattern.compile("vanilla-(.+)\\.zip");
|
||||
|
||||
/** A zip in the pack folder; {@code version} is null when unknown. */
|
||||
public record VersionedPack(Path path, String version) {}
|
||||
|
||||
private PackVersions() {}
|
||||
|
||||
public static String fileName(String version) {
|
||||
return "vanilla-" + version + ".zip";
|
||||
}
|
||||
|
||||
/** All {@code .zip} files below {@code dir} with their version, in load order. */
|
||||
public static List<VersionedPack> scan(Path dir) {
|
||||
if (!Files.isDirectory(dir)) return List.of();
|
||||
try {
|
||||
return ResourcePackLoader.zips(dir).stream()
|
||||
.map(zip -> new VersionedPack(zip, version(zip)))
|
||||
.toList();
|
||||
} catch (IOException e) {
|
||||
return List.of(); // an unreadable folder: the loader reports it
|
||||
}
|
||||
}
|
||||
|
||||
/** The pack's version, or null when it has no stamp and no {@code vanilla-<version>.zip} name. */
|
||||
private static String version(Path zip) {
|
||||
try (ZipFile file = new ZipFile(zip.toFile())) {
|
||||
ZipEntry stamp = file.getEntry(STAMP);
|
||||
if (stamp != null) {
|
||||
try (InputStream in = file.getInputStream(stamp)) {
|
||||
JsonElement root = JsonParser.parseString(new String(in.readAllBytes(), StandardCharsets.UTF_8));
|
||||
String v = root.getAsJsonObject().get("version").getAsString();
|
||||
if (!v.isBlank()) return v;
|
||||
}
|
||||
}
|
||||
} catch (IOException | RuntimeException ignored) {
|
||||
// not a zip or no readable stamp: fall back to the file name
|
||||
}
|
||||
Matcher m = FILE_NAME.matcher(zip.getFileName().toString());
|
||||
return m.matches() ? m.group(1) : null;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user