Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7063d27fb4 | |||
| dce7804fed | |||
| 1d3040610e | |||
| f83ccdc7ff | |||
| 220cda1deb | |||
| fed94f97d1 | |||
| 5330948dbd | |||
| 094aa463c5 | |||
| c5d5eae1c1 | |||
| f1844a9dd9 | |||
| 211c7e8479 | |||
| 18c5fc4ffc |
Generated
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<modules>
|
<modules>
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/modules/PixelPic.main.iml" filepath="$PROJECT_DIR$/.idea/modules/PixelPic.main.iml" />
|
<module fileurl="file://$PROJECT_DIR$/.idea/modules/PixelPic.main.iml" filepath="$PROJECT_DIR$/.idea/modules/PixelPic.main.iml" />
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/modules/PixelPics.PixelPic.main.iml" filepath="$PROJECT_DIR$/.idea/modules/PixelPics.PixelPic.main.iml" />
|
<module fileurl="file://$PROJECT_DIR$/.idea/modules/PixelPics.PixelPic.main.iml" filepath="$PROJECT_DIR$/.idea/modules/PixelPics.PixelPic.main.iml" />
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/modules/eu.mhsl.minecraft.pixelpic.PixelPic.main.iml" filepath="$PROJECT_DIR$/.idea/modules/eu.mhsl.minecraft.pixelpic.PixelPic.main.iml" />
|
<module fileurl="file://$PROJECT_DIR$/.idea/modules/PixelPics.main.iml" filepath="$PROJECT_DIR$/.idea/modules/PixelPics.main.iml" />
|
||||||
</modules>
|
</modules>
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
+10
-1
@@ -1,5 +1,6 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id 'java'
|
id 'java'
|
||||||
|
id 'com.diffplug.spotless' version '7.0.2'
|
||||||
}
|
}
|
||||||
|
|
||||||
group = 'eu.mhsl.minecraft.pixelpics'
|
group = 'eu.mhsl.minecraft.pixelpics'
|
||||||
@@ -18,7 +19,7 @@ repositories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
compileOnly("io.papermc.paper:paper-api:1.21.7-R0.1-SNAPSHOT")
|
compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT")
|
||||||
}
|
}
|
||||||
|
|
||||||
def targetJavaVersion = 21
|
def targetJavaVersion = 21
|
||||||
@@ -48,6 +49,14 @@ processResources {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
spotless {
|
||||||
|
java {
|
||||||
|
target 'src/main/java/**/*.java'
|
||||||
|
palantirJavaFormat()
|
||||||
|
removeUnusedImports()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (file("local.gradle").exists()) {
|
if (file("local.gradle").exists()) {
|
||||||
apply from: "local.gradle"
|
apply from: "local.gradle"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
|
||||||
|
|||||||
@@ -1,127 +1,154 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics;
|
package eu.mhsl.minecraft.pixelpics;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.AssetReader;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.ResourcePack;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.ResourcePackLoader;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.SkinCache;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.font.BitmapFont;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.font.FontLoader;
|
||||||
import eu.mhsl.minecraft.pixelpics.commands.PixelPicsCommand;
|
import eu.mhsl.minecraft.pixelpics.commands.PixelPicsCommand;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.listeners.OnMapInitialize;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.RenderManager;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemModelLoader;
|
||||||
import eu.mhsl.minecraft.pixelpics.render.render.DefaultScreenRenderer;
|
import eu.mhsl.minecraft.pixelpics.render.render.DefaultScreenRenderer;
|
||||||
import eu.mhsl.minecraft.pixelpics.render.render.Renderer;
|
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
|
||||||
import net.kyori.adventure.text.Component;
|
import eu.mhsl.minecraft.pixelpics.survival.CameraListener;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.survival.CraftingListener;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.survival.JoinListener;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.survival.SurvivalRecipes;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.utils.MapColorPalette;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
import org.bukkit.Material;
|
|
||||||
import org.bukkit.NamespacedKey;
|
import org.bukkit.NamespacedKey;
|
||||||
import org.bukkit.entity.Player;
|
|
||||||
import org.bukkit.plugin.java.JavaPlugin;
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
|
|
||||||
import java.io.*;
|
|
||||||
import java.net.URISyntaxException;
|
|
||||||
import java.net.URL;
|
|
||||||
import java.util.Enumeration;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.jar.JarEntry;
|
|
||||||
import java.util.jar.JarFile;
|
|
||||||
|
|
||||||
public final class Main extends JavaPlugin {
|
public final class Main extends JavaPlugin {
|
||||||
private static Main instance;
|
|
||||||
private Renderer screenRenderer;
|
|
||||||
|
|
||||||
public final NamespacedKey pictureIdFlag = Objects.requireNonNull(
|
private static Main instance;
|
||||||
NamespacedKey.fromString("imageId".toLowerCase(), this),
|
|
||||||
"Failed to create item-Flag Namespace"
|
private ResourcePack resourcePack;
|
||||||
);
|
private DefaultScreenRenderer screenRenderer;
|
||||||
|
private RenderManager renderManager;
|
||||||
|
|
||||||
|
public final NamespacedKey pictureIdFlag = new NamespacedKey(this, "imageid");
|
||||||
|
|
||||||
|
/** Marks a {@code PLAYER_HEAD} as a camera (BYTE 1). */
|
||||||
|
public final NamespacedKey cameraMarker = new NamespacedKey(this, "camera");
|
||||||
|
/** Loaded film count stored on a camera (INTEGER, 0..{@code CameraItems.MAX_FILM}). */
|
||||||
|
public final NamespacedKey filmCountKey = new NamespacedKey(this, "filmcount");
|
||||||
|
/** Marks a {@code PLAYER_HEAD} as a film roll (BYTE 1). */
|
||||||
|
public final NamespacedKey filmMarker = new NamespacedKey(this, "film");
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
public void onEnable() {
|
||||||
instance = this;
|
instance = this;
|
||||||
extractJsonResources();
|
|
||||||
|
|
||||||
Objects.requireNonNull(Bukkit.getPluginCommand("pixelPic"))
|
saveDefaultConfig();
|
||||||
.setExecutor(new PixelPicsCommand());
|
initRenderManager();
|
||||||
|
|
||||||
Bukkit.getPluginCommand("test").setExecutor((sender, command, label, args) -> {
|
Bukkit.getPluginManager().registerEvents(new OnMapInitialize(), this);
|
||||||
// Dialog dialog = Dialog.create(
|
Objects.requireNonNull(Bukkit.getPluginCommand("pixelPic")).setExecutor(new PixelPicsCommand());
|
||||||
// builder -> builder.empty()
|
|
||||||
// .base(
|
|
||||||
// DialogBase.builder(Component.text("Hello World")).build()
|
|
||||||
// )
|
|
||||||
// .type(DialogType.multiAction(
|
|
||||||
// List.of(
|
|
||||||
// ActionButton.builder(Component.text("Option 1")).action(DialogAction.staticAction(ClickEvent.callback(audience -> System.out.println("HIIIII")))).build(),
|
|
||||||
// ActionButton.builder(Component.text("Option 2")).action(DialogAction.customClick(Key.key("test"), null)).build(),
|
|
||||||
// ActionButton.builder(Component.text("Option 3")).action(DialogAction.commandTemplate("say hi")).build()
|
|
||||||
// ),
|
|
||||||
// ActionButton.builder(Component.text("Beenden")).build(),
|
|
||||||
// 3
|
|
||||||
// ))
|
|
||||||
//
|
|
||||||
// );
|
|
||||||
// sender.showDialog(dialog);
|
|
||||||
|
|
||||||
Material.getMaterial("acacia_button");
|
Bukkit.getPluginManager().registerEvents(new CameraListener(), this);
|
||||||
Bukkit.broadcast(Component.text(Material.STONE.getBlockTranslationKey().replace("block.minecraft.", "")));
|
Bukkit.getPluginManager().registerEvents(new CraftingListener(), this);
|
||||||
|
Bukkit.getPluginManager().registerEvents(new JoinListener(), this);
|
||||||
|
SurvivalRecipes.register();
|
||||||
|
|
||||||
if(!(sender instanceof Player))
|
initRenderer();
|
||||||
throw new IllegalStateException("Dieser Command kann nur von einem Spieler ausgeführt werden!");
|
}
|
||||||
|
|
||||||
File blockDir = new File(getDataFolder(), "models/block");
|
private void initRenderManager() {
|
||||||
for (File file : blockDir.listFiles()) {
|
int cores = Runtime.getRuntime().availableProcessors();
|
||||||
String blockName = file.getName().substring(0, file.getName().lastIndexOf('.'));
|
int threads = getConfig().getInt("render.threads", 0);
|
||||||
Material material = Material.getMaterial(blockName.toUpperCase());
|
if (threads <= 0) threads = Math.max(1, cores - 2);
|
||||||
System.out.println(material);
|
threads = Math.min(threads, cores);
|
||||||
if(material == null) {
|
int maxConcurrent = Math.max(1, getConfig().getInt("render.max-concurrent", 2));
|
||||||
System.out.println(blockName);
|
int queueSize = Math.max(0, getConfig().getInt("render.queue-size", 8));
|
||||||
|
int timeoutSeconds = Math.max(1, getConfig().getInt("render.timeout-seconds", 30));
|
||||||
|
|
||||||
|
this.renderManager = new RenderManager(this, threads, maxConcurrent, queueSize, timeoutSeconds);
|
||||||
|
getLogger()
|
||||||
|
.info("Render pool: " + threads + " core(s), max " + maxConcurrent + " concurrent, queue " + queueSize
|
||||||
|
+ ", timeout " + timeoutSeconds + "s.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initRenderer() {
|
||||||
|
File resourcePackDir = new File(getDataFolder(), "resourcepack");
|
||||||
|
if (!resourcePackDir.exists() && !resourcePackDir.mkdirs()) {
|
||||||
|
getLogger().warning("Could not create resource pack directory: " + resourcePackDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<ResourcePack> pack = ResourcePackLoader.load(resourcePackDir, getLogger());
|
||||||
|
if (pack.isEmpty()) {
|
||||||
|
getLogger()
|
||||||
|
.severe("No resource pack found in " + resourcePackDir.getPath()
|
||||||
|
+ " — place a vanilla resource pack (directory with assets/minecraft/... or a .zip) there. "
|
||||||
|
+ "/pixelPic is disabled until a pack is available.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resourcePack = pack.get();
|
||||||
|
AssetReader reader = new AssetReader(resourcePack);
|
||||||
|
TextureCache textures = new TextureCache(resourcePack);
|
||||||
|
BlockModelRegistry registry = new BlockModelRegistry(reader, textures);
|
||||||
|
BiomeTintProvider tintProvider = new BiomeTintProvider(textures);
|
||||||
|
|
||||||
|
CemModelLoader cemLoader = new CemModelLoader();
|
||||||
|
try (InputStream in = getResource("cem/cem_template_models.json")) {
|
||||||
|
int n = in == null ? 0 : cemLoader.load(in, getLogger());
|
||||||
|
getLogger().info("Loaded " + n + " CEM entity models.");
|
||||||
|
} catch (Exception e) {
|
||||||
|
getLogger().severe("Failed to load CEM entity models: " + e.getMessage());
|
||||||
|
}
|
||||||
|
SkinCache skinCache = new SkinCache();
|
||||||
|
BitmapFont font = FontLoader.load(resourcePack, textures, getLogger());
|
||||||
|
getLogger().info("Loaded sign font (" + (font.isEmpty() ? "no glyphs — text disabled" : "ok") + ").");
|
||||||
|
CemBaker entityBaker = new CemBaker(cemLoader, textures, skinCache);
|
||||||
|
BlockEntityBaker blockEntityBaker = new BlockEntityBaker(cemLoader, textures, skinCache, font);
|
||||||
|
|
||||||
|
this.screenRenderer = new DefaultScreenRenderer(
|
||||||
|
registry,
|
||||||
|
tintProvider,
|
||||||
|
textures,
|
||||||
|
entityBaker,
|
||||||
|
blockEntityBaker,
|
||||||
|
getLogger(),
|
||||||
|
renderManager.tracePool());
|
||||||
|
// Warm the map palette on the main thread so off-thread dithering never triggers its first init.
|
||||||
|
MapColorPalette.size();
|
||||||
|
getLogger().info("PixelPics renderer initialized with resource pack assets.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDisable() {
|
||||||
|
SurvivalRecipes.unregister();
|
||||||
|
if (renderManager != null) {
|
||||||
|
renderManager.shutdown();
|
||||||
|
renderManager = null;
|
||||||
|
}
|
||||||
|
if (resourcePack != null) {
|
||||||
|
resourcePack.close();
|
||||||
|
resourcePack = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
/** The renderer, or {@code null} when no resource pack is available (degraded mode). */
|
||||||
});
|
public DefaultScreenRenderer getScreenRenderer() {
|
||||||
}
|
|
||||||
|
|
||||||
public void extractJsonResources() {
|
|
||||||
String resourcePath = "models/block/";
|
|
||||||
File outputDir = new File(getDataFolder(), resourcePath);
|
|
||||||
if (outputDir.exists()) return;
|
|
||||||
outputDir.mkdirs();
|
|
||||||
|
|
||||||
try {
|
|
||||||
URL jarUrl = getClass().getProtectionDomain().getCodeSource().getLocation();
|
|
||||||
File jarFile = new File(jarUrl.toURI());
|
|
||||||
|
|
||||||
try (JarFile jar = new JarFile(jarFile)) {
|
|
||||||
Enumeration<JarEntry> entries = jar.entries();
|
|
||||||
|
|
||||||
while (entries.hasMoreElements()) {
|
|
||||||
JarEntry entry = entries.nextElement();
|
|
||||||
String entryName = entry.getName();
|
|
||||||
|
|
||||||
// Nur JSON-Dateien im gewünschten Ordner
|
|
||||||
if (entryName.startsWith(resourcePath) && entryName.endsWith(".json")) {
|
|
||||||
InputStream in = getResource(entryName);
|
|
||||||
if (in == null) continue;
|
|
||||||
|
|
||||||
File outFile = new File(getDataFolder(), entryName);
|
|
||||||
outFile.getParentFile().mkdirs(); // Ordnerstruktur sicherstellen
|
|
||||||
|
|
||||||
try (OutputStream out = new FileOutputStream(outFile)) {
|
|
||||||
byte[] buffer = new byte[1024];
|
|
||||||
int len;
|
|
||||||
while ((len = in.read(buffer)) != -1) {
|
|
||||||
out.write(buffer, 0, len);
|
|
||||||
}
|
|
||||||
System.out.println("Extrahiert: " + entryName);
|
|
||||||
}
|
|
||||||
|
|
||||||
in.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (IOException | URISyntaxException e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Renderer getScreenRenderer() {
|
|
||||||
if(this.screenRenderer == null) this.screenRenderer = new DefaultScreenRenderer();
|
|
||||||
return this.screenRenderer;
|
return this.screenRenderer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The render queue/limiter; always available after {@code onEnable}. */
|
||||||
|
public RenderManager getRenderManager() {
|
||||||
|
return this.renderManager;
|
||||||
|
}
|
||||||
|
|
||||||
public static Main getInstance() {
|
public static Main getInstance() {
|
||||||
return instance;
|
return instance;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds pack-relative asset paths from namespaced ids, following the vanilla layout
|
||||||
|
* {@code assets/<namespace>/<category>/<path>.<ext>}.
|
||||||
|
*/
|
||||||
|
public final class AssetPaths {
|
||||||
|
|
||||||
|
private AssetPaths() {}
|
||||||
|
|
||||||
|
/** {@code assets/<ns>/blockstates/<name>.json} for a plain block name (no {@code block/} prefix). */
|
||||||
|
public static String blockState(String blockName) {
|
||||||
|
return String.format(
|
||||||
|
"assets/%s/blockstates/%s.json", ResourceLocation.DEFAULT_NAMESPACE, blockName.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code assets/<ns>/models/<path>.json}. The id path already contains e.g. {@code block/stone}. */
|
||||||
|
public static String model(ResourceLocation id) {
|
||||||
|
return String.format("assets/%s/models/%s.json", id.namespace(), id.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code assets/<ns>/textures/<path>.png}. The id path already contains e.g. {@code block/stone}. */
|
||||||
|
public static String texture(ResourceLocation id) {
|
||||||
|
return String.format("assets/%s/textures/%s.png", id.namespace(), id.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code assets/<ns>/textures/<path>.png.mcmeta} animation metadata, if present. */
|
||||||
|
public static String textureMeta(ResourceLocation id) {
|
||||||
|
return texture(id) + ".mcmeta";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code assets/<ns>/font/<path>.json}, e.g. for {@code minecraft:default} or {@code minecraft:include/space}. */
|
||||||
|
public static String font(ResourceLocation id) {
|
||||||
|
return String.format("assets/%s/font/%s.json", id.namespace(), id.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin convenience layer over a {@link ResourcePack} for reading JSON assets.
|
||||||
|
*/
|
||||||
|
public final class AssetReader {
|
||||||
|
|
||||||
|
private final ResourcePack pack;
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
public AssetReader(ResourcePack pack) {
|
||||||
|
this.pack = pack;
|
||||||
|
}
|
||||||
|
|
||||||
|
public <T> Optional<T> readJson(String path, Class<T> type) {
|
||||||
|
return pack.read(path).flatMap(bytes -> {
|
||||||
|
try {
|
||||||
|
return Optional.ofNullable(gson.fromJson(new String(bytes, StandardCharsets.UTF_8), type));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<JsonObject> readJsonObject(String path) {
|
||||||
|
return pack.read(path).flatMap(bytes -> {
|
||||||
|
try {
|
||||||
|
return Optional.of(JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8))
|
||||||
|
.getAsJsonObject());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.model.AverageColor;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.model.Element;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.EnumSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.block.data.BlockData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-level entry point that turns a {@link BlockData} into a baked {@link ResolvedModel}, combining
|
||||||
|
* blockstate resolution, model resolution and geometry baking. Results are cached per BlockData and
|
||||||
|
* the cache is safe for concurrent access by the parallel renderer.
|
||||||
|
*/
|
||||||
|
public final class BlockModelRegistry {
|
||||||
|
|
||||||
|
private final TextureCache textures;
|
||||||
|
private final BlockStateResolver blockStateResolver;
|
||||||
|
private final ModelResolver modelResolver;
|
||||||
|
private final ModelBaker baker;
|
||||||
|
|
||||||
|
private final Map<BlockData, ResolvedModel> cache = new ConcurrentHashMap<>();
|
||||||
|
private volatile ResolvedModel waterModel;
|
||||||
|
private volatile ResolvedModel lavaModel;
|
||||||
|
|
||||||
|
public BlockModelRegistry(AssetReader reader, TextureCache textures) {
|
||||||
|
this.textures = textures;
|
||||||
|
this.blockStateResolver = new BlockStateResolver(reader);
|
||||||
|
this.modelResolver = new ModelResolver(reader);
|
||||||
|
this.baker = new ModelBaker(textures);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ResolvedModel get(BlockData data) {
|
||||||
|
return cache.computeIfAbsent(data, this::resolve);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResolvedModel resolve(BlockData data) {
|
||||||
|
Material material = data.getMaterial();
|
||||||
|
if (material == Material.WATER) return water();
|
||||||
|
if (material == Material.LAVA) return lava();
|
||||||
|
// Technical blocks that are invisible in the world (their textures only show held in hand).
|
||||||
|
if (INVISIBLE_MATERIALS.contains(material)) {
|
||||||
|
return new ResolvedModel(List.of(), 0, 0, 0, false, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Variant> variants = blockStateResolver.resolve(data);
|
||||||
|
|
||||||
|
List<Element> elements = new ArrayList<>();
|
||||||
|
AverageColor.Accumulator avgColor = new AverageColor.Accumulator();
|
||||||
|
FlatModel lastFlat = null;
|
||||||
|
|
||||||
|
for (Variant variant : variants) {
|
||||||
|
FlatModel flat = modelResolver.resolve(variant.model());
|
||||||
|
lastFlat = flat;
|
||||||
|
ModelBaker.BakedGeometry baked = baker.bake(flat, variant);
|
||||||
|
elements.addAll(baked.elements());
|
||||||
|
if (baked.hasGeometry()) {
|
||||||
|
avgColor.add(baked.averageColor());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!elements.isEmpty()) {
|
||||||
|
return new ResolvedModel(elements, avgColor.average(0xFF7F7F7F), 0, 0, false, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
int avg = fallbackColor(lastFlat);
|
||||||
|
|
||||||
|
// Block-entities (chests, signs, banners, beds, heads, …) use builtin/entity models with no
|
||||||
|
// geometry; their real shape is rendered separately through the entity scene. Return empty
|
||||||
|
// geometry so the ray passes through to that geometry instead of drawing a grey fallback cube.
|
||||||
|
if (isBlockEntityRendered(material)) {
|
||||||
|
return new ResolvedModel(List.of(), avg, 0, 0, false, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No geometry: render a flat full cube using a fallback average color.
|
||||||
|
return new ResolvedModel(List.of(solidCube(avg)), avg, 0, 0, false, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Technical blocks that render nothing in the world (barrier/light/structure void are hand-only). */
|
||||||
|
private static final EnumSet<Material> INVISIBLE_MATERIALS = buildInvisibleMaterials();
|
||||||
|
|
||||||
|
private static EnumSet<Material> buildInvisibleMaterials() {
|
||||||
|
EnumSet<Material> set = EnumSet.noneOf(Material.class);
|
||||||
|
for (String n : new String[] {"BARRIER", "LIGHT", "STRUCTURE_VOID"}) {
|
||||||
|
try {
|
||||||
|
set.add(Material.valueOf(n));
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
/* older/newer server */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Block materials whose shape is drawn by the block-entity renderer, not the block-model pipeline. */
|
||||||
|
private static final EnumSet<Material> BLOCK_ENTITY_MATERIALS = buildBlockEntityMaterials();
|
||||||
|
|
||||||
|
private static boolean isBlockEntityRendered(Material material) {
|
||||||
|
return BLOCK_ENTITY_MATERIALS.contains(material);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EnumSet<Material> buildBlockEntityMaterials() {
|
||||||
|
// Matched purely by name/identity (no Material#isBlock, which needs a live registry): the set is
|
||||||
|
// only ever queried with placed block materials, so any item-only matches are harmless.
|
||||||
|
EnumSet<Material> set = EnumSet.noneOf(Material.class);
|
||||||
|
for (Material m : Material.values()) {
|
||||||
|
String n = m.name();
|
||||||
|
boolean match = n.endsWith("_SIGN") // signs: standing/wall/(wall_)hanging
|
||||||
|
|| n.endsWith("_BANNER") // banners: standing/wall
|
||||||
|
|| n.endsWith("_BED")
|
||||||
|
|| n.endsWith("SHULKER_BOX") // SHULKER_BOX + <color>_SHULKER_BOX
|
||||||
|
|| ((n.endsWith("_SKULL") || n.endsWith("_HEAD")) && m != Material.PISTON_HEAD)
|
||||||
|
|| m == Material.CHEST
|
||||||
|
|| m == Material.TRAPPED_CHEST
|
||||||
|
|| m == Material.ENDER_CHEST
|
||||||
|
|| m == Material.CONDUIT
|
||||||
|
|| m == Material.DECORATED_POT
|
||||||
|
|| m == Material.BELL;
|
||||||
|
if (match) set.add(m);
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A full-block cube whose six faces sample a single solid color (1x1 texture). */
|
||||||
|
private Element solidCube(int color) {
|
||||||
|
int[][] tex = {{color}};
|
||||||
|
Face[] faces = new Face[6];
|
||||||
|
for (Direction d : Direction.values()) {
|
||||||
|
faces[d.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, -1);
|
||||||
|
}
|
||||||
|
return new Element(new double[] {0, 0, 0}, new double[] {1, 1, 1}, faces, null, -1, 0, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int fallbackColor(FlatModel flat) {
|
||||||
|
if (flat != null && flat.textures() != null) {
|
||||||
|
String particle = flat.textures().get("particle");
|
||||||
|
if (particle != null && !particle.startsWith("#")) {
|
||||||
|
int[][] tex = textures.get(ResourceLocation.parse(particle)).orElse(null);
|
||||||
|
if (tex != null) return AverageColor.of(tex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0xFF7F7F7F;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResolvedModel water() {
|
||||||
|
ResolvedModel m = waterModel;
|
||||||
|
if (m == null) {
|
||||||
|
m = liquid("block/water_still", 0, 0.60, 0.10);
|
||||||
|
waterModel = m;
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResolvedModel lava() {
|
||||||
|
ResolvedModel m = lavaModel;
|
||||||
|
if (m == null) {
|
||||||
|
m = liquid("block/lava_still", -1, 0.15, 0.05);
|
||||||
|
lavaModel = m;
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a simple full-cube model for a liquid texture with the given tint/transparency/reflection. */
|
||||||
|
private ResolvedModel liquid(String texturePath, int tintIndex, double transparency, double reflection) {
|
||||||
|
int[][] tex = textures.get(ResourceLocation.parse(texturePath)).orElse(null);
|
||||||
|
if (tex == null) {
|
||||||
|
return new ResolvedModel(List.of(solidCube(0xFF3F76E4)), 0xFF3F76E4, transparency, reflection, true, true);
|
||||||
|
}
|
||||||
|
Face[] faces = new Face[6];
|
||||||
|
for (Direction d : Direction.values()) {
|
||||||
|
faces[d.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, tintIndex);
|
||||||
|
}
|
||||||
|
Element cube = new Element(new double[] {0, 0, 0}, new double[] {1, 1, 1}, faces, null, -1, 0, false);
|
||||||
|
int avg = AverageColor.of(tex);
|
||||||
|
return new ResolvedModel(List.of(cube), avg, transparency, reflection, true, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.bukkit.block.data.BlockData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the property map and plain block name out of a {@link BlockData} string such as
|
||||||
|
* {@code minecraft:oak_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]}.
|
||||||
|
*/
|
||||||
|
public final class BlockStateProperties {
|
||||||
|
|
||||||
|
private BlockStateProperties() {}
|
||||||
|
|
||||||
|
/** The block name without namespace, e.g. {@code oak_stairs}. */
|
||||||
|
public static String blockName(BlockData data) {
|
||||||
|
String s = data.getAsString(false);
|
||||||
|
int bracket = s.indexOf('[');
|
||||||
|
String id = bracket < 0 ? s : s.substring(0, bracket);
|
||||||
|
int colon = id.indexOf(':');
|
||||||
|
return (colon < 0 ? id : id.substring(colon + 1)).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The {@code prop -> value} map (empty when the block has no properties). */
|
||||||
|
public static Map<String, String> properties(BlockData data) {
|
||||||
|
Map<String, String> props = new LinkedHashMap<>();
|
||||||
|
String s = data.getAsString(false);
|
||||||
|
int open = s.indexOf('[');
|
||||||
|
int close = s.lastIndexOf(']');
|
||||||
|
if (open < 0 || close < 0 || close <= open) return props;
|
||||||
|
|
||||||
|
String body = s.substring(open + 1, close);
|
||||||
|
for (String pair : body.split(",")) {
|
||||||
|
int eq = pair.indexOf('=');
|
||||||
|
if (eq < 0) continue;
|
||||||
|
props.put(pair.substring(0, eq).trim(), pair.substring(eq + 1).trim());
|
||||||
|
}
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.bukkit.block.data.BlockData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a {@link BlockData} to the list of model variants vanilla would render, by reading the
|
||||||
|
* block's {@code blockstates/<name>.json} (either {@code variants} or {@code multipart}).
|
||||||
|
*/
|
||||||
|
public final class BlockStateResolver {
|
||||||
|
|
||||||
|
private final AssetReader reader;
|
||||||
|
|
||||||
|
public BlockStateResolver(AssetReader reader) {
|
||||||
|
this.reader = reader;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Variant> resolve(BlockData data) {
|
||||||
|
String name = BlockStateProperties.blockName(data);
|
||||||
|
Map<String, String> props = BlockStateProperties.properties(data);
|
||||||
|
|
||||||
|
JsonObject root = reader.readJsonObject(AssetPaths.blockState(name)).orElse(null);
|
||||||
|
if (root == null) return List.of();
|
||||||
|
|
||||||
|
if (root.has("variants")) {
|
||||||
|
return resolveVariants(root.getAsJsonObject("variants"), props);
|
||||||
|
}
|
||||||
|
if (root.has("multipart")) {
|
||||||
|
return resolveMultipart(root.getAsJsonArray("multipart"), props);
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Variant> resolveVariants(JsonObject variants, Map<String, String> props) {
|
||||||
|
for (Map.Entry<String, JsonElement> entry : variants.entrySet()) {
|
||||||
|
if (variantKeyMatches(entry.getKey(), props)) {
|
||||||
|
return List.of(parseVariant(firstOf(entry.getValue())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Variant> resolveMultipart(JsonArray multipart, Map<String, String> props) {
|
||||||
|
List<Variant> result = new ArrayList<>();
|
||||||
|
for (JsonElement caseEl : multipart) {
|
||||||
|
JsonObject caseObj = caseEl.getAsJsonObject();
|
||||||
|
if (caseObj.has("when") && !whenMatches(caseObj.get("when"), props)) continue;
|
||||||
|
result.add(parseVariant(firstOf(caseObj.get("apply"))));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A variant key like {@code facing=east,half=bottom} matches when every pair holds for the block. */
|
||||||
|
private boolean variantKeyMatches(String key, Map<String, String> props) {
|
||||||
|
if (key.isEmpty()) return true;
|
||||||
|
for (String pair : key.split(",")) {
|
||||||
|
int eq = pair.indexOf('=');
|
||||||
|
if (eq < 0) continue;
|
||||||
|
String prop = pair.substring(0, eq).trim();
|
||||||
|
String value = pair.substring(eq + 1).trim();
|
||||||
|
if (!valueMatches(props.get(prop), value)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean whenMatches(JsonElement when, Map<String, String> props) {
|
||||||
|
JsonObject obj = when.getAsJsonObject();
|
||||||
|
if (obj.has("OR")) {
|
||||||
|
for (JsonElement sub : obj.getAsJsonArray("OR")) {
|
||||||
|
if (whenMatches(sub, props)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (obj.has("AND")) {
|
||||||
|
for (JsonElement sub : obj.getAsJsonArray("AND")) {
|
||||||
|
if (!whenMatches(sub, props)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Implicit AND over property conditions.
|
||||||
|
for (Map.Entry<String, JsonElement> e : obj.entrySet()) {
|
||||||
|
if (!valueMatches(props.get(e.getKey()), e.getValue().getAsString())) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A condition value may be an OR list like {@code "north|south"}. */
|
||||||
|
private boolean valueMatches(String actual, String expected) {
|
||||||
|
if (actual == null) return false;
|
||||||
|
for (String option : expected.split("\\|")) {
|
||||||
|
if (option.equals(actual)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject firstOf(JsonElement element) {
|
||||||
|
if (element.isJsonArray()) {
|
||||||
|
return element.getAsJsonArray().get(0).getAsJsonObject();
|
||||||
|
}
|
||||||
|
return element.getAsJsonObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Variant parseVariant(JsonObject obj) {
|
||||||
|
ResourceLocation model = ResourceLocation.parse(obj.get("model").getAsString());
|
||||||
|
int x = obj.has("x") ? obj.get("x").getAsInt() : 0;
|
||||||
|
int y = obj.has("y") ? obj.get("y").getAsInt() : 0;
|
||||||
|
boolean uvlock = obj.has("uvlock") && obj.get("uvlock").getAsBoolean();
|
||||||
|
return new Variant(model, x, y, uvlock);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combines several packs into one; the first pack that contains an asset wins. Lets an admin drop
|
||||||
|
* both an unpacked directory and one or more {@code .zip} packs.
|
||||||
|
*/
|
||||||
|
public final class CompositeResourcePack implements ResourcePack {
|
||||||
|
|
||||||
|
private final List<ResourcePack> packs;
|
||||||
|
|
||||||
|
public CompositeResourcePack(List<ResourcePack> packs) {
|
||||||
|
this.packs = List.copyOf(packs);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<byte[]> read(String path) {
|
||||||
|
for (ResourcePack pack : packs) {
|
||||||
|
Optional<byte[]> result = pack.read(path);
|
||||||
|
if (result.isPresent()) return result;
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean exists(String path) {
|
||||||
|
return packs.stream().anyMatch(pack -> pack.exists(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
packs.forEach(ResourcePack::close);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link ResourcePack} backed by a directory on disk. The root typically contains the
|
||||||
|
* {@code assets/} folder.
|
||||||
|
*/
|
||||||
|
public final class DirectoryResourcePack implements ResourcePack {
|
||||||
|
|
||||||
|
private final Path root;
|
||||||
|
|
||||||
|
public DirectoryResourcePack(Path root) {
|
||||||
|
this.root = root.toAbsolutePath().normalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<Path> resolve(String path) {
|
||||||
|
Path resolved = root.resolve(path).normalize();
|
||||||
|
// Guard against path traversal outside the pack root.
|
||||||
|
if (!resolved.startsWith(root)) return Optional.empty();
|
||||||
|
return Optional.of(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<byte[]> read(String path) {
|
||||||
|
return resolve(path).flatMap(p -> {
|
||||||
|
if (!Files.isRegularFile(p)) return Optional.empty();
|
||||||
|
try {
|
||||||
|
return Optional.of(Files.readAllBytes(p));
|
||||||
|
} catch (IOException e) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean exists(String path) {
|
||||||
|
return resolve(path).map(Files::isRegularFile).orElse(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
// nothing to release
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A model with its parent chain flattened: textures merged (child wins) and the nearest non-empty
|
||||||
|
* {@code elements} list selected (vanilla does not merge elements across parents).
|
||||||
|
*/
|
||||||
|
public record FlatModel(Map<String, String> textures, List<ModelFileDto.ElementDto> elements) {
|
||||||
|
|
||||||
|
public boolean hasElements() {
|
||||||
|
return elements != null && !elements.isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thread-safe cache of ARGB pixel grids keyed by {@code K}. Subclasses implement {@link #load} to
|
||||||
|
* produce the grid (or {@code null} on failure); a failure is cached as a sentinel so it is not
|
||||||
|
* retried, and {@link #get} returns empty for it. The grid is indexed {@code [y][x]}, top-left origin.
|
||||||
|
*/
|
||||||
|
public abstract class ImageCache<K> {
|
||||||
|
|
||||||
|
private static final int[][] MISSING = new int[0][0];
|
||||||
|
|
||||||
|
private final Map<K, int[][]> cache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** Loads the pixel grid for {@code key}, or returns {@code null} if it cannot be loaded. */
|
||||||
|
protected abstract int[][] load(K key);
|
||||||
|
|
||||||
|
public Optional<int[][]> get(K key) {
|
||||||
|
int[][] result = cache.computeIfAbsent(key, this::loadOrSentinel);
|
||||||
|
return result == MISSING ? Optional.empty() : Optional.of(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int[][] loadOrSentinel(K key) {
|
||||||
|
int[][] loaded = load(key);
|
||||||
|
return loaded == null ? MISSING : loaded;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.model.AverageColor;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.model.Element;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bakes a {@link FlatModel} plus a blockstate {@link Variant} into intersectable {@link Element}
|
||||||
|
* boxes. Texture variables are resolved against the model's merged texture map; default UVs follow
|
||||||
|
* the vanilla element extents; blockstate {@code x}/{@code y} rotations (90-degree steps) are baked
|
||||||
|
* into geometry and face directions, while a model element's own rotation is preserved for OBB
|
||||||
|
* intersection.
|
||||||
|
*/
|
||||||
|
public final class ModelBaker {
|
||||||
|
|
||||||
|
private final TextureCache textures;
|
||||||
|
|
||||||
|
public ModelBaker(TextureCache textures) {
|
||||||
|
this.textures = textures;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record BakedGeometry(List<Element> elements, int averageColor, boolean hasGeometry) {}
|
||||||
|
|
||||||
|
public BakedGeometry bake(FlatModel model, Variant variant) {
|
||||||
|
if (!model.hasElements()) {
|
||||||
|
return new BakedGeometry(List.of(), 0, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
int xSteps = ((variant.x() / 90) % 4 + 4) % 4;
|
||||||
|
int ySteps = ((variant.y() / 90) % 4 + 4) % 4;
|
||||||
|
|
||||||
|
List<Element> baked = new ArrayList<>();
|
||||||
|
AverageColor.Accumulator avgColor = new AverageColor.Accumulator();
|
||||||
|
|
||||||
|
for (ModelFileDto.ElementDto dto : model.elements()) {
|
||||||
|
if (dto.from == null || dto.to == null) continue;
|
||||||
|
|
||||||
|
double[] from = {dto.from[0] / 16.0, dto.from[1] / 16.0, dto.from[2] / 16.0};
|
||||||
|
double[] to = {dto.to[0] / 16.0, dto.to[1] / 16.0, dto.to[2] / 16.0};
|
||||||
|
|
||||||
|
// Build faces (pre variant rotation).
|
||||||
|
Face[] faces = new Face[6];
|
||||||
|
if (dto.faces != null) {
|
||||||
|
for (Map.Entry<String, ModelFileDto.FaceDto> e : dto.faces.entrySet()) {
|
||||||
|
Direction dir = Direction.fromName(e.getKey());
|
||||||
|
if (dir == null) continue;
|
||||||
|
Face face = buildFace(dir, e.getValue(), from, to, model.textures());
|
||||||
|
if (face != null) faces[dir.ordinal()] = face;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Element's own rotation (origin in 0..1, axis index, radians).
|
||||||
|
double[] rotOrigin = null;
|
||||||
|
int rotAxis = -1;
|
||||||
|
double rotAngle = 0;
|
||||||
|
boolean rescale = false;
|
||||||
|
if (dto.rotation != null && dto.rotation.angle != 0 && dto.rotation.origin != null) {
|
||||||
|
rotOrigin = new double[] {
|
||||||
|
dto.rotation.origin[0] / 16.0, dto.rotation.origin[1] / 16.0, dto.rotation.origin[2] / 16.0
|
||||||
|
};
|
||||||
|
rotAxis = axisIndex(dto.rotation.axis);
|
||||||
|
rotAngle = Math.toRadians(dto.rotation.angle);
|
||||||
|
rescale = dto.rotation.rescale;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply blockstate variant rotation (90-degree steps) to box + faces + element rotation.
|
||||||
|
for (int i = 0; i < xSteps; i++) {
|
||||||
|
double[][] r = rotateBoxX(from, to);
|
||||||
|
from = r[0];
|
||||||
|
to = r[1];
|
||||||
|
faces = rotateFacesX(faces);
|
||||||
|
if (rotAxis >= 0) {
|
||||||
|
rotOrigin = rotatePointX(rotOrigin);
|
||||||
|
AxisRotation na = rotateAxisX(rotAxis);
|
||||||
|
rotAxis = na.axis();
|
||||||
|
if (na.flip()) rotAngle = -rotAngle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int i = 0; i < ySteps; i++) {
|
||||||
|
double[][] r = rotateBoxY(from, to);
|
||||||
|
from = r[0];
|
||||||
|
to = r[1];
|
||||||
|
faces = rotateFacesY(faces);
|
||||||
|
if (rotAxis >= 0) {
|
||||||
|
rotOrigin = rotatePointY(rotOrigin);
|
||||||
|
AxisRotation na = rotateAxisY(rotAxis);
|
||||||
|
rotAxis = na.axis();
|
||||||
|
if (na.flip()) rotAngle = -rotAngle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
baked.add(new Element(from, to, faces, rotOrigin, rotAxis, rotAngle, rescale));
|
||||||
|
|
||||||
|
// Accumulate average color from the element's face textures.
|
||||||
|
for (Face f : faces) {
|
||||||
|
if (f == null) continue;
|
||||||
|
avgColor.add(AverageColor.of(f.texture));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BakedGeometry(baked, avgColor.average(0xFF7F7F7F), !baked.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Face buildFace(
|
||||||
|
Direction dir, ModelFileDto.FaceDto dto, double[] from, double[] to, Map<String, String> textureVars) {
|
||||||
|
int[][] tex = resolveTexture(dto.texture, textureVars);
|
||||||
|
if (tex == null) return null;
|
||||||
|
|
||||||
|
double u1, v1, u2, v2;
|
||||||
|
if (dto.uv != null && dto.uv.length == 4) {
|
||||||
|
u1 = dto.uv[0] / 16.0;
|
||||||
|
v1 = dto.uv[1] / 16.0;
|
||||||
|
u2 = dto.uv[2] / 16.0;
|
||||||
|
v2 = dto.uv[3] / 16.0;
|
||||||
|
} else {
|
||||||
|
double[] d = defaultUv(dir, from, to);
|
||||||
|
u1 = d[0];
|
||||||
|
v1 = d[1];
|
||||||
|
u2 = d[2];
|
||||||
|
v2 = d[3];
|
||||||
|
}
|
||||||
|
int tint = dto.tintindex != null ? dto.tintindex : -1;
|
||||||
|
return new Face(tex, u1, v1, u2, v2, dto.rotation, tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default UV (normalized 0..1) from the element extents, matching vanilla. Texture V is top-down,
|
||||||
|
* so for side faces v1 (texture top) corresponds to the element's high-Y edge: v = [1-to.y, 1-from.y].
|
||||||
|
*/
|
||||||
|
private double[] defaultUv(Direction dir, double[] f, double[] t) {
|
||||||
|
return switch (dir) {
|
||||||
|
case UP, DOWN -> new double[] {f[0], f[2], t[0], t[2]};
|
||||||
|
case NORTH, SOUTH -> new double[] {f[0], 1 - t[1], t[0], 1 - f[1]};
|
||||||
|
case WEST, EAST -> new double[] {f[2], 1 - t[1], t[2], 1 - f[1]};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private int[][] resolveTexture(String ref, Map<String, String> vars) {
|
||||||
|
if (ref == null) return null;
|
||||||
|
String current = ref;
|
||||||
|
int guard = 0;
|
||||||
|
while (current.startsWith("#") && guard++ < 16) {
|
||||||
|
current = vars.get(current.substring(1));
|
||||||
|
if (current == null) return null;
|
||||||
|
}
|
||||||
|
if (current.startsWith("#")) return null;
|
||||||
|
return textures.get(ResourceLocation.parse(current)).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int axisIndex(String axis) {
|
||||||
|
if (axis == null) return -1;
|
||||||
|
return switch (axis.toLowerCase()) {
|
||||||
|
case "x" -> 0;
|
||||||
|
case "y" -> 1;
|
||||||
|
case "z" -> 2;
|
||||||
|
default -> -1;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 90-degree box rotations around the block center (0.5, 0.5, 0.5) ---
|
||||||
|
|
||||||
|
private double[][] rotateBoxY(double[] from, double[] to) {
|
||||||
|
// (x,z) -> (z, 1-x): 90 deg clockwise viewed from above.
|
||||||
|
double[] c1 = rotatePointY(from);
|
||||||
|
double[] c2 = rotatePointY(to);
|
||||||
|
return minMax(c1, c2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private double[][] rotateBoxX(double[] from, double[] to) {
|
||||||
|
double[] c1 = rotatePointX(from);
|
||||||
|
double[] c2 = rotatePointX(to);
|
||||||
|
return minMax(c1, c2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private double[] rotatePointY(double[] p) {
|
||||||
|
double x = p[0] - 0.5, z = p[2] - 0.5;
|
||||||
|
return new double[] {0.5 + z, p[1], 0.5 - x};
|
||||||
|
}
|
||||||
|
|
||||||
|
private double[] rotatePointX(double[] p) {
|
||||||
|
double y = p[1] - 0.5, z = p[2] - 0.5;
|
||||||
|
return new double[] {p[0], 0.5 + z, 0.5 - y};
|
||||||
|
}
|
||||||
|
|
||||||
|
private double[][] minMax(double[] a, double[] b) {
|
||||||
|
double[] from = {Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.min(a[2], b[2])};
|
||||||
|
double[] to = {Math.max(a[0], b[0]), Math.max(a[1], b[1]), Math.max(a[2], b[2])};
|
||||||
|
return new double[][] {from, to};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Face[] rotateFacesY(Face[] faces) {
|
||||||
|
Face[] out = new Face[6];
|
||||||
|
for (Direction d : Direction.values()) {
|
||||||
|
out[d.rotateY(1).ordinal()] = faces[d.ordinal()];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Face[] rotateFacesX(Face[] faces) {
|
||||||
|
Face[] out = new Face[6];
|
||||||
|
for (Direction d : Direction.values()) {
|
||||||
|
out[d.rotateX(1).ordinal()] = faces[d.ordinal()];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotating an element's own rotation axis under a 90-degree block rotation.
|
||||||
|
// {@code flip} indicates the angle sign should invert.
|
||||||
|
private record AxisRotation(int axis, boolean flip) {}
|
||||||
|
|
||||||
|
private AxisRotation rotateAxisY(int axis) {
|
||||||
|
// Y rotation maps x<->z; the y axis is unchanged.
|
||||||
|
return switch (axis) {
|
||||||
|
case 0 -> new AxisRotation(2, true); // x -> z
|
||||||
|
case 2 -> new AxisRotation(0, false); // z -> x
|
||||||
|
default -> new AxisRotation(axis, false);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private AxisRotation rotateAxisX(int axis) {
|
||||||
|
// X rotation maps y<->z; the x axis is unchanged.
|
||||||
|
return switch (axis) {
|
||||||
|
case 1 -> new AxisRotation(2, true); // y -> z
|
||||||
|
case 2 -> new AxisRotation(1, false); // z -> y
|
||||||
|
default -> new AxisRotation(axis, false);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads block models and flattens their parent chains into {@link FlatModel}s. Results are cached by
|
||||||
|
* model id.
|
||||||
|
*/
|
||||||
|
public final class ModelResolver {
|
||||||
|
|
||||||
|
private static final int MAX_DEPTH = 16;
|
||||||
|
|
||||||
|
private final AssetReader reader;
|
||||||
|
private final Map<ResourceLocation, FlatModel> cache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public ModelResolver(AssetReader reader) {
|
||||||
|
this.reader = reader;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FlatModel resolve(ResourceLocation modelId) {
|
||||||
|
FlatModel cached = cache.get(modelId);
|
||||||
|
if (cached != null) return cached;
|
||||||
|
FlatModel resolved = resolve(modelId, 0);
|
||||||
|
cache.put(modelId, resolved);
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FlatModel resolve(ResourceLocation modelId, int depth) {
|
||||||
|
ModelFileDto dto =
|
||||||
|
reader.readJson(AssetPaths.model(modelId), ModelFileDto.class).orElse(null);
|
||||||
|
if (dto == null) {
|
||||||
|
return new FlatModel(new HashMap<>(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> textures = new HashMap<>();
|
||||||
|
List<ModelFileDto.ElementDto> elements = dto.elements;
|
||||||
|
|
||||||
|
if (dto.parent != null && depth < MAX_DEPTH && !dto.parent.startsWith("builtin/")) {
|
||||||
|
FlatModel parent = resolve(ResourceLocation.parse(dto.parent), depth + 1);
|
||||||
|
textures.putAll(parent.textures());
|
||||||
|
if (elements == null || elements.isEmpty()) {
|
||||||
|
elements = parent.elements();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dto.textures != null) {
|
||||||
|
textures.putAll(dto.textures);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FlatModel(textures, elements);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A namespaced identifier as used throughout Minecraft assets, e.g. {@code minecraft:block/stone}.
|
||||||
|
* When no namespace is present the default {@code minecraft} is assumed.
|
||||||
|
*/
|
||||||
|
public record ResourceLocation(String namespace, String path) {
|
||||||
|
|
||||||
|
public static final String DEFAULT_NAMESPACE = "minecraft";
|
||||||
|
|
||||||
|
public ResourceLocation {
|
||||||
|
namespace = namespace.toLowerCase();
|
||||||
|
path = path.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a string like {@code minecraft:block/stone} or {@code block/stone}.
|
||||||
|
*/
|
||||||
|
public static ResourceLocation parse(String raw) {
|
||||||
|
int colon = raw.indexOf(':');
|
||||||
|
if (colon < 0) {
|
||||||
|
return new ResourceLocation(DEFAULT_NAMESPACE, raw);
|
||||||
|
}
|
||||||
|
return new ResourceLocation(raw.substring(0, colon), raw.substring(colon + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return namespace + ":" + path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets;
|
||||||
|
|
||||||
|
import java.io.Closeable;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A read-only source of Minecraft assets. Paths are pack-relative and always use {@code /} as a
|
||||||
|
* separator, e.g. {@code assets/minecraft/blockstates/oak_fence.json}.
|
||||||
|
*
|
||||||
|
* <p>Implementations must be safe for concurrent {@link #read} calls, since the renderer accesses
|
||||||
|
* assets from multiple threads.
|
||||||
|
*/
|
||||||
|
public interface ResourcePack extends Closeable {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the raw bytes of an asset, or an empty optional if it does not exist.
|
||||||
|
*/
|
||||||
|
Optional<byte[]> read(String path);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the given asset path exists in this pack.
|
||||||
|
*/
|
||||||
|
boolean exists(String path);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
void close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a {@link ResourcePack} from the plugin's {@code resourcepack/} data folder. The folder may
|
||||||
|
* contain an unpacked pack (a directory with {@code assets/minecraft/...}) and/or one or more
|
||||||
|
* {@code .zip} packs. Returns an empty optional when nothing usable is found.
|
||||||
|
*/
|
||||||
|
public final class ResourcePackLoader {
|
||||||
|
|
||||||
|
/** The marker that identifies a valid pack root: {@code <root>/assets/minecraft}. */
|
||||||
|
private static final String MARKER = "assets/minecraft";
|
||||||
|
|
||||||
|
private ResourcePackLoader() {}
|
||||||
|
|
||||||
|
public static Optional<ResourcePack> load(File resourcePackDir, Logger logger) {
|
||||||
|
if (!resourcePackDir.isDirectory()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ResourcePack> packs = new ArrayList<>();
|
||||||
|
|
||||||
|
// Directory packs: probe the folder itself and any direct sub-folder for the assets marker.
|
||||||
|
List<Path> dirCandidates = new ArrayList<>();
|
||||||
|
dirCandidates.add(resourcePackDir.toPath());
|
||||||
|
File[] children = resourcePackDir.listFiles(File::isDirectory);
|
||||||
|
if (children != null) {
|
||||||
|
for (File child : children) dirCandidates.add(child.toPath());
|
||||||
|
}
|
||||||
|
for (Path candidate : dirCandidates) {
|
||||||
|
if (Files.isDirectory(candidate.resolve(MARKER))) {
|
||||||
|
packs.add(new DirectoryResourcePack(candidate));
|
||||||
|
logger.info("Loaded resource pack directory: " + candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zip packs anywhere under the resourcepack folder.
|
||||||
|
try (Stream<Path> walk = Files.walk(resourcePackDir.toPath())) {
|
||||||
|
List<Path> zips = walk.filter(Files::isRegularFile)
|
||||||
|
.filter(p -> p.getFileName().toString().toLowerCase().endsWith(".zip"))
|
||||||
|
.toList();
|
||||||
|
for (Path zip : zips) {
|
||||||
|
try {
|
||||||
|
ZipResourcePack pack = new ZipResourcePack(zip);
|
||||||
|
if (pack.exists(MARKER + "/blockstates") || pack.exists("pack.mcmeta") || hasAnyBlockstate(pack)) {
|
||||||
|
packs.add(pack);
|
||||||
|
logger.info("Loaded resource pack zip: " + zip);
|
||||||
|
} else {
|
||||||
|
pack.close();
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.warning("Failed to open resource pack zip " + zip + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.warning("Failed to scan resource pack directory: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packs.isEmpty()) return Optional.empty();
|
||||||
|
if (packs.size() == 1) return Optional.of(packs.getFirst());
|
||||||
|
return Optional.of(new CompositeResourcePack(packs));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasAnyBlockstate(ResourcePack pack) {
|
||||||
|
// Cheap sanity probe for a couple of guaranteed-present vanilla blockstates.
|
||||||
|
return pack.exists(AssetPaths.blockState("stone")) || pack.exists(AssetPaths.blockState("dirt"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets;
|
||||||
|
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.net.URLConnection;
|
||||||
|
import java.util.Optional;
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads and caches player skin textures (by URL) as ARGB pixel grids. Legacy 64x32 skins are
|
||||||
|
* converted to the modern 64x64 layout (so the model's overlay/second-layer bones map correctly).
|
||||||
|
* Downloads happen off the main thread (from the entity baking step) and are cached.
|
||||||
|
*/
|
||||||
|
public final class SkinCache extends ImageCache<String> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<int[][]> get(String url) {
|
||||||
|
if (url == null || url.isEmpty()) return Optional.empty();
|
||||||
|
return super.get(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected int[][] load(String url) {
|
||||||
|
try {
|
||||||
|
URL u = URI.create(url).toURL();
|
||||||
|
URLConnection conn = u.openConnection();
|
||||||
|
conn.setConnectTimeout(5000);
|
||||||
|
conn.setReadTimeout(5000);
|
||||||
|
conn.setRequestProperty("User-Agent", "PixelPics");
|
||||||
|
BufferedImage img;
|
||||||
|
try (InputStream in = conn.getInputStream()) {
|
||||||
|
img = ImageIO.read(in);
|
||||||
|
}
|
||||||
|
if (img == null) return null;
|
||||||
|
return toModern(img);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the image to a 64x64 grid, converting the legacy 64x32 layout (mirrored arm/leg) if needed. */
|
||||||
|
private int[][] toModern(BufferedImage img) {
|
||||||
|
int w = img.getWidth();
|
||||||
|
int h = img.getHeight();
|
||||||
|
int[][] out = new int[64][64];
|
||||||
|
for (int y = 0; y < Math.min(h, 64); y++) {
|
||||||
|
for (int x = 0; x < Math.min(w, 64); x++) {
|
||||||
|
out[y][x] = img.getRGB(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (h <= 32) {
|
||||||
|
// Legacy skin: copy the right arm/leg regions into the modern left arm/leg slots (mirrored).
|
||||||
|
copyMirrored(img, out, 44, 16, 36, 48); // right leg -> left leg (top/quads handled by mirror)
|
||||||
|
copyMirrored(img, out, 44, 16, 36, 52);
|
||||||
|
copyMirrored(img, out, 40, 16, 32, 48); // right arm -> left arm
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirror-copies a 16x12 limb block (legacy) into a modern slot; coarse but visually adequate. */
|
||||||
|
private void copyMirrored(BufferedImage img, int[][] out, int srcX, int srcY, int dstX, int dstY) {
|
||||||
|
for (int y = 0; y < 12 && srcY + y < img.getHeight(); y++) {
|
||||||
|
for (int x = 0; x < 16 && srcX + x < img.getWidth(); x++) {
|
||||||
|
int px = img.getRGB(srcX + (15 - x), srcY + y);
|
||||||
|
int ox = dstX + x, oy = dstY + y;
|
||||||
|
if (ox < 64 && oy < 64) out[oy][ox] = px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets;
|
||||||
|
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads and caches block textures as raw ARGB pixel grids. Textures are stored unflipped (vanilla
|
||||||
|
* UV origin is top-left); all orientation is handled by the UV math in the intersector.
|
||||||
|
*
|
||||||
|
* <p>Animated textures (e.g. {@code water_still.png}, a vertical strip of frames) are reduced to their
|
||||||
|
* first frame — but ONLY when an actual {@code .png.mcmeta} animation file is present. A tall sprite
|
||||||
|
* without one (e.g. a 64×128 entity texture like the witch/strider) is a real texture, not an animation,
|
||||||
|
* and must be loaded in full.
|
||||||
|
*/
|
||||||
|
public final class TextureCache extends ImageCache<ResourceLocation> {
|
||||||
|
|
||||||
|
private final ResourcePack pack;
|
||||||
|
|
||||||
|
public TextureCache(ResourcePack pack) {
|
||||||
|
this.pack = pack;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected int[][] load(ResourceLocation id) {
|
||||||
|
var bytes = pack.read(AssetPaths.texture(id));
|
||||||
|
if (bytes.isEmpty()) return null;
|
||||||
|
BufferedImage img;
|
||||||
|
try {
|
||||||
|
img = ImageIO.read(new ByteArrayInputStream(bytes.get()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (img == null) return null;
|
||||||
|
|
||||||
|
int width = img.getWidth();
|
||||||
|
int height = img.getHeight();
|
||||||
|
// Reduce animated strips to the first frame — but only a REAL animation (has a .mcmeta); a tall
|
||||||
|
// sprite without one (e.g. a 64×128 entity texture) is a full texture, not a frame strip.
|
||||||
|
boolean animated = height > width && height % width == 0 && pack.exists(AssetPaths.textureMeta(id));
|
||||||
|
int frameHeight = animated ? width : height;
|
||||||
|
|
||||||
|
int[][] pixels = new int[frameHeight][width];
|
||||||
|
for (int y = 0; y < frameHeight; y++) {
|
||||||
|
for (int x = 0; x < width; x++) {
|
||||||
|
pixels[y][x] = img.getRGB(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pixels;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A resolved blockstate variant: which model to use plus its {@code x}/{@code y} rotation (in
|
||||||
|
* degrees, multiples of 90) and {@code uvlock}.
|
||||||
|
*/
|
||||||
|
public record Variant(ResourceLocation model, int x, int y, boolean uvlock) {}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link ResourcePack} backed by a {@code .zip} archive (e.g. a vanilla/custom resource pack).
|
||||||
|
*
|
||||||
|
* <p>{@link ZipFile} allows concurrent {@link ZipFile#getInputStream} calls, so reads are thread
|
||||||
|
* safe. The entry lookup map is built once at construction.
|
||||||
|
*/
|
||||||
|
public final class ZipResourcePack implements ResourcePack {
|
||||||
|
|
||||||
|
private final ZipFile zipFile;
|
||||||
|
private final Map<String, ZipEntry> entries = new HashMap<>();
|
||||||
|
|
||||||
|
public ZipResourcePack(Path zipPath) throws IOException {
|
||||||
|
this.zipFile = new ZipFile(zipPath.toFile());
|
||||||
|
zipFile.stream().filter(entry -> !entry.isDirectory()).forEach(entry -> entries.put(entry.getName(), entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<byte[]> read(String path) {
|
||||||
|
ZipEntry entry = entries.get(path);
|
||||||
|
if (entry == null) return Optional.empty();
|
||||||
|
try (InputStream input = zipFile.getInputStream(entry)) {
|
||||||
|
return Optional.of(input.readAllBytes());
|
||||||
|
} catch (IOException e) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean exists(String path) {
|
||||||
|
return entries.containsKey(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
try {
|
||||||
|
zipFile.close();
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gson-bound representation of a vanilla block model JSON
|
||||||
|
* ({@code assets/minecraft/models/block/*.json}).
|
||||||
|
*/
|
||||||
|
public class ModelFileDto {
|
||||||
|
public String parent;
|
||||||
|
public Map<String, String> textures;
|
||||||
|
public List<ElementDto> elements;
|
||||||
|
|
||||||
|
public static class ElementDto {
|
||||||
|
public double[] from; // 0..16
|
||||||
|
public double[] to; // 0..16
|
||||||
|
public RotationDto rotation; // optional
|
||||||
|
public Map<String, FaceDto> faces; // keys: down/up/north/south/west/east
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class FaceDto {
|
||||||
|
public double[] uv; // optional, 0..16 (x1,y1,x2,y2)
|
||||||
|
public String texture; // e.g. "#side" or "minecraft:block/oak_planks"
|
||||||
|
public Integer tintindex;
|
||||||
|
public String cullface; // ignored by the renderer
|
||||||
|
public int rotation; // 0/90/180/270
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class RotationDto {
|
||||||
|
public double[] origin; // 0..16
|
||||||
|
public String axis; // "x" | "y" | "z"
|
||||||
|
public double angle; // -45..45 in 22.5 steps
|
||||||
|
public boolean rescale;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets.font;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Minecraft bitmap font assembled from the resource pack's {@code font/default.json} providers
|
||||||
|
* (the {@code bitmap} and {@code space} providers; {@code unihex}/{@code legacy_unicode}/{@code ttf}
|
||||||
|
* are out of scope). Maps codepoints to {@link Glyph}s and supplies advance widths, enough to rasterize
|
||||||
|
* a line of text. Immutable after construction.
|
||||||
|
*/
|
||||||
|
public final class BitmapFont {
|
||||||
|
|
||||||
|
private final Map<Integer, Glyph> glyphs;
|
||||||
|
private final Map<Integer, Integer> spaceAdvances;
|
||||||
|
private final int maxAscent;
|
||||||
|
private final int maxDescent;
|
||||||
|
|
||||||
|
public BitmapFont(Map<Integer, Glyph> glyphs, Map<Integer, Integer> spaceAdvances, int maxAscent, int maxDescent) {
|
||||||
|
this.glyphs = glyphs;
|
||||||
|
this.spaceAdvances = spaceAdvances;
|
||||||
|
this.maxAscent = maxAscent;
|
||||||
|
this.maxDescent = maxDescent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The glyph for a codepoint, or null when no bitmap provider supplies it (e.g. CJK via unihex). */
|
||||||
|
public Glyph glyph(int codepoint) {
|
||||||
|
return glyphs.get(codepoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Horizontal advance for a codepoint (font px): a space-provider advance, a glyph advance, else 0. */
|
||||||
|
public int advance(int codepoint) {
|
||||||
|
Integer sp = spaceAdvances.get(codepoint);
|
||||||
|
if (sp != null) return sp;
|
||||||
|
Glyph g = glyphs.get(codepoint);
|
||||||
|
return g != null ? g.advance() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Largest baseline offset across all glyphs (font px) — the common baseline for a rendered line. */
|
||||||
|
public int maxAscent() {
|
||||||
|
return maxAscent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Largest below-baseline extent across all glyphs (font px). */
|
||||||
|
public int maxDescent() {
|
||||||
|
return maxDescent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total vertical extent of one line (ascent + descent, font px). */
|
||||||
|
public int lineHeight() {
|
||||||
|
return maxAscent + maxDescent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return glyphs.isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets.font;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.AssetPaths;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.ResourcePack;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a {@link BitmapFont} from a resource pack's {@code font/default.json}. Resolves {@code reference}
|
||||||
|
* providers recursively, parses {@code bitmap} providers into per-codepoint {@link Glyph}s (cell size and
|
||||||
|
* per-glyph pixel width derived from the PNG), and {@code space} providers into advance overrides.
|
||||||
|
* {@code unihex}/{@code legacy_unicode}/{@code ttf} providers are skipped (CJK etc. render as gaps).
|
||||||
|
*
|
||||||
|
* <p>Provider order is priority order (first match wins, matching vanilla): an earlier provider's glyph is
|
||||||
|
* never overwritten by a later one.
|
||||||
|
*/
|
||||||
|
public final class FontLoader {
|
||||||
|
|
||||||
|
private FontLoader() {}
|
||||||
|
|
||||||
|
/** Loads {@code minecraft:default}. Returns an empty font (no glyphs) when the pack has no font assets. */
|
||||||
|
public static BitmapFont load(ResourcePack pack, TextureCache textures, Logger log) {
|
||||||
|
Builder b = new Builder(pack, textures, log);
|
||||||
|
b.loadFont(ResourceLocation.parse("minecraft:default"), new HashSet<>());
|
||||||
|
return b.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Builder {
|
||||||
|
private final ResourcePack pack;
|
||||||
|
private final TextureCache textures;
|
||||||
|
private final Logger log;
|
||||||
|
private final Map<Integer, Glyph> glyphs = new HashMap<>();
|
||||||
|
private final Map<Integer, Integer> spaceAdvances = new HashMap<>();
|
||||||
|
private int maxAscent = 0;
|
||||||
|
private int maxDescent = 0;
|
||||||
|
|
||||||
|
Builder(ResourcePack pack, TextureCache textures, Logger log) {
|
||||||
|
this.pack = pack;
|
||||||
|
this.textures = textures;
|
||||||
|
this.log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
BitmapFont build() {
|
||||||
|
return new BitmapFont(glyphs, spaceAdvances, maxAscent, maxDescent);
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadFont(ResourceLocation id, Set<String> visited) {
|
||||||
|
String path = AssetPaths.font(id);
|
||||||
|
if (!visited.add(path)) return; // cycle guard
|
||||||
|
Optional<byte[]> bytes = pack.read(path);
|
||||||
|
if (bytes.isEmpty()) return;
|
||||||
|
JsonObject root;
|
||||||
|
try {
|
||||||
|
root = JsonParser.parseString(new String(bytes.get(), StandardCharsets.UTF_8))
|
||||||
|
.getAsJsonObject();
|
||||||
|
} catch (Exception e) {
|
||||||
|
if (log != null) log.warning("PixelPics: failed to parse font " + path + ": " + e.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
JsonArray providers = root.getAsJsonArray("providers");
|
||||||
|
if (providers == null) return;
|
||||||
|
for (JsonElement el : providers) {
|
||||||
|
if (!el.isJsonObject()) continue;
|
||||||
|
provider(el.getAsJsonObject(), visited);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void provider(JsonObject p, Set<String> visited) {
|
||||||
|
String type = p.has("type") ? p.get("type").getAsString() : "";
|
||||||
|
switch (type) {
|
||||||
|
case "reference" -> {
|
||||||
|
if (p.has("id")) loadFont(ResourceLocation.parse(p.get("id").getAsString()), visited);
|
||||||
|
}
|
||||||
|
case "space" -> space(p);
|
||||||
|
case "bitmap" -> bitmap(p);
|
||||||
|
default -> {
|
||||||
|
/* unihex, legacy_unicode, ttf, … — out of scope */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void space(JsonObject p) {
|
||||||
|
JsonObject advances = p.getAsJsonObject("advances");
|
||||||
|
if (advances == null) return;
|
||||||
|
for (Map.Entry<String, JsonElement> e : advances.entrySet()) {
|
||||||
|
String key = e.getKey();
|
||||||
|
if (key.isEmpty()) continue;
|
||||||
|
int cp = key.codePointAt(0);
|
||||||
|
spaceAdvances.putIfAbsent(cp, e.getValue().getAsInt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void bitmap(JsonObject p) {
|
||||||
|
if (!p.has("file") || !p.has("chars")) return;
|
||||||
|
int height = p.has("height") ? p.get("height").getAsInt() : 8;
|
||||||
|
int ascent = p.has("ascent") ? p.get("ascent").getAsInt() : 7;
|
||||||
|
|
||||||
|
String file = p.get("file").getAsString();
|
||||||
|
if (file.endsWith(".png")) file = file.substring(0, file.length() - 4);
|
||||||
|
int[][] tex = textures.get(ResourceLocation.parse(file)).orElse(null);
|
||||||
|
if (tex == null || tex.length == 0) {
|
||||||
|
if (log != null) log.warning("PixelPics: font bitmap missing: " + file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int imgH = tex.length;
|
||||||
|
int imgW = tex[0].length;
|
||||||
|
|
||||||
|
JsonArray rows = p.getAsJsonArray("chars");
|
||||||
|
int nRows = rows.size();
|
||||||
|
if (nRows == 0) return;
|
||||||
|
int nCols = rows.get(0)
|
||||||
|
.getAsString()
|
||||||
|
.codePointCount(0, rows.get(0).getAsString().length());
|
||||||
|
if (nCols == 0) return;
|
||||||
|
int cellW = imgW / nCols;
|
||||||
|
int cellH = imgH / nRows;
|
||||||
|
if (cellW == 0 || cellH == 0) return;
|
||||||
|
double scale = height / (double) cellH;
|
||||||
|
|
||||||
|
for (int r = 0; r < nRows; r++) {
|
||||||
|
String row = rows.get(r).getAsString();
|
||||||
|
int col = 0;
|
||||||
|
for (int ci = 0; ci < row.length(); ) {
|
||||||
|
int cp = row.codePointAt(ci);
|
||||||
|
ci += Character.charCount(cp);
|
||||||
|
int thisCol = col++;
|
||||||
|
if (cp == 0 || thisCol >= nCols) continue; // 0x0000 = empty slot
|
||||||
|
if (glyphs.containsKey(cp)) continue; // earlier provider wins
|
||||||
|
int srcX = thisCol * cellW;
|
||||||
|
int srcY = r * cellH;
|
||||||
|
int glyphPx = rightmostOpaqueColumn(tex, srcX, srcY, cellW, cellH) + 1;
|
||||||
|
if (glyphPx <= 0) continue; // blank glyph (handled by space provider)
|
||||||
|
int advance = (int) (glyphPx * scale + 0.5) + 1;
|
||||||
|
glyphs.put(cp, new Glyph(tex, srcX, srcY, cellW, cellH, glyphPx, height, ascent, advance));
|
||||||
|
maxAscent = Math.max(maxAscent, ascent);
|
||||||
|
maxDescent = Math.max(maxDescent, height - ascent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rightmost column index (0-based, relative to the cell) containing an opaque pixel, or -1 if blank. */
|
||||||
|
private static int rightmostOpaqueColumn(int[][] tex, int srcX, int srcY, int cellW, int cellH) {
|
||||||
|
int last = -1;
|
||||||
|
for (int x = 0; x < cellW; x++) {
|
||||||
|
for (int y = 0; y < cellH; y++) {
|
||||||
|
int argb = tex[srcY + y][srcX + x];
|
||||||
|
if (((argb >>> 24) & 0xFF) != 0) {
|
||||||
|
last = x;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets.font;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single bitmap-font glyph: a rectangular cell {@code [srcX, srcX+cellW) × [srcY, srcY+cellH)} inside
|
||||||
|
* its provider PNG ({@code tex}, ARGB top-left), the number of opaque pixel columns to actually draw
|
||||||
|
* ({@code glyphPx}, measured to the rightmost non-transparent column), the rendered {@code height} and
|
||||||
|
* {@code ascent} (baseline offset from the top, font px) and the horizontal {@code advance} (font px).
|
||||||
|
*
|
||||||
|
* <p>Glyphs from different providers may declare different cell sizes and ascents (e.g. ascii 8px/ascent
|
||||||
|
* 7 vs accented 12px/ascent 10); rendering aligns them on a common baseline so umlauts and Latin mix.
|
||||||
|
*/
|
||||||
|
public record Glyph(
|
||||||
|
int[][] tex, int srcX, int srcY, int cellW, int cellH, int glyphPx, int height, int ascent, int advance) {
|
||||||
|
|
||||||
|
/** Vertical pixels below the baseline (font px). */
|
||||||
|
public int descent() {
|
||||||
|
return height - ascent;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets.model;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the average opaque color of a texture, used as the 100% coverage fallback.
|
||||||
|
*/
|
||||||
|
public final class AverageColor {
|
||||||
|
|
||||||
|
private AverageColor() {}
|
||||||
|
|
||||||
|
/** Average ARGB over pixels with alpha > 16; returns opaque gray when fully transparent/empty. */
|
||||||
|
public static int of(int[][] texture) {
|
||||||
|
long r = 0, g = 0, b = 0;
|
||||||
|
int count = 0;
|
||||||
|
for (int[] row : texture) {
|
||||||
|
for (int argb : row) {
|
||||||
|
if (ColorUtil.alpha(argb) <= 16) continue;
|
||||||
|
r += ColorUtil.red(argb);
|
||||||
|
g += ColorUtil.green(argb);
|
||||||
|
b += ColorUtil.blue(argb);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count == 0) return 0xFF7F7F7F;
|
||||||
|
return ColorUtil.argb(0xFF, (int) (r / count), (int) (g / count), (int) (b / count));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mutable accumulator that averages a set of already-opaque ARGB colors (e.g. per-face/per-variant). */
|
||||||
|
public static final class Accumulator {
|
||||||
|
private long r, g, b;
|
||||||
|
private int count;
|
||||||
|
|
||||||
|
public void add(int argb) {
|
||||||
|
r += ColorUtil.red(argb);
|
||||||
|
g += ColorUtil.green(argb);
|
||||||
|
b += ColorUtil.blue(argb);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opaque average of the added colors, or {@code fallback} if none were added. */
|
||||||
|
public int average(int fallback) {
|
||||||
|
if (count == 0) return fallback;
|
||||||
|
return ColorUtil.argb(0xFF, (int) (r / count), (int) (g / count), (int) (b / count));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The six block face directions, with unit normals and 90-degree rotation helpers used when baking
|
||||||
|
* blockstate {@code x}/{@code y} rotations.
|
||||||
|
*/
|
||||||
|
public enum Direction {
|
||||||
|
DOWN(0, -1, 0),
|
||||||
|
UP(0, 1, 0),
|
||||||
|
NORTH(0, 0, -1),
|
||||||
|
SOUTH(0, 0, 1),
|
||||||
|
WEST(-1, 0, 0),
|
||||||
|
EAST(1, 0, 0);
|
||||||
|
public final int nx, ny, nz;
|
||||||
|
|
||||||
|
Direction(int nx, int ny, int nz) {
|
||||||
|
this.nx = nx;
|
||||||
|
this.ny = ny;
|
||||||
|
this.nz = nz;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Direction fromName(String name) {
|
||||||
|
return switch (name.toLowerCase()) {
|
||||||
|
case "down" -> DOWN;
|
||||||
|
case "up" -> UP;
|
||||||
|
case "north" -> NORTH;
|
||||||
|
case "south" -> SOUTH;
|
||||||
|
case "west" -> WEST;
|
||||||
|
case "east" -> EAST;
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rotate this direction by {@code steps * 90} degrees around the Y axis (clockwise from above). */
|
||||||
|
public Direction rotateY(int steps) {
|
||||||
|
steps = ((steps % 4) + 4) % 4;
|
||||||
|
Direction d = this;
|
||||||
|
for (int i = 0; i < steps; i++) {
|
||||||
|
d = switch (d) {
|
||||||
|
case NORTH -> EAST;
|
||||||
|
case EAST -> SOUTH;
|
||||||
|
case SOUTH -> WEST;
|
||||||
|
case WEST -> NORTH;
|
||||||
|
// up/down unchanged
|
||||||
|
default -> d;};
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rotate this direction by {@code steps * 90} degrees around the X axis. */
|
||||||
|
public Direction rotateX(int steps) {
|
||||||
|
steps = ((steps % 4) + 4) % 4;
|
||||||
|
Direction d = this;
|
||||||
|
for (int i = 0; i < steps; i++) {
|
||||||
|
d = switch (d) {
|
||||||
|
case UP -> NORTH;
|
||||||
|
case NORTH -> DOWN;
|
||||||
|
case DOWN -> SOUTH;
|
||||||
|
case SOUTH -> UP;
|
||||||
|
// east/west unchanged
|
||||||
|
default -> d;};
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single box of a baked block model. Coordinates are normalized to the 0..1 block cube. Faces are
|
||||||
|
* indexed by {@link Direction#ordinal()} and may be {@code null} when absent.
|
||||||
|
*
|
||||||
|
* <p>An optional element rotation (from the model JSON) is kept as origin/axis/angle so the
|
||||||
|
* intersector can treat the box as oriented (OBB) when {@code angle != 0}.
|
||||||
|
*/
|
||||||
|
public final class Element {
|
||||||
|
|
||||||
|
public final double[] from; // length 3, 0..1
|
||||||
|
public final double[] to; // length 3, 0..1
|
||||||
|
public final Face[] faces; // length 6, indexed by Direction.ordinal()
|
||||||
|
|
||||||
|
// Element rotation (0..1 origin), null/zero when axis-aligned.
|
||||||
|
public final double[] rotOrigin; // length 3, 0..1, may be null
|
||||||
|
public final int rotAxis; // 0=x,1=y,2=z, -1 when none
|
||||||
|
public final double rotAngleRad; // radians
|
||||||
|
public final boolean rescale;
|
||||||
|
|
||||||
|
public Element(
|
||||||
|
double[] from,
|
||||||
|
double[] to,
|
||||||
|
Face[] faces,
|
||||||
|
double[] rotOrigin,
|
||||||
|
int rotAxis,
|
||||||
|
double rotAngleRad,
|
||||||
|
boolean rescale) {
|
||||||
|
this.from = from;
|
||||||
|
this.to = to;
|
||||||
|
this.faces = faces;
|
||||||
|
this.rotOrigin = rotOrigin;
|
||||||
|
this.rotAxis = rotAxis;
|
||||||
|
this.rotAngleRad = rotAngleRad;
|
||||||
|
this.rescale = rescale;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAxisAligned() {
|
||||||
|
return rotAxis < 0 || rotAngleRad == 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A textured face of an {@link Element}. UV coordinates are normalized to 0..1 in texture space with
|
||||||
|
* the origin at the top-left ({@code v} increasing downwards), matching the vanilla convention.
|
||||||
|
*/
|
||||||
|
public final class Face {
|
||||||
|
|
||||||
|
public final int[][] texture; // [y][x] ARGB, top-left origin
|
||||||
|
public final double u1, v1, u2, v2;
|
||||||
|
public final int rotation; // 0/90/180/270, applied to the sampled UV
|
||||||
|
public final int tintIndex; // -1 = no tint
|
||||||
|
|
||||||
|
public Face(int[][] texture, double u1, double v1, double u2, double v2, int rotation, int tintIndex) {
|
||||||
|
this.texture = texture;
|
||||||
|
this.u1 = u1;
|
||||||
|
this.v1 = v1;
|
||||||
|
this.u2 = u2;
|
||||||
|
this.v2 = v2;
|
||||||
|
this.rotation = rotation;
|
||||||
|
this.tintIndex = tintIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Samples the ARGB pixel for a position within the face, where {@code (s, t)} are in 0..1 across
|
||||||
|
* the face's two in-plane axes (s = horizontal, t = vertical, top-left origin).
|
||||||
|
*/
|
||||||
|
public int sample(double s, double t) {
|
||||||
|
// Apply face rotation by rotating the (s,t) lookup.
|
||||||
|
double rs = s, rt = t;
|
||||||
|
switch (((rotation % 360) + 360) % 360) {
|
||||||
|
case 90 -> {
|
||||||
|
rs = t;
|
||||||
|
rt = 1.0 - s;
|
||||||
|
}
|
||||||
|
case 180 -> {
|
||||||
|
rs = 1.0 - s;
|
||||||
|
rt = 1.0 - t;
|
||||||
|
}
|
||||||
|
case 270 -> {
|
||||||
|
rs = 1.0 - t;
|
||||||
|
rt = s;
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
/* 0 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double u = u1 + (u2 - u1) * rs;
|
||||||
|
double v = v1 + (v2 - v1) * rt;
|
||||||
|
|
||||||
|
int h = texture.length;
|
||||||
|
if (h == 0) return 0;
|
||||||
|
int w = texture[0].length;
|
||||||
|
|
||||||
|
int px = (int) Math.floor(u * w);
|
||||||
|
int py = (int) Math.floor(v * h);
|
||||||
|
px = Math.clamp(px, 0, w - 1);
|
||||||
|
py = Math.clamp(py, 0, h - 1);
|
||||||
|
return texture[py][px];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.assets.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fully baked, intersectable block model: a list of {@link Element} boxes plus rendering hints.
|
||||||
|
*
|
||||||
|
* <p>{@code averageColor} is the 100% coverage fallback used for blocks without geometry
|
||||||
|
* (builtin/generated models, unresolved blocks) and as a backstop. {@code hasGeometry} is false when
|
||||||
|
* the model has no usable elements; the renderer then draws a flat shaded cube using
|
||||||
|
* {@code averageColor}.
|
||||||
|
*/
|
||||||
|
public final class ResolvedModel {
|
||||||
|
|
||||||
|
public final List<Element> elements;
|
||||||
|
public final int averageColor; // ARGB
|
||||||
|
public final double transparency; // 0..1
|
||||||
|
public final double reflection; // 0..1
|
||||||
|
public final boolean occluding;
|
||||||
|
public final boolean hasGeometry;
|
||||||
|
|
||||||
|
public ResolvedModel(
|
||||||
|
List<Element> elements,
|
||||||
|
int averageColor,
|
||||||
|
double transparency,
|
||||||
|
double reflection,
|
||||||
|
boolean occluding,
|
||||||
|
boolean hasGeometry) {
|
||||||
|
this.elements = elements;
|
||||||
|
this.averageColor = averageColor;
|
||||||
|
this.transparency = transparency;
|
||||||
|
this.reflection = reflection;
|
||||||
|
this.occluding = occluding;
|
||||||
|
this.hasGeometry = hasGeometry;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,64 +1,99 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.commands;
|
package eu.mhsl.minecraft.pixelpics.commands;
|
||||||
|
|
||||||
import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer;
|
import eu.mhsl.minecraft.pixelpics.survival.PhotoService;
|
||||||
import eu.mhsl.minecraft.pixelpics.Main;
|
import eu.mhsl.minecraft.pixelpics.utils.MapManager;
|
||||||
import eu.mhsl.minecraft.pixelpics.render.render.Resolution;
|
import java.util.List;
|
||||||
import org.bukkit.Bukkit;
|
import java.util.Set;
|
||||||
import org.bukkit.Material;
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
import org.bukkit.command.Command;
|
import org.bukkit.command.Command;
|
||||||
import org.bukkit.command.CommandExecutor;
|
import org.bukkit.command.CommandExecutor;
|
||||||
import org.bukkit.command.CommandSender;
|
import org.bukkit.command.CommandSender;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
import org.bukkit.inventory.ItemStack;
|
|
||||||
import org.bukkit.inventory.meta.MapMeta;
|
|
||||||
import org.bukkit.map.MapView;
|
|
||||||
import org.bukkit.persistence.PersistentDataType;
|
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
import javax.imageio.ImageIO;
|
|
||||||
import java.awt.image.BufferedImage;
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public class PixelPicsCommand implements CommandExecutor {
|
public class PixelPicsCommand implements CommandExecutor {
|
||||||
|
|
||||||
|
private static final int DEFAULT_CLEANUP_DAYS = 30;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String @NotNull [] args) {
|
public boolean onCommand(
|
||||||
if(!(sender instanceof Player player))
|
@NotNull CommandSender sender,
|
||||||
throw new IllegalStateException("Dieser Command kann nur von einem Spieler ausgeführt werden!");
|
@NotNull Command command,
|
||||||
|
@NotNull String label,
|
||||||
if(args.length > 0)
|
@NotNull String @NotNull [] args) {
|
||||||
return false;
|
if (args.length >= 1 && args[0].equalsIgnoreCase("cleanup")) {
|
||||||
|
return cleanup(sender, args);
|
||||||
// render image
|
|
||||||
Resolution resolution = new Resolution(Resolution.Pixels._128P, Resolution.AspectRatio._1_1);
|
|
||||||
BufferedImage image = Main.getInstance().getScreenRenderer().render(player.getEyeLocation(), resolution);
|
|
||||||
|
|
||||||
// save image
|
|
||||||
UUID imageId = UUID.randomUUID();
|
|
||||||
File imageFolder = new File(Main.getInstance().getDataFolder(), "images");
|
|
||||||
try {
|
|
||||||
if(!imageFolder.exists() && !imageFolder.mkdirs())
|
|
||||||
throw new IOException("Failed to create folders for image output!");
|
|
||||||
|
|
||||||
ImageIO.write(image, "png", new File(imageFolder, String.format("%s.png", imageId)));
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new RuntimeException("Failed to save image to disk!");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// image item
|
if (!(sender instanceof Player player)) {
|
||||||
ItemStack map = new ItemStack(Material.FILLED_MAP, 1);
|
sender.sendMessage(
|
||||||
MapMeta meta = (MapMeta) map.getItemMeta();
|
Component.text("Dieser Command kann nur von einem Spieler ausgeführt werden!", NamedTextColor.RED));
|
||||||
meta.getPersistentDataContainer().set(Main.getInstance().pictureIdFlag, PersistentDataType.STRING, imageId.toString());
|
return true;
|
||||||
|
}
|
||||||
|
if (args.length > 0) return false;
|
||||||
|
|
||||||
// display image
|
// Debug shortcut: render a photo from the player's view without needing a camera or film.
|
||||||
MapView mapView = Bukkit.createMap(Bukkit.getWorlds().getFirst());
|
if (!player.hasPermission("pixelpic.admin")) {
|
||||||
mapView.getRenderers().forEach(mapView::removeRenderer);
|
player.sendActionBar(
|
||||||
mapView.addRenderer(new ImageMapRenderer(image));
|
Component.text("Dafür fehlt dir die Berechtigung — nutze eine Kamera.", NamedTextColor.RED));
|
||||||
meta.setMapView(mapView);
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
map.setItemMeta(meta);
|
PhotoService.takePhoto(player);
|
||||||
player.getInventory().addItem(map);
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code /pixelPic cleanup [days]} dry-run; {@code /pixelPic cleanup confirm [days]} deletes orphans. */
|
||||||
|
private boolean cleanup(CommandSender sender, String[] args) {
|
||||||
|
if (!sender.hasPermission("pixelpic.admin") && !sender.isOp()) {
|
||||||
|
sender.sendMessage(
|
||||||
|
Component.text("Dafür fehlt dir die Berechtigung (pixelpic.admin).", NamedTextColor.RED));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean confirm = false;
|
||||||
|
int days = DEFAULT_CLEANUP_DAYS;
|
||||||
|
for (int i = 1; i < args.length; i++) {
|
||||||
|
if (args[i].equalsIgnoreCase("confirm")) confirm = true;
|
||||||
|
else {
|
||||||
|
try {
|
||||||
|
days = Math.max(0, Integer.parseInt(args[i]));
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<Integer> inUse = MapManager.collectInUseMapIds();
|
||||||
|
long cutoff = System.currentTimeMillis() - days * 86_400_000L;
|
||||||
|
List<MapManager.StoredMap> candidates = MapManager.listStored().stream()
|
||||||
|
.filter(s -> !inUse.contains(s.id()) && s.lastModified() < cutoff)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (candidates.isEmpty()) {
|
||||||
|
sender.sendMessage(Component.text(
|
||||||
|
"Keine aufräumbaren Aufnahmen gefunden (älter als " + days + " Tage und nicht in Benutzung).",
|
||||||
|
NamedTextColor.YELLOW));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm) {
|
||||||
|
sender.sendMessage(Component.text(
|
||||||
|
candidates.size() + " Aufnahme(n) könnten gelöscht werden " + "(älter als " + days
|
||||||
|
+ " Tage, nicht in geladenen Itemframes/Online-Inventaren).",
|
||||||
|
NamedTextColor.YELLOW));
|
||||||
|
sender.sendMessage(Component.text(
|
||||||
|
"Achtung: Maps in lange ungeladenen Bereichen werden hierbei nicht "
|
||||||
|
+ "erkannt. Zum Löschen: /pixelPic cleanup confirm " + days,
|
||||||
|
NamedTextColor.GRAY));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int deleted = 0;
|
||||||
|
for (MapManager.StoredMap s : candidates) {
|
||||||
|
if (MapManager.delete(s.id())) deleted++;
|
||||||
|
}
|
||||||
|
sender.sendMessage(Component.text(deleted + " Aufnahme(n) gelöscht.", NamedTextColor.GREEN));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.listeners;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.utils.MapImageDither;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.utils.MapManager;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.server.MapInitializeEvent;
|
||||||
|
|
||||||
|
public class OnMapInitialize implements Listener {
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onMapInitialize(MapInitializeEvent event) {
|
||||||
|
int mapId = event.getMap().getId();
|
||||||
|
|
||||||
|
// Fast path: use the cached dithered indices (no re-quantization).
|
||||||
|
byte[] indices = MapManager.readIndices(mapId);
|
||||||
|
if (indices != null) {
|
||||||
|
MapManager.attachView(event.getMap(), new ImageMapRenderer(indices));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback/migration: dither from the stored PNG once, then cache for next time.
|
||||||
|
BufferedImage image = MapManager.readImage(mapId);
|
||||||
|
if (image == null) return;
|
||||||
|
indices = MapImageDither.dither(image);
|
||||||
|
MapManager.saveIndices(indices, mapId);
|
||||||
|
MapManager.attachView(event.getMap(), new ImageMapRenderer(indices));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ForkJoinPool;
|
||||||
|
import java.util.concurrent.ForkJoinWorkerThread;
|
||||||
|
import java.util.concurrent.LinkedBlockingQueue;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.ScheduledFuture;
|
||||||
|
import java.util.concurrent.ThreadFactory;
|
||||||
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-friendly scheduling for photo renders. Bounds CPU use and protects the server tick by:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>Per player:</b> at most one photo in the system at a time (running or queued).</li>
|
||||||
|
* <li><b>Globally:</b> at most {@code maxConcurrent} photos render at once, with up to
|
||||||
|
* {@code queueSize} more waiting; further requests are rejected.</li>
|
||||||
|
* <li><b>CPU:</b> the ray tracing runs on a dedicated {@link ForkJoinPool} of {@code threads}
|
||||||
|
* low-priority worker threads ({@link Thread#MIN_PRIORITY}), so it tends to use spare CPU and
|
||||||
|
* never fans out across every core like the default common pool would.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Reserve a slot with {@link #tryReserve(UUID)}; if accepted, run the heavy work via
|
||||||
|
* {@link #dispatch} (which releases the slot on completion) — or call {@link #release(UUID)} if you
|
||||||
|
* bail out before dispatching.
|
||||||
|
*/
|
||||||
|
public final class RenderManager {
|
||||||
|
|
||||||
|
public enum Outcome {
|
||||||
|
ACCEPTED,
|
||||||
|
USER_BUSY,
|
||||||
|
QUEUE_FULL
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Plugin plugin;
|
||||||
|
private final ForkJoinPool tracePool;
|
||||||
|
private final ThreadPoolExecutor dispatcher;
|
||||||
|
private final ScheduledExecutorService watchdog;
|
||||||
|
private final Set<UUID> activeUsers = ConcurrentHashMap.newKeySet();
|
||||||
|
private final AtomicInteger inFlight = new AtomicInteger();
|
||||||
|
private final int capacity;
|
||||||
|
private final long timeoutMillis;
|
||||||
|
|
||||||
|
public RenderManager(Plugin plugin, int threads, int maxConcurrent, int queueSize, int timeoutSeconds) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.capacity = maxConcurrent + queueSize;
|
||||||
|
this.timeoutMillis = Math.max(1L, timeoutSeconds) * 1000L;
|
||||||
|
this.tracePool = new ForkJoinPool(threads, lowPriorityForkJoinFactory(), null, false);
|
||||||
|
this.dispatcher = new ThreadPoolExecutor(
|
||||||
|
maxConcurrent,
|
||||||
|
maxConcurrent,
|
||||||
|
30,
|
||||||
|
TimeUnit.SECONDS,
|
||||||
|
new LinkedBlockingQueue<>(), // capacity is enforced by inFlight, not this queue
|
||||||
|
lowPriorityThreadFactory());
|
||||||
|
this.dispatcher.allowCoreThreadTimeOut(true);
|
||||||
|
this.watchdog = Executors.newSingleThreadScheduledExecutor(runnable -> {
|
||||||
|
Thread thread = new Thread(runnable, "PixelPics-render-watchdog");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bounded, low-priority pool the ray tracer must run its parallel work on. */
|
||||||
|
public ForkJoinPool tracePool() {
|
||||||
|
return tracePool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Atomically reserves a slot for {@code user}. On {@code ACCEPTED}, you must later release it. */
|
||||||
|
public Outcome tryReserve(UUID user) {
|
||||||
|
if (!activeUsers.add(user)) return Outcome.USER_BUSY;
|
||||||
|
if (inFlight.incrementAndGet() > capacity) {
|
||||||
|
inFlight.decrementAndGet();
|
||||||
|
activeUsers.remove(user);
|
||||||
|
return Outcome.QUEUE_FULL;
|
||||||
|
}
|
||||||
|
return Outcome.ACCEPTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Releases a reservation made by {@link #tryReserve}. Safe to call once per accepted reserve. */
|
||||||
|
public void release(UUID user) {
|
||||||
|
if (activeUsers.remove(user)) {
|
||||||
|
inFlight.decrementAndGet();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs {@code work} off the main thread (honoring the global concurrency limit), then delivers the
|
||||||
|
* result back on the main thread via {@code onSuccess}, or {@code onFailure} if it fails, returns
|
||||||
|
* null, or exceeds the configured timeout. The {@link AtomicBoolean} handed to {@code work} is set
|
||||||
|
* once the deadline passes — {@code work} should poll it and bail out cooperatively. Releases the
|
||||||
|
* caller's reservation when done. Requires a prior {@link #tryReserve} success.
|
||||||
|
*/
|
||||||
|
public <T> void dispatch(UUID user, Function<AtomicBoolean, T> work, Consumer<T> onSuccess, Runnable onFailure) {
|
||||||
|
dispatcher.execute(() -> {
|
||||||
|
AtomicBoolean cancelled = new AtomicBoolean(false);
|
||||||
|
ScheduledFuture<?> deadline =
|
||||||
|
watchdog.schedule(() -> cancelled.set(true), timeoutMillis, TimeUnit.MILLISECONDS);
|
||||||
|
T result = null;
|
||||||
|
boolean ok = false;
|
||||||
|
try {
|
||||||
|
result = work.apply(cancelled);
|
||||||
|
ok = result != null && !cancelled.get();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
plugin.getLogger().warning("Render job failed: " + t);
|
||||||
|
} finally {
|
||||||
|
deadline.cancel(false);
|
||||||
|
if (cancelled.get()) {
|
||||||
|
plugin.getLogger()
|
||||||
|
.warning(
|
||||||
|
"Render for " + user + " aborted after " + (timeoutMillis / 1000) + "s (timeout).");
|
||||||
|
}
|
||||||
|
release(user);
|
||||||
|
}
|
||||||
|
T finalResult = result;
|
||||||
|
boolean finalOk = ok;
|
||||||
|
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||||
|
if (finalOk) onSuccess.accept(finalResult);
|
||||||
|
else onFailure.run();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void shutdown() {
|
||||||
|
watchdog.shutdownNow();
|
||||||
|
dispatcher.shutdownNow();
|
||||||
|
tracePool.shutdownNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ForkJoinPool.ForkJoinWorkerThreadFactory lowPriorityForkJoinFactory() {
|
||||||
|
return pool -> {
|
||||||
|
ForkJoinWorkerThread thread = ForkJoinPool.defaultForkJoinWorkerThreadFactory.newThread(pool);
|
||||||
|
thread.setName("PixelPics-trace-" + thread.getPoolIndex());
|
||||||
|
thread.setPriority(Thread.MIN_PRIORITY);
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ThreadFactory lowPriorityThreadFactory() {
|
||||||
|
AtomicInteger counter = new AtomicInteger();
|
||||||
|
return runnable -> {
|
||||||
|
Thread thread = new Thread(runnable, "PixelPics-render-" + counter.incrementAndGet());
|
||||||
|
thread.setDaemon(true);
|
||||||
|
thread.setPriority(Thread.MIN_PRIORITY);
|
||||||
|
return thread;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A 3x3 linear transform plus translation (affine), used to compose entity bone hierarchies and to
|
||||||
|
* map a cube's local space to world space. The {@code apply} parameter {@code t} of a ray is
|
||||||
|
* preserved under affine maps, so ray distances stay consistent between world and local space.
|
||||||
|
*/
|
||||||
|
public final class Affine {
|
||||||
|
|
||||||
|
// row-major 3x3
|
||||||
|
public final double[] r;
|
||||||
|
public final double[] t;
|
||||||
|
|
||||||
|
public Affine(double[] r, double[] t) {
|
||||||
|
this.r = r;
|
||||||
|
this.t = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Affine identity() {
|
||||||
|
return new Affine(new double[] {1, 0, 0, 0, 1, 0, 0, 0, 1}, new double[] {0, 0, 0});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Affine translation(double x, double y, double z) {
|
||||||
|
return new Affine(new double[] {1, 0, 0, 0, 1, 0, 0, 0, 1}, new double[] {x, y, z});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Affine scale(double s) {
|
||||||
|
return new Affine(new double[] {s, 0, 0, 0, s, 0, 0, 0, s}, new double[] {0, 0, 0});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Affine rotX(double a) {
|
||||||
|
double c = Math.cos(a), s = Math.sin(a);
|
||||||
|
return new Affine(new double[] {1, 0, 0, 0, c, -s, 0, s, c}, new double[] {0, 0, 0});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Affine rotY(double a) {
|
||||||
|
double c = Math.cos(a), s = Math.sin(a);
|
||||||
|
return new Affine(new double[] {c, 0, s, 0, 1, 0, -s, 0, c}, new double[] {0, 0, 0});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Affine rotZ(double a) {
|
||||||
|
double c = Math.cos(a), s = Math.sin(a);
|
||||||
|
return new Affine(new double[] {c, -s, 0, s, c, 0, 0, 0, 1}, new double[] {0, 0, 0});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** this ∘ o (apply o first, then this). */
|
||||||
|
public Affine mul(Affine o) {
|
||||||
|
double[] a = this.r, b = o.r;
|
||||||
|
double[] nr = new double[9];
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
for (int j = 0; j < 3; j++) {
|
||||||
|
nr[i * 3 + j] = a[i * 3] * b[j] + a[i * 3 + 1] * b[3 + j] + a[i * 3 + 2] * b[6 + j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
double[] ot = o.t;
|
||||||
|
double[] nt = new double[] {
|
||||||
|
a[0] * ot[0] + a[1] * ot[1] + a[2] * ot[2] + this.t[0],
|
||||||
|
a[3] * ot[0] + a[4] * ot[1] + a[5] * ot[2] + this.t[1],
|
||||||
|
a[6] * ot[0] + a[7] * ot[1] + a[8] * ot[2] + this.t[2]
|
||||||
|
};
|
||||||
|
return new Affine(nr, nt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public double[] apply(double x, double y, double z) {
|
||||||
|
return new double[] {
|
||||||
|
r[0] * x + r[1] * y + r[2] * z + t[0],
|
||||||
|
r[3] * x + r[4] * y + r[5] * z + t[1],
|
||||||
|
r[6] * x + r[7] * y + r[8] * z + t[2]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Linear part only (for directions). */
|
||||||
|
public double[] applyLinear(double x, double y, double z) {
|
||||||
|
return new double[] {
|
||||||
|
r[0] * x + r[1] * y + r[2] * z, r[3] * x + r[4] * y + r[5] * z, r[6] * x + r[7] * y + r[8] * z
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** General affine inverse (3x3 inverse + translation). */
|
||||||
|
public Affine inverse() {
|
||||||
|
double a = r[0], b = r[1], c = r[2], d = r[3], e = r[4], f = r[5], g = r[6], h = r[7], i = r[8];
|
||||||
|
double det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
|
||||||
|
double inv = Math.abs(det) < 1e-12 ? 0 : 1.0 / det;
|
||||||
|
double[] ir = new double[] {
|
||||||
|
(e * i - f * h) * inv, (c * h - b * i) * inv, (b * f - c * e) * inv,
|
||||||
|
(f * g - d * i) * inv, (a * i - c * g) * inv, (c * d - a * f) * inv,
|
||||||
|
(d * h - e * g) * inv, (b * g - a * h) * inv, (a * e - b * d) * inv
|
||||||
|
};
|
||||||
|
double[] it = new double[] {
|
||||||
|
-(ir[0] * t[0] + ir[1] * t[1] + ir[2] * t[2]),
|
||||||
|
-(ir[3] * t[0] + ir[4] * t[1] + ir[5] * t[2]),
|
||||||
|
-(ir[6] * t[0] + ir[7] * t[1] + ir[8] * t[2])
|
||||||
|
};
|
||||||
|
return new Affine(ir, it);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a {@link BlockEntityState} to its bundled CEM model name and ordered texture-path candidates.
|
||||||
|
* Block-entity geometry ships in the same CEM set as mobs (chest/sign/banner/…); textures live under
|
||||||
|
* {@code assets/minecraft/textures/entity/}. Mirrors the role {@link EntityModels} plays for mobs.
|
||||||
|
*/
|
||||||
|
public final class BlockEntityModels {
|
||||||
|
|
||||||
|
private BlockEntityModels() {}
|
||||||
|
|
||||||
|
/** The CEM ({@code .jem}) model name for a block-entity. */
|
||||||
|
public static String cemModel(BlockEntityState s) {
|
||||||
|
return switch (s.kind()) {
|
||||||
|
case CHEST, TRAPPED_CHEST, ENDER_CHEST -> switch (s.chestKind() == null
|
||||||
|
? BlockEntityState.ChestKind.SINGLE
|
||||||
|
: s.chestKind()) {
|
||||||
|
case LEFT -> "chest_left";
|
||||||
|
case RIGHT -> "chest_right";
|
||||||
|
case SINGLE -> "chest";
|
||||||
|
};
|
||||||
|
case SIGN -> "sign";
|
||||||
|
case WALL_SIGN -> "wall_sign";
|
||||||
|
case HANGING_SIGN -> "hanging_sign";
|
||||||
|
case BANNER, WALL_BANNER -> "banner";
|
||||||
|
case BED -> "bed";
|
||||||
|
case SHULKER_BOX -> "shulker_box";
|
||||||
|
case CONDUIT -> "conduit";
|
||||||
|
case DECORATED_POT -> "decorated_pot";
|
||||||
|
case BELL -> "bell";
|
||||||
|
case HEAD, WALL_HEAD -> headModel(s.headType());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String headModel(String headType) {
|
||||||
|
if (headType == null) return "head";
|
||||||
|
return switch (headType) {
|
||||||
|
case "dragon" -> "head_dragon";
|
||||||
|
case "piglin" -> "head_piglin";
|
||||||
|
case "player" -> "head_player";
|
||||||
|
case "wither_skeleton" -> "wither_skull";
|
||||||
|
default -> "head"; // skeleton, zombie, creeper share the plain humanoid skull box
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ordered texture-path candidates; the baker uses the first that loads. */
|
||||||
|
public static List<ResourceLocation> textureCandidates(BlockEntityState s) {
|
||||||
|
List<String> paths = new ArrayList<>();
|
||||||
|
switch (s.kind()) {
|
||||||
|
case CHEST -> chestTextures(paths, s, "normal");
|
||||||
|
case TRAPPED_CHEST -> chestTextures(paths, s, "trapped");
|
||||||
|
case ENDER_CHEST -> paths.add("entity/chest/ender");
|
||||||
|
case SIGN, WALL_SIGN -> {
|
||||||
|
String wood = s.wood() == null ? "oak" : s.wood();
|
||||||
|
paths.add("entity/signs/" + wood);
|
||||||
|
paths.add("entity/sign"); // legacy single-texture fallback
|
||||||
|
}
|
||||||
|
case HANGING_SIGN -> {
|
||||||
|
String wood = s.wood() == null ? "oak" : s.wood();
|
||||||
|
paths.add("entity/signs/hanging/" + wood);
|
||||||
|
}
|
||||||
|
case BANNER, WALL_BANNER -> paths.add("entity/banner/banner_base"); // tinted by baseColorArgb
|
||||||
|
case BED -> {
|
||||||
|
String c = s.colorName() == null ? "red" : s.colorName();
|
||||||
|
paths.add("entity/bed/" + c);
|
||||||
|
}
|
||||||
|
case SHULKER_BOX -> {
|
||||||
|
if (s.colorName() != null) paths.add("entity/shulker/shulker_" + s.colorName());
|
||||||
|
paths.add("entity/shulker/shulker"); // uncoloured (purpur) default
|
||||||
|
}
|
||||||
|
case CONDUIT -> paths.add("entity/conduit/base");
|
||||||
|
case DECORATED_POT -> paths.add("entity/decorated_pot/decorated_pot_base");
|
||||||
|
case BELL -> paths.add("entity/bell/bell_body");
|
||||||
|
case HEAD, WALL_HEAD -> headTextures(paths, s.headType());
|
||||||
|
}
|
||||||
|
List<ResourceLocation> out = new ArrayList<>(paths.size());
|
||||||
|
for (String p : paths) out.add(ResourceLocation.parse(p));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void chestTextures(List<String> paths, BlockEntityState s, String base) {
|
||||||
|
BlockEntityState.ChestKind ck = s.chestKind() == null ? BlockEntityState.ChestKind.SINGLE : s.chestKind();
|
||||||
|
switch (ck) {
|
||||||
|
case LEFT -> paths.add("entity/chest/" + base + "_left");
|
||||||
|
case RIGHT -> paths.add("entity/chest/" + base + "_right");
|
||||||
|
case SINGLE -> paths.add("entity/chest/" + base);
|
||||||
|
}
|
||||||
|
paths.add("entity/chest/" + base); // fallback to the single texture
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void headTextures(List<String> paths, String headType) {
|
||||||
|
if (headType == null) {
|
||||||
|
paths.add("entity/skeleton/skeleton");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (headType) {
|
||||||
|
case "wither_skeleton" -> paths.add("entity/skeleton/wither_skeleton");
|
||||||
|
case "zombie" -> paths.add("entity/zombie/zombie");
|
||||||
|
case "creeper" -> paths.add("entity/creeper/creeper");
|
||||||
|
case "piglin" -> paths.add("entity/piglin/piglin");
|
||||||
|
case "dragon" -> paths.add("entity/enderdragon/dragon");
|
||||||
|
case "player" -> paths.add("entity/player/wide/steve"); // skin handled separately by the baker
|
||||||
|
default -> paths.add("entity/skeleton/skeleton");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable snapshot of one block-entity captured on the main thread, sufficient to bake and place it
|
||||||
|
* off-thread. Block-entities (chests, signs, banners, beds, heads, …) use vanilla {@code builtin/entity}
|
||||||
|
* block models with no geometry; their real shape lives in the bundled CEM models, so they are rendered
|
||||||
|
* through the same baking/intersection path as mobs. Bukkit-free so the baker needs no world access.
|
||||||
|
*
|
||||||
|
* <p>{@code facingDeg} is the world yaw the model should face (already converted from the block's
|
||||||
|
* facing/rotation, vanilla convention). Type-specific fields are null/0/empty when unused.
|
||||||
|
*/
|
||||||
|
public record BlockEntityState(
|
||||||
|
Kind kind,
|
||||||
|
int bx,
|
||||||
|
int by,
|
||||||
|
int bz,
|
||||||
|
float facingDeg,
|
||||||
|
ChestKind chestKind, // double-chest half (CHEST/TRAPPED_CHEST/ENDER_CHEST)
|
||||||
|
int baseColorArgb, // banner base tint (white texture); 0 = none
|
||||||
|
String colorName, // bed/shulker/banner colour variant ("red", "white", …); null = default
|
||||||
|
String wood, // sign/hanging-sign wood ("oak", "spruce", …); null = default
|
||||||
|
BedPart bedPart, // bed half
|
||||||
|
String headType, // "skeleton","wither_skeleton","zombie","creeper","piglin","dragon","player"
|
||||||
|
String skinUrl, // player-head owner skin URL; null otherwise
|
||||||
|
List<BannerPattern> patterns, // banner overlay patterns (may be empty)
|
||||||
|
List<String> sherds, // decorated-pot sherds: front/back/left/right item keys (may be empty)
|
||||||
|
BellAttach bellAttach, // bell attachment; null when not a bell
|
||||||
|
SignText frontText, // sign front-side text; null when not a sign or blank
|
||||||
|
SignText backText // sign back-side text; null when not a sign or blank
|
||||||
|
) {
|
||||||
|
public enum Kind {
|
||||||
|
CHEST,
|
||||||
|
TRAPPED_CHEST,
|
||||||
|
ENDER_CHEST,
|
||||||
|
SIGN,
|
||||||
|
WALL_SIGN,
|
||||||
|
HANGING_SIGN,
|
||||||
|
BANNER,
|
||||||
|
WALL_BANNER,
|
||||||
|
BED,
|
||||||
|
SHULKER_BOX,
|
||||||
|
HEAD,
|
||||||
|
WALL_HEAD,
|
||||||
|
CONDUIT,
|
||||||
|
DECORATED_POT,
|
||||||
|
BELL
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ChestKind {
|
||||||
|
SINGLE,
|
||||||
|
LEFT,
|
||||||
|
RIGHT
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum BedPart {
|
||||||
|
HEAD,
|
||||||
|
FOOT
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum BellAttach {
|
||||||
|
FLOOR,
|
||||||
|
CEILING,
|
||||||
|
SINGLE_WALL,
|
||||||
|
DOUBLE_WALL
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One banner overlay layer: a pattern key (e.g. "stripe_top") and the ARGB colour it is dyed with. */
|
||||||
|
public record BannerPattern(String patternKey, int colorArgb) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One side of a sign: up to four plain-text lines plus the resolved colours. {@code fillArgb} is the
|
||||||
|
* glyph fill colour (already glow/darken-adjusted); {@code outlineArgb} is the 8-directional outline
|
||||||
|
* drawn only when {@code glowing}.
|
||||||
|
*/
|
||||||
|
public record SignText(List<String> lines, int fillArgb, int outlineArgb, boolean glowing) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unwraps a {@link ModelCube} into six {@link Face}s using the standard Minecraft/Bedrock box-UV
|
||||||
|
* layout (faces packed in the canonical cross around the {@code uv} offset). Mirror swaps the
|
||||||
|
* left/right faces and flips each face horizontally.
|
||||||
|
*/
|
||||||
|
public final class BoxUv {
|
||||||
|
|
||||||
|
private BoxUv() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns Faces indexed by {@link Direction#ordinal()}. UVs are normalized by the model's DECLARED
|
||||||
|
* texel size (so a higher-res pack texture — e.g. a 128x128 sheet for a model authored at 64x64 —
|
||||||
|
* still maps proportionally, same layout). Falls back to the actual texture size if undeclared.
|
||||||
|
*/
|
||||||
|
public static Face[] build(ModelCube cube, int[][] texture, int declaredW, int declaredH) {
|
||||||
|
int texW = texture.length > 0 ? texture[0].length : 64;
|
||||||
|
int texH = texture.length > 0 ? texture.length : 64;
|
||||||
|
int nW = declaredW > 0 ? declaredW : texW;
|
||||||
|
int nH = declaredH > 0 ? declaredH : texH;
|
||||||
|
|
||||||
|
// Modern per-face UV: each face carries its own {u, v, w, h} rect directly.
|
||||||
|
if (cube.faceUv != null) {
|
||||||
|
Face[] faces = new Face[6];
|
||||||
|
for (int i = 0; i < 6; i++) {
|
||||||
|
if (cube.faceUv[i] != null) faces[i] = face(cube.faceUv[i], texture, nW, nH);
|
||||||
|
}
|
||||||
|
return faces;
|
||||||
|
}
|
||||||
|
|
||||||
|
double dx = cube.size[0], dy = cube.size[1], dz = cube.size[2];
|
||||||
|
double u = cube.uv[0], v = cube.uv[1];
|
||||||
|
|
||||||
|
// rect = {x, y, w, h} in texels, SIGNED — a negative width/height flips that axis. These match
|
||||||
|
// the OptiFine/Blockbench box-UV layout EXACTLY (up/down are flipped), paired with the (s,t) the
|
||||||
|
// EntityIntersector feeds in, so every face's texture orientation matches vanilla Java.
|
||||||
|
double[] east = {u, v + dz, dz, dy};
|
||||||
|
double[] west = {u + dz + dx, v + dz, dz, dy};
|
||||||
|
double[] north = {u + dz, v + dz, dx, dy};
|
||||||
|
double[] south = {u + 2 * dz + dx, v + dz, dx, dy};
|
||||||
|
double[] up = {u + dz + dx, v + dz, -dx, -dz};
|
||||||
|
double[] down = {u + dz + 2 * dx, v, -dx, dz};
|
||||||
|
|
||||||
|
if (cube.mirror) {
|
||||||
|
for (double[] f : new double[][] {east, west, up, down, south, north}) {
|
||||||
|
f[0] += f[2];
|
||||||
|
f[2] = -f[2];
|
||||||
|
}
|
||||||
|
double[] tmp = east;
|
||||||
|
east = west;
|
||||||
|
west = tmp; // mirror swaps the left/right faces
|
||||||
|
}
|
||||||
|
|
||||||
|
Face[] faces = new Face[6];
|
||||||
|
faces[Direction.EAST.ordinal()] = face(east, texture, nW, nH);
|
||||||
|
faces[Direction.WEST.ordinal()] = face(west, texture, nW, nH);
|
||||||
|
faces[Direction.NORTH.ordinal()] = face(north, texture, nW, nH);
|
||||||
|
faces[Direction.SOUTH.ordinal()] = face(south, texture, nW, nH);
|
||||||
|
faces[Direction.UP.ordinal()] = face(up, texture, nW, nH);
|
||||||
|
faces[Direction.DOWN.ordinal()] = face(down, texture, nW, nH);
|
||||||
|
return faces;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Face face(double[] rect, int[][] texture, int texW, int texH) {
|
||||||
|
double u1 = rect[0] / texW;
|
||||||
|
double v1 = rect[1] / texH;
|
||||||
|
double u2 = (rect[0] + rect[2]) / texW;
|
||||||
|
double v2 = (rect[1] + rect[3]) / texH;
|
||||||
|
return new Face(texture, u1, v1, u2, v2, 0, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bakes flat wall decorations (paintings, item frames) into world-space {@link EntityCube}s. These are
|
||||||
|
* not CEM models but simple textured quads, so the geometry is built directly from the captured world
|
||||||
|
* bounding box and the front-facing direction. Added to the {@link EntityScene} like any other entity.
|
||||||
|
*/
|
||||||
|
public final class DecorationBaker implements EntityBaker<DecorationState> {
|
||||||
|
|
||||||
|
private static final double MIN_THICKNESS = 0.0625; // 1px, so the slab test never degenerates
|
||||||
|
|
||||||
|
private final TextureCache textures;
|
||||||
|
|
||||||
|
public DecorationBaker(TextureCache textures) {
|
||||||
|
this.textures = textures;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RenderedEntity bake(DecorationState s) {
|
||||||
|
return s.kind() == DecorationState.Kind.PAINTING ? bakePainting(s) : bakeItemFrame(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RenderedEntity bakePainting(DecorationState s) {
|
||||||
|
int[][] art = textures.get(ResourceLocation.parse("painting/" + s.paintingArt()))
|
||||||
|
.orElse(null);
|
||||||
|
if (art == null) return null;
|
||||||
|
int[][] back = textures.get(ResourceLocation.parse("painting/back")).orElse(art);
|
||||||
|
|
||||||
|
double[] from = {s.minX(), s.minY(), s.minZ()};
|
||||||
|
double[] to = {s.maxX(), s.maxY(), s.maxZ()};
|
||||||
|
thicken(from, to, s.facing());
|
||||||
|
|
||||||
|
Face[] faces = new Face[6];
|
||||||
|
Direction front = direction(s.facing());
|
||||||
|
faces[front.ordinal()] = frontFace(art, s.facing());
|
||||||
|
faces[opposite(front).ordinal()] = new Face(back, 0, 0, 1, 1, 0, -1);
|
||||||
|
EntityCube cube = new EntityCube(from, to, faces, Affine.identity());
|
||||||
|
return RenderedEntity.of(List.of(cube));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Item frames render as the vanilla geometry: a 12x12 birch border, a recessed 10x10 leather back
|
||||||
|
* panel ({@code block/item_frame}), and the held item as a centred 8x8 sprite. Built in a canonical
|
||||||
|
* local frame (front toward +Z, centred on the block face) and oriented to the wall by an affine.
|
||||||
|
*/
|
||||||
|
private RenderedEntity bakeItemFrame(DecorationState s) {
|
||||||
|
String frameTex = s.glow() ? "block/glow_item_frame" : "block/item_frame";
|
||||||
|
int[][] leather = textures.get(ResourceLocation.parse(frameTex)).orElse(null);
|
||||||
|
int[][] wood =
|
||||||
|
textures.get(ResourceLocation.parse("block/birch_planks")).orElse(leather);
|
||||||
|
if (leather == null) return null;
|
||||||
|
|
||||||
|
Affine toWorld = Affine.translation(faceCenterX(s), faceCenterY(s), faceCenterZ(s))
|
||||||
|
.mul(facingRotation(s.facing()));
|
||||||
|
int si = Direction.SOUTH.ordinal();
|
||||||
|
|
||||||
|
List<EntityCube> cubes = new ArrayList<>(3);
|
||||||
|
// Wood border 12x12 (behind), leather back 10x10 (1px proud), item 8x8 (in front). Front = local +Z.
|
||||||
|
// `wood` is never null here (orElse(leather) and leather is non-null past the guard above).
|
||||||
|
Face[] woodFace = new Face[6];
|
||||||
|
woodFace[si] = new Face(wood, 1, 0, 0, 1, 0, -1); // tileable; flip matches the others
|
||||||
|
cubes.add(new EntityCube(px(-6, -6, 0), px(6, 6, 1), woodFace, toWorld));
|
||||||
|
Face[] back = new Face[6];
|
||||||
|
back[si] = new Face(leather, 13.0 / 16, 3.0 / 16, 3.0 / 16, 13.0 / 16, 0, -1); // centre 10x10, flipped
|
||||||
|
cubes.add(new EntityCube(px(-5, -5, 1), px(5, 5, 2), back, toWorld));
|
||||||
|
|
||||||
|
if (s.itemId() != null) {
|
||||||
|
int[][] item = resolveItem(s.itemId());
|
||||||
|
if (item != null) {
|
||||||
|
int rot = ((Math.round(s.itemRotationDeg() / 90f) * 90) % 360 + 360) % 360;
|
||||||
|
Face[] f = new Face[6];
|
||||||
|
f[si] = new Face(item, 1, 0, 0, 1, rot, -1); // full sprite, flipped to read upright
|
||||||
|
cubes.add(new EntityCube(px(-4, -4, 2), px(4, 4, 3), f, toWorld));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return RenderedEntity.of(cubes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A local-space corner in model pixels (1/16 block); z is the outward (front) offset from the wall. */
|
||||||
|
private static double[] px(double x, double y, double z) {
|
||||||
|
return new double[] {x / 16.0, y / 16.0, z / 16.0};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Item sprite: generated items live under item/, block items fall back to block/. */
|
||||||
|
private int[][] resolveItem(String id) {
|
||||||
|
int[][] t = textures.get(ResourceLocation.parse("item/" + id)).orElse(null);
|
||||||
|
if (t != null) return t;
|
||||||
|
return textures.get(ResourceLocation.parse("block/" + id)).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frame placement: in-plane centre from the bbox, depth at the outward wall surface.
|
||||||
|
private static double faceCenterX(DecorationState s) {
|
||||||
|
return s.facing().axis() == 0 ? (s.facing().sign() > 0 ? s.maxX() : s.minX()) : (s.minX() + s.maxX()) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double faceCenterY(DecorationState s) {
|
||||||
|
return s.facing().axis() == 1 ? (s.facing().sign() > 0 ? s.maxY() : s.minY()) : (s.minY() + s.maxY()) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double faceCenterZ(DecorationState s) {
|
||||||
|
return s.facing().axis() == 2 ? (s.facing().sign() > 0 ? s.maxZ() : s.minZ()) : (s.minZ() + s.maxZ()) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps the canonical local frame (front +Z, up +Y) onto the wall facing the given direction. */
|
||||||
|
private static Affine facingRotation(DecorationState.Facing f) {
|
||||||
|
return switch (f) {
|
||||||
|
case SOUTH -> Affine.identity();
|
||||||
|
case NORTH -> Affine.rotY(Math.PI);
|
||||||
|
case EAST -> Affine.rotY(Math.PI / 2);
|
||||||
|
case WEST -> Affine.rotY(-Math.PI / 2);
|
||||||
|
case UP -> Affine.rotX(-Math.PI / 2);
|
||||||
|
case DOWN -> Affine.rotX(Math.PI / 2);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Front face with the per-facing horizontal flip so the texture reads upright and unmirrored. */
|
||||||
|
private Face frontFace(int[][] tex, DecorationState.Facing facing) {
|
||||||
|
boolean flipH = facing == DecorationState.Facing.SOUTH || facing == DecorationState.Facing.WEST;
|
||||||
|
double u1 = flipH ? 1 : 0, u2 = flipH ? 0 : 1;
|
||||||
|
return new Face(tex, u1, 0, u2, 1, 0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void thicken(double[] from, double[] to, DecorationState.Facing facing) {
|
||||||
|
int a = facing.axis();
|
||||||
|
if (to[a] - from[a] >= MIN_THICKNESS) return;
|
||||||
|
if (facing.sign() > 0) from[a] = to[a] - MIN_THICKNESS;
|
||||||
|
else to[a] = from[a] + MIN_THICKNESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Direction direction(DecorationState.Facing f) {
|
||||||
|
return switch (f) {
|
||||||
|
case NORTH -> Direction.NORTH;
|
||||||
|
case SOUTH -> Direction.SOUTH;
|
||||||
|
case EAST -> Direction.EAST;
|
||||||
|
case WEST -> Direction.WEST;
|
||||||
|
case UP -> Direction.UP;
|
||||||
|
case DOWN -> Direction.DOWN;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Direction opposite(Direction d) {
|
||||||
|
return switch (d) {
|
||||||
|
case NORTH -> Direction.SOUTH;
|
||||||
|
case SOUTH -> Direction.NORTH;
|
||||||
|
case EAST -> Direction.WEST;
|
||||||
|
case WEST -> Direction.EAST;
|
||||||
|
case UP -> Direction.DOWN;
|
||||||
|
case DOWN -> Direction.UP;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable snapshot of a flat wall decoration (painting or item frame). These are Bukkit
|
||||||
|
* {@code Hanging} entities, not block-entities, but render as flat textured quads rather than CEM
|
||||||
|
* models, so they have their own state/baker. The world-space bounding box is captured directly (it
|
||||||
|
* already encodes vanilla's awkward multi-block painting offset), avoiding placement math.
|
||||||
|
*/
|
||||||
|
public record DecorationState(
|
||||||
|
Kind kind,
|
||||||
|
double minX,
|
||||||
|
double minY,
|
||||||
|
double minZ,
|
||||||
|
double maxX,
|
||||||
|
double maxY,
|
||||||
|
double maxZ,
|
||||||
|
Facing facing, // direction the front faces (away from the wall)
|
||||||
|
String paintingArt, // painting art asset key (texture painting/<art>); null for frames
|
||||||
|
String itemId, // item-frame contents material key (e.g. "diamond"); null if empty
|
||||||
|
int itemRotationDeg, // item-frame content rotation (0/45/…/315)
|
||||||
|
boolean glow // glow item frame
|
||||||
|
) {
|
||||||
|
public enum Kind {
|
||||||
|
PAINTING,
|
||||||
|
ITEM_FRAME
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Facing {
|
||||||
|
NORTH,
|
||||||
|
SOUTH,
|
||||||
|
EAST,
|
||||||
|
WEST,
|
||||||
|
UP,
|
||||||
|
DOWN;
|
||||||
|
|
||||||
|
/** The axis this face's normal lies on: 0=x, 1=y, 2=z. */
|
||||||
|
public int axis() {
|
||||||
|
return switch (this) {
|
||||||
|
case EAST, WEST -> 0;
|
||||||
|
case UP, DOWN -> 1;
|
||||||
|
case NORTH, SOUTH -> 2;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** +1 or -1 along {@link #axis()}. */
|
||||||
|
public int sign() {
|
||||||
|
return switch (this) {
|
||||||
|
case SOUTH, UP, EAST -> 1;
|
||||||
|
case NORTH, DOWN, WEST -> -1;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bakes a captured state {@code S} (mob, block-entity or decoration) into a {@link RenderedEntity},
|
||||||
|
* or returns {@code null} when it has no renderable geometry. Lets {@link EntityScene} treat all three
|
||||||
|
* render paths uniformly.
|
||||||
|
*/
|
||||||
|
public interface EntityBaker<S> {
|
||||||
|
RenderedEntity bake(S state);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A baked entity cube in world space: a local box (model pixels) plus the affine transform mapping it
|
||||||
|
* into the world, its six faces, the precomputed inverse transform and a world-space AABB for
|
||||||
|
* broad-phase culling.
|
||||||
|
*/
|
||||||
|
public final class EntityCube {
|
||||||
|
public final double[] from; // local min (px, inflated)
|
||||||
|
public final double[] to; // local max
|
||||||
|
public final Face[] faces; // by Direction.ordinal()
|
||||||
|
public final Affine toWorld;
|
||||||
|
public final Affine toLocal; // inverse
|
||||||
|
public final double[] aabbMin = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE};
|
||||||
|
public final double[] aabbMax = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE};
|
||||||
|
|
||||||
|
public EntityCube(double[] from, double[] to, Face[] faces, Affine toWorld) {
|
||||||
|
this.from = from;
|
||||||
|
this.to = to;
|
||||||
|
this.faces = faces;
|
||||||
|
this.toWorld = toWorld;
|
||||||
|
this.toLocal = toWorld.inverse();
|
||||||
|
for (int i = 0; i < 8; i++) {
|
||||||
|
double x = (i & 1) == 0 ? from[0] : to[0];
|
||||||
|
double y = (i & 2) == 0 ? from[1] : to[1];
|
||||||
|
double z = (i & 4) == 0 ? from[2] : to[2];
|
||||||
|
double[] w = toWorld.apply(x, y, z);
|
||||||
|
for (int a = 0; a < 3; a++) {
|
||||||
|
if (w[a] < aabbMin[a]) aabbMin[a] = w[a];
|
||||||
|
if (w[a] > aabbMax[a]) aabbMax[a] = w[a];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.raytrace.FaceHit;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
|
||||||
|
import org.bukkit.util.Vector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intersects a world-space ray with a single {@link EntityCube} (oriented box). The ray is mapped
|
||||||
|
* into the cube's local frame, slab-tested, and the entry face is sampled. Fully transparent texels
|
||||||
|
* are treated as holes (alpha cutout). The returned {@code t} is a world-space distance.
|
||||||
|
*/
|
||||||
|
public final class EntityIntersector {
|
||||||
|
|
||||||
|
private static final double EPS = 1e-7;
|
||||||
|
private static final int ALPHA_THRESHOLD = 16;
|
||||||
|
|
||||||
|
private EntityIntersector() {}
|
||||||
|
|
||||||
|
public static FaceHit intersect(EntityCube cube, double ox, double oy, double oz, double dx, double dy, double dz) {
|
||||||
|
double[] o = cube.toLocal.apply(ox, oy, oz);
|
||||||
|
double[] d = cube.toLocal.applyLinear(dx, dy, dz);
|
||||||
|
|
||||||
|
double tmin = Double.NEGATIVE_INFINITY, tmax = Double.POSITIVE_INFINITY;
|
||||||
|
int axis = -1;
|
||||||
|
boolean neg = false;
|
||||||
|
for (int a = 0; a < 3; a++) {
|
||||||
|
if (Math.abs(d[a]) < EPS) {
|
||||||
|
if (o[a] < cube.from[a] - EPS || o[a] > cube.to[a] + EPS) return null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
double inv = 1.0 / d[a];
|
||||||
|
double t1 = (cube.from[a] - o[a]) * inv;
|
||||||
|
double t2 = (cube.to[a] - o[a]) * inv;
|
||||||
|
boolean n = true;
|
||||||
|
if (t1 > t2) {
|
||||||
|
double tmp = t1;
|
||||||
|
t1 = t2;
|
||||||
|
t2 = tmp;
|
||||||
|
n = false;
|
||||||
|
}
|
||||||
|
if (t1 > tmin) {
|
||||||
|
tmin = t1;
|
||||||
|
axis = a;
|
||||||
|
neg = n;
|
||||||
|
}
|
||||||
|
if (t2 < tmax) tmax = t2;
|
||||||
|
if (tmin > tmax) return null;
|
||||||
|
}
|
||||||
|
if (axis < 0) return null;
|
||||||
|
double t = tmin;
|
||||||
|
if (t < EPS) {
|
||||||
|
t = tmax;
|
||||||
|
if (t < EPS) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
double px = o[0] + d[0] * t, py = o[1] + d[1] * t, pz = o[2] + d[2] * t;
|
||||||
|
Direction dir =
|
||||||
|
switch (axis) {
|
||||||
|
case 0 -> neg ? Direction.WEST : Direction.EAST;
|
||||||
|
case 1 -> neg ? Direction.DOWN : Direction.UP;
|
||||||
|
default -> neg ? Direction.NORTH : Direction.SOUTH;
|
||||||
|
};
|
||||||
|
Face face = cube.faces[dir.ordinal()];
|
||||||
|
if (face == null) return null;
|
||||||
|
|
||||||
|
double fx = frac(px, cube.from[0], cube.to[0]);
|
||||||
|
double fy = frac(py, cube.from[1], cube.to[1]);
|
||||||
|
double fz = frac(pz, cube.from[2], cube.to[2]);
|
||||||
|
// (s,t) = Blockbench/Java box-UV (lerp_x, lerp_y) for this face (see BoxUv). Front/right faces
|
||||||
|
// run their horizontal axis opposite to back/left (they're viewed from the other side).
|
||||||
|
double s, tt;
|
||||||
|
switch (dir) {
|
||||||
|
case UP -> {
|
||||||
|
s = fx;
|
||||||
|
tt = fz;
|
||||||
|
}
|
||||||
|
case DOWN -> {
|
||||||
|
s = fx;
|
||||||
|
tt = 1 - fz;
|
||||||
|
}
|
||||||
|
case NORTH -> {
|
||||||
|
s = 1 - fx;
|
||||||
|
tt = 1 - fy;
|
||||||
|
}
|
||||||
|
case SOUTH -> {
|
||||||
|
s = fx;
|
||||||
|
tt = 1 - fy;
|
||||||
|
}
|
||||||
|
case EAST -> {
|
||||||
|
s = 1 - fz;
|
||||||
|
tt = 1 - fy;
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
s = fz;
|
||||||
|
tt = 1 - fy;
|
||||||
|
} // WEST
|
||||||
|
}
|
||||||
|
int color = face.sample(s, tt);
|
||||||
|
if (ColorUtil.alpha(color) <= ALPHA_THRESHOLD) return null;
|
||||||
|
|
||||||
|
Vector world = new Vector(ox + dx * t, oy + dy * t, oz + dz * t);
|
||||||
|
double[] n = cube.toWorld.applyLinear(dir.nx, dir.ny, dir.nz);
|
||||||
|
Vector normal = new Vector(n[0], n[1], n[2]).normalize();
|
||||||
|
return new FaceHit(t, world, normal, color, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double frac(double v, double lo, double hi) {
|
||||||
|
double span = hi - lo;
|
||||||
|
if (span < 1e-6) return 0;
|
||||||
|
double f = (v - lo) / span;
|
||||||
|
return f < 0 ? 0 : Math.min(f, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps an entity type to its CEM ({@code .jem}) Java model name and candidate texture paths. Most types
|
||||||
|
* use the type key directly for both; small override maps handle the exceptions. The models are vanilla
|
||||||
|
* Java models (already posed); variant-specific textures (cow/sheep colour, etc.) are handled here.
|
||||||
|
*/
|
||||||
|
public final class EntityModels {
|
||||||
|
|
||||||
|
private EntityModels() {}
|
||||||
|
|
||||||
|
// Type key -> CEM (.jem) model name. Default is the type key itself; these are the exceptions
|
||||||
|
// (mob reuses another mob's model, or the CEM set only ships a version-suffixed/renamed name).
|
||||||
|
private static final Map<String, String> CEM_OVERRIDE = Map.ofEntries(
|
||||||
|
Map.entry("husk", "zombie"),
|
||||||
|
Map.entry("giant", "zombie"),
|
||||||
|
Map.entry("mooshroom", "cow"),
|
||||||
|
Map.entry("ocelot", "cat"),
|
||||||
|
Map.entry("cave_spider", "spider"),
|
||||||
|
Map.entry("elder_guardian", "guardian"),
|
||||||
|
Map.entry("glow_squid", "squid"),
|
||||||
|
Map.entry("mule", "donkey"),
|
||||||
|
Map.entry("skeleton_horse", "horse"),
|
||||||
|
Map.entry("zombie_horse", "horse"),
|
||||||
|
Map.entry("trader_llama", "llama"),
|
||||||
|
Map.entry("stray", "skeleton"),
|
||||||
|
Map.entry("wither_skeleton", "skeleton"),
|
||||||
|
Map.entry("zoglin", "hoglin"),
|
||||||
|
Map.entry("piglin_brute", "piglin"),
|
||||||
|
Map.entry("zombified_piglin", "piglin"),
|
||||||
|
Map.entry("evoker", "illager"),
|
||||||
|
Map.entry("vindicator", "illager"),
|
||||||
|
Map.entry("illusioner", "illager"),
|
||||||
|
Map.entry("wandering_trader", "villager"),
|
||||||
|
Map.entry("ender_dragon", "dragon"),
|
||||||
|
Map.entry("mannequin", "player"),
|
||||||
|
Map.entry("camel_husk", "camel"),
|
||||||
|
Map.entry("rabbit", "rabbit_21.11"),
|
||||||
|
Map.entry("pufferfish", "puffer_fish_big"),
|
||||||
|
Map.entry("tropical_fish", "tropical_fish_a"));
|
||||||
|
|
||||||
|
/** The CEM model name for an entity type (boats/rafts share the boat hull). */
|
||||||
|
public static String cemModel(String typeKey) {
|
||||||
|
if (typeKey.endsWith("_boat") || typeKey.endsWith("_raft")) return "boat";
|
||||||
|
return CEM_OVERRIDE.getOrDefault(typeKey, typeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type key -> texture path override (where the first derived candidate is wrong).
|
||||||
|
private static final Map<String, String> TEX_OVERRIDE = Map.<String, String>ofEntries(
|
||||||
|
Map.entry("cow", "entity/cow/cow_temperate"),
|
||||||
|
Map.entry("mooshroom", "entity/cow/mooshroom_red"),
|
||||||
|
Map.entry("zombie", "entity/zombie/zombie"),
|
||||||
|
Map.entry("husk", "entity/zombie/husk"),
|
||||||
|
Map.entry("drowned", "entity/zombie/drowned"),
|
||||||
|
Map.entry("zombified_piglin", "entity/piglin/zombified_piglin"),
|
||||||
|
Map.entry("skeleton", "entity/skeleton/skeleton"),
|
||||||
|
Map.entry("stray", "entity/skeleton/stray"),
|
||||||
|
Map.entry("wither_skeleton", "entity/skeleton/wither_skeleton"),
|
||||||
|
Map.entry("creeper", "entity/creeper/creeper"),
|
||||||
|
Map.entry("spider", "entity/spider/spider"),
|
||||||
|
Map.entry("enderman", "entity/enderman/enderman"),
|
||||||
|
Map.entry("player", "entity/player/wide/steve"),
|
||||||
|
// Textures whose folder/name doesn't follow the "entity/<key>/<key>" pattern.
|
||||||
|
Map.entry("iron_golem", "entity/iron_golem/iron_golem"),
|
||||||
|
Map.entry("polar_bear", "entity/bear/polarbear"),
|
||||||
|
Map.entry("ender_dragon", "entity/enderdragon/dragon"),
|
||||||
|
Map.entry("magma_cube", "entity/slime/magmacube"),
|
||||||
|
Map.entry("tropical_fish", "entity/fish/tropical_a"),
|
||||||
|
Map.entry("bogged", "entity/skeleton/bogged"),
|
||||||
|
Map.entry("donkey", "entity/horse/donkey"),
|
||||||
|
Map.entry("mule", "entity/horse/mule"),
|
||||||
|
Map.entry("skeleton_horse", "entity/horse/horse_skeleton"),
|
||||||
|
Map.entry("zombie_horse", "entity/horse/horse_zombie"),
|
||||||
|
Map.entry("trader_llama", "entity/llama/llama_creamy"),
|
||||||
|
Map.entry("cave_spider", "entity/spider/cave_spider"),
|
||||||
|
Map.entry("guardian", "entity/guardian/guardian"),
|
||||||
|
Map.entry("elder_guardian", "entity/guardian/guardian_elder"),
|
||||||
|
Map.entry("piglin_brute", "entity/piglin/piglin_brute"),
|
||||||
|
Map.entry("zoglin", "entity/hoglin/zoglin"),
|
||||||
|
Map.entry("illusioner", "entity/illager/illusioner"),
|
||||||
|
Map.entry("giant", "entity/zombie/zombie"),
|
||||||
|
// Illagers share one texture folder; none follow the entity/<key>/<key> pattern.
|
||||||
|
Map.entry("pillager", "entity/illager/pillager"),
|
||||||
|
Map.entry("vindicator", "entity/illager/vindicator"),
|
||||||
|
Map.entry("evoker", "entity/illager/evoker"),
|
||||||
|
Map.entry("ravager", "entity/illager/ravager"),
|
||||||
|
Map.entry("vex", "entity/illager/vex"),
|
||||||
|
// Fish share entity/fish/; squids share entity/squid/.
|
||||||
|
Map.entry("cod", "entity/fish/cod"),
|
||||||
|
Map.entry("salmon", "entity/fish/salmon"),
|
||||||
|
Map.entry("pufferfish", "entity/fish/pufferfish"),
|
||||||
|
Map.entry("glow_squid", "entity/squid/glow_squid"),
|
||||||
|
// Variant-only textures with no plain base file — pick a sensible default variant.
|
||||||
|
Map.entry("cat", "entity/cat/cat_tabby"),
|
||||||
|
Map.entry("ocelot", "entity/cat/ocelot"), // ocelot texture lives in the cat folder now
|
||||||
|
Map.entry("axolotl", "entity/axolotl/axolotl_wild"),
|
||||||
|
Map.entry("parrot", "entity/parrot/parrot_red_blue"),
|
||||||
|
Map.entry("turtle", "entity/turtle/turtle"),
|
||||||
|
Map.entry("wind_charge", "entity/projectiles/wind_charge"),
|
||||||
|
Map.entry("camel_husk", "entity/camel/camel_husk"),
|
||||||
|
Map.entry("armor_stand", "entity/armorstand/armorstand"), // texture folder is "armorstand"
|
||||||
|
Map.entry("happy_ghast", "entity/ghast/happy_ghast"),
|
||||||
|
Map.entry("parched", "entity/skeleton/parched"), // husk-style skeleton, texture in skeleton/
|
||||||
|
Map.entry("zombie_nautilus_coral", "entity/nautilus/zombie_nautilus_coral"),
|
||||||
|
Map.entry("mannequin", "entity/player/wide/steve"));
|
||||||
|
|
||||||
|
/** Ordered texture-path candidates; the baker uses the first that loads. */
|
||||||
|
public static List<ResourceLocation> textureCandidates(String typeKey, String variant) {
|
||||||
|
List<ResourceLocation> list = new ArrayList<>();
|
||||||
|
if (typeKey.endsWith("_boat")) {
|
||||||
|
String wood = typeKey.substring(0, typeKey.length() - "_boat".length());
|
||||||
|
if (wood.endsWith("_chest")) wood = wood.substring(0, wood.length() - "_chest".length());
|
||||||
|
list.add(ResourceLocation.parse("entity/boat/" + wood));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
if (variant != null) {
|
||||||
|
for (String p : variantPaths(typeKey, variant)) list.add(ResourceLocation.parse(p));
|
||||||
|
}
|
||||||
|
String override = TEX_OVERRIDE.get(typeKey);
|
||||||
|
if (override != null) list.add(ResourceLocation.parse(override));
|
||||||
|
list.add(ResourceLocation.parse("entity/" + typeKey + "/temperate_" + typeKey)); // legacy 1.21 default
|
||||||
|
list.add(ResourceLocation.parse("entity/" + typeKey + "/" + typeKey));
|
||||||
|
list.add(ResourceLocation.parse("entity/" + typeKey));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant-specific texture paths (modern pack naming is "entity/<folder>/<entity>_<variant>", with a
|
||||||
|
* handful of mismatches the small maps below normalise). Returned paths are tried before the generic
|
||||||
|
* fallbacks, so an unknown variant still degrades to the base texture.
|
||||||
|
*/
|
||||||
|
private static List<String> variantPaths(String typeKey, String v) {
|
||||||
|
return switch (typeKey) {
|
||||||
|
case "cat" -> List.of("entity/cat/cat_" + v);
|
||||||
|
case "axolotl" -> List.of("entity/axolotl/axolotl_" + v);
|
||||||
|
case "wolf" -> List.of("entity/wolf/wolf_" + v, "entity/wolf/wolf");
|
||||||
|
case "horse" -> List.of("entity/horse/horse_" + HORSE_COLOR.getOrDefault(v, v));
|
||||||
|
case "llama" -> List.of("entity/llama/llama_" + v);
|
||||||
|
case "cow" -> List.of("entity/cow/cow_" + v);
|
||||||
|
case "pig" -> List.of("entity/pig/pig_" + v);
|
||||||
|
case "chicken" -> List.of("entity/chicken/chicken_" + v);
|
||||||
|
case "frog" -> List.of("entity/frog/frog_" + v);
|
||||||
|
case "panda" -> List.of(v.equals("normal") ? "entity/panda/panda" : "entity/panda/panda_" + v);
|
||||||
|
case "fox" -> List.of(v.equals("snow") ? "entity/fox/fox_snow" : "entity/fox/fox");
|
||||||
|
case "parrot" -> List.of("entity/parrot/parrot_" + PARROT_COLOR.getOrDefault(v, v));
|
||||||
|
case "rabbit" -> List.of("entity/rabbit/rabbit_" + RABBIT_TYPE.getOrDefault(v, v));
|
||||||
|
case "mooshroom" -> List.of("entity/cow/mooshroom_" + v);
|
||||||
|
case "shulker" -> List.of("entity/shulker/shulker_" + v);
|
||||||
|
// 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 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,87 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.raytrace.FaceHit;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All baked entities (mobs + block-entities) for one render. Provides the nearest hit along a ray,
|
||||||
|
* using per-entity and per-cube AABB broad-phase culling. Block-entities are baked into the same list
|
||||||
|
* and tested identically, so the raytracer needs no special case. Immutable after construction → safe
|
||||||
|
* for the parallel tracer.
|
||||||
|
*/
|
||||||
|
public final class EntityScene {
|
||||||
|
|
||||||
|
private static final double EPS = 1e-7;
|
||||||
|
private final List<RenderedEntity> entities;
|
||||||
|
|
||||||
|
public EntityScene(
|
||||||
|
List<EntityState> states,
|
||||||
|
CemBaker baker,
|
||||||
|
List<BlockEntityState> blockEntities,
|
||||||
|
BlockEntityBaker beBaker,
|
||||||
|
List<DecorationState> decorations,
|
||||||
|
DecorationBaker decoBaker) {
|
||||||
|
this.entities = new ArrayList<>(states.size() + blockEntities.size() + decorations.size());
|
||||||
|
addAll(states, baker);
|
||||||
|
addAll(blockEntities, beBaker);
|
||||||
|
addAll(decorations, decoBaker);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bakes each state and keeps the ones with geometry. A null baker (path disabled) is skipped. */
|
||||||
|
private <S> void addAll(List<S> states, EntityBaker<S> baker) {
|
||||||
|
if (baker == null) return;
|
||||||
|
for (S s : states) {
|
||||||
|
RenderedEntity e = baker.bake(s);
|
||||||
|
if (e != null && !e.cubes.isEmpty()) entities.add(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return entities.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nearest entity hit with {@code t < maxT}, or null. */
|
||||||
|
public FaceHit nearestHit(double ox, double oy, double oz, double dx, double dy, double dz, double maxT) {
|
||||||
|
FaceHit best = null;
|
||||||
|
double bestT = maxT;
|
||||||
|
for (RenderedEntity e : entities) {
|
||||||
|
if (!rayAabb(e.aabbMin, e.aabbMax, ox, oy, oz, dx, dy, dz, bestT)) continue;
|
||||||
|
for (EntityCube cube : e.cubes) {
|
||||||
|
if (!rayAabb(cube.aabbMin, cube.aabbMax, ox, oy, oz, dx, dy, dz, bestT)) continue;
|
||||||
|
FaceHit hit = EntityIntersector.intersect(cube, ox, oy, oz, dx, dy, dz);
|
||||||
|
if (hit != null && hit.t() > EPS && hit.t() < bestT) {
|
||||||
|
best = hit;
|
||||||
|
bestT = hit.t();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean rayAabb(
|
||||||
|
double[] min, double[] max, double ox, double oy, double oz, double dx, double dy, double dz, double maxT) {
|
||||||
|
double tmin = 0, tmax = maxT;
|
||||||
|
double[] o = {ox, oy, oz}, d = {dx, dy, dz};
|
||||||
|
for (int a = 0; a < 3; a++) {
|
||||||
|
if (Math.abs(d[a]) < EPS) {
|
||||||
|
if (o[a] < min[a] || o[a] > max[a]) return false;
|
||||||
|
} else {
|
||||||
|
double inv = 1.0 / d[a];
|
||||||
|
double t1 = (min[a] - o[a]) * inv;
|
||||||
|
double t2 = (max[a] - o[a]) * inv;
|
||||||
|
if (t1 > t2) {
|
||||||
|
double tmp = t1;
|
||||||
|
t1 = t2;
|
||||||
|
t2 = tmp;
|
||||||
|
}
|
||||||
|
if (t1 > tmin) tmin = t1;
|
||||||
|
if (t2 < tmax) tmax = t2;
|
||||||
|
if (tmin > tmax) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable snapshot of one entity captured on the main thread, sufficient to bake and pose it
|
||||||
|
* off-thread. Angles are in degrees (Minecraft convention).
|
||||||
|
*/
|
||||||
|
public record EntityState(
|
||||||
|
String typeKey, // e.g. "cow", "zombie", "player"
|
||||||
|
double x,
|
||||||
|
double y,
|
||||||
|
double z,
|
||||||
|
float bodyYaw,
|
||||||
|
boolean baby,
|
||||||
|
double width,
|
||||||
|
double height,
|
||||||
|
boolean player,
|
||||||
|
String skinUrl,
|
||||||
|
boolean slim,
|
||||||
|
String variant, // texture-selecting variant key (e.g. "ashen", "warm", "tabby"); for villagers the biome type,
|
||||||
|
// or null
|
||||||
|
int tint, // ARGB multiplier for tintable layers (sheep wool); 0 = none
|
||||||
|
double sizeScale, // extra model scale (slime/magma-cube size); 1.0 = default
|
||||||
|
String profession, // villager profession key (e.g. "farmer", "librarian", "none"), or null
|
||||||
|
int villagerLevel, // villager profession level 1-5 (badge tier); 0 = none/unknown
|
||||||
|
String markings, // horse coat markings style (e.g. "white", "whitefield", "whitedots", "blackdots"), or null
|
||||||
|
boolean saddle, // horse/donkey/mule is saddled
|
||||||
|
boolean chest, // donkey/mule/llama is carrying a chest
|
||||||
|
String bodyEquip, // horse armor material (iron/gold/diamond/leather) OR llama carpet colour OR "trader_llama";
|
||||||
|
// null = none
|
||||||
|
Equipment equipment, // worn humanoid armor (players, armor stands, zombies, …); null = none/not a wearer
|
||||||
|
boolean invisible // entity is invisible -> render only its equipment (like vanilla), not the body
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** 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,28 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single box of an entity model, in Bedrock model-pixel coordinates (16 px = 1 block).
|
||||||
|
* {@code origin} is the minimum corner, {@code uv} is the box-UV texture offset.
|
||||||
|
*/
|
||||||
|
public final class ModelCube {
|
||||||
|
public final double[] origin; // 3, min corner (px)
|
||||||
|
public final double[] size; // 3 (px)
|
||||||
|
public final double inflate; // px, expands the box on all sides (overlay layers)
|
||||||
|
public final double[] uv; // 2, box-UV offset (texels)
|
||||||
|
public final boolean mirror;
|
||||||
|
/** Optional modern per-face UV, indexed by {@code Direction.ordinal()}: {u, v, w, h} texels (h/w may be negative for flips). Null = use box-UV. */
|
||||||
|
public final double[][] faceUv;
|
||||||
|
|
||||||
|
public ModelCube(double[] origin, double[] size, double inflate, double[] uv, boolean mirror) {
|
||||||
|
this(origin, size, inflate, uv, mirror, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ModelCube(double[] origin, double[] size, double inflate, double[] uv, boolean mirror, double[][] faceUv) {
|
||||||
|
this.origin = origin;
|
||||||
|
this.size = size;
|
||||||
|
this.inflate = inflate;
|
||||||
|
this.uv = uv;
|
||||||
|
this.mirror = mirror;
|
||||||
|
this.faceUv = faceUv;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** A baked entity: its world-space cubes and overall AABB (for broad-phase culling). */
|
||||||
|
public final class RenderedEntity {
|
||||||
|
public final List<EntityCube> cubes;
|
||||||
|
public final double[] aabbMin;
|
||||||
|
public final double[] aabbMax;
|
||||||
|
|
||||||
|
public RenderedEntity(List<EntityCube> cubes, double[] aabbMin, double[] aabbMax) {
|
||||||
|
this.cubes = cubes;
|
||||||
|
this.aabbMin = aabbMin;
|
||||||
|
this.aabbMax = aabbMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wraps baked world-space cubes, computing the overall broad-phase AABB from their per-cube AABBs. */
|
||||||
|
public static RenderedEntity of(List<EntityCube> cubes) {
|
||||||
|
double[] min = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE};
|
||||||
|
double[] max = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE};
|
||||||
|
for (EntityCube c : cubes) {
|
||||||
|
for (int a = 0; a < 3; a++) {
|
||||||
|
if (c.aabbMin[a] < min[a]) min[a] = c.aabbMin[a];
|
||||||
|
if (c.aabbMax[a] > max[a]) max[a] = c.aabbMax[a];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new RenderedEntity(cubes, min, max);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-place ARGB pixel-grid compositing shared by the entity/block-entity bakers: deep copy, tint and
|
||||||
|
* src-over alpha overlay. Grids are indexed {@code [y][x]}; all operations assume the ARGB layout used
|
||||||
|
* throughout the renderer.
|
||||||
|
*/
|
||||||
|
public final class TextureOps {
|
||||||
|
|
||||||
|
private TextureOps() {}
|
||||||
|
|
||||||
|
/** A row-by-row copy so callers can tint/overlay without mutating the cached source texture. */
|
||||||
|
public static int[][] deepCopy(int[][] src) {
|
||||||
|
int[][] out = new int[src.length][];
|
||||||
|
for (int y = 0; y < src.length; y++) out[y] = src[y].clone();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multiplies every non-transparent texel by an ARGB tint (RGB channels only), in place. */
|
||||||
|
public static void tint(int[][] tex, int argb) {
|
||||||
|
int tr = (argb >> 16) & 0xFF, tg = (argb >> 8) & 0xFF, tb = argb & 0xFF;
|
||||||
|
for (int[] row : tex) {
|
||||||
|
for (int x = 0; x < row.length; x++) {
|
||||||
|
int p = row[x];
|
||||||
|
int a = (p >>> 24) & 0xFF;
|
||||||
|
if (a == 0) continue;
|
||||||
|
int r = ((p >> 16) & 0xFF) * tr / 255, g = ((p >> 8) & 0xFF) * tg / 255, b = (p & 0xFF) * tb / 255;
|
||||||
|
row[x] = (a << 24) | (r << 16) | (g << 8) | b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Standard src-over alpha composite of {@code src} onto {@code dst} (clipped to the overlap), in place. */
|
||||||
|
public static void overlay(int[][] dst, int[][] src) {
|
||||||
|
int h = Math.min(dst.length, src.length);
|
||||||
|
for (int y = 0; y < h; y++) {
|
||||||
|
int w = Math.min(dst[y].length, src[y].length);
|
||||||
|
for (int x = 0; x < w; x++) {
|
||||||
|
int sp = src[y][x];
|
||||||
|
int sa = (sp >>> 24) & 0xFF;
|
||||||
|
if (sa == 0) continue;
|
||||||
|
if (sa == 255) {
|
||||||
|
dst[y][x] = sp;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int dp = dst[y][x];
|
||||||
|
int da = (dp >>> 24) & 0xFF;
|
||||||
|
int outA = sa + da * (255 - sa) / 255;
|
||||||
|
int sr = (sp >> 16) & 0xFF, sg = (sp >> 8) & 0xFF, sb = sp & 0xFF;
|
||||||
|
int dr = (dp >> 16) & 0xFF, dg = (dp >> 8) & 0xFF, db = dp & 0xFF;
|
||||||
|
int r = (sr * sa + dr * da * (255 - sa) / 255) / Math.max(1, outA);
|
||||||
|
int g = (sg * sa + dg * da * (255 - sa) / 255) / Math.max(1, outA);
|
||||||
|
int b = (sb * sa + db * da * (255 - sa) / 255) / Math.max(1, outA);
|
||||||
|
dst[y][x] = (outA << 24) | (r << 16) | (g << 8) | b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Palette-swap recolouring used for armor trims: each (non-transparent) texel of {@code tex} is matched
|
||||||
|
* to its nearest entry in the grayscale source palette {@code from} (8-colour key, RGB distance) and
|
||||||
|
* replaced by the same-index colour from the material palette {@code to}, preserving alpha. The vanilla
|
||||||
|
* trim patterns are authored in those 8 key shades, so this reproduces the runtime palette swap.
|
||||||
|
*/
|
||||||
|
public static void paletteSwap(int[][] tex, int[] from, int[] to) {
|
||||||
|
int n = Math.min(from.length, to.length);
|
||||||
|
if (n == 0) return;
|
||||||
|
for (int[] row : tex) {
|
||||||
|
for (int x = 0; x < row.length; x++) {
|
||||||
|
int p = row[x];
|
||||||
|
int a = (p >>> 24) & 0xFF;
|
||||||
|
if (a == 0) continue;
|
||||||
|
int pr = (p >> 16) & 0xFF, pg = (p >> 8) & 0xFF, pb = p & 0xFF;
|
||||||
|
int best = 0, bestD = Integer.MAX_VALUE;
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
int c = from[i];
|
||||||
|
int dr = pr - ((c >> 16) & 0xFF), dg = pg - ((c >> 8) & 0xFF), db = pb - (c & 0xFF);
|
||||||
|
int d = dr * dr + dg * dg + db * db;
|
||||||
|
if (d < bestD) {
|
||||||
|
bestD = d;
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row[x] = (a << 24) | (to[best] & 0xFFFFFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static approximation of the (animated) enchantment glint: a single glint frame is tiled over the
|
||||||
|
* texture and additively blended — tinted by {@code glintColor} and scaled by {@code strength} and the
|
||||||
|
* frame's luminance — onto the texture's opaque texels only (so it sheens the armor, not the
|
||||||
|
* transparent background). Not animated; just a frozen purple shimmer.
|
||||||
|
*/
|
||||||
|
public static void addGlint(int[][] tex, int[][] glint, int glintColor, double strength) {
|
||||||
|
if (glint == null || glint.length == 0 || glint[0].length == 0) return;
|
||||||
|
int gh = glint.length, gw = glint[0].length;
|
||||||
|
int cr = (glintColor >> 16) & 0xFF, cg = (glintColor >> 8) & 0xFF, cb = glintColor & 0xFF;
|
||||||
|
for (int y = 0; y < tex.length; y++) {
|
||||||
|
for (int x = 0; x < tex[y].length; x++) {
|
||||||
|
int p = tex[y][x];
|
||||||
|
int a = (p >>> 24) & 0xFF;
|
||||||
|
if (a == 0) continue; // only sheen actual armor pixels
|
||||||
|
int gp = glint[y % gh][x % gw];
|
||||||
|
int ga = (gp >>> 24) & 0xFF;
|
||||||
|
int gl = Math.max((gp >> 16) & 0xFF, Math.max((gp >> 8) & 0xFF, gp & 0xFF));
|
||||||
|
double inten = strength * (ga / 255.0) * (gl / 255.0);
|
||||||
|
if (inten <= 0) continue;
|
||||||
|
int r = clamp(((p >> 16) & 0xFF) + (int) (cr * inten));
|
||||||
|
int g = clamp(((p >> 8) & 0xFF) + (int) (cg * inten));
|
||||||
|
int b = clamp((p & 0xFF) + (int) (cb * inten));
|
||||||
|
tex[y][x] = (a << 24) | (r << 16) | (g << 8) | b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int clamp(int v) {
|
||||||
|
return v < 0 ? 0 : (Math.min(v, 255));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.entity.cem;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.SkinCache;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.font.BitmapFont;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityModels;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.EntityBaker;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.TextureOps;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bakes a {@link BlockEntityState} into world-space cubes using the bundled CEM models, reusing
|
||||||
|
* {@link CemGeometry}. Unlike mobs, block-entities are not dropped to the ground by their model bounds;
|
||||||
|
* each type is placed by an affine that reproduces the vanilla BlockEntityRenderer: the block cell
|
||||||
|
* centre, a yaw from the block's facing/rotation, and a per-type local offset/scale.
|
||||||
|
*
|
||||||
|
* <p>The CEM block-entity models are authored centred on X/Z with their base near Y=0 (after the px→
|
||||||
|
* block scale), so the placement is {@code T(cell centre) · rotY(yaw) · T(localOffset)} and most types
|
||||||
|
* only need defaults. Wall-mounted and scaled types (signs, wall heads, beds) override via {@link Place}.
|
||||||
|
*/
|
||||||
|
public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
|
||||||
|
|
||||||
|
private static final double SIGN_SCALE = 0.6666667; // vanilla SignRenderer model scale
|
||||||
|
|
||||||
|
// Sign-text placement (model px in the board's local frame; calibrated against the test harness).
|
||||||
|
// The text block is scaled to fill the board's writable area (W×H) so few-line signs are rendered as
|
||||||
|
// large as possible (legibility on low-res maps), capped so a single line isn't blown up absurdly.
|
||||||
|
private static final double SIGN_TEXT_W = 22.0, SIGN_TEXT_H = 11.0, SIGN_TEXT_CAP = 0.5; // board 24x12
|
||||||
|
private static final double HANG_TEXT_W = 13.0, HANG_TEXT_H = 9.0, HANG_TEXT_CAP = 0.42; // board 14x10
|
||||||
|
private static final double SIGN_TEXT_CY = 20.0; // board Y∈[14,26], centre 20
|
||||||
|
private static final double HANGING_TEXT_CY = 5.0; // board Y∈[0,10], centre 5
|
||||||
|
private static final double BOARD_FRONT_Z = 1.0; // both boards: front/back faces at Z=±1
|
||||||
|
private static final double TEXT_Z_EPS = 0.05; // lift the text just off the board face
|
||||||
|
private static final double TEXT_THICK = 0.5; // slab thickness so the ray test never degenerates
|
||||||
|
|
||||||
|
private final CemModelLoader models;
|
||||||
|
private final TextureCache textures;
|
||||||
|
private final SkinCache skins;
|
||||||
|
private final BitmapFont font;
|
||||||
|
|
||||||
|
public BlockEntityBaker(CemModelLoader models, TextureCache textures, SkinCache skins, BitmapFont font) {
|
||||||
|
this.models = models;
|
||||||
|
this.textures = textures;
|
||||||
|
this.skins = skins;
|
||||||
|
this.font = font;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the baked block-entity, or null when it has no model/texture (then nothing renders). */
|
||||||
|
@Override
|
||||||
|
public RenderedEntity bake(BlockEntityState s) {
|
||||||
|
List<Layer> layers = layers(s);
|
||||||
|
if (layers.isEmpty()) return null;
|
||||||
|
// Head model depends on the texture aspect (skeleton 64x32 vs zombie/player 64x64), so resolve it
|
||||||
|
// from the chosen texture rather than statically.
|
||||||
|
CemModelLoader.CemModel model = models.get(modelName(s, layers.getFirst().tex));
|
||||||
|
if (model == null) return null;
|
||||||
|
|
||||||
|
Place p = place(s);
|
||||||
|
Affine pre = Affine.scale(p.scale / 16.0);
|
||||||
|
Affine placement = Affine.translation(s.bx() + 0.5, s.by(), s.bz() + 0.5)
|
||||||
|
.mul(Affine.rotY(Math.toRadians(p.yaw)))
|
||||||
|
.mul(Affine.translation(p.lx, p.ly, p.lz));
|
||||||
|
|
||||||
|
List<EntityCube> cubes = new ArrayList<>();
|
||||||
|
for (Layer layer : layers) {
|
||||||
|
for (CemGeometry.Baked b :
|
||||||
|
CemGeometry.bakeModel(model, layer.tex, pre, layer.hidden, layer.texW, layer.texH, layer.boxUv)) {
|
||||||
|
cubes.add(new EntityCube(b.from(), b.to(), b.faces(), placement.mul(b.world())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addSignText(s, placement.mul(pre), cubes);
|
||||||
|
return cubes.isEmpty() ? null : RenderedEntity.of(cubes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends flat text quads in front of (and behind) the sign board, rasterized from the captured
|
||||||
|
* {@link BlockEntityState.SignText}. Built in the board's model-px frame and transformed by the same
|
||||||
|
* {@code placement·pre} as the CEM board, so the text rides exactly on the board.
|
||||||
|
*
|
||||||
|
* <p>The CEM sign model faces north by default and is aimed with {@code rotY(180−facingDeg)} where
|
||||||
|
* {@code facingDeg} is the direction the sign's FRONT faces — so the model's −Z (NORTH) face ends up
|
||||||
|
* being the front the player reads, and +Z (SOUTH) is the back. Each face uses an unflipped UV: the
|
||||||
|
* intersector already mirrors NORTH relative to SOUTH ({@code s=1−fx} vs {@code s=fx}), so both read
|
||||||
|
* left-to-right when viewed from outside.
|
||||||
|
*/
|
||||||
|
private void addSignText(BlockEntityState s, Affine toWorld, List<EntityCube> cubes) {
|
||||||
|
if (font == null || font.isEmpty()) return;
|
||||||
|
boolean hanging = s.kind() == BlockEntityState.Kind.HANGING_SIGN;
|
||||||
|
boolean sign = hanging || s.kind() == BlockEntityState.Kind.SIGN || s.kind() == BlockEntityState.Kind.WALL_SIGN;
|
||||||
|
if (!sign) return;
|
||||||
|
double cy = hanging ? HANGING_TEXT_CY : SIGN_TEXT_CY;
|
||||||
|
double tw = hanging ? HANG_TEXT_W : SIGN_TEXT_W;
|
||||||
|
double th = hanging ? HANG_TEXT_H : SIGN_TEXT_H;
|
||||||
|
double cap = hanging ? HANG_TEXT_CAP : SIGN_TEXT_CAP;
|
||||||
|
addSide(s.frontText(), Direction.NORTH, tw, th, cap, cy, toWorld, cubes);
|
||||||
|
addSide(s.backText(), Direction.SOUTH, tw, th, cap, cy, toWorld, cubes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addSide(
|
||||||
|
BlockEntityState.SignText t,
|
||||||
|
Direction faceDir,
|
||||||
|
double tw,
|
||||||
|
double th,
|
||||||
|
double cap,
|
||||||
|
double cy,
|
||||||
|
Affine toWorld,
|
||||||
|
List<EntityCube> cubes) {
|
||||||
|
if (t == null) return;
|
||||||
|
int[][] bmp = SignTextRasterizer.rasterize(
|
||||||
|
trimBlankLines(t.lines()), font, t.fillArgb(), t.outlineArgb(), t.glowing());
|
||||||
|
if (bmp == null) return;
|
||||||
|
// Scale so the text block fills the board's writable area, whichever dimension binds, capped.
|
||||||
|
double fpm = Math.min(cap, Math.min(tw / bmp[0].length, th / bmp.length));
|
||||||
|
double blockW = bmp[0].length * fpm;
|
||||||
|
double blockH = bmp.length * fpm;
|
||||||
|
|
||||||
|
double z0, z1;
|
||||||
|
if (faceDir == Direction.NORTH) { // front: −Z face, text sits just past −1
|
||||||
|
z1 = -BOARD_FRONT_Z - TEXT_Z_EPS;
|
||||||
|
z0 = z1 - TEXT_THICK;
|
||||||
|
} else { // back: +Z face, text sits just past +1
|
||||||
|
z0 = BOARD_FRONT_Z + TEXT_Z_EPS;
|
||||||
|
z1 = z0 + TEXT_THICK;
|
||||||
|
}
|
||||||
|
Face[] faces = new Face[6];
|
||||||
|
faces[faceDir.ordinal()] = new Face(bmp, 0, 0, 1, 1, 0, -1);
|
||||||
|
double[] from = {-blockW / 2, cy - blockH / 2, z0};
|
||||||
|
double[] to = {blockW / 2, cy + blockH / 2, z1};
|
||||||
|
cubes.add(new EntityCube(from, to, faces, toWorld));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops leading/trailing blank lines so short text fills the board; keeps interior blanks. */
|
||||||
|
private static List<String> trimBlankLines(List<String> lines) {
|
||||||
|
int lo = 0, hi = lines.size();
|
||||||
|
while (lo < hi && lines.get(lo).isBlank()) lo++;
|
||||||
|
while (hi > lo && lines.get(hi - 1).isBlank()) hi--;
|
||||||
|
return lines.subList(lo, hi);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The CEM model name; for heads it depends on the texture's aspect (64x32 vs square 64x64). */
|
||||||
|
private String modelName(BlockEntityState s, int[][] tex) {
|
||||||
|
if (s.kind() != BlockEntityState.Kind.HEAD && s.kind() != BlockEntityState.Kind.WALL_HEAD) {
|
||||||
|
return BlockEntityModels.cemModel(s);
|
||||||
|
}
|
||||||
|
String t = s.headType();
|
||||||
|
if ("dragon".equals(t)) return "head_dragon";
|
||||||
|
if ("piglin".equals(t)) return "head_piglin";
|
||||||
|
// Square textures (zombie, player) carry a hat overlay → use the 64x64 player-head model; the
|
||||||
|
// 2:1 skull textures (skeleton, wither_skeleton, creeper) use the 64x32 skull model.
|
||||||
|
boolean square = tex.length > 0 && tex.length == tex[0].length;
|
||||||
|
return square ? "head_player" : "head";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single bake pass: one texture applied to the parts not in {@code hidden}, with optional UV size
|
||||||
|
* and a flag forcing box-UV (for standalone part textures not matching the model's per-face UV). */
|
||||||
|
private record Layer(int[][] tex, Set<String> hidden, int texW, int texH, boolean boxUv) {
|
||||||
|
Layer(int[][] tex, Set<String> hidden) {
|
||||||
|
this(tex, hidden, 0, 0, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Some types paint different parts with different textures (pot sherds, conduit cage/heart). */
|
||||||
|
private List<Layer> layers(BlockEntityState s) {
|
||||||
|
if (s.kind() == BlockEntityState.Kind.DECORATED_POT) {
|
||||||
|
return potLayers(s);
|
||||||
|
}
|
||||||
|
if (s.kind() == BlockEntityState.Kind.CONDUIT) {
|
||||||
|
return conduitLayers();
|
||||||
|
}
|
||||||
|
int[][] tex = resolveTexture(s);
|
||||||
|
return tex == null ? List.of() : List.of(new Layer(tex, hiddenParts(s)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Set<String> CONDUIT_PARTS = Set.of("eye", "cage", "base", "wind");
|
||||||
|
|
||||||
|
/** The conduit's cage and inner heart use separate textures; the eye/wind (active state) are skipped. */
|
||||||
|
private List<Layer> conduitLayers() {
|
||||||
|
int[][] cage =
|
||||||
|
textures.get(ResourceLocation.parse("entity/conduit/cage")).orElse(null);
|
||||||
|
int[][] base =
|
||||||
|
textures.get(ResourceLocation.parse("entity/conduit/base")).orElse(null);
|
||||||
|
// Each part's texture is authored at its own native size (32x16) with a box-UV layout, so force
|
||||||
|
// box-UV and normalise by the texture's own size (the model's per-face UV assumes a combined sheet).
|
||||||
|
List<Layer> layers = new ArrayList<>(2);
|
||||||
|
if (cage != null)
|
||||||
|
layers.add(new Layer(cage, onlyPart("cage", CONDUIT_PARTS), cage[0].length, cage.length, true));
|
||||||
|
if (base != null)
|
||||||
|
layers.add(new Layer(base, onlyPart("base", CONDUIT_PARTS), base[0].length, base.length, true));
|
||||||
|
return layers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hidden set that leaves only {@code keep} visible out of {@code all}. */
|
||||||
|
private static Set<String> onlyPart(String keep, Set<String> all) {
|
||||||
|
Set<String> hidden = new HashSet<>(all);
|
||||||
|
hidden.remove(keep);
|
||||||
|
return hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Set<String> POT_PARTS = Set.of("neck", "top", "bottom", "front", "back", "left", "right");
|
||||||
|
private static final String[] POT_FACES = {"front", "left", "right", "back"}; // matches sherd capture order
|
||||||
|
|
||||||
|
private List<Layer> potLayers(BlockEntityState s) {
|
||||||
|
int[][] base = textures.get(ResourceLocation.parse("entity/decorated_pot/decorated_pot_base"))
|
||||||
|
.orElse(null);
|
||||||
|
int[][] side = textures.get(ResourceLocation.parse("entity/decorated_pot/decorated_pot_side"))
|
||||||
|
.orElse(null);
|
||||||
|
if (base == null) return List.of();
|
||||||
|
List<Layer> layers = new ArrayList<>();
|
||||||
|
// Structure (rim/neck/foot) comes from the combined base texture; the four sides are NOT in it.
|
||||||
|
layers.add(new Layer(base, new HashSet<>(List.of("front", "back", "left", "right"))));
|
||||||
|
// Each side: its sherd pattern if set, else the plain brick side. The model's per-face UV maps the
|
||||||
|
// centre of the 16x16 texture onto the face (centred, edges intact).
|
||||||
|
for (int i = 0; i < POT_FACES.length; i++) {
|
||||||
|
int[][] tex = side;
|
||||||
|
if (i < s.sherds().size()) {
|
||||||
|
String sherd = s.sherds().get(i);
|
||||||
|
if (sherd != null && sherd.endsWith("_pottery_sherd")) {
|
||||||
|
int[][] pat = textures.get(ResourceLocation.parse(
|
||||||
|
"entity/decorated_pot/" + sherd.replace("_pottery_sherd", "_pottery_pattern")))
|
||||||
|
.orElse(null);
|
||||||
|
if (pat != null) tex = pat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tex == null) continue;
|
||||||
|
layers.add(new Layer(tex, onlyPart(POT_FACES[i], POT_PARTS)));
|
||||||
|
}
|
||||||
|
return layers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- placement parameters per type ---
|
||||||
|
|
||||||
|
/** Local placement: applied yaw (deg), model scale, and a local-frame offset (blocks). */
|
||||||
|
private record Place(double yaw, double scale, double lx, double ly, double lz) {}
|
||||||
|
|
||||||
|
private Place place(BlockEntityState s) {
|
||||||
|
double yaw = 180 - s.facingDeg(); // model default faces north; rotate by the block's facing
|
||||||
|
return switch (s.kind()) {
|
||||||
|
case SIGN -> new Place(yaw, SIGN_SCALE, 0, 0, 0);
|
||||||
|
case WALL_SIGN -> new Place(yaw, SIGN_SCALE, 0, -0.3125, 0.4375); // drop to mid-block, push to wall
|
||||||
|
case WALL_HEAD -> new Place(yaw, 1.0, 0, 0.25, 0.25); // mid-height, against the wall
|
||||||
|
case WALL_BANNER -> new Place(yaw, 1.0, 0, -0.16, 0.4375);
|
||||||
|
default -> new Place(yaw, 1.0, 0, 0, 0);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parts to omit (the unused bed half / its legs, the conduit's open-state shell). */
|
||||||
|
private Set<String> hiddenParts(BlockEntityState s) {
|
||||||
|
return switch (s.kind()) {
|
||||||
|
case BED -> s.bedPart() == BlockEntityState.BedPart.HEAD
|
||||||
|
? Set.of("foot", "leg3", "leg4")
|
||||||
|
: Set.of("head", "leg1", "leg2");
|
||||||
|
case CONDUIT -> Set.of("eye", "wind");
|
||||||
|
default -> Set.of();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- texture resolution ---
|
||||||
|
|
||||||
|
private int[][] resolveTexture(BlockEntityState s) {
|
||||||
|
// Player heads use the owner's skin when available.
|
||||||
|
if (s.skinUrl() != null
|
||||||
|
&& (s.kind() == BlockEntityState.Kind.HEAD || s.kind() == BlockEntityState.Kind.WALL_HEAD)) {
|
||||||
|
int[][] skin = skins.get(s.skinUrl()).orElse(null);
|
||||||
|
if (skin != null) return skin;
|
||||||
|
}
|
||||||
|
boolean isBanner = s.kind() == BlockEntityState.Kind.BANNER || s.kind() == BlockEntityState.Kind.WALL_BANNER;
|
||||||
|
for (ResourceLocation rl : BlockEntityModels.textureCandidates(s)) {
|
||||||
|
int[][] t = textures.get(rl).orElse(null);
|
||||||
|
if (t == null) continue;
|
||||||
|
if (isBanner) return bakeBanner(t, s);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composites a banner texture: tint the white {@code banner_base} cloth with the base colour, then
|
||||||
|
* alpha-overlay each pattern mask ({@code entity/banner/<key>}) dyed with its own colour, in order.
|
||||||
|
*/
|
||||||
|
private int[][] bakeBanner(int[][] base, BlockEntityState s) {
|
||||||
|
int[][] out = TextureOps.deepCopy(base);
|
||||||
|
if (s.baseColorArgb() != 0) TextureOps.tint(out, s.baseColorArgb());
|
||||||
|
for (BlockEntityState.BannerPattern pat : s.patterns()) {
|
||||||
|
int[][] mask = textures.get(ResourceLocation.parse("entity/banner/" + pat.patternKey()))
|
||||||
|
.orElse(null);
|
||||||
|
if (mask == null) continue;
|
||||||
|
int[][] dyed = TextureOps.deepCopy(mask);
|
||||||
|
TextureOps.tint(dyed, pat.colorArgb());
|
||||||
|
TextureOps.overlay(out, dyed);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.entity.cem;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.SkinCache;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.BoxUv;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.EntityBaker;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.EntityModels;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.ModelCube;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.TextureOps;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bakes an {@link EntityState} into world-space cubes using a vanilla Java {@link CemModelLoader.CemModel}
|
||||||
|
* (OptiFine-CEM format). These models are already correctly posed (standing), so no animation/lay-down
|
||||||
|
* logic is needed. The CEM model space (px, invertAxis "xy") is mapped to the world by an inner X/Y flip
|
||||||
|
* + px→block scale + an outer Y-flip (upright); the model is then dropped onto the ground and placed at
|
||||||
|
* the entity's position/yaw. Calibrated against fox/pig/cow.
|
||||||
|
*/
|
||||||
|
public final class CemBaker implements EntityBaker<EntityState> {
|
||||||
|
|
||||||
|
// Parts representing an alternate state (rolled-up, sleeping, …) that must not render in the idle pose.
|
||||||
|
private static final Map<String, Set<String>> HIDDEN_PARTS = Map.of(
|
||||||
|
"armadillo", Set.of("cube"), // the rolled-up ball
|
||||||
|
"illager", Set.of("left_arm", "right_arm"));
|
||||||
|
|
||||||
|
private final CemModelLoader models;
|
||||||
|
private final TextureCache textures;
|
||||||
|
private final SkinCache skins;
|
||||||
|
|
||||||
|
public CemBaker(CemModelLoader models, TextureCache textures, SkinCache skins) {
|
||||||
|
this.models = models;
|
||||||
|
this.textures = textures;
|
||||||
|
this.skins = skins;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RenderedEntity bake(EntityState s) {
|
||||||
|
int[][] tex = resolveTexture(s);
|
||||||
|
String cem = EntityModels.cemModel(s.typeKey());
|
||||||
|
// Same-UV overlays are composited straight into the base texture (no extra geometry -> no ray Z-fighting).
|
||||||
|
if (s.typeKey().equals("villager") || s.typeKey().equals("zombie_villager")) {
|
||||||
|
tex = compositeVillager(s, tex);
|
||||||
|
} else if (cem.equals("horse")) {
|
||||||
|
tex = compositeHorse(s, tex); // coat markings + horse armor
|
||||||
|
} else if (cem.equals("llama")) {
|
||||||
|
tex = compositeLlama(s, tex); // dyed/trader carpet decor
|
||||||
|
} else if (cem.equals("nautilus")) {
|
||||||
|
tex = compositeNautilus(s, tex); // body armor + saddle (same-UV overlays)
|
||||||
|
}
|
||||||
|
CemModelLoader.CemModel model = models.get(cem);
|
||||||
|
// A visible entity needs its body model+texture; an invisible one renders only its equipment
|
||||||
|
// (vanilla hides the body but still draws worn armor).
|
||||||
|
boolean invisible = s.invisible();
|
||||||
|
if (!invisible && (model == null || tex == null)) return fallbackBox(s, tex);
|
||||||
|
|
||||||
|
double sc = (s.baby() ? 0.5 : 1.0) * s.sizeScale();
|
||||||
|
// CEM model px -> entity-local blocks. Identity orientation (no axis flip) preserves ALL part
|
||||||
|
// rotations and handedness; only px->block scaling is applied.
|
||||||
|
Affine pre = Affine.scale(sc / 16.0);
|
||||||
|
|
||||||
|
Set<String> hidden = new HashSet<>(HIDDEN_PARTS.getOrDefault(cem, Set.of()));
|
||||||
|
// Donkeys/llamas carry the chest boxes inside the base model; hide them unless a chest is equipped.
|
||||||
|
if (!s.chest()) {
|
||||||
|
if (cem.equals("donkey")) {
|
||||||
|
hidden.add("left_chest");
|
||||||
|
hidden.add("right_chest");
|
||||||
|
} else if (cem.equals("llama")) {
|
||||||
|
hidden.add("chest_left");
|
||||||
|
hidden.add("chest_right");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The body model is baked even when invisible — not drawn, but used as the ground-snap reference
|
||||||
|
// so equipment stays at body height (e.g. a lone helmet sits at the head, not on the floor).
|
||||||
|
List<CemGeometry.Baked> body =
|
||||||
|
(model != null && tex != null) ? CemGeometry.bakeModel(model, tex, pre, hidden) : List.of();
|
||||||
|
|
||||||
|
List<CemGeometry.Baked> baked = new ArrayList<>();
|
||||||
|
if (!invisible) {
|
||||||
|
baked.addAll(body);
|
||||||
|
// Sheep: render the inflated, dye-tinted wool fur layer over the body (transparent where the face shows).
|
||||||
|
if (s.typeKey().equals("sheep")) {
|
||||||
|
CemModelLoader.CemModel wool = models.get("sheep_wool");
|
||||||
|
int[][] woolTex = textures.get(ResourceLocation.parse("entity/sheep/sheep_wool"))
|
||||||
|
.orElse(null);
|
||||||
|
if (wool != null && woolTex != null) {
|
||||||
|
int[][] t = TextureOps.deepCopy(woolTex);
|
||||||
|
if (s.tint() != 0) TextureOps.tint(t, s.tint());
|
||||||
|
baked.addAll(CemGeometry.bakeModel(wool, t, pre, hidden));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Guardian: the CEM model ships a RIGHT body side-panel but no left one, and the main body box's
|
||||||
|
// left face is transparent in the texture → a see-through hole on the left. Add the mirrored left panel.
|
||||||
|
if (cem.equals("guardian")) {
|
||||||
|
double[] org = {-8, 2, -6}, size = {2, 12, 12};
|
||||||
|
ModelCube mc = new ModelCube(org, size, 0, new double[] {0, 28}, true);
|
||||||
|
Face[] faces = BoxUv.build(mc, tex, model.texW(), model.texH());
|
||||||
|
baked.add(new CemGeometry.Baked(
|
||||||
|
org, new double[] {org[0] + size[0], org[1] + size[1], org[2] + size[2]}, faces, pre));
|
||||||
|
}
|
||||||
|
// Saddle: an extra inflated layer from the *_saddle CEM model, showing only its saddle-specific parts.
|
||||||
|
if (s.saddle()) addSaddleLayer(s, cem, model, pre, baked);
|
||||||
|
}
|
||||||
|
// Humanoid armor (players, armor stands, zombies, …): extra inflated armor_layer models. Rendered
|
||||||
|
// even when invisible, so an invisible armored entity shows floating armor like in vanilla.
|
||||||
|
if (s.equipment() != null) addArmorLayers(s, pre, baked);
|
||||||
|
if (baked.isEmpty()) return invisible ? null : fallbackBox(s, tex);
|
||||||
|
|
||||||
|
// Ground-snap from the body extent (preserves visible behaviour); fall back to the drawn geometry.
|
||||||
|
List<CemGeometry.Baked> ref = (invisible && !body.isEmpty()) ? body : baked;
|
||||||
|
double minY = Double.MAX_VALUE;
|
||||||
|
for (CemGeometry.Baked b : ref) minY = Math.min(minY, b.minWorldY());
|
||||||
|
|
||||||
|
Affine place = Affine.translation(s.x(), s.y(), s.z())
|
||||||
|
.mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw())))
|
||||||
|
.mul(Affine.translation(0, -minY, 0));
|
||||||
|
|
||||||
|
List<EntityCube> cubes = new ArrayList<>(baked.size());
|
||||||
|
for (CemGeometry.Baked b : baked) cubes.add(new EntityCube(b.from(), b.to(), b.faces(), place.mul(b.world())));
|
||||||
|
return RenderedEntity.of(cubes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- texture resolution (player skin, dyed sheep wool, variant candidates) ---
|
||||||
|
private int[][] resolveTexture(EntityState s) {
|
||||||
|
if (s.player()) {
|
||||||
|
int[][] skin = skins.get(s.skinUrl()).orElse(null);
|
||||||
|
if (skin != null) return skin;
|
||||||
|
int[][] def = textures.get(
|
||||||
|
ResourceLocation.parse(s.slim() ? "entity/player/slim/steve" : "entity/player/wide/steve"))
|
||||||
|
.orElse(null);
|
||||||
|
if (def != null) return def;
|
||||||
|
}
|
||||||
|
for (ResourceLocation rl : EntityModels.textureCandidates(s.typeKey(), s.variant())) {
|
||||||
|
int[][] t = textures.get(rl).orElse(null);
|
||||||
|
if (t != null) return t;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- villager texture compositing (biome type + profession + level badge over the base body) ---
|
||||||
|
// Profession-level (1-5) -> badge texture, like Mojang's VillagerProfessionLayer.
|
||||||
|
private static final String[] LEVEL_BADGE = {"stone", "iron", "gold", "emerald", "diamond"};
|
||||||
|
|
||||||
|
private int[][] compositeVillager(EntityState s, int[][] base) {
|
||||||
|
if (base == null) return null;
|
||||||
|
String folder = s.typeKey(); // "villager" or "zombie_villager"
|
||||||
|
int[][] out = TextureOps.deepCopy(base);
|
||||||
|
// Biome-type clothing overlay (always, if known).
|
||||||
|
if (s.variant() != null) overlayIfPresent(out, "entity/" + folder + "/type/" + s.variant());
|
||||||
|
String prof = s.profession();
|
||||||
|
if (prof != null && !prof.equals("none")) {
|
||||||
|
overlayIfPresent(out, "entity/" + folder + "/profession/" + prof);
|
||||||
|
// Level badge: only for real professions (not the work-less nitwit) and a known level.
|
||||||
|
if (!prof.equals("nitwit") && s.villagerLevel() >= 1) {
|
||||||
|
int lvl = Math.min(5, s.villagerLevel());
|
||||||
|
overlayIfPresent(out, "entity/" + folder + "/profession_level/" + LEVEL_BADGE[lvl - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alpha-composite an overlay texture onto {@code dst} in place, if it exists and matches dst's size. */
|
||||||
|
private void overlayIfPresent(int[][] dst, String path) {
|
||||||
|
int[][] o = textures.get(ResourceLocation.parse(path)).orElse(null);
|
||||||
|
if (o == null || o.length != dst.length || o[0].length != dst[0].length) return; // missing or HD-mismatch
|
||||||
|
TextureOps.overlay(dst, o);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- horse / llama equipment compositing (same UV layout as the base model) ---
|
||||||
|
private int[][] compositeHorse(EntityState s, int[][] base) {
|
||||||
|
if (base == null || (s.markings() == null && s.bodyEquip() == null)) return base;
|
||||||
|
int[][] out = TextureOps.deepCopy(base);
|
||||||
|
if (s.markings() != null) overlayIfPresent(out, "entity/horse/horse_markings_" + s.markings());
|
||||||
|
if (s.bodyEquip() != null) overlayIfPresent(out, "entity/equipment/horse_body/" + s.bodyEquip());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int[][] compositeLlama(EntityState s, int[][] base) {
|
||||||
|
if (base == null || s.bodyEquip() == null) return base;
|
||||||
|
int[][] out = TextureOps.deepCopy(base);
|
||||||
|
overlayIfPresent(out, "entity/equipment/llama_body/" + s.bodyEquip());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nautilus body armor + saddle: same 128² UV as the nautilus texture, so composited as overlays. */
|
||||||
|
private int[][] compositeNautilus(EntityState s, int[][] base) {
|
||||||
|
if (base == null || (s.bodyEquip() == null && !s.saddle())) return base;
|
||||||
|
int[][] out = TextureOps.deepCopy(base);
|
||||||
|
if (s.bodyEquip() != null) overlayIfPresent(out, "entity/equipment/nautilus_body/" + s.bodyEquip());
|
||||||
|
if (s.saddle()) overlayIfPresent(out, "entity/equipment/nautilus_saddle/saddle");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bake the saddle as a separate inflated layer; only the saddle-specific parts (those not in the base model). */
|
||||||
|
private void addSaddleLayer(
|
||||||
|
EntityState s, String cem, CemModelLoader.CemModel base, Affine pre, List<CemGeometry.Baked> baked) {
|
||||||
|
String saddleModel = cem.equals("donkey") ? "donkey_saddle" : (cem.equals("horse") ? "horse_saddle" : null);
|
||||||
|
if (saddleModel == null) return; // llamas and other mounts have no saddle model
|
||||||
|
CemModelLoader.CemModel sm = models.get(saddleModel);
|
||||||
|
if (sm == null) return;
|
||||||
|
int[][] saddleTex = textures.get(ResourceLocation.parse("entity/equipment/" + s.typeKey() + "_saddle/saddle"))
|
||||||
|
.orElse(null);
|
||||||
|
if (saddleTex == null) return;
|
||||||
|
// Show only the saddle-specific parts: hide every part the base body model also defines.
|
||||||
|
Set<String> hideBase = new HashSet<>();
|
||||||
|
for (CemModelLoader.CemPart p : base.parts()) hideBase.add(p.name());
|
||||||
|
baked.addAll(CemGeometry.bakeModel(sm, saddleTex, pre, hideBase));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- humanoid armor ---------------------------------------------------------------------------
|
||||||
|
// Vanilla splits worn armor across two inflated layer models that overlay the standard humanoid body:
|
||||||
|
// armor_layer_1 (texture entity/equipment/humanoid/<mat>): head=helmet, body+arms=chestplate,
|
||||||
|
// shoes=boots;
|
||||||
|
// armor_layer_2 (texture entity/equipment/humanoid_leggings/<mat>): waist+legs=leggings.
|
||||||
|
// Each slot may use a different material, so each is baked separately, showing only its parts.
|
||||||
|
private static final Set<String> ARMOR1_HEAD = Set.of("head");
|
||||||
|
private static final Set<String> ARMOR1_CHEST = Set.of("body", "left_arm", "right_arm");
|
||||||
|
private static final Set<String> ARMOR1_FEET = Set.of("left_shoe", "right_shoe");
|
||||||
|
private static final Set<String> ARMOR2_LEGS = Set.of("waist", "left_leg", "right_leg");
|
||||||
|
private static final int GLINT_COLOR = 0xFF8040CC; // approximated enchantment-glint purple
|
||||||
|
|
||||||
|
private void addArmorLayers(EntityState s, Affine pre, List<CemGeometry.Baked> baked) {
|
||||||
|
EntityState.Equipment eq = s.equipment();
|
||||||
|
bakeArmorPiece(eq.head(), "armor_layer_1", ARMOR1_HEAD, "humanoid", pre, baked);
|
||||||
|
// Chest slot: an elytra renders its own wing model instead of the chestplate layer.
|
||||||
|
if (eq.chest() != null && eq.chest().asset().equals("elytra")) {
|
||||||
|
bakeElytra(eq.chest(), pre, baked);
|
||||||
|
} else {
|
||||||
|
bakeArmorPiece(eq.chest(), "armor_layer_1", ARMOR1_CHEST, "humanoid", pre, baked);
|
||||||
|
}
|
||||||
|
bakeArmorPiece(eq.legs(), "armor_layer_2", ARMOR2_LEGS, "humanoid_leggings", pre, baked);
|
||||||
|
bakeArmorPiece(eq.feet(), "armor_layer_1", ARMOR1_FEET, "humanoid", pre, baked);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bake one armor slot: its layer model with only {@code show} parts, textured for the material. */
|
||||||
|
private void bakeArmorPiece(
|
||||||
|
EntityState.EquipPiece piece,
|
||||||
|
String modelName,
|
||||||
|
Set<String> show,
|
||||||
|
String layerFolder,
|
||||||
|
Affine pre,
|
||||||
|
List<CemGeometry.Baked> baked) {
|
||||||
|
if (piece == null) return;
|
||||||
|
CemModelLoader.CemModel model = models.get(modelName);
|
||||||
|
if (model == null) return;
|
||||||
|
int[][] tex = buildArmorTexture(piece, layerFolder);
|
||||||
|
if (tex == null) return;
|
||||||
|
Set<String> hidden = new HashSet<>();
|
||||||
|
for (CemModelLoader.CemPart p : model.parts()) if (!show.contains(p.name())) hidden.add(p.name());
|
||||||
|
baked.addAll(CemGeometry.bakeModel(model, tex, pre, hidden));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve + composite a single armor slot's texture: material base (+leather dye/overlay), trim, glint. */
|
||||||
|
private int[][] buildArmorTexture(EntityState.EquipPiece piece, String layerFolder) {
|
||||||
|
String asset = piece.asset();
|
||||||
|
int[][] base = textures.get(ResourceLocation.parse("entity/equipment/" + layerFolder + "/" + asset))
|
||||||
|
.orElse(null);
|
||||||
|
if (base == null) return null;
|
||||||
|
int[][] out = TextureOps.deepCopy(base);
|
||||||
|
// Leather is dyeable: tint the base layer, then composite the (undyed) overlay layer on top.
|
||||||
|
if (asset.equals("leather")) {
|
||||||
|
if (piece.dyeColor() != 0) TextureOps.tint(out, piece.dyeColor());
|
||||||
|
overlayIfPresent(out, "entity/equipment/" + layerFolder + "/leather_overlay");
|
||||||
|
}
|
||||||
|
if (piece.trimMaterial() != null && piece.trimPattern() != null) {
|
||||||
|
applyTrim(out, layerFolder, piece.trimPattern(), piece.trimMaterial(), asset);
|
||||||
|
}
|
||||||
|
if (piece.glint()) applyGlint(out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Armor trim: recolour the grayscale pattern via the material palette (same UV) and overlay it. */
|
||||||
|
private void applyTrim(int[][] armorTex, String layerFolder, String pattern, String material, String armorAsset) {
|
||||||
|
int[][] mask = textures.get(ResourceLocation.parse("trims/entity/" + layerFolder + "/" + pattern))
|
||||||
|
.orElse(null);
|
||||||
|
if (mask == null || mask.length != armorTex.length || mask[0].length != armorTex[0].length) return;
|
||||||
|
int[] from = palette8("trims/color_palettes/trim_palette");
|
||||||
|
// Vanilla uses the darker palette variant when the trim material matches the armor material.
|
||||||
|
String palette = material;
|
||||||
|
if (material.equals(armorAsset)
|
||||||
|
&& textures.get(ResourceLocation.parse("trims/color_palettes/" + material + "_darker"))
|
||||||
|
.isPresent()) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First row of an 8×1 trim colour-palette texture; null if missing. */
|
||||||
|
private int[] palette8(String path) {
|
||||||
|
int[][] p = textures.get(ResourceLocation.parse(path)).orElse(null);
|
||||||
|
return (p == null || p.length == 0) ? null : p[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Static enchantment-glint approximation over an armor/elytra/item texture (in place). */
|
||||||
|
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: bake the wing model with the default elytra texture (no chestplate). */
|
||||||
|
private void bakeElytra(EntityState.EquipPiece piece, Affine pre, List<CemGeometry.Baked> baked) {
|
||||||
|
CemModelLoader.CemModel model = models.get("elytra");
|
||||||
|
if (model == null) return;
|
||||||
|
int[][] tex = textures.get(ResourceLocation.parse("entity/equipment/wings/elytra"))
|
||||||
|
.orElse(null);
|
||||||
|
if (tex == null) return;
|
||||||
|
int[][] out = TextureOps.deepCopy(tex);
|
||||||
|
if (piece.glint()) applyGlint(out);
|
||||||
|
baked.addAll(CemGeometry.bakeModel(model, out, pre, Set.of()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private RenderedEntity fallbackBox(EntityState s, int[][] tex) {
|
||||||
|
double w = Math.max(0.3, s.width()) * 16 * s.sizeScale(), h = Math.max(0.3, s.height()) * 16 * s.sizeScale();
|
||||||
|
double[] from = {-w / 2, 0, -w / 2};
|
||||||
|
double[] to = {w / 2, h, w / 2};
|
||||||
|
int[][] t = tex != null ? tex : flat(0xFF8C8C8C);
|
||||||
|
ModelCube box =
|
||||||
|
new ModelCube(new double[] {-w / 2, 0, -w / 2}, new double[] {w, h, w}, 0, new double[] {0, 0}, false);
|
||||||
|
Face[] faces = BoxUv.build(box, t, Math.max(64, (int) (2 * (w + w))), Math.max(64, (int) (2 * (w + h))));
|
||||||
|
Affine place = Affine.translation(s.x(), s.y(), s.z())
|
||||||
|
.mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw())))
|
||||||
|
.mul(Affine.scale(1.0 / 16.0));
|
||||||
|
List<EntityCube> cubes = new ArrayList<>();
|
||||||
|
cubes.add(new EntityCube(from, to, faces, place));
|
||||||
|
return RenderedEntity.of(cubes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int[][] flat(int argb) {
|
||||||
|
int[][] t = new int[1][1];
|
||||||
|
t[0][0] = argb;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.entity.cem;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.BoxUv;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.ModelCube;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared, stateless CEM geometry baking used by both {@link CemBaker} (mobs) and the block-entity
|
||||||
|
* baker. It turns a {@link CemModelLoader.CemModel} into a list of {@link Baked} boxes (local cube +
|
||||||
|
* its model→pre-space transform), faithfully reproducing the OptiFine/Blockbench CEM transform. The
|
||||||
|
* placement into the world (yaw, ground-snap, block cell) is the caller's responsibility.
|
||||||
|
*/
|
||||||
|
final class CemGeometry {
|
||||||
|
|
||||||
|
private CemGeometry() {}
|
||||||
|
|
||||||
|
/** A baked box: local min/max corner, its six faces and the transform mapping it into pre-space. */
|
||||||
|
record Baked(double[] from, double[] to, Face[] faces, Affine world) {
|
||||||
|
double minWorldY() {
|
||||||
|
double m = Double.MAX_VALUE;
|
||||||
|
for (int i = 0; i < 8; i++) {
|
||||||
|
double x = (i & 1) == 0 ? from[0] : to[0];
|
||||||
|
double y = (i & 2) == 0 ? from[1] : to[1];
|
||||||
|
double z = (i & 4) == 0 ? from[2] : to[2];
|
||||||
|
m = Math.min(m, world.apply(x, y, z)[1]);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bake all parts of a model with the given pre-transform; parts in {@code hidden} are skipped. */
|
||||||
|
static List<Baked> bakeModel(CemModelLoader.CemModel model, int[][] tex, Affine pre, Set<String> hidden) {
|
||||||
|
return bakeModel(model, tex, pre, hidden, 0, 0, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bake all parts with an explicit texture size for UV normalisation ({@code texW}/{@code texH} of 0
|
||||||
|
* fall back to the model's declared size; use a real size when the applied texture differs from the
|
||||||
|
* model's declared {@code textureSize}, e.g. a 16x16 sherd on a 32x32-authored pot face). When
|
||||||
|
* {@code ignoreFaceUv} is set, box-UV is forced even if the model declares per-face UV — used for a
|
||||||
|
* standalone texture (e.g. the conduit cage) whose layout is box-UV, not the combined-sheet layout.
|
||||||
|
*/
|
||||||
|
static List<Baked> bakeModel(
|
||||||
|
CemModelLoader.CemModel model,
|
||||||
|
int[][] tex,
|
||||||
|
Affine pre,
|
||||||
|
Set<String> hidden,
|
||||||
|
int texW,
|
||||||
|
int texH,
|
||||||
|
boolean ignoreFaceUv) {
|
||||||
|
int nw = texW > 0 ? texW : model.texW();
|
||||||
|
int nh = texH > 0 ? texH : model.texH();
|
||||||
|
List<Baked> out = new ArrayList<>();
|
||||||
|
for (CemModelLoader.CemPart p : model.parts()) {
|
||||||
|
double[] o = {-p.translate()[0], -p.translate()[1], -p.translate()[2]};
|
||||||
|
bakePart(p, pre, o, 0, hidden, nw, nh, tex, ignoreFaceUv, out);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Faithful OptiFine/Blockbench CEM transform: each part is a group whose rotation pivots around its
|
||||||
|
* origin {@code O} (top-level: {@code -translate}; submodel: {@code translate}, accumulated with the
|
||||||
|
* parent origin from the 2nd nesting level on). Top-level boxes are absolute; nested boxes are offset
|
||||||
|
* by their group origin. The group transform is {@code parent · T(O) · R · T(-O)}.
|
||||||
|
*/
|
||||||
|
private static void bakePart(
|
||||||
|
CemModelLoader.CemPart part,
|
||||||
|
Affine parentWorld,
|
||||||
|
double[] o,
|
||||||
|
int depth,
|
||||||
|
Set<String> hidden,
|
||||||
|
int texW,
|
||||||
|
int texH,
|
||||||
|
int[][] tex,
|
||||||
|
boolean ignoreFaceUv,
|
||||||
|
List<Baked> out) {
|
||||||
|
if (hidden.contains(part.name())) return;
|
||||||
|
Affine world = parentWorld
|
||||||
|
.mul(Affine.translation(o[0], o[1], o[2]))
|
||||||
|
.mul(Affine.rotZ(Math.toRadians(part.rotate()[2])))
|
||||||
|
.mul(Affine.rotY(Math.toRadians(part.rotate()[1])))
|
||||||
|
.mul(Affine.rotX(Math.toRadians(part.rotate()[0])))
|
||||||
|
.mul(Affine.translation(-o[0], -o[1], -o[2]));
|
||||||
|
|
||||||
|
double ox = depth > 0 ? o[0] : 0, oy = depth > 0 ? o[1] : 0, oz = depth > 0 ? o[2] : 0;
|
||||||
|
for (CemModelLoader.CemBox b : part.boxes()) {
|
||||||
|
double inf = b.inflate();
|
||||||
|
double[] org = {b.origin()[0] + ox, b.origin()[1] + oy, b.origin()[2] + oz};
|
||||||
|
double[] from = {org[0] - inf, org[1] - inf, org[2] - inf};
|
||||||
|
double[] to = {org[0] + b.size()[0] + inf, org[1] + b.size()[1] + inf, org[2] + b.size()[2] + inf};
|
||||||
|
double[][] faceUv = ignoreFaceUv ? null : b.faceUv();
|
||||||
|
ModelCube mc = new ModelCube(org, b.size(), inf, b.uv(), b.mirror(), faceUv);
|
||||||
|
Face[] faces = BoxUv.build(mc, tex, texW, texH);
|
||||||
|
out.add(new Baked(from, to, faces, world));
|
||||||
|
}
|
||||||
|
for (CemModelLoader.CemPart child : part.children()) {
|
||||||
|
double[] t = child.translate();
|
||||||
|
// submodel origin = its translate, accumulated with this group's origin from the 2nd level on.
|
||||||
|
double[] co =
|
||||||
|
depth >= 1 ? new double[] {t[0] + o[0], t[1] + o[1], t[2] + o[2]} : new double[] {t[0], t[1], t[2]};
|
||||||
|
bakePart(child, world, co, depth + 1, hidden, texW, texH, tex, ignoreFaceUv, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.entity.cem;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads OptiFine-CEM ({@code .jem}) entity models from the bundled {@code cem_template_models.json}
|
||||||
|
* (CEM Template Loader data) into baked-ready {@link CemModel}s. These are the vanilla Java entity
|
||||||
|
* models, already in the correct standing pose — no Bedrock geometry / animation needed.
|
||||||
|
*
|
||||||
|
* <p>Format per part: {@code coordinates} are ABSOLUTE model pixels, {@code translate} is the rotation
|
||||||
|
* pivot (negated), {@code rotate} is degrees. {@code invertAxis "xy"} is handled by the baker's flips.
|
||||||
|
*/
|
||||||
|
public final class CemModelLoader {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A box: absolute min corner + size (px), inflate, box-UV offset (texels), horizontal texture mirror,
|
||||||
|
* and optional per-face UV ({@code faceUv}, indexed by {@link eu.mhsl.minecraft.pixelpics.assets.model.Direction}
|
||||||
|
* ordinal, each {@code {x, y, w, h}} texels; null = use box-UV).
|
||||||
|
*/
|
||||||
|
public record CemBox(
|
||||||
|
double[] origin, double[] size, double inflate, double[] uv, boolean mirror, double[][] faceUv) {}
|
||||||
|
|
||||||
|
/** A model part: its (raw) translate, rotation (deg), boxes and nested submodels. The rotation pivot
|
||||||
|
* is {@code -(sum of translates from the root to this part)} — accumulated by the baker. */
|
||||||
|
public record CemPart(
|
||||||
|
String name, double[] translate, double[] rotate, List<CemBox> boxes, List<CemPart> children) {}
|
||||||
|
|
||||||
|
/** A whole model: declared texture size and its top-level parts. */
|
||||||
|
public record CemModel(int texW, int texH, List<CemPart> parts) {}
|
||||||
|
|
||||||
|
private final Map<String, CemModel> models = new HashMap<>();
|
||||||
|
|
||||||
|
public CemModel get(String name) {
|
||||||
|
return models.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int size() {
|
||||||
|
return models.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the CEM template-models JSON stream. Returns the number of models loaded. */
|
||||||
|
public int load(InputStream in, Logger logger) {
|
||||||
|
JsonObject root = JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8))
|
||||||
|
.getAsJsonObject();
|
||||||
|
JsonObject modelsObj = root.getAsJsonObject("models");
|
||||||
|
for (Map.Entry<String, JsonElement> e : modelsObj.entrySet()) {
|
||||||
|
try {
|
||||||
|
JsonObject entry = e.getValue().getAsJsonObject();
|
||||||
|
if (!entry.has("model")) continue;
|
||||||
|
JsonObject model =
|
||||||
|
JsonParser.parseString(entry.get("model").getAsString()).getAsJsonObject();
|
||||||
|
int tw = model.getAsJsonArray("textureSize").get(0).getAsInt();
|
||||||
|
int th = model.getAsJsonArray("textureSize").get(1).getAsInt();
|
||||||
|
List<CemPart> parts = new ArrayList<>();
|
||||||
|
for (JsonElement pe : model.getAsJsonArray("models")) parts.add(parsePart(pe.getAsJsonObject()));
|
||||||
|
models.put(e.getKey(), new CemModel(tw, th, parts));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
if (logger != null) logger.warning("Failed to parse CEM model " + e.getKey() + ": " + ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return models.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
private CemPart parsePart(JsonObject p) {
|
||||||
|
double[] translate = arr3(p, "translate");
|
||||||
|
double[] rotate = arr3(p, "rotate");
|
||||||
|
boolean partMirror = mirrorsU(p); // mirrorTexture "u" — applies to all of the part's boxes
|
||||||
|
List<CemBox> boxes = new ArrayList<>();
|
||||||
|
if (p.has("boxes")) {
|
||||||
|
for (JsonElement be : p.getAsJsonArray("boxes")) {
|
||||||
|
JsonObject b = be.getAsJsonObject();
|
||||||
|
if (!b.has("coordinates")) continue;
|
||||||
|
JsonArray c = b.getAsJsonArray("coordinates");
|
||||||
|
double[] origin = {
|
||||||
|
c.get(0).getAsDouble(), c.get(1).getAsDouble(), c.get(2).getAsDouble()
|
||||||
|
};
|
||||||
|
double[] size = {
|
||||||
|
c.get(3).getAsDouble(), c.get(4).getAsDouble(), c.get(5).getAsDouble()
|
||||||
|
};
|
||||||
|
double inflate = b.has("sizeAdd") ? b.get("sizeAdd").getAsDouble() : 0;
|
||||||
|
double[] uv = b.has("textureOffset")
|
||||||
|
? new double[] {
|
||||||
|
b.getAsJsonArray("textureOffset").get(0).getAsDouble(),
|
||||||
|
b.getAsJsonArray("textureOffset").get(1).getAsDouble()
|
||||||
|
}
|
||||||
|
: new double[] {0, 0};
|
||||||
|
boxes.add(new CemBox(origin, size, inflate, uv, partMirror || mirrorsU(b), parseFaceUv(b)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<CemPart> children = new ArrayList<>();
|
||||||
|
if (p.has("submodels"))
|
||||||
|
for (JsonElement se : p.getAsJsonArray("submodels")) children.add(parsePart(se.getAsJsonObject()));
|
||||||
|
if (p.has("submodel")) children.add(parsePart(p.getAsJsonObject("submodel")));
|
||||||
|
String name = p.has("part")
|
||||||
|
? p.get("part").getAsString()
|
||||||
|
: (p.has("id") ? p.get("id").getAsString() : "");
|
||||||
|
return new CemPart(name, translate, rotate, boxes, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CEM per-face UV keys ordered by Direction ordinal (DOWN, UP, NORTH, SOUTH, WEST, EAST).
|
||||||
|
private static final String[] FACE_UV_KEYS = {"uvDown", "uvUp", "uvNorth", "uvSouth", "uvWest", "uvEast"};
|
||||||
|
|
||||||
|
/** Parses per-face UV ({@code uvNorth} etc., each {@code [u1,v1,u2,v2]}) into {@code {x,y,w,h}}, or null. */
|
||||||
|
private static double[][] parseFaceUv(JsonObject b) {
|
||||||
|
boolean any = false;
|
||||||
|
for (String k : FACE_UV_KEYS)
|
||||||
|
if (b.has(k)) {
|
||||||
|
any = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!any) return null;
|
||||||
|
double[][] faces = new double[6][];
|
||||||
|
for (int i = 0; i < FACE_UV_KEYS.length; i++) {
|
||||||
|
if (!b.has(FACE_UV_KEYS[i])) continue;
|
||||||
|
JsonArray a = b.getAsJsonArray(FACE_UV_KEYS[i]);
|
||||||
|
double u1 = a.get(0).getAsDouble(), v1 = a.get(1).getAsDouble();
|
||||||
|
double u2 = a.get(2).getAsDouble(), v2 = a.get(3).getAsDouble();
|
||||||
|
faces[i] = new double[] {u1, v1, u2 - u1, v2 - v1};
|
||||||
|
}
|
||||||
|
return faces;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean mirrorsU(JsonObject o) {
|
||||||
|
return o.has("mirrorTexture") && o.get("mirrorTexture").getAsString().contains("u");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double[] arr3(JsonObject o, String key) {
|
||||||
|
if (!o.has(key) || !o.get(key).isJsonArray()) return new double[] {0, 0, 0};
|
||||||
|
JsonArray a = o.getAsJsonArray(key);
|
||||||
|
return new double[] {
|
||||||
|
a.get(0).getAsDouble(), a.get(1).getAsDouble(), a.get(2).getAsDouble()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.entity.cem;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.font.BitmapFont;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.font.Glyph;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rasterizes a sign side's text lines into a transparent ARGB grid ({@code int[][]}, top-left origin) at
|
||||||
|
* the font's native pixel resolution. Lines are centred horizontally; the block of lines fills the canvas
|
||||||
|
* top-to-bottom (blank lines reserve their slot so vertical centring matches vanilla, which always lays
|
||||||
|
* out four lines). The baker maps the result onto a quad whose model size keeps the canvas aspect, so the
|
||||||
|
* text is never stretched. Glyphs from different providers (ascii/accented) are aligned on a common
|
||||||
|
* baseline. Glowing text gets an 8-directional outline.
|
||||||
|
*/
|
||||||
|
final class SignTextRasterizer {
|
||||||
|
|
||||||
|
private SignTextRasterizer() {}
|
||||||
|
|
||||||
|
/** Returns the rasterized panel, or null when there is nothing to draw (no font/glyphs). */
|
||||||
|
static int[][] rasterize(List<String> lines, BitmapFont font, int fillArgb, int outlineArgb, boolean glow) {
|
||||||
|
if (font.isEmpty() || lines.isEmpty()) return null;
|
||||||
|
|
||||||
|
int ascent = font.maxAscent();
|
||||||
|
int pitch = font.lineHeight(); // ascent + descent
|
||||||
|
int margin = glow ? 1 : 0; // room for the outline
|
||||||
|
|
||||||
|
int contentW = 1;
|
||||||
|
for (String line : lines) contentW = Math.max(contentW, lineWidth(line, font));
|
||||||
|
int contentH = Math.max(1, lines.size() * pitch);
|
||||||
|
|
||||||
|
int w = contentW + 2 * margin;
|
||||||
|
int h = contentH + 2 * margin;
|
||||||
|
int[][] out = new int[h][w];
|
||||||
|
boolean[][] covered = new boolean[h][w];
|
||||||
|
|
||||||
|
// Pass 1: stamp glyph coverage (fill colour) into `out` + `covered`.
|
||||||
|
boolean any = false;
|
||||||
|
for (int li = 0; li < lines.size(); li++) {
|
||||||
|
String line = lines.get(li);
|
||||||
|
int penX = margin + (contentW - lineWidth(line, font)) / 2;
|
||||||
|
int baseline = margin + li * pitch + ascent;
|
||||||
|
for (int ci = 0; ci < line.length(); ) {
|
||||||
|
int cp = line.codePointAt(ci);
|
||||||
|
ci += Character.charCount(cp);
|
||||||
|
Glyph g = font.glyph(cp);
|
||||||
|
if (g != null) {
|
||||||
|
any |= blit(g, penX, baseline, fillArgb, out, covered);
|
||||||
|
}
|
||||||
|
penX += font.advance(cp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!any) return null;
|
||||||
|
|
||||||
|
// Pass 2 (glow only): paint the outline into uncovered neighbours of covered pixels.
|
||||||
|
if (glow) {
|
||||||
|
for (int y = 0; y < h; y++) {
|
||||||
|
for (int x = 0; x < w; x++) {
|
||||||
|
if (!covered[y][x]) continue;
|
||||||
|
for (int dy = -1; dy <= 1; dy++) {
|
||||||
|
for (int dx = -1; dx <= 1; dx++) {
|
||||||
|
if (dx == 0 && dy == 0) continue;
|
||||||
|
int nx = x + dx, ny = y + dy;
|
||||||
|
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
|
||||||
|
if (!covered[ny][nx]) out[ny][nx] = outlineArgb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blits a glyph's opaque pixels as {@code fillArgb}; aligns the glyph's baseline to {@code baseline}. */
|
||||||
|
private static boolean blit(Glyph g, int penX, int baseline, int fillArgb, int[][] out, boolean[][] covered) {
|
||||||
|
double scale = g.height() / (double) g.cellH();
|
||||||
|
int rw = Math.max(1, (int) (g.glyphPx() * scale + 0.5));
|
||||||
|
int rh = g.height();
|
||||||
|
int top = baseline - g.ascent();
|
||||||
|
boolean any = false;
|
||||||
|
for (int dy = 0; dy < rh; dy++) {
|
||||||
|
int oy = top + dy;
|
||||||
|
if (oy < 0 || oy >= out.length) continue;
|
||||||
|
int sy = g.srcY() + (int) (dy / scale);
|
||||||
|
for (int dx = 0; dx < rw; dx++) {
|
||||||
|
int ox = penX + dx;
|
||||||
|
if (ox < 0 || ox >= out[0].length) continue;
|
||||||
|
int sx = g.srcX() + (int) (dx / scale);
|
||||||
|
int argb = g.tex()[sy][sx];
|
||||||
|
if (((argb >>> 24) & 0xFF) == 0) continue;
|
||||||
|
out[oy][ox] = fillArgb;
|
||||||
|
covered[oy][ox] = true;
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total advance width of a line in font px. */
|
||||||
|
private 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.render.model;
|
|
||||||
|
|
||||||
import com.google.common.base.Preconditions;
|
|
||||||
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.model.MultiModel.MultiModelBuilder;
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.model.CrossModel.CrossModelBuilder;
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.model.StaticModel.StaticModelBuilder;
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.model.OctahedronModel.OctahedronModelBuilder;
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.model.SphereModel.SphereModelBuilder;
|
|
||||||
|
|
||||||
|
|
||||||
public abstract class AbstractModel implements Model {
|
|
||||||
|
|
||||||
final int textureSize;
|
|
||||||
final int[][] texture;
|
|
||||||
|
|
||||||
private final double transparencyFactor;
|
|
||||||
private final double reflectionFactor;
|
|
||||||
private final boolean occluding;
|
|
||||||
|
|
||||||
AbstractModel(int[][] texture, double transparencyFactor, double reflectionFactor,
|
|
||||||
boolean occluding) {
|
|
||||||
Preconditions.checkNotNull(texture);
|
|
||||||
Preconditions.checkArgument(texture.length > 0, "texture cannot be empty");
|
|
||||||
Preconditions.checkArgument(texture.length == texture[0].length, "texture must be a square array");
|
|
||||||
|
|
||||||
this.textureSize = texture.length;
|
|
||||||
this.texture = texture;
|
|
||||||
|
|
||||||
this.transparencyFactor = transparencyFactor;
|
|
||||||
this.reflectionFactor = reflectionFactor;
|
|
||||||
this.occluding = occluding;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public double getTransparencyFactor() {
|
|
||||||
return transparencyFactor;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public double getReflectionFactor() {
|
|
||||||
return reflectionFactor;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isOccluding() {
|
|
||||||
return occluding;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static abstract class Builder {
|
|
||||||
|
|
||||||
final int[][] texture;
|
|
||||||
|
|
||||||
double transparencyFactor;
|
|
||||||
double reflectionFactor;
|
|
||||||
boolean occluding;
|
|
||||||
|
|
||||||
Builder(int[][] texture) {
|
|
||||||
this.texture = texture;
|
|
||||||
|
|
||||||
this.transparencyFactor = 0;
|
|
||||||
this.reflectionFactor = 0;
|
|
||||||
this.occluding = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static SimpleModel.SimpleModelBuilder createSimple(int[][] texture) {
|
|
||||||
return new SimpleModel.SimpleModelBuilder(texture);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static MultiModelBuilder createMulti(int[][] topTexture, int[][] sideTexture,
|
|
||||||
int[][] bottomTexture) {
|
|
||||||
return new MultiModelBuilder(topTexture, sideTexture, bottomTexture);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static StaticModelBuilder createStatic(int color) {
|
|
||||||
return new StaticModelBuilder(color);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static CrossModelBuilder createCross(int[][] texture) {
|
|
||||||
return new CrossModelBuilder(texture);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static SphereModelBuilder createSphere(int[][] texture) {
|
|
||||||
return new SphereModelBuilder(texture);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static OctahedronModelBuilder createOctahedron(int[][] texture) {
|
|
||||||
return new OctahedronModelBuilder(texture);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder transparency(double transparencyFactor) {
|
|
||||||
this.transparencyFactor = transparencyFactor;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder reflection(double reflectionFactor) {
|
|
||||||
this.reflectionFactor = reflectionFactor;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Builder occlusion() {
|
|
||||||
this.occluding = true;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public abstract Model build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.render.model;
|
|
||||||
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
|
|
||||||
import org.bukkit.block.Block;
|
|
||||||
import org.bukkit.util.Vector;
|
|
||||||
|
|
||||||
public class CrossModel extends AbstractModel {
|
|
||||||
|
|
||||||
private static final Vector NORMAL_ONE = new Vector(1, 0, 1).normalize();
|
|
||||||
private static final Vector NORMAL_TWO = new Vector(-1, 0, 1).normalize();
|
|
||||||
|
|
||||||
private static final Vector POINT_ONE = new Vector(1, 0, 0);
|
|
||||||
private static final Vector POINT_TWO = new Vector(1, 0, 1);
|
|
||||||
|
|
||||||
private CrossModel(int[][] texture, double transparencyFactor, double reflectionFactor,
|
|
||||||
boolean occluding) {
|
|
||||||
super(texture, transparencyFactor, reflectionFactor, occluding);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Intersection intersect(Block block, Intersection currentIntersection) {
|
|
||||||
Vector linePoint = currentIntersection.getPoint();
|
|
||||||
Vector lineDirection = currentIntersection.getDirection();
|
|
||||||
|
|
||||||
Vector blockPoint = block.getLocation().toVector();
|
|
||||||
Vector planePoint = block.getLocation().add(0.5, 0, 0.5).toVector();
|
|
||||||
|
|
||||||
double distance = Double.POSITIVE_INFINITY;
|
|
||||||
int color = 0;
|
|
||||||
Vector target = null;
|
|
||||||
|
|
||||||
Vector intersectionOne = MathUtil.getLinePlaneIntersection(linePoint, lineDirection, planePoint, NORMAL_ONE,
|
|
||||||
true);
|
|
||||||
if (intersectionOne != null) {
|
|
||||||
intersectionOne.subtract(blockPoint);
|
|
||||||
if (isInsideBlock(intersectionOne)) {
|
|
||||||
color = getColor(intersectionOne, POINT_ONE);
|
|
||||||
distance = linePoint.distanceSquared(intersectionOne.add(blockPoint));
|
|
||||||
target = intersectionOne;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Vector intersectionTwo = MathUtil.getLinePlaneIntersection(linePoint, lineDirection, planePoint, NORMAL_TWO,
|
|
||||||
true);
|
|
||||||
if (intersectionTwo != null) {
|
|
||||||
intersectionTwo.subtract(blockPoint);
|
|
||||||
if (isInsideBlock(intersectionTwo)) {
|
|
||||||
int colorTwo = getColor(intersectionTwo, POINT_TWO);
|
|
||||||
double distanceTwo = linePoint.distanceSquared(intersectionTwo.add(blockPoint));
|
|
||||||
if ((distanceTwo < distance && (colorTwo >> 24) != 0) || (color >> 24) == 0) {
|
|
||||||
target = intersectionTwo;
|
|
||||||
color = colorTwo;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target == null) {
|
|
||||||
target = linePoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Intersection.of(currentIntersection.getNormal(), target, lineDirection, color);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isInsideBlock(Vector vec) {
|
|
||||||
return vec.getX() >= 0 && vec.getZ() < 1 && vec.getY() >= 0 && vec.getY() < 1 && vec.getZ() >= 0
|
|
||||||
&& vec.getZ() < 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int getColor(Vector vec, Vector base) {
|
|
||||||
double xOffset = Math.sqrt(Math.pow(vec.getX() - base.getX(), 2) + Math.pow(vec.getZ() - base.getZ(), 2));
|
|
||||||
double yOffset = vec.getY();
|
|
||||||
|
|
||||||
int pixelY = (int) Math.floor(yOffset * textureSize);
|
|
||||||
int pixelX = (int) Math.floor(xOffset / Math.sqrt(2) * textureSize);
|
|
||||||
|
|
||||||
return texture[pixelY][pixelX];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class CrossModelBuilder extends Builder {
|
|
||||||
|
|
||||||
CrossModelBuilder(int[][] texture) {
|
|
||||||
super(texture);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public CrossModel build() {
|
|
||||||
return new CrossModel(texture, transparencyFactor, reflectionFactor, occluding);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.render.model;
|
|
||||||
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
|
|
||||||
import org.bukkit.block.Block;
|
|
||||||
|
|
||||||
public interface Model {
|
|
||||||
|
|
||||||
Intersection intersect(Block block, Intersection currentIntersection);
|
|
||||||
|
|
||||||
double getTransparencyFactor();
|
|
||||||
|
|
||||||
double getReflectionFactor();
|
|
||||||
|
|
||||||
boolean isOccluding();
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.render.model;
|
|
||||||
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
|
|
||||||
import org.bukkit.block.Block;
|
|
||||||
import org.bukkit.util.Vector;
|
|
||||||
|
|
||||||
public class MultiModel extends SimpleModel {
|
|
||||||
|
|
||||||
private final int[][] topTexture;
|
|
||||||
private final int[][] bottomTexture;
|
|
||||||
|
|
||||||
private MultiModel(int[][] topTexture, int[][] sideTexture, int[][] bottomTexture,
|
|
||||||
double transparencyFactor, double reflectionFactor, boolean occluding) {
|
|
||||||
super(sideTexture, transparencyFactor, reflectionFactor, occluding);
|
|
||||||
|
|
||||||
this.topTexture = topTexture;
|
|
||||||
this.bottomTexture = bottomTexture;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Intersection intersect(Block block, Intersection currentIntersection) {
|
|
||||||
if (!currentIntersection.getNormal().equals(UP) && !currentIntersection.getNormal().equals(DOWN)) {
|
|
||||||
return super.intersect(block, currentIntersection);
|
|
||||||
}
|
|
||||||
|
|
||||||
Vector normal = currentIntersection.getNormal();
|
|
||||||
Vector point = currentIntersection.getPoint();
|
|
||||||
Vector direction = currentIntersection.getDirection();
|
|
||||||
|
|
||||||
double yOffset = point.getX() - (int) point.getX();
|
|
||||||
double xOffset = point.getZ() - (int) point.getZ();
|
|
||||||
|
|
||||||
int pixelY = (int) Math.floor((yOffset < 0 ? yOffset + 1 : yOffset) * textureSize);
|
|
||||||
int pixelX = (int) Math.floor((xOffset < 0 ? xOffset + 1 : xOffset) * textureSize);
|
|
||||||
|
|
||||||
if (normal.equals(UP)) {
|
|
||||||
return Intersection.of(normal, point, direction, topTexture[pixelY][pixelX]);
|
|
||||||
} else {
|
|
||||||
return Intersection.of(normal, point, direction, bottomTexture[pixelY][pixelX]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class MultiModelBuilder extends SimpleModelBuilder {
|
|
||||||
|
|
||||||
private final int[][] topTexture;
|
|
||||||
private final int[][] bottomTexture;
|
|
||||||
|
|
||||||
MultiModelBuilder(int[][] topTexture, int[][] sideTexture, int[][] bottomTexture) {
|
|
||||||
super(sideTexture);
|
|
||||||
|
|
||||||
this.topTexture = topTexture;
|
|
||||||
this.bottomTexture = bottomTexture;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public MultiModel build() {
|
|
||||||
return new MultiModel(topTexture, texture, bottomTexture, transparencyFactor,
|
|
||||||
reflectionFactor, occluding);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.render.model;
|
|
||||||
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
|
|
||||||
import org.bukkit.block.Block;
|
|
||||||
import org.bukkit.util.Vector;
|
|
||||||
|
|
||||||
public class OctahedronModel extends AbstractModel {
|
|
||||||
|
|
||||||
private static final double RADIUS = 0.5;
|
|
||||||
|
|
||||||
private static final Vector[] NORMALS = new Vector[]{new Vector(-1, -1, -1), new Vector(-1, -1, 1),
|
|
||||||
new Vector(-1, 1, -1), new Vector(-1, 1, 1), new Vector(1, -1, -1), new Vector(1, -1, 1),
|
|
||||||
new Vector(1, 1, -1), new Vector(1, 1, 1)};
|
|
||||||
|
|
||||||
private OctahedronModel(int[][] texture, double transparencyFactor, double reflectionFactor,
|
|
||||||
boolean occluding) {
|
|
||||||
super(texture, transparencyFactor, reflectionFactor, occluding);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Intersection intersect(Block block, Intersection currentIntersection) {
|
|
||||||
Vector linePoint = currentIntersection.getPoint();
|
|
||||||
Vector lineDirection = currentIntersection.getDirection();
|
|
||||||
Vector blockPoint = block.getLocation().toVector();
|
|
||||||
Vector centerPoint = blockPoint.clone().add(new Vector(0.5, 0.5, 0.5));
|
|
||||||
|
|
||||||
Vector lastIntersection = null;
|
|
||||||
double lastDistance = Double.POSITIVE_INFINITY;
|
|
||||||
for (int i = 0; i < 8; i++) {
|
|
||||||
Vector planePoint = new Vector(i < 4 ? -0.5 : 0.5, 0, 0).add(centerPoint);
|
|
||||||
Vector planeNormal = NORMALS[i];
|
|
||||||
|
|
||||||
Vector intersection = MathUtil.getLinePlaneIntersection(linePoint, lineDirection, planePoint, planeNormal,
|
|
||||||
false);
|
|
||||||
if (intersection == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isInsideBlock(blockPoint, planeNormal, intersection)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
double distance = intersection.distance(linePoint);
|
|
||||||
if (distance < lastDistance) {
|
|
||||||
lastIntersection = intersection;
|
|
||||||
lastDistance = distance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastIntersection == null) {
|
|
||||||
return currentIntersection;
|
|
||||||
}
|
|
||||||
|
|
||||||
double dist = linePoint.distance(centerPoint);
|
|
||||||
double minDist = dist - RADIUS;
|
|
||||||
double maxDist = dist + RADIUS;
|
|
||||||
double factor = (lastDistance - minDist) / (maxDist - minDist);
|
|
||||||
|
|
||||||
double yOffset = lastIntersection.getX() - (int) lastIntersection.getX();
|
|
||||||
double xOffset = lastIntersection.getZ() - (int) lastIntersection.getZ();
|
|
||||||
|
|
||||||
int pixelY = (int) Math.floor((yOffset < 0 ? yOffset + 1 : yOffset) * textureSize);
|
|
||||||
int pixelX = (int) Math.floor((xOffset < 0 ? xOffset + 1 : xOffset) * textureSize);
|
|
||||||
|
|
||||||
return Intersection.of(currentIntersection.getNormal(), lastIntersection, lineDirection,
|
|
||||||
0xFF000000 | MathUtil.weightedColorSum(texture[pixelY][pixelX], 0, 1 - factor, factor));
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isInsideBlock(Vector blockPoint, Vector planeNormal, Vector intersection) {
|
|
||||||
intersection = intersection.clone().subtract(blockPoint);
|
|
||||||
|
|
||||||
if (intersection.getX() < 0 || intersection.getX() >= 1 || intersection.getY() < 0 || intersection.getY() >= 1
|
|
||||||
|| intersection.getZ() < 0 || intersection.getZ() >= 1) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean posX = planeNormal.getX() >= 0;
|
|
||||||
boolean posY = planeNormal.getY() >= 0;
|
|
||||||
boolean posZ = planeNormal.getZ() >= 0;
|
|
||||||
|
|
||||||
boolean blockX = intersection.getX() >= 0.5;
|
|
||||||
boolean blockY = intersection.getY() >= 0.5;
|
|
||||||
boolean blockZ = intersection.getZ() >= 0.5;
|
|
||||||
|
|
||||||
return posX == blockX && posY == blockY && posZ == blockZ;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class OctahedronModelBuilder extends Builder {
|
|
||||||
|
|
||||||
OctahedronModelBuilder(int[][] texture) {
|
|
||||||
super(texture);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Model build() {
|
|
||||||
return new OctahedronModel(texture, transparencyFactor, reflectionFactor, occluding);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.render.model;
|
|
||||||
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
|
|
||||||
import org.bukkit.block.Block;
|
|
||||||
import org.bukkit.util.Vector;
|
|
||||||
|
|
||||||
public class SimpleModel extends AbstractModel {
|
|
||||||
|
|
||||||
static final Vector UP = new Vector(0, 1, 0);
|
|
||||||
static final Vector DOWN = new Vector(0, -1, 0);
|
|
||||||
private static final Vector NORTH = new Vector(0, 0, -1);
|
|
||||||
private static final Vector SOUTH = new Vector(0, 0, 1);
|
|
||||||
private static final Vector EAST = new Vector(1, 0, 0);
|
|
||||||
private static final Vector WEST = new Vector(-1, 0, 0);
|
|
||||||
|
|
||||||
SimpleModel(int[][] texture, double transparencyFactor, double reflectionFactor,
|
|
||||||
boolean occluding) {
|
|
||||||
super(texture, transparencyFactor, reflectionFactor, occluding);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Intersection intersect(Block block, Intersection currentIntersection) {
|
|
||||||
double yOffset;
|
|
||||||
double xOffset;
|
|
||||||
|
|
||||||
Vector normal = currentIntersection.getNormal();
|
|
||||||
Vector point = currentIntersection.getPoint();
|
|
||||||
Vector direction = currentIntersection.getDirection();
|
|
||||||
|
|
||||||
if (normal.equals(NORTH) || normal.equals(SOUTH)) {
|
|
||||||
yOffset = point.getY() - (int) point.getY();
|
|
||||||
xOffset = point.getX() - (int) point.getX();
|
|
||||||
} else if (normal.equals(EAST) || normal.equals(WEST)) {
|
|
||||||
yOffset = point.getY() - (int) point.getY();
|
|
||||||
xOffset = point.getZ() - (int) point.getZ();
|
|
||||||
} else {
|
|
||||||
yOffset = point.getX() - (int) point.getX();
|
|
||||||
xOffset = point.getZ() - (int) point.getZ();
|
|
||||||
}
|
|
||||||
|
|
||||||
int pixelY = (int) Math.floor((yOffset < 0 ? yOffset + 1 : yOffset) * textureSize);
|
|
||||||
int pixelX = (int) Math.floor((xOffset < 0 ? xOffset + 1 : xOffset) * textureSize);
|
|
||||||
|
|
||||||
return Intersection.of(normal, point, direction, texture[pixelY][pixelX]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class SimpleModelBuilder extends Builder {
|
|
||||||
|
|
||||||
protected SimpleModelBuilder(int[][] texture) {
|
|
||||||
super(texture);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Model build() {
|
|
||||||
return new SimpleModel(texture, transparencyFactor, reflectionFactor, occluding);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.render.model;
|
|
||||||
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
|
|
||||||
import org.bukkit.Location;
|
|
||||||
import org.bukkit.block.Block;
|
|
||||||
import org.bukkit.util.Vector;
|
|
||||||
|
|
||||||
public class SphereModel extends AbstractModel {
|
|
||||||
|
|
||||||
private final double radius;
|
|
||||||
private final Vector offset;
|
|
||||||
|
|
||||||
private SphereModel(int[][] texture, double transparencyFactor, double reflectionFactor,
|
|
||||||
boolean occluding, double radius, Vector offset) {
|
|
||||||
super(texture, transparencyFactor, reflectionFactor, occluding);
|
|
||||||
|
|
||||||
this.radius = radius;
|
|
||||||
this.offset = offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Intersection intersect(Block block, Intersection currentIntersection) {
|
|
||||||
Vector linePoint = currentIntersection.getPoint();
|
|
||||||
Vector lineDirection = currentIntersection.getDirection();
|
|
||||||
Vector blockPoint = block.getLocation().toVector();
|
|
||||||
Vector centerPoint = block.getLocation().add(0.5, 0.5, 0.5).add(offset).toVector();
|
|
||||||
|
|
||||||
double a = lineDirection.dot(lineDirection);
|
|
||||||
double b = 2 * (linePoint.dot(lineDirection) - centerPoint.dot(lineDirection));
|
|
||||||
double c = linePoint.dot(linePoint) - 2 * centerPoint.dot(linePoint) + centerPoint.dot(centerPoint)
|
|
||||||
- Math.pow(radius, 2);
|
|
||||||
|
|
||||||
double delta = Math.pow(b, 2) - 4 * a * c;
|
|
||||||
if (delta < 0) {
|
|
||||||
return Intersection.of(currentIntersection.getNormal(), linePoint, lineDirection);
|
|
||||||
}
|
|
||||||
|
|
||||||
double dist = linePoint.distance(centerPoint);
|
|
||||||
double minDist = dist - radius;
|
|
||||||
double maxDist = dist + radius;
|
|
||||||
|
|
||||||
if (delta == 0) {
|
|
||||||
double t = -b / (2 * a);
|
|
||||||
Vector intersection = lineDirection.clone().add(lineDirection.clone().multiply(t));
|
|
||||||
if (!isInsideBlock(blockPoint, intersection)) {
|
|
||||||
return currentIntersection;
|
|
||||||
}
|
|
||||||
double currentDist = intersection.distance(linePoint);
|
|
||||||
double factor = (currentDist - minDist) / (maxDist - minDist);
|
|
||||||
Vector normal = intersection.clone().subtract(centerPoint).normalize();
|
|
||||||
return Intersection.of(normal, intersection, lineDirection, getColor(centerPoint, intersection, factor));
|
|
||||||
}
|
|
||||||
|
|
||||||
double deltaSqrt = Math.sqrt(delta);
|
|
||||||
|
|
||||||
double tOne = (-b + deltaSqrt) / (2 * a);
|
|
||||||
double tTwo = (-b - deltaSqrt) / (2 * a);
|
|
||||||
|
|
||||||
Vector intersectionOne = linePoint.clone().add(lineDirection.clone().multiply(tOne));
|
|
||||||
Vector intersectionTwo = linePoint.clone().add(lineDirection.clone().multiply(tTwo));
|
|
||||||
|
|
||||||
boolean first = intersectionOne.distanceSquared(linePoint) < intersectionTwo.distanceSquared(linePoint);
|
|
||||||
double currentDist = (first ? intersectionOne : intersectionTwo).distance(linePoint);
|
|
||||||
double factor = (currentDist - minDist) / (maxDist - minDist);
|
|
||||||
if (first && isInsideBlock(blockPoint, intersectionOne)) {
|
|
||||||
Vector normal = intersectionOne.clone().subtract(centerPoint).normalize();
|
|
||||||
return Intersection.of(normal, intersectionOne, lineDirection,
|
|
||||||
getColor(centerPoint, intersectionOne, factor));
|
|
||||||
} else if (isInsideBlock(blockPoint, intersectionTwo)) {
|
|
||||||
Vector normal = intersectionTwo.clone().subtract(centerPoint).normalize();
|
|
||||||
return Intersection.of(normal, intersectionTwo, lineDirection,
|
|
||||||
getColor(centerPoint, intersectionTwo, factor));
|
|
||||||
} else {
|
|
||||||
return currentIntersection;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private int getColor(Vector base, Vector intersection, double factor) {
|
|
||||||
Location loc = base.toLocation(null);
|
|
||||||
loc.setDirection(intersection.clone().subtract(base).normalize());
|
|
||||||
|
|
||||||
double perimeter = Math.round(2 * Math.PI * radius);
|
|
||||||
double yawDiv = 360 / perimeter;
|
|
||||||
double pitchDiv = 180 / perimeter;
|
|
||||||
|
|
||||||
int pixelX = (int) ((loc.getYaw() % yawDiv) / (yawDiv / textureSize));
|
|
||||||
int pixelY = (int) (((loc.getPitch() + 90) % pitchDiv) / (pitchDiv / textureSize));
|
|
||||||
|
|
||||||
return 0xFF000000 | MathUtil.weightedColorSum(texture[pixelY][pixelX], 0, 1 - factor, factor);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isInsideBlock(Vector blockPoint, Vector intersection) {
|
|
||||||
intersection = intersection.clone().subtract(blockPoint);
|
|
||||||
|
|
||||||
return intersection.getX() >= 0 && intersection.getX() < 1 && intersection.getY() >= 0
|
|
||||||
&& intersection.getY() < 1 && intersection.getZ() >= 0 && intersection.getZ() < 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class SphereModelBuilder extends Builder {
|
|
||||||
|
|
||||||
private double radius;
|
|
||||||
private Vector offset;
|
|
||||||
|
|
||||||
SphereModelBuilder(int[][] texture) {
|
|
||||||
super(texture);
|
|
||||||
|
|
||||||
this.radius = 0.5;
|
|
||||||
this.offset = new Vector();
|
|
||||||
}
|
|
||||||
|
|
||||||
public SphereModelBuilder radius(double radius) {
|
|
||||||
this.radius = radius;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public SphereModelBuilder offset(Vector offset) {
|
|
||||||
this.offset = offset.clone();
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Model build() {
|
|
||||||
return new SphereModel(texture, transparencyFactor, reflectionFactor, occluding, radius,
|
|
||||||
offset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.render.model;
|
|
||||||
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.model.AbstractModel.Builder;
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
|
|
||||||
import org.bukkit.block.Block;
|
|
||||||
|
|
||||||
public class StaticModel implements Model {
|
|
||||||
|
|
||||||
private final int color;
|
|
||||||
private final double transparencyFactor;
|
|
||||||
private final double reflectionFactor;
|
|
||||||
private final boolean occluding;
|
|
||||||
|
|
||||||
private StaticModel(int color, double transparencyFactor, double reflectionFactor, boolean occluding) {
|
|
||||||
this.color = color;
|
|
||||||
this.transparencyFactor = transparencyFactor;
|
|
||||||
this.reflectionFactor = reflectionFactor;
|
|
||||||
this.occluding = occluding;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Intersection intersect(Block block, Intersection currentIntersection) {
|
|
||||||
return Intersection.of(currentIntersection.getNormal(), currentIntersection.getPoint(),
|
|
||||||
currentIntersection.getDirection(), color);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public double getTransparencyFactor() {
|
|
||||||
return transparencyFactor;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public double getReflectionFactor() {
|
|
||||||
return reflectionFactor;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isOccluding() {
|
|
||||||
return occluding;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class StaticModelBuilder extends Builder {
|
|
||||||
|
|
||||||
private final int color;
|
|
||||||
|
|
||||||
StaticModelBuilder(int color) {
|
|
||||||
super(new int[1][1]);
|
|
||||||
|
|
||||||
this.color = color;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public StaticModel build() {
|
|
||||||
return new StaticModel(color, transparencyFactor, reflectionFactor, occluding);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.render.raytrace;
|
|
||||||
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.model.Model;
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.registry.AdvancedModelRegistry;
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.util.BlockRaytracer;
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
|
|
||||||
import org.bukkit.Color;
|
|
||||||
import org.bukkit.Location;
|
|
||||||
import org.bukkit.Material;
|
|
||||||
import org.bukkit.World;
|
|
||||||
import org.bukkit.block.Block;
|
|
||||||
import org.bukkit.block.data.BlockData;
|
|
||||||
import org.bukkit.util.Vector;
|
|
||||||
|
|
||||||
public class AdvancedRaytracer implements Raytracer {
|
|
||||||
private final int maxDistance;
|
|
||||||
private final int reflectionDepth;
|
|
||||||
|
|
||||||
private final AdvancedModelRegistry textureRegistry;
|
|
||||||
private Block reflectedBlock;
|
|
||||||
|
|
||||||
public AdvancedRaytracer() {
|
|
||||||
this(300, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
public AdvancedRaytracer(int maxDistance, int reflectionDepth) {
|
|
||||||
this.maxDistance = maxDistance;
|
|
||||||
this.reflectionDepth = reflectionDepth;
|
|
||||||
|
|
||||||
this.textureRegistry = new AdvancedModelRegistry();
|
|
||||||
this.textureRegistry.initialize();
|
|
||||||
|
|
||||||
this.reflectedBlock = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int trace(World world, Vector point, Vector direction) {
|
|
||||||
return trace(world, point, direction, reflectionDepth);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int trace(World world, Vector point, Vector direction, int reflectionDepth) {
|
|
||||||
Location loc = point.toLocation(world);
|
|
||||||
loc.setDirection(direction);
|
|
||||||
BlockRaytracer iterator = new BlockRaytracer(loc);
|
|
||||||
int baseColor = Color.fromRGB(65, 89, 252).asRGB();
|
|
||||||
Vector finalIntersection = null;
|
|
||||||
|
|
||||||
int reflectionColor = 0;
|
|
||||||
double reflectionFactor = 0;
|
|
||||||
boolean reflected = false;
|
|
||||||
|
|
||||||
Vector transparencyStart = null;
|
|
||||||
int transparencyColor = 0;
|
|
||||||
double transparencyFactor = 0;
|
|
||||||
|
|
||||||
Material occlusionMaterial = null;
|
|
||||||
BlockData occlusionData = null;
|
|
||||||
|
|
||||||
for (int i = 0; i < maxDistance; i++) {
|
|
||||||
if (!iterator.hasNext()) break;
|
|
||||||
Block block = iterator.next();
|
|
||||||
if (reflectedBlock != null && reflectedBlock.equals(block)) continue;
|
|
||||||
reflectedBlock = null;
|
|
||||||
|
|
||||||
Material material = block.getType();
|
|
||||||
if (material == Material.AIR) {
|
|
||||||
occlusionMaterial = null;
|
|
||||||
occlusionData = null;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Model textureModel = textureRegistry.getModel(block.getType(), block.getBlockData(), block.getTemperature(), block.getHumidity());
|
|
||||||
Intersection currentIntersection = Intersection.of(
|
|
||||||
MathUtil.toVector(iterator.getIntersectionFace()),
|
|
||||||
i == 0 ? point : iterator.getIntersectionPoint(),
|
|
||||||
direction
|
|
||||||
);
|
|
||||||
Intersection newIntersection = textureModel.intersect(block, currentIntersection);
|
|
||||||
|
|
||||||
if (newIntersection == null) continue;
|
|
||||||
|
|
||||||
int color = newIntersection.getColor();
|
|
||||||
|
|
||||||
if (!reflected && textureModel.getReflectionFactor() > 0 && reflectionDepth > 0 && (color >> 24) != 0) {
|
|
||||||
reflectedBlock = block;
|
|
||||||
reflectionColor = trace(
|
|
||||||
world,
|
|
||||||
newIntersection.getPoint(),
|
|
||||||
MathUtil.reflectVector(
|
|
||||||
point,
|
|
||||||
direction,
|
|
||||||
newIntersection.getPoint(),
|
|
||||||
newIntersection.getNormal()
|
|
||||||
),
|
|
||||||
reflectionDepth - 1
|
|
||||||
);
|
|
||||||
reflectionFactor = textureModel.getReflectionFactor();
|
|
||||||
reflected = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (transparencyStart == null && textureModel.getTransparencyFactor() > 0) {
|
|
||||||
transparencyStart = newIntersection.getPoint();
|
|
||||||
transparencyColor = newIntersection.getColor();
|
|
||||||
transparencyFactor = textureModel.getTransparencyFactor();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (textureModel.isOccluding()) {
|
|
||||||
BlockData data = block.getBlockData();
|
|
||||||
|
|
||||||
if (material == occlusionMaterial && data.equals(occlusionData)) continue;
|
|
||||||
|
|
||||||
occlusionMaterial = material;
|
|
||||||
occlusionData = data;
|
|
||||||
} else {
|
|
||||||
occlusionMaterial = null;
|
|
||||||
occlusionData = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (transparencyStart != null && textureModel.getTransparencyFactor() > 0) continue;
|
|
||||||
if ((color >> 24) == 0) continue;
|
|
||||||
|
|
||||||
baseColor = color;
|
|
||||||
finalIntersection = newIntersection.getPoint();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (transparencyStart != null) {
|
|
||||||
baseColor = MathUtil.weightedColorSum(
|
|
||||||
baseColor,
|
|
||||||
transparencyColor,
|
|
||||||
transparencyFactor,
|
|
||||||
(1
|
|
||||||
- transparencyFactor)
|
|
||||||
* (1 + transparencyStart.distance(finalIntersection == null ? transparencyStart : finalIntersection)
|
|
||||||
/ 5.0));
|
|
||||||
}
|
|
||||||
if (reflected) {
|
|
||||||
baseColor = MathUtil.weightedColorSum(
|
|
||||||
baseColor,
|
|
||||||
reflectionColor,
|
|
||||||
1 - reflectionFactor,
|
|
||||||
reflectionFactor
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseColor & 0xFFFFFF;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.render.raytrace;
|
|
||||||
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.model.Model;
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.registry.AdvancedModelRegistry;
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.registry.ModelRegistry;
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.util.BlockRaytracer;
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
|
|
||||||
import org.bukkit.Color;
|
|
||||||
import org.bukkit.Location;
|
|
||||||
import org.bukkit.Material;
|
|
||||||
import org.bukkit.World;
|
|
||||||
import org.bukkit.block.Biome;
|
|
||||||
import org.bukkit.block.Block;
|
|
||||||
import org.bukkit.block.data.BlockData;
|
|
||||||
import org.bukkit.util.Vector;
|
|
||||||
|
|
||||||
public class DefaultRaytracer implements Raytracer {
|
|
||||||
private final int maxDistance;
|
|
||||||
private final int reflectionDepth;
|
|
||||||
|
|
||||||
private final ModelRegistry textureRegistry;
|
|
||||||
private Block reflectedBlock;
|
|
||||||
|
|
||||||
public DefaultRaytracer() {
|
|
||||||
this(300, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
public DefaultRaytracer(int maxDistance, int reflectionDepth) {
|
|
||||||
this.maxDistance = maxDistance;
|
|
||||||
this.reflectionDepth = reflectionDepth;
|
|
||||||
|
|
||||||
this.textureRegistry = new AdvancedModelRegistry();
|
|
||||||
this.textureRegistry.initialize();
|
|
||||||
|
|
||||||
this.reflectedBlock = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int trace(World world, Vector point, Vector direction) {
|
|
||||||
return trace(world, point, direction, reflectionDepth);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int trace(World world, Vector point, Vector direction, int reflectionDepth) {
|
|
||||||
Location loc = point.toLocation(world);
|
|
||||||
loc.setDirection(direction);
|
|
||||||
BlockRaytracer iterator = new BlockRaytracer(loc);
|
|
||||||
int baseColor = Color.fromRGB(65, 89, 252).asRGB();
|
|
||||||
Vector finalIntersection = null;
|
|
||||||
|
|
||||||
int reflectionColor = 0;
|
|
||||||
double reflectionFactor = 0;
|
|
||||||
boolean reflected = false;
|
|
||||||
|
|
||||||
Vector transparencyStart = null;
|
|
||||||
int transparencyColor = 0;
|
|
||||||
double transparencyFactor = 0;
|
|
||||||
|
|
||||||
Material occlusionMaterial = null;
|
|
||||||
BlockData occlusionData = null;
|
|
||||||
|
|
||||||
for (int i = 0; i < maxDistance; i++) {
|
|
||||||
if (!iterator.hasNext()) break;
|
|
||||||
Block block = iterator.next();
|
|
||||||
if (reflectedBlock != null && reflectedBlock.equals(block)) continue;
|
|
||||||
reflectedBlock = null;
|
|
||||||
|
|
||||||
Material material = block.getType();
|
|
||||||
if (material == Material.AIR) {
|
|
||||||
occlusionMaterial = null;
|
|
||||||
occlusionData = null;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Biome biome = block.getBiome();
|
|
||||||
Model textureModel = textureRegistry.getModel(block);
|
|
||||||
Intersection currentIntersection = Intersection.of(
|
|
||||||
MathUtil.toVector(iterator.getIntersectionFace()),
|
|
||||||
i == 0 ? point : iterator.getIntersectionPoint(),
|
|
||||||
direction
|
|
||||||
);
|
|
||||||
Intersection newIntersection = textureModel.intersect(block, currentIntersection);
|
|
||||||
|
|
||||||
if (newIntersection == null) continue;
|
|
||||||
|
|
||||||
int color = newIntersection.getColor();
|
|
||||||
|
|
||||||
if (!reflected && textureModel.getReflectionFactor() > 0 && reflectionDepth > 0 && (color >> 24) != 0) {
|
|
||||||
reflectedBlock = block;
|
|
||||||
reflectionColor = trace(
|
|
||||||
world,
|
|
||||||
newIntersection.getPoint(),
|
|
||||||
MathUtil.reflectVector(
|
|
||||||
point,
|
|
||||||
direction,
|
|
||||||
newIntersection.getPoint(),
|
|
||||||
newIntersection.getNormal()
|
|
||||||
),
|
|
||||||
reflectionDepth - 1
|
|
||||||
);
|
|
||||||
reflectionFactor = textureModel.getReflectionFactor();
|
|
||||||
reflected = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (transparencyStart == null && textureModel.getTransparencyFactor() > 0) {
|
|
||||||
transparencyStart = newIntersection.getPoint();
|
|
||||||
transparencyColor = newIntersection.getColor();
|
|
||||||
transparencyFactor = textureModel.getTransparencyFactor();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (textureModel.isOccluding()) {
|
|
||||||
BlockData data = block.getBlockData();
|
|
||||||
|
|
||||||
if (material == occlusionMaterial && data.equals(occlusionData)) continue;
|
|
||||||
|
|
||||||
occlusionMaterial = material;
|
|
||||||
occlusionData = data;
|
|
||||||
} else {
|
|
||||||
occlusionMaterial = null;
|
|
||||||
occlusionData = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (transparencyStart != null && textureModel.getTransparencyFactor() > 0) continue;
|
|
||||||
if ((color >> 24) == 0) continue;
|
|
||||||
|
|
||||||
baseColor = color;
|
|
||||||
finalIntersection = newIntersection.getPoint();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (transparencyStart != null) {
|
|
||||||
baseColor = MathUtil.weightedColorSum(
|
|
||||||
baseColor,
|
|
||||||
transparencyColor,
|
|
||||||
transparencyFactor,
|
|
||||||
(1
|
|
||||||
- transparencyFactor)
|
|
||||||
* (1 + transparencyStart.distance(finalIntersection == null ? transparencyStart : finalIntersection)
|
|
||||||
/ 5.0));
|
|
||||||
}
|
|
||||||
if (reflected) {
|
|
||||||
baseColor = MathUtil.weightedColorSum(
|
|
||||||
baseColor,
|
|
||||||
reflectionColor,
|
|
||||||
1 - reflectionFactor,
|
|
||||||
reflectionFactor
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseColor & 0xFFFFFF;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.raytrace;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.model.Element;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import org.bukkit.util.Vector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intersects a ray (in block coordinates) with a {@link ResolvedModel}'s element boxes and returns
|
||||||
|
* the nearest opaque face hit. Axis-aligned boxes use a slab test; rotated elements are handled as
|
||||||
|
* oriented boxes by transforming the ray into the element's local frame. Fully transparent texels
|
||||||
|
* (alpha ≤ threshold) are treated as holes so the ray passes through (cutout for plants, glass).
|
||||||
|
*/
|
||||||
|
public final class ElementIntersector {
|
||||||
|
|
||||||
|
private static final double EPS = 1e-7;
|
||||||
|
private static final int ALPHA_THRESHOLD = 16;
|
||||||
|
|
||||||
|
private ElementIntersector() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param ox,oy,oz ray origin in block-local coordinates (world origin minus block min corner)
|
||||||
|
* @param dx,dy,dz ray direction (need not be normalized)
|
||||||
|
* @param bx,by,bz block min corner in world coordinates (for reconstructing the world hit point)
|
||||||
|
*/
|
||||||
|
public static FaceHit intersect(
|
||||||
|
ResolvedModel model,
|
||||||
|
double ox,
|
||||||
|
double oy,
|
||||||
|
double oz,
|
||||||
|
double dx,
|
||||||
|
double dy,
|
||||||
|
double dz,
|
||||||
|
int bx,
|
||||||
|
int by,
|
||||||
|
int bz) {
|
||||||
|
List<Candidate> candidates = new ArrayList<>(model.elements.size());
|
||||||
|
for (int i = 0; i < model.elements.size(); i++) {
|
||||||
|
Element element = model.elements.get(i);
|
||||||
|
Candidate c = element.isAxisAligned()
|
||||||
|
? intersectAabb(element, ox, oy, oz, dx, dy, dz)
|
||||||
|
: intersectObb(element, ox, oy, oz, dx, dy, dz);
|
||||||
|
if (c != null) candidates.add(new Candidate(c.element, c.t, c.dir, c.s, c.t2, c.normal, i));
|
||||||
|
}
|
||||||
|
if (candidates.isEmpty()) return null;
|
||||||
|
// Sort by depth; for coplanar faces (equal t) render later elements first, matching vanilla's
|
||||||
|
// draw order so overlays (e.g. the tinted grass side overlay) sit on top of the base face.
|
||||||
|
candidates.sort((a, b) -> {
|
||||||
|
if (Math.abs(a.t - b.t) > 1e-4) return Double.compare(a.t, b.t);
|
||||||
|
return Integer.compare(b.order, a.order);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (Candidate c : candidates) {
|
||||||
|
Face face = c.element.faces[c.dir.ordinal()];
|
||||||
|
if (face == null) continue;
|
||||||
|
int color = face.sample(c.s, c.t2);
|
||||||
|
if (ColorUtil.alpha(color) <= ALPHA_THRESHOLD) continue;
|
||||||
|
|
||||||
|
Vector world = new Vector(bx + ox + dx * c.t, by + oy + dy * c.t, bz + oz + dz * c.t);
|
||||||
|
Vector normal = new Vector(c.normal[0], c.normal[1], c.normal[2]);
|
||||||
|
return new FaceHit(c.t, world, normal, color, face.tintIndex);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Candidate(
|
||||||
|
Element element, double t, Direction dir, double s, double t2, double[] normal, int order) {}
|
||||||
|
|
||||||
|
private static Candidate intersectAabb(
|
||||||
|
Element e, double ox, double oy, double oz, double dx, double dy, double dz) {
|
||||||
|
return slab(e, ox, oy, oz, dx, dy, dz, e.from, e.to, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rotated element: transform the ray into the element's local (unrotated) frame, then slab-test. */
|
||||||
|
private static Candidate intersectObb(Element e, double ox, double oy, double oz, double dx, double dy, double dz) {
|
||||||
|
double[] o = rotate(ox - e.rotOrigin[0], oy - e.rotOrigin[1], oz - e.rotOrigin[2], e.rotAxis, -e.rotAngleRad);
|
||||||
|
o[0] += e.rotOrigin[0];
|
||||||
|
o[1] += e.rotOrigin[1];
|
||||||
|
o[2] += e.rotOrigin[2];
|
||||||
|
double[] d = rotate(dx, dy, dz, e.rotAxis, -e.rotAngleRad);
|
||||||
|
return slab(e, o[0], o[1], o[2], d[0], d[1], d[2], e.from, e.to, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Candidate slab(
|
||||||
|
Element e,
|
||||||
|
double ox,
|
||||||
|
double oy,
|
||||||
|
double oz,
|
||||||
|
double dx,
|
||||||
|
double dy,
|
||||||
|
double dz,
|
||||||
|
double[] from,
|
||||||
|
double[] to,
|
||||||
|
Element obb) {
|
||||||
|
double tmin = Double.NEGATIVE_INFINITY;
|
||||||
|
double tmax = Double.POSITIVE_INFINITY;
|
||||||
|
int axis = -1;
|
||||||
|
boolean negFace = false; // entered through the low-coordinate face
|
||||||
|
|
||||||
|
double[] o = {ox, oy, oz};
|
||||||
|
double[] d = {dx, dy, dz};
|
||||||
|
for (int a = 0; a < 3; a++) {
|
||||||
|
if (Math.abs(d[a]) < EPS) {
|
||||||
|
if (o[a] < from[a] - EPS || o[a] > to[a] + EPS) return null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
double inv = 1.0 / d[a];
|
||||||
|
double t1 = (from[a] - o[a]) * inv;
|
||||||
|
double t2 = (to[a] - o[a]) * inv;
|
||||||
|
boolean neg = true;
|
||||||
|
if (t1 > t2) {
|
||||||
|
double tmp = t1;
|
||||||
|
t1 = t2;
|
||||||
|
t2 = tmp;
|
||||||
|
neg = false;
|
||||||
|
}
|
||||||
|
if (t1 > tmin) {
|
||||||
|
tmin = t1;
|
||||||
|
axis = a;
|
||||||
|
negFace = neg;
|
||||||
|
}
|
||||||
|
if (t2 < tmax) tmax = t2;
|
||||||
|
if (tmin > tmax) return null;
|
||||||
|
}
|
||||||
|
if (axis < 0) return null;
|
||||||
|
|
||||||
|
double tEntry = tmin;
|
||||||
|
if (tEntry < EPS) {
|
||||||
|
// origin inside the box (e.g. camera within a block): use exit point instead
|
||||||
|
tEntry = tmax;
|
||||||
|
if (tEntry < EPS) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
double px = o[0] + d[0] * tEntry;
|
||||||
|
double py = o[1] + d[1] * tEntry;
|
||||||
|
double pz = o[2] + d[2] * tEntry;
|
||||||
|
|
||||||
|
Direction dir = faceFor(axis, negFace);
|
||||||
|
double[] normal = {dir.nx, dir.ny, dir.nz};
|
||||||
|
if (obb != null) {
|
||||||
|
// rotate the normal back into block space
|
||||||
|
normal = rotate(normal[0], normal[1], normal[2], obb.rotAxis, obb.rotAngleRad);
|
||||||
|
}
|
||||||
|
|
||||||
|
double fracX = frac(px, from[0], to[0]);
|
||||||
|
double fracY = frac(py, from[1], to[1]);
|
||||||
|
double fracZ = frac(pz, from[2], to[2]);
|
||||||
|
|
||||||
|
double s, t;
|
||||||
|
switch (dir) {
|
||||||
|
// Texture V is top-down (0 = texture top). For side faces the texture top is the block
|
||||||
|
// top (high Y), so t = 1 - fracY.
|
||||||
|
case UP, DOWN -> {
|
||||||
|
s = fracX;
|
||||||
|
t = fracZ;
|
||||||
|
}
|
||||||
|
case NORTH, SOUTH -> {
|
||||||
|
s = fracX;
|
||||||
|
t = 1 - fracY;
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
s = fracZ;
|
||||||
|
t = 1 - fracY;
|
||||||
|
} // WEST, EAST
|
||||||
|
}
|
||||||
|
return new Candidate(e, tEntry, dir, s, t, normal, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Direction faceFor(int axis, boolean negFace) {
|
||||||
|
return switch (axis) {
|
||||||
|
case 0 -> negFace ? Direction.WEST : Direction.EAST;
|
||||||
|
case 1 -> negFace ? Direction.DOWN : Direction.UP;
|
||||||
|
default -> negFace ? Direction.NORTH : Direction.SOUTH;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double frac(double v, double lo, double hi) {
|
||||||
|
double span = hi - lo;
|
||||||
|
if (span < 1e-6) return 0;
|
||||||
|
double f = (v - lo) / span;
|
||||||
|
return f < 0 ? 0 : Math.min(f, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rotate (x,y,z) around the given axis (0=x,1=y,2=z) by angle radians. */
|
||||||
|
private static double[] rotate(double x, double y, double z, int axis, double angle) {
|
||||||
|
double cos = Math.cos(angle);
|
||||||
|
double sin = Math.sin(angle);
|
||||||
|
return switch (axis) {
|
||||||
|
case 0 -> new double[] {x, y * cos - z * sin, y * sin + z * cos};
|
||||||
|
case 1 -> new double[] {x * cos + z * sin, y, -x * sin + z * cos};
|
||||||
|
default -> new double[] {x * cos - y * sin, x * sin + y * cos, z};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.raytrace;
|
||||||
|
|
||||||
|
import org.bukkit.util.Vector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The result of intersecting a ray with a block's geometry: the world-space hit point and normal,
|
||||||
|
* the sampled ARGB color (before shading/tinting) and the face's tint index ({@code -1} = none).
|
||||||
|
*/
|
||||||
|
public record FaceHit(double t, Vector point, Vector normal, int color, int tintIndex) {}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.render.raytrace;
|
|
||||||
|
|
||||||
import org.bukkit.World;
|
|
||||||
import org.bukkit.util.Vector;
|
|
||||||
|
|
||||||
public interface Raytracer {
|
|
||||||
|
|
||||||
int trace(World world, Vector point, Vector direction);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.raytrace;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.EntityScene;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.sky.SkyRenderer;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTint;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.tint.TintResolver;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.block.Biome;
|
||||||
|
import org.bukkit.block.data.BlockData;
|
||||||
|
import org.bukkit.util.Vector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traces a single ray against a {@link WorldSnapshot}, sampling block models via the
|
||||||
|
* {@link ElementIntersector} and applying biome tint, directional face shading, transparency and
|
||||||
|
* reflection. Contains no Bukkit world access, so it is safe to invoke from worker threads.
|
||||||
|
*/
|
||||||
|
public final class SnapshotRaytracer {
|
||||||
|
|
||||||
|
private static final int BIOME_BLEND_RADIUS = 2;
|
||||||
|
|
||||||
|
// Distance fog (atmospheric perspective).
|
||||||
|
private static final double FOG_START = 128;
|
||||||
|
private static final double FOG_END = 256;
|
||||||
|
private static final double FOG_MAX = 0.75;
|
||||||
|
|
||||||
|
// Vanilla-style ambient occlusion brightness per occlusion level (0=most occluded .. 3=open). Kept subtle.
|
||||||
|
private static final double[] AO_BRIGHTNESS = {0.55, 0.70, 0.85, 1.0};
|
||||||
|
|
||||||
|
private final BlockModelRegistry registry;
|
||||||
|
private final BiomeTintProvider tintProvider;
|
||||||
|
private final SkyRenderer skyRenderer;
|
||||||
|
private final double maxDistance;
|
||||||
|
private final int reflectionDepth;
|
||||||
|
private final int maxSteps;
|
||||||
|
private final Map<Long, BiomeTint> tintCache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public SnapshotRaytracer(
|
||||||
|
BlockModelRegistry registry,
|
||||||
|
BiomeTintProvider tintProvider,
|
||||||
|
SkyRenderer skyRenderer,
|
||||||
|
double maxDistance,
|
||||||
|
int reflectionDepth) {
|
||||||
|
this.registry = registry;
|
||||||
|
this.tintProvider = tintProvider;
|
||||||
|
this.skyRenderer = skyRenderer;
|
||||||
|
this.maxDistance = maxDistance;
|
||||||
|
this.reflectionDepth = reflectionDepth;
|
||||||
|
this.maxSteps = (int) (maxDistance * 3) + 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int trace(WorldSnapshot snapshot, Vector origin, Vector direction, SkyContext sky, EntityScene scene) {
|
||||||
|
return trace(snapshot, origin, direction, sky, scene, reflectionDepth);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int trace(
|
||||||
|
WorldSnapshot snapshot, Vector origin, Vector direction, SkyContext sky, EntityScene scene, int depth) {
|
||||||
|
double ox = origin.getX(), oy = origin.getY(), oz = origin.getZ();
|
||||||
|
double dx = direction.getX(), dy = direction.getY(), dz = direction.getZ();
|
||||||
|
|
||||||
|
VoxelDDA dda = new VoxelDDA(ox, oy, oz, dx, dy, dz);
|
||||||
|
|
||||||
|
int skyColor = skyRenderer.colorFor(direction, origin, sky);
|
||||||
|
int baseColor = skyColor;
|
||||||
|
Vector finalPoint = null;
|
||||||
|
|
||||||
|
int reflectionColor = 0;
|
||||||
|
double reflectionFactor = 0;
|
||||||
|
boolean reflected = false;
|
||||||
|
|
||||||
|
Vector transparencyStart = null;
|
||||||
|
int transparencyColor = 0;
|
||||||
|
double transparencyFactor = 0;
|
||||||
|
|
||||||
|
BlockData occlusion = null;
|
||||||
|
|
||||||
|
for (int i = 0; i < maxSteps && dda.tCurrent <= maxDistance; i++) {
|
||||||
|
int bx = dda.x, by = dda.y, bz = dda.z;
|
||||||
|
BlockData data = snapshot.getBlockData(bx, by, bz);
|
||||||
|
if (data.getMaterial() == Material.AIR) {
|
||||||
|
occlusion = null;
|
||||||
|
dda.advance();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ResolvedModel model = registry.get(data);
|
||||||
|
FaceHit hit = ElementIntersector.intersect(model, ox - bx, oy - by, oz - bz, dx, dy, dz, bx, by, bz);
|
||||||
|
if (hit == null) {
|
||||||
|
occlusion = null;
|
||||||
|
dda.advance();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int color = shadeAndTint(hit, data, snapshot, bx, by, bz);
|
||||||
|
|
||||||
|
if (!reflected && model.reflection > 0 && depth > 0) {
|
||||||
|
Vector reflectDir = MathUtil.reflectVector(direction, hit.normal());
|
||||||
|
Vector reflectStart =
|
||||||
|
hit.point().clone().add(hit.normal().clone().multiply(1e-3));
|
||||||
|
reflectionColor = trace(snapshot, reflectStart, reflectDir, sky, scene, depth - 1);
|
||||||
|
reflectionFactor = model.reflection;
|
||||||
|
reflected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transparencyStart == null && model.transparency > 0) {
|
||||||
|
transparencyStart = hit.point();
|
||||||
|
transparencyColor = color;
|
||||||
|
transparencyFactor = model.transparency;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.occluding) {
|
||||||
|
if (data.equals(occlusion)) {
|
||||||
|
dda.advance();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
occlusion = data;
|
||||||
|
} else {
|
||||||
|
occlusion = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transparencyStart != null && model.transparency > 0) {
|
||||||
|
dda.advance();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
baseColor = color;
|
||||||
|
finalPoint = hit.point();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entities: if one is closer than the opaque block/sky, it becomes the surface.
|
||||||
|
if (scene != null && !scene.isEmpty()) {
|
||||||
|
double blockDist = finalPoint != null ? origin.distance(finalPoint) : maxDistance;
|
||||||
|
FaceHit eh = scene.nearestHit(ox, oy, oz, dx, dy, dz, blockDist);
|
||||||
|
if (eh != null) {
|
||||||
|
baseColor = ColorUtil.shade(eh.color(), shadeFactor(eh.normal()));
|
||||||
|
finalPoint = eh.point();
|
||||||
|
reflected = false;
|
||||||
|
if (transparencyStart != null && origin.distance(transparencyStart) >= eh.t()) {
|
||||||
|
transparencyStart = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transparencyStart != null) {
|
||||||
|
baseColor = ColorUtil.mix(
|
||||||
|
baseColor,
|
||||||
|
transparencyColor,
|
||||||
|
transparencyFactor,
|
||||||
|
(1 - transparencyFactor)
|
||||||
|
* (1
|
||||||
|
+ transparencyStart.distance(finalPoint == null ? transparencyStart : finalPoint)
|
||||||
|
/ 5.0));
|
||||||
|
}
|
||||||
|
if (reflected) {
|
||||||
|
baseColor = ColorUtil.mix(baseColor, reflectionColor, 1 - reflectionFactor, reflectionFactor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distance fog (atmospheric perspective): fade distant geometry toward the sky color.
|
||||||
|
if (finalPoint != null) {
|
||||||
|
double fog = fogFactor(origin.distance(finalPoint));
|
||||||
|
if (fog > 0) baseColor = ColorUtil.mix(baseColor, skyColor, 1 - fog, fog);
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseColor & 0xFFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int shadeAndTint(FaceHit hit, BlockData data, WorldSnapshot snapshot, int bx, int by, int bz) {
|
||||||
|
int color = hit.color();
|
||||||
|
if (hit.tintIndex() >= 0) {
|
||||||
|
BiomeTint tint = blendedTint(snapshot, bx, by, bz);
|
||||||
|
if (tint != null) {
|
||||||
|
int tintColor = TintResolver.resolve(data, hit.tintIndex(), tint);
|
||||||
|
if (tintColor != -1) color = ColorUtil.multiply(color, tintColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
double light = shadeFactor(hit.normal()) * ambientOcclusion(hit, snapshot, bx, by, bz);
|
||||||
|
return ColorUtil.shade(color, light);
|
||||||
|
}
|
||||||
|
|
||||||
|
private double fogFactor(double distance) {
|
||||||
|
if (distance <= FOG_START) return 0;
|
||||||
|
double f = (distance - FOG_START) / (FOG_END - FOG_START);
|
||||||
|
return Math.clamp(f, 0, FOG_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vanilla-style smooth ambient occlusion: darkens face corners by how many of the three blocks
|
||||||
|
* touching that corner (in the layer just outside the face) are solid, bilinearly interpolated
|
||||||
|
* across the face. Only applied to axis-aligned faces.
|
||||||
|
*/
|
||||||
|
private double ambientOcclusion(FaceHit hit, WorldSnapshot snapshot, int bx, int by, int bz) {
|
||||||
|
double nx = hit.normal().getX(),
|
||||||
|
ny = hit.normal().getY(),
|
||||||
|
nz = hit.normal().getZ();
|
||||||
|
double ax = Math.abs(nx), ay = Math.abs(ny), az = Math.abs(nz);
|
||||||
|
if (Math.max(ax, Math.max(ay, az)) < 0.99) return 1.0; // skip rotated/diagonal faces
|
||||||
|
|
||||||
|
double lx = hit.point().getX() - bx;
|
||||||
|
double ly = hit.point().getY() - by;
|
||||||
|
double lz = hit.point().getZ() - bz;
|
||||||
|
|
||||||
|
// Offset to the layer just outside the face, plus the two in-plane unit axes and face coords.
|
||||||
|
int ofx = (int) Math.round(nx), ofy = (int) Math.round(ny), ofz = (int) Math.round(nz);
|
||||||
|
int ux, uy, uz, vx, vy, vz;
|
||||||
|
double su, sv;
|
||||||
|
if (ay > 0.5) { // up/down
|
||||||
|
ux = 1;
|
||||||
|
uy = 0;
|
||||||
|
uz = 0;
|
||||||
|
vx = 0;
|
||||||
|
vy = 0;
|
||||||
|
vz = 1;
|
||||||
|
su = lx;
|
||||||
|
sv = lz;
|
||||||
|
} else if (ax > 0.5) { // east/west
|
||||||
|
ux = 0;
|
||||||
|
uy = 0;
|
||||||
|
uz = 1;
|
||||||
|
vx = 0;
|
||||||
|
vy = 1;
|
||||||
|
vz = 0;
|
||||||
|
su = lz;
|
||||||
|
sv = ly;
|
||||||
|
} else { // north/south
|
||||||
|
ux = 1;
|
||||||
|
uy = 0;
|
||||||
|
uz = 0;
|
||||||
|
vx = 0;
|
||||||
|
vy = 1;
|
||||||
|
vz = 0;
|
||||||
|
su = lx;
|
||||||
|
sv = ly;
|
||||||
|
}
|
||||||
|
|
||||||
|
double b00 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, -1, -1);
|
||||||
|
double b10 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, +1, -1);
|
||||||
|
double b01 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, -1, +1);
|
||||||
|
double b11 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, +1, +1);
|
||||||
|
|
||||||
|
double top = b00 + (b10 - b00) * su;
|
||||||
|
double bottom = b01 + (b11 - b01) * su;
|
||||||
|
return top + (bottom - top) * sv;
|
||||||
|
}
|
||||||
|
|
||||||
|
private double aoCorner(
|
||||||
|
WorldSnapshot snapshot,
|
||||||
|
int bx,
|
||||||
|
int by,
|
||||||
|
int bz,
|
||||||
|
int ofx,
|
||||||
|
int ofy,
|
||||||
|
int ofz,
|
||||||
|
int ux,
|
||||||
|
int uy,
|
||||||
|
int uz,
|
||||||
|
int vx,
|
||||||
|
int vy,
|
||||||
|
int vz,
|
||||||
|
int du,
|
||||||
|
int dv) {
|
||||||
|
boolean side1 = solid(snapshot, bx + ofx + du * ux, by + ofy + du * uy, bz + ofz + du * uz);
|
||||||
|
boolean side2 = solid(snapshot, bx + ofx + dv * vx, by + ofy + dv * vy, bz + ofz + dv * vz);
|
||||||
|
boolean corner = solid(
|
||||||
|
snapshot, bx + ofx + du * ux + dv * vx, by + ofy + du * uy + dv * vy, bz + ofz + du * uz + dv * vz);
|
||||||
|
int level = (side1 && side2) ? 0 : 3 - (side1 ? 1 : 0) - (side2 ? 1 : 0) - (corner ? 1 : 0);
|
||||||
|
return AO_BRIGHTNESS[Math.clamp(level, 0, 3)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean solid(WorldSnapshot snapshot, int x, int y, int z) {
|
||||||
|
Material m = snapshot.getBlockData(x, y, z).getMaterial();
|
||||||
|
return m != Material.AIR && m.isOccluding();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Biome-blended tint: averages the per-biome tint over a {@code (2r+1)x(2r+1)} neighbourhood in
|
||||||
|
* X/Z (vanilla biome blend radius, default 2), giving smooth grass/foliage gradients across biome
|
||||||
|
* borders instead of hard edges. Cached per column.
|
||||||
|
*/
|
||||||
|
private BiomeTint blendedTint(WorldSnapshot snapshot, int bx, int by, int bz) {
|
||||||
|
long key = (((long) bx) & 0xFFFFFFFFL) | (((long) bz) << 32);
|
||||||
|
BiomeTint cached = tintCache.get(key);
|
||||||
|
if (cached != null) return cached;
|
||||||
|
|
||||||
|
long[] g = new long[3], f = new long[3], d = new long[3], w = new long[3];
|
||||||
|
int n = 0;
|
||||||
|
for (int dx = -BIOME_BLEND_RADIUS; dx <= BIOME_BLEND_RADIUS; dx++) {
|
||||||
|
for (int dz = -BIOME_BLEND_RADIUS; dz <= BIOME_BLEND_RADIUS; dz++) {
|
||||||
|
Biome biome = snapshot.getBiome(bx + dx, by, bz + dz);
|
||||||
|
if (biome == null) continue;
|
||||||
|
BiomeTint t = tintProvider.forBiome(biome);
|
||||||
|
accumulate(g, t.grass());
|
||||||
|
accumulate(f, t.foliage());
|
||||||
|
accumulate(d, t.dryFoliage());
|
||||||
|
accumulate(w, t.water());
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (n == 0) return null;
|
||||||
|
BiomeTint result = new BiomeTint(average(g, n), average(f, n), average(d, n), average(w, n));
|
||||||
|
tintCache.put(key, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void accumulate(long[] acc, int argb) {
|
||||||
|
acc[0] += (argb >> 16) & 0xFF;
|
||||||
|
acc[1] += (argb >> 8) & 0xFF;
|
||||||
|
acc[2] += argb & 0xFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int average(long[] acc, int n) {
|
||||||
|
return 0xFF000000 | (((int) (acc[0] / n)) << 16) | (((int) (acc[1] / n)) << 8) | ((int) (acc[2] / n));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vanilla-style directional shading: top 1.0, north/south 0.8, east/west 0.6, bottom 0.5. */
|
||||||
|
private double shadeFactor(Vector normal) {
|
||||||
|
double ax = Math.abs(normal.getX());
|
||||||
|
double ay = Math.abs(normal.getY());
|
||||||
|
double az = Math.abs(normal.getZ());
|
||||||
|
if (ay >= ax && ay >= az) return normal.getY() >= 0 ? 1.0 : 0.5;
|
||||||
|
if (az >= ax) return 0.8;
|
||||||
|
return 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.raytrace;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Amanatides-Woo voxel traversal: walks the integer block grid a ray passes through, in order,
|
||||||
|
* without any Bukkit world access (safe to run off the main thread).
|
||||||
|
*/
|
||||||
|
public final class VoxelDDA {
|
||||||
|
|
||||||
|
public int x, y, z;
|
||||||
|
public double tCurrent; // ray parameter at which the current voxel was entered
|
||||||
|
|
||||||
|
private final int stepX, stepY, stepZ;
|
||||||
|
private final double tDeltaX, tDeltaY, tDeltaZ;
|
||||||
|
private double tMaxX, tMaxY, tMaxZ;
|
||||||
|
|
||||||
|
public VoxelDDA(double ox, double oy, double oz, double dx, double dy, double dz) {
|
||||||
|
this.x = (int) Math.floor(ox);
|
||||||
|
this.y = (int) Math.floor(oy);
|
||||||
|
this.z = (int) Math.floor(oz);
|
||||||
|
this.tCurrent = 0;
|
||||||
|
|
||||||
|
this.stepX = dx > 0 ? 1 : (dx < 0 ? -1 : 0);
|
||||||
|
this.stepY = dy > 0 ? 1 : (dy < 0 ? -1 : 0);
|
||||||
|
this.stepZ = dz > 0 ? 1 : (dz < 0 ? -1 : 0);
|
||||||
|
|
||||||
|
this.tDeltaX = dx == 0 ? Double.POSITIVE_INFINITY : Math.abs(1.0 / dx);
|
||||||
|
this.tDeltaY = dy == 0 ? Double.POSITIVE_INFINITY : Math.abs(1.0 / dy);
|
||||||
|
this.tDeltaZ = dz == 0 ? Double.POSITIVE_INFINITY : Math.abs(1.0 / dz);
|
||||||
|
|
||||||
|
this.tMaxX = boundary(ox, dx, x, stepX);
|
||||||
|
this.tMaxY = boundary(oy, dy, y, stepY);
|
||||||
|
this.tMaxZ = boundary(oz, dz, z, stepZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double boundary(double origin, double dir, int voxel, int step) {
|
||||||
|
if (dir == 0) return Double.POSITIVE_INFINITY;
|
||||||
|
double next = step > 0 ? (voxel + 1) : voxel;
|
||||||
|
return (next - origin) / dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advance to the next voxel along the ray, updating {@link #tCurrent}. */
|
||||||
|
public void advance() {
|
||||||
|
if (tMaxX < tMaxY) {
|
||||||
|
if (tMaxX < tMaxZ) {
|
||||||
|
x += stepX;
|
||||||
|
tCurrent = tMaxX;
|
||||||
|
tMaxX += tDeltaX;
|
||||||
|
} else {
|
||||||
|
z += stepZ;
|
||||||
|
tCurrent = tMaxZ;
|
||||||
|
tMaxZ += tDeltaZ;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (tMaxY < tMaxZ) {
|
||||||
|
y += stepY;
|
||||||
|
tCurrent = tMaxY;
|
||||||
|
tMaxY += tDeltaY;
|
||||||
|
} else {
|
||||||
|
z += stepZ;
|
||||||
|
tCurrent = tMaxZ;
|
||||||
|
tMaxZ += tDeltaZ;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.render.registry;
|
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import eu.mhsl.minecraft.pixelpics.Main;
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.model.AbstractModel;
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.model.Model;
|
|
||||||
import org.bukkit.Color;
|
|
||||||
import org.bukkit.Material;
|
|
||||||
import org.bukkit.block.Block;
|
|
||||||
import org.bukkit.block.data.BlockData;
|
|
||||||
|
|
||||||
import javax.imageio.ImageIO;
|
|
||||||
import java.awt.image.BufferedImage;
|
|
||||||
import java.io.*;
|
|
||||||
import java.net.URL;
|
|
||||||
import java.util.*;
|
|
||||||
|
|
||||||
import static eu.mhsl.minecraft.pixelpics.render.registry.DefaultModelRegistry.TEXTURE_SIZE;
|
|
||||||
|
|
||||||
public class AdvancedModelRegistry implements ModelRegistry {
|
|
||||||
private final Gson gson = new Gson();
|
|
||||||
|
|
||||||
private final Map<Material, Map<BlockData, Model>> modelMap = new HashMap<>();
|
|
||||||
private final Set<String> tintedBlocks = Set.of("grass", "grass_block", "leaves", "oak_leaves", "water", "vine", "sugar_cane");
|
|
||||||
|
|
||||||
public record BlockInfo(String parent, BlockTextures textures){}
|
|
||||||
public record BlockTextures(
|
|
||||||
String texture,
|
|
||||||
String bottom,
|
|
||||||
String top,
|
|
||||||
String all,
|
|
||||||
String particle,
|
|
||||||
String end,
|
|
||||||
String side,
|
|
||||||
String cross,
|
|
||||||
String rail,
|
|
||||||
String overlay
|
|
||||||
){}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void initialize() {
|
|
||||||
System.out.println(modelMap);
|
|
||||||
|
|
||||||
File blockDir = new File(Main.getInstance().getDataFolder(), "models/block");
|
|
||||||
for (File file : Objects.requireNonNull(blockDir.listFiles())) {
|
|
||||||
addModelFromFile(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
registerModel(Material.LAVA, AbstractModel.Builder.createSimple(getTextureArray("lava_still"))
|
|
||||||
.transparency(0.15).reflection(0.05).occlusion().build());
|
|
||||||
registerModel(Material.WATER, AbstractModel.Builder.createSimple(getTextureArray("water_still"))
|
|
||||||
.transparency(0.60).reflection(0.1).occlusion().build());
|
|
||||||
} catch (Exception ignored) { }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Model getModel(Block block) {
|
|
||||||
return ModelRegistry.super.getModel(block);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Model getModel(Material material, BlockData blockData) {
|
|
||||||
return getModel(material, blockData, 0.8, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Model getModel(Material material, BlockData blockData, double temperature, double humidity) {
|
|
||||||
return modelMap.computeIfAbsent(material, key -> new HashMap<>()).getOrDefault(blockData,
|
|
||||||
blockData == null ? getDefaultModel()
|
|
||||||
: modelMap.get(material).getOrDefault(null, getDefaultModel()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Model getDefaultModel() {
|
|
||||||
return AbstractModel.Builder.createStatic(Color.PURPLE.asRGB()).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void registerModel(Material material, Model blockModel) {
|
|
||||||
modelMap.computeIfAbsent(material, key -> new HashMap<>())
|
|
||||||
.put(null, blockModel);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void addModelFromFile(File file) {
|
|
||||||
String blockName = file.getName().substring(0, file.getName().lastIndexOf('.'));
|
|
||||||
Material material = Material.getMaterial(blockName.toUpperCase());
|
|
||||||
if(material == null) return;
|
|
||||||
|
|
||||||
Model model = getModelFromFile(file);
|
|
||||||
if(model == null) return;
|
|
||||||
|
|
||||||
registerModel(material, model);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Model getModelFromFile(File file) {
|
|
||||||
try (Reader reader = new FileReader(file)) {
|
|
||||||
BlockInfo blockInfo = gson.fromJson(reader, BlockInfo.class);
|
|
||||||
|
|
||||||
if(blockInfo.textures.all != null) {
|
|
||||||
return AbstractModel.Builder.createSimple(
|
|
||||||
getTextureArray(blockInfo.textures.all.substring(blockInfo.textures.all.lastIndexOf('/') + 1))
|
|
||||||
).build();
|
|
||||||
}
|
|
||||||
if(blockInfo.textures.cross != null) {
|
|
||||||
return AbstractModel.Builder.createCross(
|
|
||||||
getTextureArray(blockInfo.textures.cross.substring(blockInfo.textures.cross.lastIndexOf('/') + 1))
|
|
||||||
).build();
|
|
||||||
}
|
|
||||||
if(blockInfo.textures.side != null && blockInfo.textures.bottom != null && blockInfo.textures.top != null) {
|
|
||||||
return AbstractModel.Builder.createMulti(
|
|
||||||
getTextureArray(blockInfo.textures.top.substring(blockInfo.textures.top.lastIndexOf('/') + 1)),
|
|
||||||
getTextureArray(blockInfo.textures.side.substring(blockInfo.textures.side.lastIndexOf('/') + 1)),
|
|
||||||
getTextureArray(blockInfo.textures.bottom.substring(blockInfo.textures.bottom.lastIndexOf('/') + 1))
|
|
||||||
).build();
|
|
||||||
}
|
|
||||||
if(blockInfo.textures.side != null && blockInfo.textures.end != null) {
|
|
||||||
return AbstractModel.Builder.createMulti(
|
|
||||||
getTextureArray(blockInfo.textures.end.substring(blockInfo.textures.end.lastIndexOf('/') + 1)),
|
|
||||||
getTextureArray(blockInfo.textures.side.substring(blockInfo.textures.side.lastIndexOf('/') + 1)),
|
|
||||||
getTextureArray(blockInfo.textures.end.substring(blockInfo.textures.end.lastIndexOf('/') + 1))
|
|
||||||
).build();
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.out.println(e.getMessage());
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int[][] getTextureArray(String textureName) {
|
|
||||||
int[][] texture = new int[TEXTURE_SIZE][TEXTURE_SIZE];
|
|
||||||
BufferedImage img;
|
|
||||||
URL url = this.getClass().getClassLoader().getResource(String.format("textures/block/%s.png", textureName));
|
|
||||||
if (url == null) {
|
|
||||||
throw new RuntimeException("Block Texture Resource not found.");
|
|
||||||
}
|
|
||||||
try (InputStream input = url.openConnection().getInputStream()) {
|
|
||||||
img = ImageIO.read(input);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int pixelY = 0; pixelY < TEXTURE_SIZE; pixelY++) {
|
|
||||||
for (int pixelX = 0; pixelX < TEXTURE_SIZE; pixelX++) {
|
|
||||||
texture[TEXTURE_SIZE - 1 - pixelY][TEXTURE_SIZE - 1 - pixelX] = img.getRGB(pixelX, pixelY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return texture;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int tintPixel(int baseColor, int tintColor) {
|
|
||||||
int a = (baseColor >> 24) & 0xFF;
|
|
||||||
int r = ((baseColor >> 16) & 0xFF) * ((tintColor >> 16) & 0xFF) / 255;
|
|
||||||
int g = ((baseColor >> 8) & 0xFF) * ((tintColor >> 8) & 0xFF) / 255;
|
|
||||||
int b = (baseColor & 0xFF) * (tintColor & 0xFF) / 255;
|
|
||||||
return (a << 24) | (r << 16) | (g << 8) | b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.render.registry;
|
|
||||||
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.model.AbstractModel.Builder;
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.model.Model;
|
|
||||||
import org.bukkit.Color;
|
|
||||||
import org.bukkit.Material;
|
|
||||||
import org.bukkit.block.data.BlockData;
|
|
||||||
|
|
||||||
import javax.imageio.ImageIO;
|
|
||||||
import java.awt.image.BufferedImage;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.net.URL;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
public class DefaultModelRegistry implements ModelRegistry {
|
|
||||||
|
|
||||||
private static final String IMAGE_RESOURCE = "terrain.png";
|
|
||||||
static final int TEXTURE_SIZE = 16;
|
|
||||||
|
|
||||||
private final Map<Material, Map<BlockData, Model>> modelMap;
|
|
||||||
private BufferedImage textures;
|
|
||||||
|
|
||||||
public DefaultModelRegistry() {
|
|
||||||
this.modelMap = new HashMap<>();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void initialize() {
|
|
||||||
URL url = this.getClass().getClassLoader().getResource(IMAGE_RESOURCE);
|
|
||||||
if (url == null) {
|
|
||||||
throw new RuntimeException("Default resource \"terrain.png\" is missing");
|
|
||||||
}
|
|
||||||
try (InputStream input = url.openConnection().getInputStream()) {
|
|
||||||
this.textures = ImageIO.read(input);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
registerModel(Material.GRASS_BLOCK, Builder.createMulti(textureIndex(0, 0), textureIndex(0, 3), textureIndex(0, 2)).build());
|
|
||||||
registerModel(Material.STONE, Builder.createSimple(textureIndex(0, 1)).build());
|
|
||||||
registerModel(Material.DIRT, Builder.createSimple(textureIndex(0, 2)).build());
|
|
||||||
registerModel(Material.OAK_PLANKS, Builder.createSimple(textureIndex(0, 4)).build());
|
|
||||||
registerModel(Material.SPRUCE_PLANKS,
|
|
||||||
Builder.createSimple(textureIndex(0, 4)).build());
|
|
||||||
registerModel(Material.BIRCH_PLANKS,
|
|
||||||
Builder.createSimple(textureIndex(0, 4)).build());
|
|
||||||
registerModel(Material.JUNGLE_PLANKS,
|
|
||||||
Builder.createSimple(textureIndex(0, 4)).build());
|
|
||||||
registerModel(Material.ACACIA_PLANKS,
|
|
||||||
Builder.createSimple(textureIndex(0, 4)).build());
|
|
||||||
registerModel(Material.DARK_OAK_PLANKS,
|
|
||||||
Builder.createSimple(textureIndex(0, 4)).build());
|
|
||||||
registerModel(Material.BRICK, Builder.createSimple(textureIndex(0, 7)).build());
|
|
||||||
registerModel(Material.TNT, Builder.createMulti(textureIndex(0, 9),
|
|
||||||
textureIndex(0, 8), textureIndex(0, 10)).build());
|
|
||||||
registerModel(Material.WATER, Builder.createStatic(0xFF000000 | Color.fromRGB(0, 5, 60).asRGB())
|
|
||||||
.transparency(0.60).reflection(0.1).occlusion().build());
|
|
||||||
registerModel(Material.DIAMOND_BLOCK,
|
|
||||||
Builder.createSimple(textureIndex(3, 3)).reflection(0.75).build());
|
|
||||||
registerModel(Material.POPPY, Builder.createCross(textureIndex(0, 12)).build());
|
|
||||||
registerModel(Material.DANDELION, Builder.createCross(textureIndex(0, 13)).build());
|
|
||||||
registerModel(Material.OAK_SAPLING,
|
|
||||||
Builder.createCross(textureIndex(0, 15)).build());
|
|
||||||
registerModel(Material.SPRUCE_SAPLING,
|
|
||||||
Builder.createCross(textureIndex(0, 15)).build());
|
|
||||||
registerModel(Material.BIRCH_SAPLING,
|
|
||||||
Builder.createCross(textureIndex(0, 15)).build());
|
|
||||||
registerModel(Material.JUNGLE_SAPLING,
|
|
||||||
Builder.createCross(textureIndex(0, 15)).build());
|
|
||||||
registerModel(Material.ACACIA_SAPLING,
|
|
||||||
Builder.createCross(textureIndex(0, 15)).build());
|
|
||||||
registerModel(Material.DARK_OAK_SAPLING,
|
|
||||||
Builder.createCross(textureIndex(0, 15)).build());
|
|
||||||
|
|
||||||
registerModel(Material.COBBLESTONE,
|
|
||||||
Builder.createSimple(textureIndex(1, 0)).build());
|
|
||||||
registerModel(Material.BEDROCK, Builder.createSimple(textureIndex(1, 1)).build());
|
|
||||||
registerModel(Material.SAND, Builder.createSimple(textureIndex(1, 2)).build());
|
|
||||||
registerModel(Material.GRAVEL, Builder.createSimple(textureIndex(1, 3)).build());
|
|
||||||
registerModel(Material.OAK_LOG, Builder.createMulti(textureIndex(1, 5),
|
|
||||||
textureIndex(1, 4), textureIndex(1, 5)).build());
|
|
||||||
registerModel(Material.SPRUCE_LOG, Builder.createMulti(textureIndex(1, 5),
|
|
||||||
textureIndex(1, 4), textureIndex(1, 5)).build());
|
|
||||||
registerModel(Material.BIRCH_LOG, Builder.createMulti(textureIndex(1, 5),
|
|
||||||
textureIndex(1, 4), textureIndex(1, 5)).build());
|
|
||||||
registerModel(Material.JUNGLE_LOG, Builder.createMulti(textureIndex(1, 5),
|
|
||||||
textureIndex(1, 4), textureIndex(1, 5)).build());
|
|
||||||
registerModel(Material.ACACIA_LOG, Builder.createMulti(textureIndex(1, 5),
|
|
||||||
textureIndex(1, 4), textureIndex(1, 5)).build());
|
|
||||||
registerModel(Material.DARK_OAK_LOG, Builder.createMulti(textureIndex(1, 5),
|
|
||||||
textureIndex(1, 4), textureIndex(1, 5)).build());
|
|
||||||
registerModel(Material.OAK_WOOD, Builder.createSimple(textureIndex(1, 4)).build());
|
|
||||||
registerModel(Material.SPRUCE_WOOD,
|
|
||||||
Builder.createSimple(textureIndex(1, 4)).build());
|
|
||||||
registerModel(Material.BIRCH_WOOD, Builder.createSimple(textureIndex(1, 4)).build());
|
|
||||||
registerModel(Material.JUNGLE_WOOD,
|
|
||||||
Builder.createSimple(textureIndex(1, 4)).build());
|
|
||||||
registerModel(Material.ACACIA_WOOD,
|
|
||||||
Builder.createSimple(textureIndex(1, 4)).build());
|
|
||||||
registerModel(Material.DARK_OAK_WOOD,
|
|
||||||
Builder.createSimple(textureIndex(1, 4)).build());
|
|
||||||
registerModel(Material.OAK_LEAVES, Builder.createSimple(textureIndex(1, 6)).build());
|
|
||||||
registerModel(Material.SPRUCE_LEAVES,
|
|
||||||
Builder.createSimple(textureIndex(1, 6)).build());
|
|
||||||
registerModel(Material.BIRCH_LEAVES,
|
|
||||||
Builder.createSimple(textureIndex(1, 6)).build());
|
|
||||||
registerModel(Material.JUNGLE_LEAVES,
|
|
||||||
Builder.createSimple(textureIndex(1, 6)).build());
|
|
||||||
registerModel(Material.ACACIA_LEAVES,
|
|
||||||
Builder.createSimple(textureIndex(1, 6)).build());
|
|
||||||
registerModel(Material.DARK_OAK_LEAVES,
|
|
||||||
Builder.createSimple(textureIndex(1, 6)).build());
|
|
||||||
registerModel(Material.IRON_BLOCK,
|
|
||||||
Builder.createMulti(textureIndex(1, 7),
|
|
||||||
textureIndex(2, 7), textureIndex(3, 7)).build());
|
|
||||||
registerModel(Material.GOLD_BLOCK, Builder.createMulti(textureIndex(1, 8),
|
|
||||||
textureIndex(2, 8), textureIndex(3, 8)).build());
|
|
||||||
registerModel(Material.RED_MUSHROOM,
|
|
||||||
Builder.createCross(textureIndex(1, 12)).build());
|
|
||||||
registerModel(Material.BROWN_MUSHROOM,
|
|
||||||
Builder.createCross(textureIndex(1, 13)).build());
|
|
||||||
registerModel(Material.LAVA, Builder.createSimple(textureIndex(2, 14))
|
|
||||||
.transparency(0.15).reflection(0.05).occlusion().build());
|
|
||||||
|
|
||||||
registerModel(Material.GOLD_ORE, Builder.createSimple(textureIndex(2, 0)).build());
|
|
||||||
registerModel(Material.IRON_ORE, Builder.createSimple(textureIndex(2, 1)).build());
|
|
||||||
registerModel(Material.COAL_ORE, Builder.createSimple(textureIndex(2, 2)).build());
|
|
||||||
|
|
||||||
registerModel(Material.GLASS,
|
|
||||||
Builder.createSimple(textureIndex(3, 1)).occlusion().build());
|
|
||||||
|
|
||||||
registerModel(Material.SHORT_GRASS, Builder.createCross(textureIndex(5, 6)).build());
|
|
||||||
registerModel(Material.SUGAR_CANE, Builder.createCross(textureIndex(5, 5)).build());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Model getModel(Material material, BlockData blockData) {
|
|
||||||
return modelMap.computeIfAbsent(material, key -> new HashMap<>()).getOrDefault(blockData,
|
|
||||||
blockData == null ? getDefaultModel()
|
|
||||||
: modelMap.get(material).getOrDefault(null, getDefaultModel()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Model getDefaultModel() {
|
|
||||||
return Builder.createStatic(Color.PURPLE.asRGB()).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void registerModel(Material material, Model blockModel) {
|
|
||||||
modelMap.computeIfAbsent(material, key -> new HashMap<>())
|
|
||||||
.put(null, blockModel);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int[][] textureIndex(int verticalIndex, int horizontalIndex) {
|
|
||||||
int[][] texture = new int[TEXTURE_SIZE][TEXTURE_SIZE];
|
|
||||||
|
|
||||||
int offsetY = verticalIndex * TEXTURE_SIZE + (TEXTURE_SIZE - 1);
|
|
||||||
int offsetX = horizontalIndex * TEXTURE_SIZE;
|
|
||||||
|
|
||||||
for (int pixelY = 0; pixelY < TEXTURE_SIZE; pixelY++) {
|
|
||||||
for (int pixelX = 0; pixelX < TEXTURE_SIZE; pixelX++) {
|
|
||||||
texture[pixelY][pixelX] = textures.getRGB(offsetX + pixelX, offsetY - pixelY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return texture;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.render.registry;
|
|
||||||
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.model.Model;
|
|
||||||
import org.bukkit.Material;
|
|
||||||
import org.bukkit.block.Block;
|
|
||||||
import org.bukkit.block.data.BlockData;
|
|
||||||
|
|
||||||
public interface ModelRegistry {
|
|
||||||
|
|
||||||
void initialize();
|
|
||||||
|
|
||||||
default Model getModel(Block block) {
|
|
||||||
return getModel(block.getType(), block.getBlockData());
|
|
||||||
}
|
|
||||||
|
|
||||||
default Model getModel(Material material) {
|
|
||||||
return getModel(material, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
Model getModel(Material material, BlockData blockData);
|
|
||||||
|
|
||||||
Model getDefaultModel();
|
|
||||||
}
|
|
||||||
+173
-37
@@ -1,52 +1,183 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.render.render;
|
package eu.mhsl.minecraft.pixelpics.render.render;
|
||||||
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.raytrace.DefaultRaytracer;
|
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
|
||||||
import eu.mhsl.minecraft.pixelpics.render.raytrace.Raytracer;
|
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.cem.BlockEntityBaker;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.raytrace.SnapshotRaytracer;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.sky.SkyRenderer;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.snapshot.BlockEntitySnapshotBuilder;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.snapshot.DecorationSnapshotBuilder;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.snapshot.EntitySnapshotBuilder;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.snapshot.SnapshotBuilder;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
|
||||||
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
|
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
|
||||||
import org.bukkit.Location;
|
|
||||||
import org.bukkit.World;
|
|
||||||
import org.bukkit.util.Vector;
|
|
||||||
|
|
||||||
import java.awt.image.BufferedImage;
|
import java.awt.image.BufferedImage;
|
||||||
import java.awt.image.DataBufferInt;
|
import java.awt.image.DataBufferInt;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ForkJoinPool;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
import java.util.stream.IntStream;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.util.Vector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the scene by capturing a world snapshot on the main thread ({@link #prepare}) and then
|
||||||
|
* tracing one ray per pixel in parallel against that snapshot ({@link #execute}).
|
||||||
|
*/
|
||||||
public class DefaultScreenRenderer implements Renderer {
|
public class DefaultScreenRenderer implements Renderer {
|
||||||
|
|
||||||
private static final double FOV_YAW_DEG = 53;
|
/** Horizontal half field-of-view; the vertical half is derived from the output aspect ratio. */
|
||||||
private static final double FOV_PITCH_DEG = 23;
|
private static final double H_FOV_HALF_RAD = Math.toRadians(35);
|
||||||
|
|
||||||
private static final double FOV_YAW_RAD = Math.toRadians(FOV_YAW_DEG);
|
|
||||||
private static final double FOV_PITCH_RAD = Math.toRadians(FOV_PITCH_DEG);
|
|
||||||
|
|
||||||
private static final Vector BASE_VEC = new Vector(1, 0, 0);
|
private static final Vector BASE_VEC = new Vector(1, 0, 0);
|
||||||
|
|
||||||
private final Raytracer raytracer;
|
private static final double MAX_DISTANCE = 256;
|
||||||
|
private static final int REFLECTION_DEPTH = 4;
|
||||||
|
|
||||||
public DefaultScreenRenderer() {
|
/** Supersampling factor: SSAA x SSAA rays per output pixel, downsampled gamma-correctly. */
|
||||||
this.raytracer = new DefaultRaytracer();
|
private static final int SSAA = 3;
|
||||||
|
|
||||||
|
private final SnapshotRaytracer raytracer;
|
||||||
|
private final CemBaker entityBaker;
|
||||||
|
private final BlockEntityBaker blockEntityBaker;
|
||||||
|
private final DecorationBaker decorationBaker;
|
||||||
|
private final Logger logger;
|
||||||
|
/** Bounds parallel ray tracing to a fixed, low-priority pool; {@code null} = use the common pool. */
|
||||||
|
private final ForkJoinPool tracePool;
|
||||||
|
|
||||||
|
public DefaultScreenRenderer(
|
||||||
|
BlockModelRegistry registry,
|
||||||
|
BiomeTintProvider tintProvider,
|
||||||
|
TextureCache textures,
|
||||||
|
CemBaker entityBaker,
|
||||||
|
BlockEntityBaker blockEntityBaker,
|
||||||
|
Logger logger) {
|
||||||
|
this(registry, tintProvider, textures, entityBaker, blockEntityBaker, logger, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public DefaultScreenRenderer(
|
||||||
|
BlockModelRegistry registry,
|
||||||
|
BiomeTintProvider tintProvider,
|
||||||
|
TextureCache textures,
|
||||||
|
CemBaker entityBaker,
|
||||||
|
BlockEntityBaker blockEntityBaker,
|
||||||
|
Logger logger,
|
||||||
|
ForkJoinPool tracePool) {
|
||||||
|
SkyRenderer skyRenderer = new SkyRenderer(textures);
|
||||||
|
this.raytracer = new SnapshotRaytracer(registry, tintProvider, skyRenderer, MAX_DISTANCE, REFLECTION_DEPTH);
|
||||||
|
this.entityBaker = entityBaker;
|
||||||
|
this.blockEntityBaker = blockEntityBaker;
|
||||||
|
this.decorationBaker = new DecorationBaker(textures);
|
||||||
|
this.logger = logger;
|
||||||
|
this.tracePool = tracePool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience: prepare and execute in one call (must run on the main thread). */
|
||||||
@Override
|
@Override
|
||||||
public BufferedImage render(Location eyeLocation, Resolution resolution) {
|
public BufferedImage render(Location eyeLocation, Resolution resolution) {
|
||||||
int width = resolution.getWidth();
|
return execute(prepare(eyeLocation, resolution, null));
|
||||||
int height = resolution.getHeight();
|
|
||||||
|
|
||||||
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
|
||||||
int[] imageData = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
|
|
||||||
|
|
||||||
World world = eyeLocation.getWorld();
|
|
||||||
Vector linePoint = eyeLocation.toVector();
|
|
||||||
List<Vector> rayMap = buildRayMap(eyeLocation, resolution);
|
|
||||||
for (int i = 0; i < rayMap.size(); i++) {
|
|
||||||
imageData[i] = raytracer.trace(world, linePoint, rayMap.get(i));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Builds the (supersampled) ray map and captures world + entities. MUST run on the main thread. */
|
||||||
|
public RenderJob prepare(Location eyeLocation, Resolution resolution, UUID shooter) {
|
||||||
|
int superW = resolution.getWidth() * SSAA;
|
||||||
|
int superH = resolution.getHeight() * SSAA;
|
||||||
|
List<Vector> rayMap = buildRayMap(eyeLocation, superW, superH);
|
||||||
|
WorldSnapshot snapshot = SnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE, logger);
|
||||||
|
List<EntityState> entities = EntitySnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE, shooter);
|
||||||
|
List<BlockEntityState> blockEntities = BlockEntitySnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE);
|
||||||
|
List<DecorationState> decorations = DecorationSnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE);
|
||||||
|
|
||||||
|
World world = eyeLocation.getWorld();
|
||||||
|
long dayTime = world.getTime();
|
||||||
|
long fullTime = world.getFullTime();
|
||||||
|
int moonPhase = (int) (fullTime / 24000L % 8L);
|
||||||
|
SkyContext sky = new SkyContext(dayTime, moonPhase, fullTime);
|
||||||
|
|
||||||
|
return new RenderJob(
|
||||||
|
snapshot,
|
||||||
|
rayMap,
|
||||||
|
eyeLocation.toVector(),
|
||||||
|
resolution.getWidth(),
|
||||||
|
resolution.getHeight(),
|
||||||
|
sky,
|
||||||
|
entities,
|
||||||
|
blockEntities,
|
||||||
|
decorations);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Traces every (super)ray in parallel, then downsamples gamma-correctly. Safe off the main thread. */
|
||||||
|
public BufferedImage execute(RenderJob job) {
|
||||||
|
return execute(job, new AtomicBoolean(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As {@link #execute(RenderJob)}, but cooperatively abortable: once {@code cancelled} is set, the
|
||||||
|
* parallel loops short-circuit (remaining work becomes a no-op) so a stuck/overlong render drains
|
||||||
|
* out quickly. The returned image is then partial and should be discarded by the caller.
|
||||||
|
*/
|
||||||
|
public BufferedImage execute(RenderJob job, AtomicBoolean cancelled) {
|
||||||
|
int finalW = job.width();
|
||||||
|
int finalH = job.height();
|
||||||
|
int superW = finalW * SSAA;
|
||||||
|
List<Vector> rayMap = job.rayMap();
|
||||||
|
WorldSnapshot snapshot = job.snapshot();
|
||||||
|
Vector origin = job.origin();
|
||||||
|
SkyContext sky = job.sky();
|
||||||
|
EntityScene scene = new EntityScene(
|
||||||
|
job.entities(), entityBaker, job.blockEntities(), blockEntityBaker, job.decorations(), decorationBaker);
|
||||||
|
|
||||||
|
int[] superBuf = new int[rayMap.size()];
|
||||||
|
runParallel(() -> IntStream.range(0, rayMap.size()).parallel().forEach(i -> {
|
||||||
|
if (!cancelled.get()) superBuf[i] = raytracer.trace(snapshot, origin, rayMap.get(i), sky, scene);
|
||||||
|
}));
|
||||||
|
|
||||||
|
BufferedImage image = new BufferedImage(finalW, finalH, BufferedImage.TYPE_INT_RGB);
|
||||||
|
int[] imageData = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
|
||||||
|
runParallel(() -> IntStream.range(0, finalH).parallel().forEach(fy -> {
|
||||||
|
if (cancelled.get()) return;
|
||||||
|
int[] block = new int[SSAA * SSAA];
|
||||||
|
for (int fx = 0; fx < finalW; fx++) {
|
||||||
|
int n = 0;
|
||||||
|
for (int sy = 0; sy < SSAA; sy++) {
|
||||||
|
int srcRow = (fy * SSAA + sy) * superW + fx * SSAA;
|
||||||
|
for (int sx = 0; sx < SSAA; sx++) {
|
||||||
|
block[n++] = superBuf[srcRow + sx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imageData[fy * finalW + fx] = ColorUtil.averageLinear(block, 0, n);
|
||||||
|
}
|
||||||
|
}));
|
||||||
return image;
|
return image;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Vector> buildRayMap(Location eyeLocation, Resolution resolution) {
|
/**
|
||||||
|
* Runs a parallel-stream task. With a {@link #tracePool} the work is confined to that bounded,
|
||||||
|
* low-priority pool (parallel streams adopt the pool of the running fork-join worker); without one
|
||||||
|
* it runs inline on the common pool (used by the offline render tools).
|
||||||
|
*/
|
||||||
|
private void runParallel(Runnable task) {
|
||||||
|
if (tracePool == null) {
|
||||||
|
task.run();
|
||||||
|
} else {
|
||||||
|
tracePool.submit(task).join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Vector> buildRayMap(Location eyeLocation, int width, int height) {
|
||||||
Vector lineDirection = eyeLocation.getDirection();
|
Vector lineDirection = eyeLocation.getDirection();
|
||||||
|
|
||||||
double x = lineDirection.getX();
|
double x = lineDirection.getX();
|
||||||
@@ -56,26 +187,31 @@ public class DefaultScreenRenderer implements Renderer {
|
|||||||
double angleYaw = Math.atan2(z, x);
|
double angleYaw = Math.atan2(z, x);
|
||||||
double anglePitch = Math.atan2(y, Math.sqrt(x * x + z * z));
|
double anglePitch = Math.atan2(y, Math.sqrt(x * x + z * z));
|
||||||
|
|
||||||
Vector lowerLeftCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, -FOV_YAW_RAD, -FOV_PITCH_RAD, angleYaw, anglePitch);
|
// Derive the vertical half-FOV from the horizontal one so square output is not distorted.
|
||||||
Vector upperLeftCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, -FOV_YAW_RAD, FOV_PITCH_RAD, angleYaw, anglePitch);
|
double yawHalf = H_FOV_HALF_RAD;
|
||||||
Vector lowerRightCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, FOV_YAW_RAD, -FOV_PITCH_RAD, angleYaw, anglePitch);
|
double pitchHalf = Math.atan(Math.tan(yawHalf) * ((double) height / width));
|
||||||
Vector upperRightCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, FOV_YAW_RAD, FOV_PITCH_RAD, angleYaw, anglePitch);
|
|
||||||
|
Vector lowerLeftCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, -yawHalf, -pitchHalf, angleYaw, anglePitch);
|
||||||
|
Vector upperLeftCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, -yawHalf, pitchHalf, angleYaw, anglePitch);
|
||||||
|
Vector lowerRightCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, yawHalf, -pitchHalf, angleYaw, anglePitch);
|
||||||
|
Vector upperRightCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, yawHalf, pitchHalf, angleYaw, anglePitch);
|
||||||
|
|
||||||
int width = resolution.getWidth();
|
|
||||||
int height = resolution.getHeight();
|
|
||||||
List<Vector> rayMap = new ArrayList<>(width * height);
|
List<Vector> rayMap = new ArrayList<>(width * height);
|
||||||
|
|
||||||
Vector leftFraction = upperLeftCorner.clone().subtract(lowerLeftCorner).multiply(1.0 / (height - 1));
|
Vector leftFraction = upperLeftCorner.clone().subtract(lowerLeftCorner).multiply(1.0 / (height - 1));
|
||||||
Vector rightFraction = upperRightCorner.clone().subtract(lowerRightCorner).multiply(1.0 / (height - 1));
|
Vector rightFraction =
|
||||||
|
upperRightCorner.clone().subtract(lowerRightCorner).multiply(1.0 / (height - 1));
|
||||||
|
|
||||||
for (int pitch = 0; pitch < height; pitch++) {
|
for (int pitch = 0; pitch < height; pitch++) {
|
||||||
|
Vector leftPitch =
|
||||||
Vector leftPitch = upperLeftCorner.clone().subtract(leftFraction.clone().multiply(pitch));
|
upperLeftCorner.clone().subtract(leftFraction.clone().multiply(pitch));
|
||||||
Vector rightPitch = upperRightCorner.clone().subtract(rightFraction.clone().multiply(pitch));
|
Vector rightPitch =
|
||||||
|
upperRightCorner.clone().subtract(rightFraction.clone().multiply(pitch));
|
||||||
Vector yawFraction = rightPitch.clone().subtract(leftPitch).multiply(1.0 / (width - 1));
|
Vector yawFraction = rightPitch.clone().subtract(leftPitch).multiply(1.0 / (width - 1));
|
||||||
|
|
||||||
for (int yaw = 0; yaw < width; yaw++) {
|
for (int yaw = 0; yaw < width; yaw++) {
|
||||||
Vector ray = leftPitch.clone().add(yawFraction.clone().multiply(yaw)).normalize();
|
Vector ray =
|
||||||
|
leftPitch.clone().add(yawFraction.clone().multiply(yaw)).normalize();
|
||||||
rayMap.add(ray);
|
rayMap.add(ray);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.render;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
|
||||||
|
import java.util.List;
|
||||||
|
import org.bukkit.util.Vector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A prepared render: the world snapshot (captured on the main thread) plus the ray map, camera origin,
|
||||||
|
* the sky context (time of day / moon phase) and the captured entity, block-entity and decoration
|
||||||
|
* (painting / item frame) states. {@link DefaultScreenRenderer#execute} can run this off the main thread.
|
||||||
|
*/
|
||||||
|
public record RenderJob(
|
||||||
|
WorldSnapshot snapshot,
|
||||||
|
List<Vector> rayMap,
|
||||||
|
Vector origin,
|
||||||
|
int width,
|
||||||
|
int height,
|
||||||
|
SkyContext sky,
|
||||||
|
List<EntityState> entities,
|
||||||
|
List<BlockEntityState> blockEntities,
|
||||||
|
List<DecorationState> decorations) {
|
||||||
|
|
||||||
|
/** Backwards-compatible constructor (no block-entities/decorations), used by the standalone harness. */
|
||||||
|
public RenderJob(
|
||||||
|
WorldSnapshot snapshot,
|
||||||
|
List<Vector> rayMap,
|
||||||
|
Vector origin,
|
||||||
|
int width,
|
||||||
|
int height,
|
||||||
|
SkyContext sky,
|
||||||
|
List<EntityState> entities) {
|
||||||
|
this(snapshot, rayMap, origin, width, height, sky, entities, List.of(), List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience for callers that supply entities + block-entities but no decorations. */
|
||||||
|
public RenderJob(
|
||||||
|
WorldSnapshot snapshot,
|
||||||
|
List<Vector> rayMap,
|
||||||
|
Vector origin,
|
||||||
|
int width,
|
||||||
|
int height,
|
||||||
|
SkyContext sky,
|
||||||
|
List<EntityState> entities,
|
||||||
|
List<BlockEntityState> blockEntities) {
|
||||||
|
this(snapshot, rayMap, origin, width, height, sky, entities, blockEntities, List.of());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.render.render;
|
package eu.mhsl.minecraft.pixelpics.render.render;
|
||||||
|
|
||||||
import org.bukkit.Location;
|
|
||||||
|
|
||||||
import java.awt.image.BufferedImage;
|
import java.awt.image.BufferedImage;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
|
||||||
public interface Renderer {
|
public interface Renderer {
|
||||||
BufferedImage render(Location eyeLocation, Resolution resolution);
|
BufferedImage render(Location eyeLocation, Resolution resolution);
|
||||||
|
|||||||
@@ -2,19 +2,16 @@ package eu.mhsl.minecraft.pixelpics.render.render;
|
|||||||
|
|
||||||
import com.google.common.base.Preconditions;
|
import com.google.common.base.Preconditions;
|
||||||
|
|
||||||
import java.util.*;
|
|
||||||
|
|
||||||
public final class Resolution {
|
public final class Resolution {
|
||||||
|
|
||||||
private final int width;
|
private final int width;
|
||||||
private final int height;
|
private final int height;
|
||||||
|
|
||||||
public Resolution(Pixels pixels, AspectRatio aspectRatio) {
|
public Resolution(Pixels pixels, AspectRatio aspectRatio) {
|
||||||
Preconditions.checkNotNull(pixels);
|
this(
|
||||||
Preconditions.checkNotNull(aspectRatio);
|
(int) Math.round(
|
||||||
|
Preconditions.checkNotNull(pixels).height * Preconditions.checkNotNull(aspectRatio).ratio),
|
||||||
this.height = pixels.height;
|
pixels.height);
|
||||||
this.width = (int) Math.round(pixels.height * aspectRatio.ratio);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Resolution(int width, int height) {
|
public Resolution(int width, int height) {
|
||||||
@@ -34,29 +31,25 @@ public final class Resolution {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public enum Pixels {
|
public enum Pixels {
|
||||||
_128P(128, "128p"),
|
_128P(128),
|
||||||
_256P(256, "256p");
|
_256P(256);
|
||||||
|
|
||||||
private final int height;
|
private final int height;
|
||||||
private final List<String> aliases;
|
|
||||||
|
|
||||||
Pixels(int height, String... aliases) {
|
Pixels(int height) {
|
||||||
this.height = height;
|
this.height = height;
|
||||||
this.aliases = Collections.unmodifiableList(Arrays.asList(aliases));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum AspectRatio {
|
public enum AspectRatio {
|
||||||
_1_1(1, "1:1"),
|
_1_1(1),
|
||||||
_2_1(2, "2:1"),
|
_2_1(2),
|
||||||
_3_2(3 / 2.0, "3:2");
|
_3_2(3 / 2.0);
|
||||||
|
|
||||||
private final double ratio;
|
private final double ratio;
|
||||||
private final List<String> aliases;
|
|
||||||
|
|
||||||
AspectRatio(double ratio, String... aliases) {
|
AspectRatio(double ratio) {
|
||||||
this.ratio = ratio;
|
this.ratio = ratio;
|
||||||
this.aliases = Collections.unmodifiableList(Arrays.asList(aliases));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.sky;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-render sky state captured on the main thread: the world time of day (0..24000), the moon phase
|
||||||
|
* (0..7) and the absolute world time (for continuous cloud drift). Immutable so it can be read from
|
||||||
|
* worker threads.
|
||||||
|
*/
|
||||||
|
public record SkyContext(long dayTime, int moonPhase, long fullTime) {}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.sky;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
|
||||||
|
import org.bukkit.util.Vector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes a time-of-day dependent sky color for rays that escape the world: a day/night gradient
|
||||||
|
* with twilight glow, the sun and moon (with phase), stars at night and a procedural cloud layer.
|
||||||
|
* All inputs are immutable ({@link SkyContext} + captured textures), so it is thread safe.
|
||||||
|
*/
|
||||||
|
public final class SkyRenderer {
|
||||||
|
|
||||||
|
private static final double TICKS_PER_DAY = 24000.0;
|
||||||
|
private static final double CLOUD_HEIGHT = 192.0;
|
||||||
|
private static final double CLOUD_CELL = 12.0; // world blocks per cloud texel
|
||||||
|
private static final double CLOUD_SPEED = 0.03; // blocks per tick, drift along +X
|
||||||
|
|
||||||
|
private static final double SUN_HALF = 0.085; // angular half-size (radians)
|
||||||
|
private static final double MOON_HALF = 0.075;
|
||||||
|
|
||||||
|
// Gradient endpoints (RGB).
|
||||||
|
private static final int DAY_ZENITH = rgb(86, 138, 252);
|
||||||
|
private static final int DAY_HORIZON = rgb(170, 205, 255);
|
||||||
|
private static final int NIGHT_ZENITH = rgb(2, 3, 12);
|
||||||
|
private static final int NIGHT_HORIZON = rgb(10, 14, 40);
|
||||||
|
private static final int SUNSET_ORANGE = rgb(255, 150, 70);
|
||||||
|
private static final int SUNSET_RED = rgb(205, 70, 60);
|
||||||
|
private static final int TWI_PURPLE = rgb(80, 42, 92);
|
||||||
|
|
||||||
|
private final int[][] sunTexture;
|
||||||
|
private final int[][] moonTexture;
|
||||||
|
private final int[][] cloudTexture;
|
||||||
|
|
||||||
|
public SkyRenderer(TextureCache textures) {
|
||||||
|
this.sunTexture =
|
||||||
|
textures.get(ResourceLocation.parse("environment/sun")).orElse(null);
|
||||||
|
this.moonTexture =
|
||||||
|
textures.get(ResourceLocation.parse("environment/moon_phases")).orElse(null);
|
||||||
|
this.cloudTexture =
|
||||||
|
textures.get(ResourceLocation.parse("environment/clouds")).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int colorFor(Vector direction, Vector origin, SkyContext ctx) {
|
||||||
|
double dx = direction.getX(), dy = direction.getY(), dz = direction.getZ();
|
||||||
|
double len = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||||
|
if (len < 1e-9) return DAY_ZENITH;
|
||||||
|
dx /= len;
|
||||||
|
dy /= len;
|
||||||
|
dz /= len;
|
||||||
|
|
||||||
|
// Sun/moon position, derived exactly from Minecraft's sky transforms:
|
||||||
|
// celestialAngle ca = getTimeOfDay(dayTime); the sun is rotated by ca*360deg about the X axis
|
||||||
|
// (after a -90deg Y rotation), giving sunDir = (-sin(2*pi*ca), cos(2*pi*ca), 0) in world space.
|
||||||
|
double ca = celestialAngle(ctx.dayTime());
|
||||||
|
double ang = ca * 2 * Math.PI;
|
||||||
|
double sunX = -Math.sin(ang), sunY = Math.cos(ang);
|
||||||
|
double dayFactor = smoothstep(-0.20, 0.25, sunY);
|
||||||
|
|
||||||
|
// Base vertical gradient, blended day<->night.
|
||||||
|
double up = Math.clamp(dy, 0, 1);
|
||||||
|
int dayColor = lerp(DAY_HORIZON, DAY_ZENITH, up);
|
||||||
|
int nightColor = lerp(NIGHT_HORIZON, NIGHT_ZENITH, up);
|
||||||
|
int color = lerp(nightColor, dayColor, dayFactor);
|
||||||
|
|
||||||
|
// Sunrise/sunset: a full-sky warm wash (orange at the horizon -> red -> purple at the zenith),
|
||||||
|
// strongest while the sun is near the horizon and warmer toward its azimuth. Matches vanilla.
|
||||||
|
double twilight = Math.clamp(1 - Math.abs(sunY) / 0.45, 0, 1);
|
||||||
|
if (twilight > 0) {
|
||||||
|
double az = Math.clamp(dx * Math.signum(sunX) * 0.5 + 0.5, 0, 1); // 1 toward sun .. 0 away
|
||||||
|
int grad = up < 0.40
|
||||||
|
? lerp(SUNSET_ORANGE, SUNSET_RED, up / 0.40)
|
||||||
|
: lerp(SUNSET_RED, TWI_PURPLE, (up - 0.40) / 0.60);
|
||||||
|
int twiColor = lerp(lerp(TWI_PURPLE, grad, 0.55), grad, az); // cooler away from the sun
|
||||||
|
color = lerp(color, twiColor, twilight * 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stars: at night, faded out by daylight and twilight.
|
||||||
|
if (dy > 0) {
|
||||||
|
double visibility = (1 - dayFactor) * (1 - twilight);
|
||||||
|
if (visibility > 0.05) {
|
||||||
|
double star = starField(dx, dy, dz);
|
||||||
|
if (star > 0) {
|
||||||
|
int s = (int) (star * 255 * visibility);
|
||||||
|
color = add(color, s, s, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warm bloom halo around the sun near the horizon.
|
||||||
|
if (sunY > -0.20) {
|
||||||
|
double cosSun = dx * sunX + dy * sunY;
|
||||||
|
if (cosSun > 0) {
|
||||||
|
double bloom = Math.pow(Math.clamp(cosSun, 0, 1), 16) * Math.clamp(sunY + 0.3, 0, 1);
|
||||||
|
color = lerp(color, rgb(255, 235, 190), bloom * 0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sun disc (soft glowing disc, texture used only as a shape mask).
|
||||||
|
if (sunY > -0.15) {
|
||||||
|
color = overlayDisc(color, dx, dy, dz, sunX, sunY, 0, SUN_HALF, sunTexture, rgb(255, 244, 214), -1);
|
||||||
|
}
|
||||||
|
// Moon disc (phase shape from the texture's alpha).
|
||||||
|
if (-sunY > -0.15) {
|
||||||
|
color = overlayDisc(
|
||||||
|
color, dx, dy, dz, -sunX, -sunY, 0, MOON_HALF, moonTexture, rgb(228, 228, 238), ctx.moonPhase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cloud layer: the ray crosses the cloud plane at y = CLOUD_HEIGHT; the world hit point is
|
||||||
|
// mapped to a clouds.png texel exactly as vanilla does (see clouds()). Horizontal drift uses
|
||||||
|
// the world time (fullTime * CLOUD_SPEED along +X).
|
||||||
|
if (dy > 0.02 && origin.getY() < CLOUD_HEIGHT) {
|
||||||
|
double t = (CLOUD_HEIGHT - origin.getY()) / dy;
|
||||||
|
double cx = origin.getX() + dx * t + ctx.fullTime() * CLOUD_SPEED;
|
||||||
|
double cz = origin.getZ() + dz * t;
|
||||||
|
double coverage = clouds(cx, cz);
|
||||||
|
if (coverage > 0) {
|
||||||
|
int cloudColor = lerp(rgb(45, 48, 60), rgb(236, 240, 248), dayFactor);
|
||||||
|
if (twilight > 0) cloudColor = lerp(cloudColor, rgb(150, 95, 85), twilight * 0.45);
|
||||||
|
double fade = Math.clamp((dy - 0.02) * 4, 0, 1); // fade out near the horizon (single-plane sampling)
|
||||||
|
color = lerp(color, cloudColor, coverage * fade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return color & 0xFFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draws a sun/moon disc, sampling a texture when available (moonPhase ≥ 0 picks the phase tile). */
|
||||||
|
private int overlayDisc(
|
||||||
|
int base,
|
||||||
|
double dx,
|
||||||
|
double dy,
|
||||||
|
double dz,
|
||||||
|
double cx,
|
||||||
|
double cy,
|
||||||
|
double cz,
|
||||||
|
double half,
|
||||||
|
int[][] texture,
|
||||||
|
int solid,
|
||||||
|
int moonPhase) {
|
||||||
|
double cos = dx * cx + dy * cy + dz * cz;
|
||||||
|
if (cos <= 0) return base;
|
||||||
|
double sinHalf = Math.sin(half);
|
||||||
|
// Local disc coordinates: project the direction onto the plane around the body axis.
|
||||||
|
// right = normalize(body x worldUp); discUp = right x body
|
||||||
|
double crx = cz, cry = 0, crz = -cx;
|
||||||
|
double crl = Math.sqrt(crx * crx + crz * crz);
|
||||||
|
if (crl < 1e-6) {
|
||||||
|
crx = 1;
|
||||||
|
cry = 0;
|
||||||
|
crz = 0;
|
||||||
|
crl = 1;
|
||||||
|
}
|
||||||
|
crx /= crl;
|
||||||
|
cry /= crl;
|
||||||
|
crz /= crl;
|
||||||
|
// discUp = right cross body
|
||||||
|
double ux = cry * cz - crz * cy;
|
||||||
|
double uy = crz * cx - crx * cz;
|
||||||
|
double uz = crx * cy - cry * cx;
|
||||||
|
|
||||||
|
double u = dx * crx + dy * cry + dz * crz;
|
||||||
|
double v = dx * ux + dy * uy + dz * uz;
|
||||||
|
// The sun and moon are flat SQUARE billboards in Minecraft, not round discs.
|
||||||
|
double m = Math.max(Math.abs(u), Math.abs(v)) / sinHalf; // 0 center .. 1 square edge
|
||||||
|
if (m > 1) return base;
|
||||||
|
|
||||||
|
double su = u / sinHalf * 0.5 + 0.5;
|
||||||
|
double sv = v / sinHalf * 0.5 + 0.5;
|
||||||
|
|
||||||
|
// The texture is used only as a shape/phase mask; the body color is always `solid` so the
|
||||||
|
// texture's black transparent texels never bleed in as a dark rim.
|
||||||
|
double alpha;
|
||||||
|
if (moonPhase >= 0 && texture != null && texture.length > 0) {
|
||||||
|
alpha = bodyAlpha(texture, su, sv, moonPhase) > 80 ? 1.0 : 0.0; // phase shape
|
||||||
|
} else {
|
||||||
|
alpha = 1 - smoothstep(0.92, 1.0, m); // solid square, faint edge softening
|
||||||
|
}
|
||||||
|
if (alpha <= 0.02) return base;
|
||||||
|
return lerp(base, solid, alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alpha of the body texture at the disc coordinate; moonPhase≥0 selects a tile in the 4x2 grid. */
|
||||||
|
private int bodyAlpha(int[][] texture, double su, double sv, int moonPhase) {
|
||||||
|
int h = texture.length;
|
||||||
|
int w = texture[0].length;
|
||||||
|
double u = su, v = 1 - sv; // texture v is top-down
|
||||||
|
int col = moonPhase % 4;
|
||||||
|
int row = (moonPhase / 4) % 2;
|
||||||
|
u = (col + u) / 4.0;
|
||||||
|
v = (row + v) / 2.0;
|
||||||
|
int px = clamp((int) (u * w), 0, w - 1);
|
||||||
|
int py = clamp((int) (v * h), 0, h - 1);
|
||||||
|
return ColorUtil.alpha(texture[py][px]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sparse pseudo-random star field keyed on the quantized direction. */
|
||||||
|
private double starField(double dx, double dy, double dz) {
|
||||||
|
int gx = (int) Math.floor(dx * 320);
|
||||||
|
int gy = (int) Math.floor(dy * 320);
|
||||||
|
int gz = (int) Math.floor(dz * 320);
|
||||||
|
int h = hash(gx, gy, gz);
|
||||||
|
if ((h & 0x1FF) != 0) return 0; // ~1/512 cells contain a star
|
||||||
|
return 0.5 + ((h >>> 9) & 0xFF) / 510.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exact vanilla cloud coverage. Minecraft tiles {@code clouds.png} (256x256) over the world with
|
||||||
|
* each texel covering a {@link #CLOUD_CELL} (=12) block square, so the pattern repeats every
|
||||||
|
* 256*12 = 3072 blocks. A world position maps to texel
|
||||||
|
* {@code (col = floorMod(floor(x/12), 256), row = floorMod(floor(z/12), 256))} with the texture's
|
||||||
|
* U axis along world X and V axis along world Z; a texel is a cloud where its alpha > 0. This
|
||||||
|
* reproduces the blocky cloud shapes and their world alignment exactly. Falls back to value noise
|
||||||
|
* only when the texture is missing from the pack.
|
||||||
|
*/
|
||||||
|
private double clouds(double x, double z) {
|
||||||
|
if (cloudTexture != null && cloudTexture.length > 0) {
|
||||||
|
int w = cloudTexture[0].length;
|
||||||
|
int h = cloudTexture.length;
|
||||||
|
int tx = Math.floorMod((int) Math.floor(x / CLOUD_CELL), w);
|
||||||
|
int tz = Math.floorMod((int) Math.floor(z / CLOUD_CELL), h);
|
||||||
|
int alpha = ColorUtil.alpha(cloudTexture[tz][tx]);
|
||||||
|
return alpha > 16 ? 0.85 : 0.0;
|
||||||
|
}
|
||||||
|
double scale = 0.012;
|
||||||
|
double n = valueNoise(x * scale, z * scale) * 0.6 + valueNoise(x * scale * 2.3, z * scale * 2.3) * 0.4;
|
||||||
|
return smoothstep(0.52, 0.72, n) * 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
private double valueNoise(double x, double z) {
|
||||||
|
int x0 = (int) Math.floor(x), z0 = (int) Math.floor(z);
|
||||||
|
double fx = x - x0, fz = z - z0;
|
||||||
|
double sx = fx * fx * (3 - 2 * fx);
|
||||||
|
double sz = fz * fz * (3 - 2 * fz);
|
||||||
|
double n00 = rand(x0, z0), n10 = rand(x0 + 1, z0);
|
||||||
|
double n01 = rand(x0, z0 + 1), n11 = rand(x0 + 1, z0 + 1);
|
||||||
|
double nx0 = n00 + (n10 - n00) * sx;
|
||||||
|
double nx1 = n01 + (n11 - n01) * sx;
|
||||||
|
return nx0 + (nx1 - nx0) * sz;
|
||||||
|
}
|
||||||
|
|
||||||
|
private double rand(int x, int z) {
|
||||||
|
return (hash(x, z, 0) & 0xFFFF) / 65535.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int hash(int x, int y, int z) {
|
||||||
|
int h = x * 374761393 + y * 668265263 + z * 2147483647;
|
||||||
|
h = (h ^ (h >>> 13)) * 1274126177;
|
||||||
|
return h ^ (h >>> 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- small color/math helpers ---
|
||||||
|
|
||||||
|
private static int rgb(int r, int g, int b) {
|
||||||
|
return (r << 16) | (g << 8) | b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int lerp(int a, int b, double t) {
|
||||||
|
t = Math.clamp(t, 0, 1);
|
||||||
|
return ColorUtil.mix(a, b, 1 - t, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int add(int c, int r, int g, int b) {
|
||||||
|
int cr = Math.min(255, ((c >> 16) & 0xFF) + r);
|
||||||
|
int cg = Math.min(255, ((c >> 8) & 0xFF) + g);
|
||||||
|
int cb = Math.min(255, (c & 0xFF) + b);
|
||||||
|
return rgb(cr, cg, cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minecraft's {@code Level.getTimeOfDay}: the celestial angle as a fraction [0,1). */
|
||||||
|
private static double celestialAngle(long dayTime) {
|
||||||
|
double d = frac(dayTime / TICKS_PER_DAY - 0.25);
|
||||||
|
double e = 0.5 - Math.cos(d * Math.PI) / 2.0;
|
||||||
|
return (d * 2.0 + e) / 3.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double frac(double v) {
|
||||||
|
return v - Math.floor(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double smoothstep(double edge0, double edge1, double x) {
|
||||||
|
double t = Math.clamp((x - edge0) / (edge1 - edge0), 0, 1);
|
||||||
|
return t * t * (3 - 2 * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int clamp(int v, int lo, int hi) {
|
||||||
|
return v < lo ? lo : Math.min(v, hi);
|
||||||
|
}
|
||||||
|
}
|
||||||
+429
@@ -0,0 +1,429 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.snapshot;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.BannerPattern;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.BedPart;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.BellAttach;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.ChestKind;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.Kind;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.bukkit.DyeColor;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.block.Banner;
|
||||||
|
import org.bukkit.block.BlockFace;
|
||||||
|
import org.bukkit.block.BlockState;
|
||||||
|
import org.bukkit.block.DecoratedPot;
|
||||||
|
import org.bukkit.block.Sign;
|
||||||
|
import org.bukkit.block.Skull;
|
||||||
|
import org.bukkit.block.banner.Pattern;
|
||||||
|
import org.bukkit.block.data.BlockData;
|
||||||
|
import org.bukkit.block.data.Directional;
|
||||||
|
import org.bukkit.block.data.Rotatable;
|
||||||
|
import org.bukkit.block.data.type.Bed;
|
||||||
|
import org.bukkit.block.data.type.Bell;
|
||||||
|
import org.bukkit.block.data.type.Chest;
|
||||||
|
import org.bukkit.block.sign.Side;
|
||||||
|
import org.bukkit.block.sign.SignSide;
|
||||||
|
import org.bukkit.profile.PlayerProfile;
|
||||||
|
import org.bukkit.util.Vector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures block-entities (chests, signs, banners, beds, heads, …) near the view frustum into immutable
|
||||||
|
* {@link BlockEntityState}s. MUST run on the main thread (live {@link BlockState} access). Reads each
|
||||||
|
* loaded chunk's tile-entities rather than scanning every block, then keeps only those inside the
|
||||||
|
* camera's bounding box.
|
||||||
|
*/
|
||||||
|
public final class BlockEntitySnapshotBuilder {
|
||||||
|
|
||||||
|
private BlockEntitySnapshotBuilder() {}
|
||||||
|
|
||||||
|
public static List<BlockEntityState> build(Location eye, List<Vector> rayMap, double maxDistance) {
|
||||||
|
World world = eye.getWorld();
|
||||||
|
FrustumBounds bounds = FrustumBounds.of(eye.toVector(), rayMap, maxDistance);
|
||||||
|
// 1-block margin so block-entities straddling the frustum edge are still captured.
|
||||||
|
int bMinX = (int) Math.floor(bounds.minX) - 1, bMaxX = (int) Math.ceil(bounds.maxX) + 1;
|
||||||
|
int bMinY = (int) Math.floor(bounds.minY) - 1, bMaxY = (int) Math.ceil(bounds.maxY) + 1;
|
||||||
|
int bMinZ = (int) Math.floor(bounds.minZ) - 1, bMaxZ = (int) Math.ceil(bounds.maxZ) + 1;
|
||||||
|
|
||||||
|
int minCX = bMinX >> 4, maxCX = bMaxX >> 4, minCZ = bMinZ >> 4, maxCZ = bMaxZ >> 4;
|
||||||
|
|
||||||
|
List<BlockEntityState> out = new ArrayList<>();
|
||||||
|
for (int cx = minCX; cx <= maxCX; cx++) {
|
||||||
|
for (int cz = minCZ; cz <= maxCZ; cz++) {
|
||||||
|
if (!world.isChunkLoaded(cx, cz)) continue;
|
||||||
|
for (BlockState ts : world.getChunkAt(cx, cz).getTileEntities(false)) {
|
||||||
|
int bx = ts.getX(), by = ts.getY(), bz = ts.getZ();
|
||||||
|
if (bx < bMinX || bx > bMaxX || by < bMinY || by > bMaxY || bz < bMinZ || bz > bMaxZ) continue;
|
||||||
|
try {
|
||||||
|
BlockEntityState s = classify(ts);
|
||||||
|
if (s != null) out.add(s);
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
// Unsupported on this server version / odd state — skip this one.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BlockEntityState classify(BlockState ts) {
|
||||||
|
Material mat = ts.getType();
|
||||||
|
String n = mat.name();
|
||||||
|
BlockData data = ts.getBlockData();
|
||||||
|
int bx = ts.getX(), by = ts.getY(), bz = ts.getZ();
|
||||||
|
|
||||||
|
// --- chests ---
|
||||||
|
if (mat == Material.CHEST || mat == Material.TRAPPED_CHEST || mat == Material.ENDER_CHEST) {
|
||||||
|
Kind kind = mat == Material.TRAPPED_CHEST
|
||||||
|
? Kind.TRAPPED_CHEST
|
||||||
|
: mat == Material.ENDER_CHEST ? Kind.ENDER_CHEST : Kind.CHEST;
|
||||||
|
ChestKind ck = ChestKind.SINGLE;
|
||||||
|
if (data instanceof Chest cd) {
|
||||||
|
ck = switch (cd.getType()) {
|
||||||
|
case LEFT -> ChestKind.LEFT;
|
||||||
|
case RIGHT -> ChestKind.RIGHT;
|
||||||
|
case SINGLE -> ChestKind.SINGLE;};
|
||||||
|
}
|
||||||
|
return base(kind, bx, by, bz, facingYaw(data)).chestKind(ck).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- beds ---
|
||||||
|
if (data instanceof Bed bed) {
|
||||||
|
BedPart part = bed.getPart() == Bed.Part.HEAD ? BedPart.HEAD : BedPart.FOOT;
|
||||||
|
return base(Kind.BED, bx, by, bz, faceToYaw(bed.getFacing()))
|
||||||
|
.bedPart(part)
|
||||||
|
.colorName(stripColor(n, "_BED"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- shulker boxes ---
|
||||||
|
if (n.endsWith("SHULKER_BOX")) {
|
||||||
|
String color = n.equals("SHULKER_BOX") ? null : stripColor(n, "_SHULKER_BOX");
|
||||||
|
return base(Kind.SHULKER_BOX, bx, by, bz, facingYaw(data))
|
||||||
|
.colorName(color)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- banners ---
|
||||||
|
if (n.endsWith("_BANNER")) {
|
||||||
|
boolean wall = n.endsWith("_WALL_BANNER");
|
||||||
|
float yaw = wall ? facingYaw(data) : rotationYaw(data);
|
||||||
|
Builder b = base(wall ? Kind.WALL_BANNER : Kind.BANNER, bx, by, bz, yaw);
|
||||||
|
if (ts instanceof Banner banner) {
|
||||||
|
DyeColor base = banner.getBaseColor();
|
||||||
|
b.baseColorArgb(ColorUtil.dyeArgb(base, 0xFFFFFFFF));
|
||||||
|
List<BannerPattern> pats = new ArrayList<>();
|
||||||
|
for (Pattern p : banner.getPatterns()) {
|
||||||
|
pats.add(new BannerPattern(patternKey(p), ColorUtil.dyeArgb(p.getColor(), 0xFFFFFFFF)));
|
||||||
|
}
|
||||||
|
b.patterns(pats);
|
||||||
|
}
|
||||||
|
return b.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- signs ---
|
||||||
|
if (n.endsWith("_SIGN")) {
|
||||||
|
String wood = signWood(n);
|
||||||
|
Kind kind;
|
||||||
|
float yaw;
|
||||||
|
if (n.endsWith("_WALL_SIGN")) {
|
||||||
|
kind = Kind.WALL_SIGN;
|
||||||
|
yaw = facingYaw(data);
|
||||||
|
} else if (n.endsWith("_HANGING_SIGN")) {
|
||||||
|
kind = Kind.HANGING_SIGN;
|
||||||
|
yaw = n.endsWith("_WALL_HANGING_SIGN") ? facingYaw(data) : rotationYaw(data);
|
||||||
|
} else {
|
||||||
|
kind = Kind.SIGN;
|
||||||
|
yaw = rotationYaw(data);
|
||||||
|
}
|
||||||
|
Builder b = base(kind, bx, by, bz, yaw).wood(wood);
|
||||||
|
if (ts instanceof Sign sign) {
|
||||||
|
b.frontText(signText(sign.getSide(Side.FRONT)));
|
||||||
|
b.backText(signText(sign.getSide(Side.BACK)));
|
||||||
|
}
|
||||||
|
return b.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- heads / skulls ---
|
||||||
|
if (n.endsWith("_SKULL") || n.endsWith("_HEAD")) {
|
||||||
|
boolean wall = n.contains("_WALL_");
|
||||||
|
String headType = headType(n);
|
||||||
|
float yaw = wall ? facingYaw(data) : rotationYaw(data);
|
||||||
|
Builder b = base(wall ? Kind.WALL_HEAD : Kind.HEAD, bx, by, bz, yaw).headType(headType);
|
||||||
|
if ("player".equals(headType) && ts instanceof Skull skull) {
|
||||||
|
b.skinUrl(skinUrl(skull));
|
||||||
|
}
|
||||||
|
return b.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- conduit ---
|
||||||
|
if (mat == Material.CONDUIT) {
|
||||||
|
return base(Kind.CONDUIT, bx, by, bz, 0).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- decorated pot ---
|
||||||
|
if (mat == Material.DECORATED_POT) {
|
||||||
|
Builder b = base(Kind.DECORATED_POT, bx, by, bz, facingYaw(data));
|
||||||
|
if (ts instanceof DecoratedPot pot) b.sherds(sherds(pot));
|
||||||
|
return b.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- bell ---
|
||||||
|
if (mat == Material.BELL) {
|
||||||
|
BellAttach attach = BellAttach.FLOOR;
|
||||||
|
if (data instanceof Bell bd) {
|
||||||
|
attach = switch (bd.getAttachment()) {
|
||||||
|
case FLOOR -> BellAttach.FLOOR;
|
||||||
|
case CEILING -> BellAttach.CEILING;
|
||||||
|
case SINGLE_WALL -> BellAttach.SINGLE_WALL;
|
||||||
|
case DOUBLE_WALL -> BellAttach.DOUBLE_WALL;};
|
||||||
|
}
|
||||||
|
return base(Kind.BELL, bx, by, bz, facingYaw(data))
|
||||||
|
.bellAttach(attach)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- facing / rotation helpers ---
|
||||||
|
|
||||||
|
/** Yaw preferring the block's {@link Directional} facing (wall-mounted block-entities). */
|
||||||
|
private static float facingYaw(BlockData data) {
|
||||||
|
if (data instanceof Directional d) return faceToYaw(d.getFacing());
|
||||||
|
if (data instanceof Rotatable r) return faceToYaw(r.getRotation());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yaw preferring the block's {@link Rotatable} rotation (free-standing block-entities). */
|
||||||
|
private static float rotationYaw(BlockData data) {
|
||||||
|
if (data instanceof Rotatable r) return faceToYaw(r.getRotation());
|
||||||
|
return facingYaw(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yaw in degrees for a block face, 0 = south increasing clockwise (vanilla rotation convention). */
|
||||||
|
private static float faceToYaw(BlockFace face) {
|
||||||
|
Float y = FACE_YAW.get(face);
|
||||||
|
return y == null ? 0 : y;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Map<BlockFace, Float> FACE_YAW = buildFaceYaw();
|
||||||
|
|
||||||
|
private static Map<BlockFace, Float> buildFaceYaw() {
|
||||||
|
Map<BlockFace, Float> m = new EnumMap<>(BlockFace.class);
|
||||||
|
m.put(BlockFace.SOUTH, 0f);
|
||||||
|
m.put(BlockFace.SOUTH_SOUTH_WEST, 22.5f);
|
||||||
|
m.put(BlockFace.SOUTH_WEST, 45f);
|
||||||
|
m.put(BlockFace.WEST_SOUTH_WEST, 67.5f);
|
||||||
|
m.put(BlockFace.WEST, 90f);
|
||||||
|
m.put(BlockFace.WEST_NORTH_WEST, 112.5f);
|
||||||
|
m.put(BlockFace.NORTH_WEST, 135f);
|
||||||
|
m.put(BlockFace.NORTH_NORTH_WEST, 157.5f);
|
||||||
|
m.put(BlockFace.NORTH, 180f);
|
||||||
|
m.put(BlockFace.NORTH_NORTH_EAST, 202.5f);
|
||||||
|
m.put(BlockFace.NORTH_EAST, 225f);
|
||||||
|
m.put(BlockFace.EAST_NORTH_EAST, 247.5f);
|
||||||
|
m.put(BlockFace.EAST, 270f);
|
||||||
|
m.put(BlockFace.EAST_SOUTH_EAST, 292.5f);
|
||||||
|
m.put(BlockFace.SOUTH_EAST, 315f);
|
||||||
|
m.put(BlockFace.SOUTH_SOUTH_EAST, 337.5f);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- data extraction helpers ---
|
||||||
|
|
||||||
|
private static String stripColor(String name, String suffix) {
|
||||||
|
return name.substring(0, name.length() - suffix.length()).toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One sign side → {@link BlockEntityState.SignText}, or null when all four lines are blank. */
|
||||||
|
private static BlockEntityState.SignText signText(SignSide side) {
|
||||||
|
String[] raw = side.getLines();
|
||||||
|
List<String> lines = new ArrayList<>(raw.length);
|
||||||
|
boolean any = false;
|
||||||
|
for (String l : raw) {
|
||||||
|
String s = l == null ? "" : l.replaceAll("§.", ""); // strip legacy §-codes
|
||||||
|
if (!s.isEmpty()) any = true;
|
||||||
|
lines.add(s);
|
||||||
|
}
|
||||||
|
if (!any) return null;
|
||||||
|
DyeColor dye = side.getColor();
|
||||||
|
boolean glow = side.isGlowingText();
|
||||||
|
return new BlockEntityState.SignText(
|
||||||
|
lines, ColorUtil.signFillArgb(dye, glow), ColorUtil.signOutlineArgb(dye), glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String signWood(String name) {
|
||||||
|
String s = name;
|
||||||
|
for (String suf : new String[] {"_WALL_HANGING_SIGN", "_HANGING_SIGN", "_WALL_SIGN", "_SIGN"}) {
|
||||||
|
if (s.endsWith(suf)) {
|
||||||
|
s = s.substring(0, s.length() - suf.length());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String headType(String name) {
|
||||||
|
String s = name.replace("_WALL_", "_");
|
||||||
|
return switch (s) {
|
||||||
|
case "PLAYER_HEAD" -> "player";
|
||||||
|
case "ZOMBIE_HEAD" -> "zombie";
|
||||||
|
case "CREEPER_HEAD" -> "creeper";
|
||||||
|
case "DRAGON_HEAD" -> "dragon";
|
||||||
|
case "PIGLIN_HEAD" -> "piglin";
|
||||||
|
case "WITHER_SKELETON_SKULL" -> "wither_skeleton";
|
||||||
|
default -> "skeleton";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> sherds(DecoratedPot pot) {
|
||||||
|
// Order: front, left, right, back — matches the CEM decorated_pot face parts.
|
||||||
|
List<String> out = new ArrayList<>(4);
|
||||||
|
for (DecoratedPot.Side side : new DecoratedPot.Side[] {
|
||||||
|
DecoratedPot.Side.FRONT, DecoratedPot.Side.LEFT, DecoratedPot.Side.RIGHT, DecoratedPot.Side.BACK
|
||||||
|
}) {
|
||||||
|
Material m = pot.getSherd(side);
|
||||||
|
out.add(m.name().toLowerCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String skinUrl(Skull skull) {
|
||||||
|
try {
|
||||||
|
PlayerProfile profile = skull.getOwnerProfile();
|
||||||
|
if (profile != null && profile.getTextures().getSkin() != null) {
|
||||||
|
return profile.getTextures().getSkin().toString();
|
||||||
|
}
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The banner pattern's path key (e.g. "stripe_bottom"); every PatternType key accessor is marked
|
||||||
|
* for removal in this API with no stable replacement, so the warning is suppressed here. */
|
||||||
|
@SuppressWarnings("removal")
|
||||||
|
private static String patternKey(Pattern p) {
|
||||||
|
return p.getPattern().getKey().getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- small fluent builder to keep the 15-field record construction readable ---
|
||||||
|
|
||||||
|
private static Builder base(Kind kind, int bx, int by, int bz, float yaw) {
|
||||||
|
return new Builder(kind, bx, by, bz, yaw);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Builder {
|
||||||
|
private final Kind kind;
|
||||||
|
private final int bx, by, bz;
|
||||||
|
private final float yaw;
|
||||||
|
private ChestKind chestKind;
|
||||||
|
private int baseColorArgb;
|
||||||
|
private String colorName;
|
||||||
|
private String wood;
|
||||||
|
private BedPart bedPart;
|
||||||
|
private String headType;
|
||||||
|
private String skinUrl;
|
||||||
|
private List<BannerPattern> patterns = List.of();
|
||||||
|
private List<String> sherds = List.of();
|
||||||
|
private BellAttach bellAttach;
|
||||||
|
private BlockEntityState.SignText frontText;
|
||||||
|
private BlockEntityState.SignText backText;
|
||||||
|
|
||||||
|
Builder(Kind kind, int bx, int by, int bz, float yaw) {
|
||||||
|
this.kind = kind;
|
||||||
|
this.bx = bx;
|
||||||
|
this.by = by;
|
||||||
|
this.bz = bz;
|
||||||
|
this.yaw = yaw;
|
||||||
|
}
|
||||||
|
|
||||||
|
Builder chestKind(ChestKind v) {
|
||||||
|
this.chestKind = v;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
Builder baseColorArgb(int v) {
|
||||||
|
this.baseColorArgb = v;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
Builder colorName(String v) {
|
||||||
|
this.colorName = v;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
Builder wood(String v) {
|
||||||
|
this.wood = v;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
Builder bedPart(BedPart v) {
|
||||||
|
this.bedPart = v;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
Builder headType(String v) {
|
||||||
|
this.headType = v;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
Builder skinUrl(String v) {
|
||||||
|
this.skinUrl = v;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
Builder patterns(List<BannerPattern> v) {
|
||||||
|
this.patterns = v;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
Builder sherds(List<String> v) {
|
||||||
|
this.sherds = v;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
Builder bellAttach(BellAttach v) {
|
||||||
|
this.bellAttach = v;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
Builder frontText(BlockEntityState.SignText v) {
|
||||||
|
this.frontText = v;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
Builder backText(BlockEntityState.SignText v) {
|
||||||
|
this.backText = v;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
BlockEntityState build() {
|
||||||
|
return new BlockEntityState(
|
||||||
|
kind,
|
||||||
|
bx,
|
||||||
|
by,
|
||||||
|
bz,
|
||||||
|
yaw,
|
||||||
|
chestKind,
|
||||||
|
baseColorArgb,
|
||||||
|
colorName,
|
||||||
|
wood,
|
||||||
|
bedPart,
|
||||||
|
headType,
|
||||||
|
skinUrl,
|
||||||
|
patterns,
|
||||||
|
sherds,
|
||||||
|
bellAttach,
|
||||||
|
frontText,
|
||||||
|
backText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.snapshot;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.block.BlockFace;
|
||||||
|
import org.bukkit.entity.Entity;
|
||||||
|
import org.bukkit.entity.GlowItemFrame;
|
||||||
|
import org.bukkit.entity.ItemFrame;
|
||||||
|
import org.bukkit.entity.Painting;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.util.BoundingBox;
|
||||||
|
import org.bukkit.util.Vector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures flat wall decorations (paintings + item frames) near the view frustum into immutable
|
||||||
|
* {@link DecorationState}s. MUST run on the main thread. Uses each entity's world bounding box directly,
|
||||||
|
* which already encodes vanilla's painting placement offsets.
|
||||||
|
*/
|
||||||
|
public final class DecorationSnapshotBuilder {
|
||||||
|
|
||||||
|
private DecorationSnapshotBuilder() {}
|
||||||
|
|
||||||
|
public static List<DecorationState> build(Location eye, List<Vector> rayMap, double maxDistance) {
|
||||||
|
FrustumBounds bounds = FrustumBounds.of(eye.toVector(), rayMap, maxDistance);
|
||||||
|
Collection<Entity> nearby = bounds.nearbyEntities(eye.getWorld(), 2);
|
||||||
|
List<DecorationState> out = new ArrayList<>();
|
||||||
|
for (Entity e : nearby) {
|
||||||
|
try {
|
||||||
|
DecorationState s = toState(e);
|
||||||
|
if (s != null) out.add(s);
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DecorationState toState(Entity e) {
|
||||||
|
if (e instanceof Painting painting) {
|
||||||
|
BoundingBox bb = e.getBoundingBox();
|
||||||
|
String art = painting.getArt().assetId().value();
|
||||||
|
return new DecorationState(
|
||||||
|
DecorationState.Kind.PAINTING,
|
||||||
|
bb.getMinX(),
|
||||||
|
bb.getMinY(),
|
||||||
|
bb.getMinZ(),
|
||||||
|
bb.getMaxX(),
|
||||||
|
bb.getMaxY(),
|
||||||
|
bb.getMaxZ(),
|
||||||
|
facing(e.getFacing()),
|
||||||
|
art,
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
if (e instanceof ItemFrame frame) {
|
||||||
|
BoundingBox bb = e.getBoundingBox();
|
||||||
|
boolean glow =
|
||||||
|
e instanceof GlowItemFrame || e.getType().getKey().getKey().equals("glow_item_frame");
|
||||||
|
String itemId = itemId(frame.getItem());
|
||||||
|
int rot = frame.getRotation().ordinal() * 45;
|
||||||
|
return new DecorationState(
|
||||||
|
DecorationState.Kind.ITEM_FRAME,
|
||||||
|
bb.getMinX(),
|
||||||
|
bb.getMinY(),
|
||||||
|
bb.getMinZ(),
|
||||||
|
bb.getMaxX(),
|
||||||
|
bb.getMaxY(),
|
||||||
|
bb.getMaxZ(),
|
||||||
|
facing(e.getFacing()),
|
||||||
|
null,
|
||||||
|
itemId,
|
||||||
|
rot,
|
||||||
|
glow);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The framed item's material key (e.g. "diamond"); the baker resolves item/ or block/ textures from it. */
|
||||||
|
private static String itemId(ItemStack item) {
|
||||||
|
if (item == null || item.getType().isAir()) return null;
|
||||||
|
return item.getType().getKey().getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DecorationState.Facing facing(BlockFace face) {
|
||||||
|
return switch (face) {
|
||||||
|
case NORTH -> DecorationState.Facing.NORTH;
|
||||||
|
case SOUTH -> DecorationState.Facing.SOUTH;
|
||||||
|
case EAST -> DecorationState.Facing.EAST;
|
||||||
|
case WEST -> DecorationState.Facing.WEST;
|
||||||
|
case UP -> DecorationState.Facing.UP;
|
||||||
|
default -> DecorationState.Facing.DOWN;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
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.util.ColorUtil;
|
||||||
|
import io.papermc.paper.datacomponent.DataComponentTypes;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.function.DoubleSupplier;
|
||||||
|
import org.bukkit.Keyed;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.entity.AbstractHorse;
|
||||||
|
import org.bukkit.entity.AbstractNautilus;
|
||||||
|
import org.bukkit.entity.Ageable;
|
||||||
|
import org.bukkit.entity.ArmorStand;
|
||||||
|
import org.bukkit.entity.Axolotl;
|
||||||
|
import org.bukkit.entity.Cat;
|
||||||
|
import org.bukkit.entity.ChestedHorse;
|
||||||
|
import org.bukkit.entity.Chicken;
|
||||||
|
import org.bukkit.entity.Cow;
|
||||||
|
import org.bukkit.entity.Entity;
|
||||||
|
import org.bukkit.entity.Fox;
|
||||||
|
import org.bukkit.entity.Frog;
|
||||||
|
import org.bukkit.entity.Horse;
|
||||||
|
import org.bukkit.entity.LivingEntity;
|
||||||
|
import org.bukkit.entity.Llama;
|
||||||
|
import org.bukkit.entity.MushroomCow;
|
||||||
|
import org.bukkit.entity.Panda;
|
||||||
|
import org.bukkit.entity.Parrot;
|
||||||
|
import org.bukkit.entity.Pig;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.entity.Rabbit;
|
||||||
|
import org.bukkit.entity.Sheep;
|
||||||
|
import org.bukkit.entity.Shulker;
|
||||||
|
import org.bukkit.entity.Slime;
|
||||||
|
import org.bukkit.entity.TraderLlama;
|
||||||
|
import org.bukkit.entity.Villager;
|
||||||
|
import org.bukkit.entity.Wolf;
|
||||||
|
import org.bukkit.entity.Zombie;
|
||||||
|
import org.bukkit.entity.ZombieVillager;
|
||||||
|
import org.bukkit.inventory.EntityEquipment;
|
||||||
|
import org.bukkit.inventory.EquipmentSlot;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.meta.ArmorMeta;
|
||||||
|
import org.bukkit.inventory.meta.ItemMeta;
|
||||||
|
import org.bukkit.inventory.meta.LeatherArmorMeta;
|
||||||
|
import org.bukkit.potion.PotionEffectType;
|
||||||
|
import org.bukkit.util.Vector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures entities near the view frustum into immutable {@link EntityState}s. MUST run on the main
|
||||||
|
* thread (live entity access). The camera entity is skipped.
|
||||||
|
*/
|
||||||
|
public final class EntitySnapshotBuilder {
|
||||||
|
|
||||||
|
private EntitySnapshotBuilder() {}
|
||||||
|
|
||||||
|
// Technical / non-mob entity types that have no meaningful geometry; rendering them would only
|
||||||
|
// produce stray fallback boxes. Markers, displays, item frames, paintings, projectiles, drops, etc.
|
||||||
|
private static final Set<String> NON_RENDERABLE = Set.of(
|
||||||
|
"area_effect_cloud",
|
||||||
|
"marker",
|
||||||
|
"interaction",
|
||||||
|
"item_frame",
|
||||||
|
"glow_item_frame",
|
||||||
|
"painting",
|
||||||
|
"block_display",
|
||||||
|
"item_display",
|
||||||
|
"text_display",
|
||||||
|
"fishing_bobber",
|
||||||
|
"lightning_bolt",
|
||||||
|
"eye_of_ender",
|
||||||
|
"experience_orb",
|
||||||
|
"experience_bottle",
|
||||||
|
"egg",
|
||||||
|
"snowball",
|
||||||
|
"potion",
|
||||||
|
"ender_pearl",
|
||||||
|
"tnt",
|
||||||
|
"falling_block",
|
||||||
|
"item");
|
||||||
|
|
||||||
|
// Entities whose vanilla renderer draws the humanoid armor layers (HumanoidArmorLayer) and held
|
||||||
|
// items. Their CEM bodies share standard humanoid proportions, so the armor_layer_1/2 models align.
|
||||||
|
private static final Set<String> HUMANOID_ARMOR_WEARERS = Set.of(
|
||||||
|
"player",
|
||||||
|
"mannequin",
|
||||||
|
"armor_stand",
|
||||||
|
"giant",
|
||||||
|
"zombie",
|
||||||
|
"husk",
|
||||||
|
"drowned",
|
||||||
|
"zombie_villager",
|
||||||
|
"zombified_piglin",
|
||||||
|
"skeleton",
|
||||||
|
"stray",
|
||||||
|
"wither_skeleton",
|
||||||
|
"bogged",
|
||||||
|
"piglin",
|
||||||
|
"piglin_brute");
|
||||||
|
|
||||||
|
public static List<EntityState> build(Location eye, List<Vector> rayMap, double maxDistance, UUID shooter) {
|
||||||
|
FrustumBounds bounds = FrustumBounds.of(eye.toVector(), rayMap, maxDistance);
|
||||||
|
Collection<Entity> nearby = bounds.nearbyEntities(eye.getWorld(), 2);
|
||||||
|
List<EntityState> states = new ArrayList<>();
|
||||||
|
for (Entity e : nearby) {
|
||||||
|
if (shooter != null && e.getUniqueId().equals(shooter)) continue;
|
||||||
|
EntityState s = toState(e);
|
||||||
|
if (s != null) states.add(s);
|
||||||
|
}
|
||||||
|
return states;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EntityState toState(Entity e) {
|
||||||
|
Location loc = e.getLocation();
|
||||||
|
// Skip non-renderable technical entities.
|
||||||
|
String type = e.getType().getKey().getKey();
|
||||||
|
// Boats now have a bundled geometry.boat; rafts use a different hull we don't ship yet — skip those.
|
||||||
|
if (NON_RENDERABLE.contains(type) || type.endsWith("_raft")) return null;
|
||||||
|
|
||||||
|
float bodyYaw = loc.getYaw();
|
||||||
|
if (e instanceof LivingEntity le) {
|
||||||
|
bodyYaw = le.getBodyYaw();
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean baby = (e instanceof Ageable a && !a.isAdult()) || (e instanceof Zombie z && z.isAdult());
|
||||||
|
|
||||||
|
// Invisible entities render only their equipment (like vanilla): the generic invisible flag, an
|
||||||
|
// invisibility potion effect, or an explicitly-hidden armor stand.
|
||||||
|
boolean invisible = e.isInvisible()
|
||||||
|
|| (e instanceof ArmorStand as && !as.isVisible())
|
||||||
|
|| (e instanceof LivingEntity inv && inv.hasPotionEffect(PotionEffectType.INVISIBILITY));
|
||||||
|
|
||||||
|
double width = safeDim(e::getWidth, () -> e.getBoundingBox().getWidthX());
|
||||||
|
double height = safeDim(e::getHeight, () -> e.getBoundingBox().getHeight());
|
||||||
|
|
||||||
|
boolean player = e instanceof Player;
|
||||||
|
String skinUrl = null;
|
||||||
|
boolean slim = false;
|
||||||
|
if (player) {
|
||||||
|
String[] skin = resolveSkin((Player) e);
|
||||||
|
skinUrl = skin[0];
|
||||||
|
slim = "slim".equals(skin[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
String variant = null;
|
||||||
|
int tint = 0;
|
||||||
|
double sizeScale = 1.0;
|
||||||
|
String profession = null;
|
||||||
|
int villagerLevel = 0;
|
||||||
|
String markings = null;
|
||||||
|
boolean saddle = false;
|
||||||
|
boolean chest = false;
|
||||||
|
String bodyEquip = null;
|
||||||
|
try {
|
||||||
|
// Slime & magma cube (MagmaCube extends Slime) scale their model by size (1/2/4).
|
||||||
|
if (e instanceof Slime sl) sizeScale = sl.getSize();
|
||||||
|
// MushroomCow extends Cow, ZombieVillager does not extend Villager — order matters.
|
||||||
|
if (e instanceof Sheep sh) {
|
||||||
|
tint = ColorUtil.dyeArgb(sh.getColor(), 0);
|
||||||
|
} else if (e instanceof Cat c) {
|
||||||
|
variant = keyOf(c.getCatType());
|
||||||
|
} else if (e instanceof Wolf w) {
|
||||||
|
variant = keyOf(w.getVariant());
|
||||||
|
} else if (e instanceof Axolotl a) {
|
||||||
|
variant = keyOf(a.getVariant());
|
||||||
|
} else if (e instanceof Parrot p) {
|
||||||
|
variant = keyOf(p.getVariant());
|
||||||
|
} else if (e instanceof Rabbit r) {
|
||||||
|
variant = keyOf(r.getRabbitType());
|
||||||
|
} else if (e instanceof Horse h) {
|
||||||
|
variant = keyOf(h.getColor());
|
||||||
|
markings = markingsKey(h.getStyle());
|
||||||
|
saddle = isSaddled(h);
|
||||||
|
bodyEquip = horseArmorKey(h);
|
||||||
|
} else if (e instanceof Llama l) {
|
||||||
|
variant = keyOf(l.getColor());
|
||||||
|
chest = l.isCarryingChest();
|
||||||
|
// Trader llamas wear a fixed decor; normal llamas carry a dyed carpet in the decor slot.
|
||||||
|
bodyEquip = (e instanceof TraderLlama) ? "trader_llama" : carpetKey(l);
|
||||||
|
} else if (e instanceof ChestedHorse ch) {
|
||||||
|
// Donkey & mule (llama already handled above).
|
||||||
|
chest = ch.isCarryingChest();
|
||||||
|
saddle = isSaddled(ch);
|
||||||
|
} else if (e instanceof AbstractHorse ah) {
|
||||||
|
// Skeleton/zombie horse: only saddle (no colour/markings/armor variants).
|
||||||
|
saddle = isSaddled(ah);
|
||||||
|
} else if (e instanceof AbstractNautilus nl) {
|
||||||
|
// Nautilus body armor + saddle are same-UV overlays (like horse armor).
|
||||||
|
EntityEquipment eq = nl.getEquipment();
|
||||||
|
bodyEquip = equipAsset(eq.getItem(EquipmentSlot.BODY));
|
||||||
|
ItemStack sd = eq.getItem(EquipmentSlot.SADDLE);
|
||||||
|
saddle = !sd.getType().isAir();
|
||||||
|
} else if (e instanceof Fox f) {
|
||||||
|
variant = keyOf(f.getFoxType());
|
||||||
|
} else if (e instanceof MushroomCow mc) {
|
||||||
|
variant = keyOf(mc.getVariant());
|
||||||
|
} else if (e instanceof Panda pa) {
|
||||||
|
variant = keyOf(pa.getMainGene());
|
||||||
|
} else if (e instanceof Frog fr) {
|
||||||
|
variant = keyOf(fr.getVariant());
|
||||||
|
} else if (e instanceof Shulker s) {
|
||||||
|
variant = s.getColor() == null ? null : keyOf(s.getColor());
|
||||||
|
} else if (e instanceof ZombieVillager zv) {
|
||||||
|
variant = keyOf(zv.getVillagerType());
|
||||||
|
// ZombieVillager exposes no level via Bukkit -> no profession-level badge (matches vanilla).
|
||||||
|
} else if (e instanceof Villager vi) {
|
||||||
|
variant = keyOf(vi.getVillagerType());
|
||||||
|
profession = keyOf(vi.getProfession());
|
||||||
|
villagerLevel = vi.getVillagerLevel();
|
||||||
|
} else if (e instanceof Cow co) {
|
||||||
|
variant = keyOf(co.getVariant());
|
||||||
|
} else if (e instanceof Pig pg) {
|
||||||
|
variant = keyOf(pg.getVariant());
|
||||||
|
} else if (e instanceof Chicken ch) {
|
||||||
|
variant = keyOf(ch.getVariant());
|
||||||
|
}
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
// Unsupported on this server version — fall back to the base texture.
|
||||||
|
}
|
||||||
|
|
||||||
|
EntityState.Equipment equipment = null;
|
||||||
|
if (HUMANOID_ARMOR_WEARERS.contains(type) && e instanceof LivingEntity wearer) {
|
||||||
|
equipment = captureEquipment(wearer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new EntityState(
|
||||||
|
type,
|
||||||
|
loc.getX(),
|
||||||
|
loc.getY(),
|
||||||
|
loc.getZ(),
|
||||||
|
bodyYaw,
|
||||||
|
baby,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
player,
|
||||||
|
skinUrl,
|
||||||
|
slim,
|
||||||
|
variant,
|
||||||
|
tint,
|
||||||
|
sizeScale,
|
||||||
|
profession,
|
||||||
|
villagerLevel,
|
||||||
|
markings,
|
||||||
|
saddle,
|
||||||
|
chest,
|
||||||
|
bodyEquip,
|
||||||
|
equipment,
|
||||||
|
invisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Worn armor (4 slots) from a humanoid wearer; null when nothing is equipped. */
|
||||||
|
private static EntityState.Equipment captureEquipment(LivingEntity le) {
|
||||||
|
try {
|
||||||
|
EntityEquipment eq = le.getEquipment();
|
||||||
|
if (eq == null) return null;
|
||||||
|
EntityState.Equipment equip = new EntityState.Equipment(
|
||||||
|
armorPiece(eq.getHelmet()), armorPiece(eq.getChestplate()),
|
||||||
|
armorPiece(eq.getLeggings()), armorPiece(eq.getBoots()));
|
||||||
|
return equip.isEmpty() ? null : equip;
|
||||||
|
} catch (Throwable t) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One armor slot -> EquipPiece (asset, leather dye, trim, glint); null for empty / non-armor items. */
|
||||||
|
private static EntityState.EquipPiece armorPiece(ItemStack it) {
|
||||||
|
if (it == null || it.getType().isAir()) return null;
|
||||||
|
String asset = armorAsset(it.getType());
|
||||||
|
if (asset == null) return null; // not a humanoid-armor item (e.g. a mob head / pumpkin)
|
||||||
|
int dye = 0;
|
||||||
|
String trimMat = null, trimPat = null;
|
||||||
|
boolean glint = false;
|
||||||
|
if (it.hasItemMeta()) {
|
||||||
|
ItemMeta meta = it.getItemMeta();
|
||||||
|
glint = meta.hasEnchants();
|
||||||
|
if (meta instanceof LeatherArmorMeta lam) dye = lam.getColor().asARGB();
|
||||||
|
if (meta instanceof ArmorMeta am && am.getTrim() != null) {
|
||||||
|
trimMat = keyOf(am.getTrim().getMaterial());
|
||||||
|
trimPat = keyOf(am.getTrim().getPattern());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new EntityState.EquipPiece(asset, dye, trimMat, trimPat, glint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Item Material -> equipment asset id (= texture name): strips the slot suffix; null if not armor. */
|
||||||
|
private static String armorAsset(Material m) {
|
||||||
|
String key = m.getKey().getKey();
|
||||||
|
if (key.equals("elytra")) return "elytra";
|
||||||
|
if (key.equals("turtle_helmet")) return "turtle_scute";
|
||||||
|
String base = null;
|
||||||
|
for (String suf : new String[] {"_helmet", "_chestplate", "_leggings", "_boots"}) {
|
||||||
|
if (key.endsWith(suf)) {
|
||||||
|
base = key.substring(0, key.length() - suf.length());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (base == null) return null;
|
||||||
|
return base.equals("golden") ? "gold" : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Horse coat markings overlay key (vanilla texture suffix); null for the plain NONE style. */
|
||||||
|
private static String markingsKey(Horse.Style style) {
|
||||||
|
if (style == null || style == Horse.Style.NONE) return null;
|
||||||
|
return style.name().toLowerCase(Locale.ROOT).replace("_", ""); // WHITE_DOTS -> whitedots
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Horse armor material -> equipment/horse_body texture key (golden uses the "gold" file); null if none. */
|
||||||
|
private static String horseArmorKey(Horse h) {
|
||||||
|
ItemStack a = h.getInventory().getArmor();
|
||||||
|
if (a == null || a.getType().isAir()) return null;
|
||||||
|
String k = a.getType().getKey().getKey().replace("_horse_armor", "");
|
||||||
|
return k.equals("golden") ? "gold" : k;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Llama carpet decor -> equipment/llama_body colour key; null if none. */
|
||||||
|
private static String carpetKey(Llama l) {
|
||||||
|
ItemStack d = l.getInventory().getDecor();
|
||||||
|
if (d == null || d.getType().isAir()) return null;
|
||||||
|
String k = d.getType().getKey().getKey();
|
||||||
|
return k.endsWith("_carpet") ? k.substring(0, k.length() - "_carpet".length()) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Equipment asset id (= equipment/<asset> texture name) from an item's EQUIPPABLE component; null if none. */
|
||||||
|
private static String equipAsset(ItemStack it) {
|
||||||
|
if (it == null || it.getType().isAir()) return null;
|
||||||
|
try {
|
||||||
|
var comp = it.getData(DataComponentTypes.EQUIPPABLE);
|
||||||
|
if (comp != null && comp.assetId() != null) return comp.assetId().value();
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a horse-like mount carries a saddle in its dedicated saddle slot. */
|
||||||
|
private static boolean isSaddled(AbstractHorse h) {
|
||||||
|
ItemStack st = h.getInventory().getSaddle();
|
||||||
|
return st != null && !st.getType().isAir();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registry/Keyed values yield their key path; plain enums yield their lower-case name. */
|
||||||
|
private static String keyOf(Object o) {
|
||||||
|
return switch (o) {
|
||||||
|
case null -> null;
|
||||||
|
case Keyed k -> k.getKey().getKey();
|
||||||
|
case Enum<?> en -> en.name().toLowerCase(Locale.ROOT);
|
||||||
|
default -> o.toString().toLowerCase(Locale.ROOT);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns {skinUrl, model} from the player's profile texture property, or {null, null}. */
|
||||||
|
private static String[] resolveSkin(Player player) {
|
||||||
|
try {
|
||||||
|
for (ProfileProperty prop : player.getPlayerProfile().getProperties()) {
|
||||||
|
if (!prop.getName().equals("textures")) continue;
|
||||||
|
String json = new String(Base64.getDecoder().decode(prop.getValue()), StandardCharsets.UTF_8);
|
||||||
|
JsonObject root = JsonParser.parseString(json).getAsJsonObject();
|
||||||
|
JsonObject skin = root.getAsJsonObject("textures").getAsJsonObject("SKIN");
|
||||||
|
String url = skin.get("url").getAsString();
|
||||||
|
String model = null;
|
||||||
|
if (skin.has("metadata") && skin.getAsJsonObject("metadata").has("model")) {
|
||||||
|
model = skin.getAsJsonObject("metadata").get("model").getAsString();
|
||||||
|
}
|
||||||
|
return new String[] {url, model};
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
return new String[] {null, null};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads a dimension via {@code primary}, falling back to {@code fallback} on any version mismatch. */
|
||||||
|
private static double safeDim(DoubleSupplier primary, DoubleSupplier fallback) {
|
||||||
|
try {
|
||||||
|
return primary.getAsDouble();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
return fallback.getAsDouble();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.snapshot;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.entity.Entity;
|
||||||
|
import org.bukkit.util.Vector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Axis-aligned world-space bounds of the camera frustum: the min/max corner of the camera origin
|
||||||
|
* together with every ray endpoint at {@code maxDistance}. Shared by all snapshot builders to size
|
||||||
|
* their chunk/entity queries identically.
|
||||||
|
*/
|
||||||
|
final class FrustumBounds {
|
||||||
|
|
||||||
|
final double minX, minY, minZ;
|
||||||
|
final double maxX, maxY, maxZ;
|
||||||
|
|
||||||
|
private FrustumBounds(double minX, double minY, double minZ, double maxX, double maxY, double maxZ) {
|
||||||
|
this.minX = minX;
|
||||||
|
this.minY = minY;
|
||||||
|
this.minZ = minZ;
|
||||||
|
this.maxX = maxX;
|
||||||
|
this.maxY = maxY;
|
||||||
|
this.maxZ = maxZ;
|
||||||
|
}
|
||||||
|
|
||||||
|
static FrustumBounds of(Vector origin, List<Vector> rayMap, double maxDistance) {
|
||||||
|
double minX = origin.getX(), minY = origin.getY(), minZ = origin.getZ();
|
||||||
|
double maxX = origin.getX(), maxY = origin.getY(), maxZ = origin.getZ();
|
||||||
|
for (Vector ray : rayMap) {
|
||||||
|
double fx = origin.getX() + ray.getX() * maxDistance;
|
||||||
|
double fy = origin.getY() + ray.getY() * maxDistance;
|
||||||
|
double fz = origin.getZ() + ray.getZ() * maxDistance;
|
||||||
|
minX = Math.min(minX, fx);
|
||||||
|
maxX = Math.max(maxX, fx);
|
||||||
|
minY = Math.min(minY, fy);
|
||||||
|
maxY = Math.max(maxY, fy);
|
||||||
|
minZ = Math.min(minZ, fz);
|
||||||
|
maxZ = Math.max(maxZ, fz);
|
||||||
|
}
|
||||||
|
return new FrustumBounds(minX, minY, minZ, maxX, maxY, maxZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All entities whose centre falls within these bounds expanded by {@code margin} on every side. */
|
||||||
|
Collection<Entity> nearbyEntities(World world, double margin) {
|
||||||
|
Location center = new Location(world, (minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2);
|
||||||
|
double hx = (maxX - minX) / 2 + margin;
|
||||||
|
double hy = (maxY - minY) / 2 + margin;
|
||||||
|
double hz = (maxZ - minZ) / 2 + margin;
|
||||||
|
return world.getNearbyEntities(center, hx, hy, hz);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.snapshot;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
import org.bukkit.ChunkSnapshot;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.util.Vector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures the world region covered by the camera frustum into a {@link WorldSnapshot}.
|
||||||
|
*
|
||||||
|
* <p>MUST be called on the main server thread: it reads live chunks. Only already-loaded chunks are
|
||||||
|
* captured (no forced generation), so the call is cheap and rays into unloaded areas hit sky.
|
||||||
|
*/
|
||||||
|
public final class SnapshotBuilder {
|
||||||
|
|
||||||
|
/** Safety cap on captured chunks to avoid pathological memory/latency. */
|
||||||
|
private static final int MAX_CHUNKS = 4096;
|
||||||
|
|
||||||
|
private SnapshotBuilder() {}
|
||||||
|
|
||||||
|
public static WorldSnapshot build(Location eye, List<Vector> rayMap, double maxDistance, Logger logger) {
|
||||||
|
World world = eye.getWorld();
|
||||||
|
FrustumBounds bounds = FrustumBounds.of(eye.toVector(), rayMap, maxDistance);
|
||||||
|
|
||||||
|
int worldMinY = world.getMinHeight();
|
||||||
|
int worldMaxY = world.getMaxHeight();
|
||||||
|
int clampedMinY = Math.max(worldMinY, (int) Math.floor(bounds.minY) - 1);
|
||||||
|
int clampedMaxY = Math.min(worldMaxY, (int) Math.ceil(bounds.maxY) + 1);
|
||||||
|
|
||||||
|
int minCX = (int) Math.floor(bounds.minX) >> 4;
|
||||||
|
int maxCX = (int) Math.floor(bounds.maxX) >> 4;
|
||||||
|
int minCZ = (int) Math.floor(bounds.minZ) >> 4;
|
||||||
|
int maxCZ = (int) Math.floor(bounds.maxZ) >> 4;
|
||||||
|
|
||||||
|
Map<Long, ChunkSnapshot> chunks = new HashMap<>();
|
||||||
|
int captured = 0;
|
||||||
|
int skipped = 0;
|
||||||
|
for (int cx = minCX; cx <= maxCX; cx++) {
|
||||||
|
for (int cz = minCZ; cz <= maxCZ; cz++) {
|
||||||
|
if (captured >= MAX_CHUNKS) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!world.isChunkLoaded(cx, cz)) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ChunkSnapshot cs = world.getChunkAt(cx, cz).getChunkSnapshot(false, true, false);
|
||||||
|
chunks.put(WorldSnapshot.chunkKey(cx, cz), cs);
|
||||||
|
captured++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipped > 0) {
|
||||||
|
logger.fine(
|
||||||
|
String.format("Snapshot captured %d chunks, skipped %d (unloaded or over cap)", captured, skipped));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WorldSnapshot(chunks, clampedMinY, clampedMaxY, Material.AIR.createBlockData());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.snapshot;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import org.bukkit.ChunkSnapshot;
|
||||||
|
import org.bukkit.block.Biome;
|
||||||
|
import org.bukkit.block.data.BlockData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An immutable, thread-safe view of a bounded region of the world, backed by {@link ChunkSnapshot}s.
|
||||||
|
* Block/biome lookups outside the captured region return air/null so rays simply terminate there.
|
||||||
|
*/
|
||||||
|
public final class WorldSnapshot {
|
||||||
|
|
||||||
|
private final Map<Long, ChunkSnapshot> chunks;
|
||||||
|
private final int minY;
|
||||||
|
private final int maxY; // exclusive
|
||||||
|
private final BlockData air;
|
||||||
|
|
||||||
|
public WorldSnapshot(Map<Long, ChunkSnapshot> chunks, int minY, int maxY, BlockData air) {
|
||||||
|
this.chunks = chunks;
|
||||||
|
this.minY = minY;
|
||||||
|
this.maxY = maxY;
|
||||||
|
this.air = air;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long chunkKey(int chunkX, int chunkZ) {
|
||||||
|
return ((long) chunkX << 32) ^ (chunkZ & 0xFFFFFFFFL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public BlockData getBlockData(int x, int y, int z) {
|
||||||
|
if (y < minY || y >= maxY) return air;
|
||||||
|
ChunkSnapshot cs = chunks.get(chunkKey(x >> 4, z >> 4));
|
||||||
|
if (cs == null) return air;
|
||||||
|
return cs.getBlockData(x & 15, y, z & 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Biome getBiome(int x, int y, int z) {
|
||||||
|
if (y < minY || y >= maxY) return null;
|
||||||
|
ChunkSnapshot cs = chunks.get(chunkKey(x >> 4, z >> 4));
|
||||||
|
if (cs == null) return null;
|
||||||
|
return cs.getBiome(x & 15, y, z & 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int minY() {
|
||||||
|
return minY;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int maxY() {
|
||||||
|
return maxY;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.tint;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hardcoded vanilla temperature/downfall and water color per biome. Used to drive the colormap
|
||||||
|
* lookup, since Paper does not expose the client-side per-block climate reliably. Unknown biomes
|
||||||
|
* fall back to a plains-like default.
|
||||||
|
*/
|
||||||
|
public final class BiomeClimate {
|
||||||
|
|
||||||
|
public record Climate(double temperature, double downfall, int water) {}
|
||||||
|
|
||||||
|
public static final int DEFAULT_WATER = 0x3F76E4;
|
||||||
|
public static final Climate DEFAULT = new Climate(0.8, 0.4, DEFAULT_WATER);
|
||||||
|
|
||||||
|
private static final Map<String, Climate> TABLE = new HashMap<>();
|
||||||
|
|
||||||
|
private static void put(String key, double t, double d) {
|
||||||
|
TABLE.put(key, new Climate(t, d, DEFAULT_WATER));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void put(String key, double t, double d, int water) {
|
||||||
|
TABLE.put(key, new Climate(t, d, water));
|
||||||
|
}
|
||||||
|
|
||||||
|
static {
|
||||||
|
put("plains", 0.8, 0.4);
|
||||||
|
put("sunflower_plains", 0.8, 0.4);
|
||||||
|
put("snowy_plains", 0.0, 0.5);
|
||||||
|
put("ice_spikes", 0.0, 0.5);
|
||||||
|
put("desert", 2.0, 0.0);
|
||||||
|
put("swamp", 0.8, 0.9, 0x617B64);
|
||||||
|
put("mangrove_swamp", 0.8, 0.9, 0x3A7A6A);
|
||||||
|
put("forest", 0.7, 0.8);
|
||||||
|
put("flower_forest", 0.7, 0.8);
|
||||||
|
put("birch_forest", 0.6, 0.6);
|
||||||
|
put("old_growth_birch_forest", 0.6, 0.6);
|
||||||
|
put("dark_forest", 0.7, 0.8);
|
||||||
|
put("old_growth_pine_taiga", 0.3, 0.8);
|
||||||
|
put("old_growth_spruce_taiga", 0.25, 0.8);
|
||||||
|
put("taiga", 0.25, 0.8);
|
||||||
|
put("snowy_taiga", -0.5, 0.4);
|
||||||
|
put("savanna", 2.0, 0.0);
|
||||||
|
put("savanna_plateau", 2.0, 0.0);
|
||||||
|
put("windswept_hills", 0.2, 0.3);
|
||||||
|
put("windswept_gravelly_hills", 0.2, 0.3);
|
||||||
|
put("windswept_forest", 0.2, 0.3);
|
||||||
|
put("windswept_savanna", 2.0, 0.0);
|
||||||
|
put("jungle", 0.95, 0.9);
|
||||||
|
put("sparse_jungle", 0.95, 0.8);
|
||||||
|
put("bamboo_jungle", 0.95, 0.9);
|
||||||
|
put("badlands", 2.0, 0.0);
|
||||||
|
put("eroded_badlands", 2.0, 0.0);
|
||||||
|
put("wooded_badlands", 2.0, 0.0);
|
||||||
|
put("meadow", 0.5, 0.8);
|
||||||
|
put("cherry_grove", 0.5, 0.8, 0x5DB7DD);
|
||||||
|
put("grove", -0.2, 0.8);
|
||||||
|
put("snowy_slopes", -0.3, 0.9);
|
||||||
|
put("frozen_peaks", -0.7, 0.9);
|
||||||
|
put("jagged_peaks", -0.7, 0.9);
|
||||||
|
put("stony_peaks", 1.0, 0.3);
|
||||||
|
put("river", 0.5, 0.5);
|
||||||
|
put("frozen_river", 0.0, 0.5, 0x3938C9);
|
||||||
|
put("beach", 0.8, 0.4);
|
||||||
|
put("snowy_beach", 0.05, 0.3, 0x3D57D6);
|
||||||
|
put("stony_shore", 0.2, 0.3);
|
||||||
|
put("warm_ocean", 0.5, 0.5, 0x43D5EE);
|
||||||
|
put("lukewarm_ocean", 0.5, 0.5, 0x45ADF2);
|
||||||
|
put("deep_lukewarm_ocean", 0.5, 0.5, 0x45ADF2);
|
||||||
|
put("ocean", 0.5, 0.5);
|
||||||
|
put("deep_ocean", 0.5, 0.5);
|
||||||
|
put("cold_ocean", 0.5, 0.5, 0x3D57D6);
|
||||||
|
put("deep_cold_ocean", 0.5, 0.5, 0x3D57D6);
|
||||||
|
put("frozen_ocean", 0.0, 0.5, 0x3938C9);
|
||||||
|
put("deep_frozen_ocean", 0.5, 0.5, 0x3938C9);
|
||||||
|
put("mushroom_fields", 0.9, 1.0);
|
||||||
|
put("dripstone_caves", 0.8, 0.4);
|
||||||
|
put("lush_caves", 0.5, 0.5);
|
||||||
|
put("deep_dark", 0.8, 0.4);
|
||||||
|
put("nether_wastes", 2.0, 0.0, 0x905957);
|
||||||
|
put("soul_sand_valley", 2.0, 0.0, 0x905957);
|
||||||
|
put("crimson_forest", 2.0, 0.0, 0x905957);
|
||||||
|
put("warped_forest", 2.0, 0.0, 0x905957);
|
||||||
|
put("basalt_deltas", 2.0, 0.0, 0x3F76E4);
|
||||||
|
put("the_end", 0.5, 0.5, 0x62529E);
|
||||||
|
put("end_highlands", 0.5, 0.5, 0x62529E);
|
||||||
|
put("end_midlands", 0.5, 0.5, 0x62529E);
|
||||||
|
put("small_end_islands", 0.5, 0.5, 0x62529E);
|
||||||
|
put("end_barrens", 0.5, 0.5, 0x62529E);
|
||||||
|
put("the_void", 0.5, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BiomeClimate() {}
|
||||||
|
|
||||||
|
public static Climate forKey(String biomePath) {
|
||||||
|
return TABLE.getOrDefault(biomePath, DEFAULT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.tint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The biome-dependent tint colors (RGB) for the colormap-driven channels.
|
||||||
|
*/
|
||||||
|
public record BiomeTint(int grass, int foliage, int dryFoliage, int water) {}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.tint;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import org.bukkit.block.Biome;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes per-biome grass/foliage tint colors by sampling the resource pack's colormaps using the
|
||||||
|
* vanilla temperature/downfall formula, plus a per-biome water color. Results are cached per biome.
|
||||||
|
*/
|
||||||
|
public final class BiomeTintProvider {
|
||||||
|
|
||||||
|
private final int[][] grassMap;
|
||||||
|
private final int[][] foliageMap;
|
||||||
|
private final int[][] dryFoliageMap;
|
||||||
|
private final Map<String, BiomeTint> cache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public BiomeTintProvider(TextureCache textures) {
|
||||||
|
this.grassMap = textures.get(ResourceLocation.parse("colormap/grass")).orElse(null);
|
||||||
|
this.foliageMap =
|
||||||
|
textures.get(ResourceLocation.parse("colormap/foliage")).orElse(null);
|
||||||
|
this.dryFoliageMap =
|
||||||
|
textures.get(ResourceLocation.parse("colormap/dry_foliage")).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public BiomeTint forBiome(Biome biome) {
|
||||||
|
return cache.computeIfAbsent(keyOf(biome), this::compute);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String keyOf(Biome biome) {
|
||||||
|
try {
|
||||||
|
return biome.getKey().getKey();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
return "plains";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private BiomeTint compute(String key) {
|
||||||
|
BiomeClimate.Climate climate = BiomeClimate.forKey(key);
|
||||||
|
int grass = sample(grassMap, climate.temperature(), climate.downfall(), 0xFF91BD59);
|
||||||
|
int foliage = sample(foliageMap, climate.temperature(), climate.downfall(), 0xFF77AB2F);
|
||||||
|
int dry = sample(dryFoliageMap, climate.temperature(), climate.downfall(), 0xFFA9A05B);
|
||||||
|
|
||||||
|
// Vanilla per-biome grass/foliage color overrides and modifiers that the colormap alone misses.
|
||||||
|
switch (key) {
|
||||||
|
case "swamp", "mangrove_swamp" -> {
|
||||||
|
grass = 0xFF6A7039;
|
||||||
|
foliage = 0xFF6A7039;
|
||||||
|
}
|
||||||
|
case "badlands", "eroded_badlands", "wooded_badlands" -> {
|
||||||
|
grass = 0xFF90814D;
|
||||||
|
foliage = 0xFF9E814D;
|
||||||
|
}
|
||||||
|
case "dark_forest" -> {
|
||||||
|
// DARK_FOREST modifier: ((color & 0xFEFEFE) + 0x28340A) >> 1
|
||||||
|
grass = 0xFF000000 | (((grass & 0xFEFEFE) + 0x28340A) >> 1);
|
||||||
|
foliage = 0xFF000000 | (((foliage & 0xFEFEFE) + 0x28340A) >> 1);
|
||||||
|
}
|
||||||
|
default -> {}
|
||||||
|
}
|
||||||
|
return new BiomeTint(grass, foliage, dry, 0xFF000000 | climate.water());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vanilla colormap lookup: x = (1-temp)*255, y = (1-downfall*temp)*255. */
|
||||||
|
private int sample(int[][] colormap, double temperature, double downfall, int fallback) {
|
||||||
|
if (colormap == null || colormap.length == 0) return fallback;
|
||||||
|
double temp = Math.clamp(temperature, 0, 1);
|
||||||
|
double down = Math.clamp(downfall, 0, 1) * temp;
|
||||||
|
int x = (int) ((1.0 - temp) * 255.0);
|
||||||
|
int y = (int) ((1.0 - down) * 255.0);
|
||||||
|
int h = colormap.length;
|
||||||
|
int w = colormap[0].length;
|
||||||
|
x = Math.clamp(x, 0, w - 1);
|
||||||
|
y = Math.clamp(y, 0, h - 1);
|
||||||
|
return 0xFF000000 | (colormap[y][x] & 0xFFFFFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.tint;
|
||||||
|
|
||||||
|
import org.bukkit.block.data.BlockData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a tinted face (material + tintindex) to the concrete tint color, choosing between the
|
||||||
|
* biome-driven channels and a handful of vanilla constants.
|
||||||
|
*/
|
||||||
|
public final class TintResolver {
|
||||||
|
|
||||||
|
private static final int BIRCH = 0xFF80A755;
|
||||||
|
private static final int SPRUCE = 0xFF619961;
|
||||||
|
private static final int LILY_PAD = 0xFF208030;
|
||||||
|
private static final int STEM = 0xFF60A017;
|
||||||
|
|
||||||
|
private TintResolver() {}
|
||||||
|
|
||||||
|
/** Returns the ARGB tint to multiply with, or {@code -1} when the face should not be tinted. */
|
||||||
|
public static int resolve(BlockData data, int tintIndex, BiomeTint biomeTint) {
|
||||||
|
if (tintIndex < 0) return -1;
|
||||||
|
String name = data.getMaterial().name().toLowerCase();
|
||||||
|
|
||||||
|
if (name.equals("birch_leaves")) return BIRCH;
|
||||||
|
if (name.equals("spruce_leaves")) return SPRUCE;
|
||||||
|
if (name.endsWith("leaves") || name.equals("vine")) return biomeTint.foliage();
|
||||||
|
if (name.equals("lily_pad")) return LILY_PAD;
|
||||||
|
|
||||||
|
if (name.equals("water") || name.equals("water_cauldron") || name.equals("bubble_column")) {
|
||||||
|
return biomeTint.water();
|
||||||
|
}
|
||||||
|
if (name.equals("redstone_wire")) return redstone(data);
|
||||||
|
if (name.endsWith("stem")) return STEM;
|
||||||
|
|
||||||
|
// grass_block (top/overlay), short_grass, tall_grass, fern, large_fern, sugar_cane, ...
|
||||||
|
if (name.contains("grass")
|
||||||
|
|| name.equals("fern")
|
||||||
|
|| name.equals("large_fern")
|
||||||
|
|| name.equals("sugar_cane")
|
||||||
|
|| name.equals("potted_fern")) {
|
||||||
|
return biomeTint.grass();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default for unknown tinted faces: grass channel (the most common tintindex 0 use).
|
||||||
|
return biomeTint.grass();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int redstone(BlockData data) {
|
||||||
|
int power = 0;
|
||||||
|
String s = data.getAsString(false);
|
||||||
|
int idx = s.indexOf("power=");
|
||||||
|
if (idx >= 0) {
|
||||||
|
int end = idx + 6;
|
||||||
|
int e = end;
|
||||||
|
while (e < s.length() && Character.isDigit(s.charAt(e))) e++;
|
||||||
|
try {
|
||||||
|
power = Integer.parseInt(s.substring(end, e));
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int r = Math.min(255, 75 + power * 12);
|
||||||
|
return 0xFF000000 | (r << 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.render.util;
|
|
||||||
|
|
||||||
import org.bukkit.Location;
|
|
||||||
import org.bukkit.block.Block;
|
|
||||||
import org.bukkit.block.BlockFace;
|
|
||||||
import org.bukkit.util.BlockIterator;
|
|
||||||
import org.bukkit.util.Vector;
|
|
||||||
import org.jetbrains.annotations.NotNull;
|
|
||||||
|
|
||||||
public class BlockRaytracer extends BlockIterator {
|
|
||||||
|
|
||||||
private final Vector position;
|
|
||||||
private final Vector direction;
|
|
||||||
|
|
||||||
private Block lastBlock;
|
|
||||||
private BlockFace currentFace;
|
|
||||||
|
|
||||||
public BlockRaytracer(Location loc) {
|
|
||||||
super(loc);
|
|
||||||
|
|
||||||
this.position = loc.toVector();
|
|
||||||
this.direction = loc.getDirection();
|
|
||||||
}
|
|
||||||
|
|
||||||
public BlockFace getIntersectionFace() {
|
|
||||||
if (currentFace == null) {
|
|
||||||
throw new IllegalStateException("Called before next()");
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentFace;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Vector getIntersectionPoint() {
|
|
||||||
BlockFace lastFace = getIntersectionFace();
|
|
||||||
Vector planeNormal = new Vector(lastFace.getModX(), lastFace.getModY(), lastFace.getModZ());
|
|
||||||
Vector planePoint = lastBlock.getLocation()
|
|
||||||
.add(0.5, 0.5, 0.5)
|
|
||||||
.toVector()
|
|
||||||
.add(planeNormal.clone().multiply(0.5));
|
|
||||||
|
|
||||||
return MathUtil.getLinePlaneIntersection(position, direction, planePoint, planeNormal, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public @NotNull Block next() {
|
|
||||||
Block currentBlock = super.next();
|
|
||||||
currentFace = lastBlock == null ? BlockFace.SELF : currentBlock.getFace(lastBlock);
|
|
||||||
|
|
||||||
return (lastBlock = currentBlock);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.render.util;
|
||||||
|
|
||||||
|
import org.bukkit.Color;
|
||||||
|
import org.bukkit.DyeColor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpers for packed ARGB integer colors.
|
||||||
|
*/
|
||||||
|
public final class ColorUtil {
|
||||||
|
|
||||||
|
private ColorUtil() {}
|
||||||
|
|
||||||
|
public static int alpha(int argb) {
|
||||||
|
return (argb >> 24) & 0xFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int red(int argb) {
|
||||||
|
return (argb >> 16) & 0xFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int green(int argb) {
|
||||||
|
return (argb >> 8) & 0xFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int blue(int argb) {
|
||||||
|
return argb & 0xFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int argb(int a, int r, int g, int b) {
|
||||||
|
return (a << 24) | (r << 16) | (g << 8) | b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opaque ARGB for a Bukkit dye colour, or {@code fallback} when {@code dye} is null. */
|
||||||
|
public static int dyeArgb(DyeColor dye, int fallback) {
|
||||||
|
if (dye == null) return fallback;
|
||||||
|
Color c = dye.getColor();
|
||||||
|
return argb(0xFF, c.getRed(), c.getGreen(), c.getBlue());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The vanilla sign-text colour for a dye (opaque ARGB), from Mojang's {@code DyeColor.getTextColor()}
|
||||||
|
* table (the firework/text colours, NOT the cloth colours). Null = black (the default sign ink).
|
||||||
|
*/
|
||||||
|
public static int signTextColor(DyeColor dye) {
|
||||||
|
int rgb =
|
||||||
|
switch (dye == null ? DyeColor.BLACK : dye) {
|
||||||
|
case WHITE -> 0xF9FFFE;
|
||||||
|
case ORANGE -> 0xF9801D;
|
||||||
|
case MAGENTA -> 0xC74EBD;
|
||||||
|
case LIGHT_BLUE -> 0x3AB3DA;
|
||||||
|
case YELLOW -> 0xFED83D;
|
||||||
|
case LIME -> 0x80C71F;
|
||||||
|
case PINK -> 0xF38BAA;
|
||||||
|
case GRAY -> 0x474F52;
|
||||||
|
case LIGHT_GRAY -> 0x9D9D97;
|
||||||
|
case CYAN -> 0x169C9C;
|
||||||
|
case PURPLE -> 0x8932B8;
|
||||||
|
case BLUE -> 0x3C44AA;
|
||||||
|
case BROWN -> 0x835432;
|
||||||
|
case GREEN -> 0x5E7C16;
|
||||||
|
case RED -> 0xB02E26;
|
||||||
|
case BLACK -> 0x1D1D21;
|
||||||
|
};
|
||||||
|
return 0xFF000000 | rgb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fill colour for sign text: glowing text uses the full dye colour; non-glowing text is the dye
|
||||||
|
* colour darkened to 40% (matching vanilla {@code SignRenderer}, which is why normal ink looks dim).
|
||||||
|
*/
|
||||||
|
public static int signFillArgb(DyeColor dye, boolean glowing) {
|
||||||
|
int base = signTextColor(dye);
|
||||||
|
return glowing ? base : (0xFF000000 | (shade(base, 0.4) & 0xFFFFFF));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 8-directional outline colour drawn around glowing sign text (vanilla {@code getDarkColor}):
|
||||||
|
* the dye colour darkened to 40%, except glowing BLACK ink which gets a light cream outline so it
|
||||||
|
* stays readable.
|
||||||
|
*/
|
||||||
|
public static int signOutlineArgb(DyeColor dye) {
|
||||||
|
if ((dye == null ? DyeColor.BLACK : dye) == DyeColor.BLACK) return 0xFFF0EBCC;
|
||||||
|
return 0xFF000000 | (shade(signTextColor(dye), 0.4) & 0xFFFFFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multiplies the RGB channels of {@code base} by {@code tint} (per-channel, 0..255), keeping base alpha. */
|
||||||
|
public static int multiply(int base, int tint) {
|
||||||
|
int a = alpha(base);
|
||||||
|
int r = red(base) * red(tint) / 255;
|
||||||
|
int g = green(base) * green(tint) / 255;
|
||||||
|
int b = blue(base) * blue(tint) / 255;
|
||||||
|
return argb(a, r, g, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scales the RGB channels by {@code factor} (0..1), keeping alpha. Used for directional face shading. */
|
||||||
|
public static int shade(int argb, double factor) {
|
||||||
|
int a = alpha(argb);
|
||||||
|
int r = clamp((int) (red(argb) * factor));
|
||||||
|
int g = clamp((int) (green(argb) * factor));
|
||||||
|
int b = clamp((int) (blue(argb) * factor));
|
||||||
|
return argb(a, r, g, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int clamp(int v) {
|
||||||
|
return v < 0 ? 0 : Math.min(v, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalized weighted average of two RGB colors (alpha ignored, result opaque-RGB in the low 24 bits).
|
||||||
|
* Weights need not sum to 1; they are normalized by their total.
|
||||||
|
*/
|
||||||
|
public static int mix(int rgbA, int rgbB, double weightA, double weightB) {
|
||||||
|
double total = weightA + weightB;
|
||||||
|
int r = (int) ((red(rgbA) * weightA + red(rgbB) * weightB) / total);
|
||||||
|
int g = (int) ((green(rgbA) * weightA + green(rgbB) * weightB) / total);
|
||||||
|
int b = (int) ((blue(rgbA) * weightA + blue(rgbB) * weightB) / total);
|
||||||
|
return (r << 16) | (g << 8) | b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Gamma-correct (linear-light) averaging ---
|
||||||
|
|
||||||
|
private static final float[] SRGB_TO_LINEAR = new float[256];
|
||||||
|
|
||||||
|
static {
|
||||||
|
for (int i = 0; i < 256; i++) {
|
||||||
|
double c = i / 255.0;
|
||||||
|
SRGB_TO_LINEAR[i] = (float) (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float toLinear(int channel) {
|
||||||
|
return SRGB_TO_LINEAR[channel & 0xFF];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int toSrgb(double linear) {
|
||||||
|
double c = linear <= 0.0031308 ? linear * 12.92 : 1.055 * Math.pow(linear, 1 / 2.4) - 0.055;
|
||||||
|
return clamp((int) Math.round(c * 255.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Averages a set of RGB colors in linear light and returns the sRGB result (opaque). */
|
||||||
|
public static int averageLinear(int[] colors, int from, int count) {
|
||||||
|
double r = 0, g = 0, b = 0;
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
int c = colors[from + i];
|
||||||
|
r += toLinear((c >> 16) & 0xFF);
|
||||||
|
g += toLinear((c >> 8) & 0xFF);
|
||||||
|
b += toLinear(c & 0xFF);
|
||||||
|
}
|
||||||
|
return argb(0xFF, toSrgb(r / count), toSrgb(g / count), toSrgb(b / count));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.render.util;
|
|
||||||
|
|
||||||
import org.bukkit.util.Vector;
|
|
||||||
|
|
||||||
public final class Intersection {
|
|
||||||
|
|
||||||
private final Vector normal;
|
|
||||||
private final Vector point;
|
|
||||||
private final Vector direction;
|
|
||||||
private final int color;
|
|
||||||
|
|
||||||
private Intersection(Vector normal, Vector point, Vector direction, int color) {
|
|
||||||
this.normal = normal;
|
|
||||||
this.point = point;
|
|
||||||
this.direction = direction;
|
|
||||||
this.color = color;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Vector getNormal() {
|
|
||||||
return normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Vector getPoint() {
|
|
||||||
return point;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Vector getDirection() {
|
|
||||||
return direction;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getColor() {
|
|
||||||
return color;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Intersection of(Vector normal, Vector point, Vector direction) {
|
|
||||||
return of(normal, point, direction, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Intersection of(Vector normal, Vector point, Vector direction, int color) {
|
|
||||||
return new Intersection(normal, point, direction, color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.render.util;
|
package eu.mhsl.minecraft.pixelpics.render.util;
|
||||||
|
|
||||||
import org.bukkit.Color;
|
|
||||||
import org.bukkit.block.BlockFace;
|
import org.bukkit.block.BlockFace;
|
||||||
import org.bukkit.util.Vector;
|
import org.bukkit.util.Vector;
|
||||||
|
|
||||||
@@ -8,7 +7,7 @@ public class MathUtil {
|
|||||||
|
|
||||||
private MathUtil() {}
|
private MathUtil() {}
|
||||||
|
|
||||||
public static Vector yawPitchRotation(Vector base, double angleYaw, double anglePitch) {
|
private static Vector yawPitchRotation(Vector base, double angleYaw, double anglePitch) {
|
||||||
double oldX = base.getX();
|
double oldX = base.getX();
|
||||||
double oldY = base.getY();
|
double oldY = base.getY();
|
||||||
double oldZ = base.getZ();
|
double oldZ = base.getZ();
|
||||||
@@ -25,44 +24,17 @@ public class MathUtil {
|
|||||||
return new Vector(newX, newY, newZ);
|
return new Vector(newX, newY, newZ);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Vector doubleYawPitchRotation(Vector base, double firstYaw, double firstPitch, double secondYaw,
|
public static Vector doubleYawPitchRotation(
|
||||||
double secondPitch) {
|
Vector base, double firstYaw, double firstPitch, double secondYaw, double secondPitch) {
|
||||||
return yawPitchRotation(yawPitchRotation(base, firstYaw, firstPitch), secondYaw, secondPitch);
|
return yawPitchRotation(yawPitchRotation(base, firstYaw, firstPitch), secondYaw, secondPitch);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Vector reflectVector(Vector linePoint, Vector lineDirection, Vector planePoint, Vector planeNormal) {
|
/** Reflects {@code direction} across the plane with the given (unit) {@code normal}. */
|
||||||
return lineDirection.clone().subtract(planeNormal.clone().multiply(2 * lineDirection.dot(planeNormal)));
|
public static Vector reflectVector(Vector direction, Vector normal) {
|
||||||
|
return direction.clone().subtract(normal.clone().multiply(2 * direction.dot(normal)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Vector toVector(BlockFace face) {
|
public static Vector toVector(BlockFace face) {
|
||||||
return new Vector(face.getModX(), face.getModY(), face.getModZ());
|
return new Vector(face.getModX(), face.getModY(), face.getModZ());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static int weightedColorSum(int rgbOne, int rgbTwo, double weightOne, double weightTwo) {
|
|
||||||
Color colorOne = Color.fromRGB(rgbOne & 0xFFFFFF);
|
|
||||||
Color colorTwo = Color.fromRGB(rgbTwo & 0xFFFFFF);
|
|
||||||
|
|
||||||
double total = weightOne + weightTwo;
|
|
||||||
int newRed = (int) ((colorOne.getRed() * weightOne + colorTwo.getRed() * weightTwo) / total);
|
|
||||||
int newGreen = (int) ((colorOne.getGreen() * weightOne + colorTwo.getGreen() * weightTwo) / total);
|
|
||||||
int newBlue = (int) ((colorOne.getBlue() * weightOne + colorTwo.getBlue() * weightTwo) / total);
|
|
||||||
|
|
||||||
return Color.fromRGB(newRed, newGreen, newBlue).asRGB();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Vector getLinePlaneIntersection(Vector linePoint, Vector lineDirection, Vector planePoint,
|
|
||||||
Vector planeNormal, boolean allowBackwards) {
|
|
||||||
double d = planePoint.dot(planeNormal);
|
|
||||||
double t = (d - planeNormal.dot(linePoint)) / planeNormal.dot(lineDirection);
|
|
||||||
|
|
||||||
if (t < 0 && !allowBackwards) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
double x = linePoint.getX() + lineDirection.getX() * t;
|
|
||||||
double y = linePoint.getY() + lineDirection.getY() * t;
|
|
||||||
double z = linePoint.getZ() + lineDirection.getZ() * t;
|
|
||||||
|
|
||||||
return new Vector(x, y, z);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.survival;
|
||||||
|
|
||||||
|
import com.destroystokyo.paper.profile.PlayerProfile;
|
||||||
|
import com.destroystokyo.paper.profile.ProfileProperty;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.Main;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
|
import net.kyori.adventure.text.format.TextDecoration;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.NamespacedKey;
|
||||||
|
import org.bukkit.inventory.EquipmentSlot;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.meta.ItemMeta;
|
||||||
|
import org.bukkit.inventory.meta.SkullMeta;
|
||||||
|
import org.bukkit.inventory.meta.components.EquippableComponent;
|
||||||
|
import org.bukkit.persistence.PersistentDataContainer;
|
||||||
|
import org.bukkit.persistence.PersistentDataType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory and identity helpers for the survival items: the camera and the film roll are custom-skinned
|
||||||
|
* {@code PLAYER_HEAD}s (the texture renders client-side without a resource pack), the photo is the
|
||||||
|
* existing {@code FILLED_MAP}. Identity is carried by PDC markers (robust against renames), and the
|
||||||
|
* camera's loaded film count lives in PDC as well.
|
||||||
|
*/
|
||||||
|
public final class CameraItems {
|
||||||
|
|
||||||
|
/** Maximum film rolls a camera can hold; one roll yields one photo. */
|
||||||
|
public static final int MAX_FILM = 8;
|
||||||
|
|
||||||
|
private static final int CAMERA_MODEL_DATA = 1001;
|
||||||
|
private static final int FILM_MODEL_DATA = 1002;
|
||||||
|
|
||||||
|
static final String CAMERA_TEXTURE_B64 =
|
||||||
|
"eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZDlkMmNiZjAyZDMwOGI2MDY1YTZmZThjNjU3MWI2MzU2NjMzZjQxOTJlOGVjNzEyMTNjNzcwNzgwZTNkZTRlMiJ9fX0=";
|
||||||
|
static final String FILM_TEXTURE_B64 =
|
||||||
|
"eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvMTVkMGY0OGJlNzNkYmIwZDJjYjE1NTRjMmUzODZiNWNjM2FiMjFhNGRjYWU4ZmYzOGI3NzRhZDNkMDFkMGE1OSJ9fX0=";
|
||||||
|
|
||||||
|
private CameraItems() {}
|
||||||
|
|
||||||
|
// --- factories ---
|
||||||
|
|
||||||
|
/** A camera holding {@code filmCount} (clamped to 0..{@link #MAX_FILM}) loaded film rolls. */
|
||||||
|
public static ItemStack createCamera(int filmCount) {
|
||||||
|
int count = Math.clamp(filmCount, 0, MAX_FILM);
|
||||||
|
ItemStack item = new ItemStack(Material.PLAYER_HEAD);
|
||||||
|
SkullMeta meta = (SkullMeta) item.getItemMeta();
|
||||||
|
applyHead(meta, "pixelpics:camera", CAMERA_TEXTURE_B64);
|
||||||
|
meta.displayName(Component.text("Kamera", NamedTextColor.AQUA).decoration(TextDecoration.ITALIC, false));
|
||||||
|
meta.setCustomModelData(CAMERA_MODEL_DATA);
|
||||||
|
meta.getPersistentDataContainer().set(Main.getInstance().cameraMarker, PersistentDataType.BYTE, (byte) 1);
|
||||||
|
meta.getPersistentDataContainer().set(Main.getInstance().filmCountKey, PersistentDataType.INTEGER, count);
|
||||||
|
applyCameraLore(meta, count);
|
||||||
|
makeUnwearable(meta);
|
||||||
|
item.setItemMeta(meta);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single film roll. */
|
||||||
|
public static ItemStack createFilm() {
|
||||||
|
ItemStack item = new ItemStack(Material.PLAYER_HEAD);
|
||||||
|
SkullMeta meta = (SkullMeta) item.getItemMeta();
|
||||||
|
applyHead(meta, "pixelpics:film", FILM_TEXTURE_B64);
|
||||||
|
meta.displayName(Component.text("Filmrolle", NamedTextColor.GREEN).decoration(TextDecoration.ITALIC, false));
|
||||||
|
meta.setCustomModelData(FILM_MODEL_DATA);
|
||||||
|
meta.lore(List.of(
|
||||||
|
Component.text("Lädt eine Kamera auf.", NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false)));
|
||||||
|
meta.getPersistentDataContainer().set(Main.getInstance().filmMarker, PersistentDataType.BYTE, (byte) 1);
|
||||||
|
makeUnwearable(meta);
|
||||||
|
item.setItemMeta(meta);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a copy of {@code camera} with its film count set to {@code newCount} and lore refreshed. */
|
||||||
|
public static ItemStack withFilmCount(ItemStack camera, int newCount) {
|
||||||
|
int count = Math.clamp(newCount, 0, MAX_FILM);
|
||||||
|
ItemStack copy = camera.clone();
|
||||||
|
SkullMeta meta = (SkullMeta) copy.getItemMeta();
|
||||||
|
meta.getPersistentDataContainer().set(Main.getInstance().filmCountKey, PersistentDataType.INTEGER, count);
|
||||||
|
applyCameraLore(meta, count);
|
||||||
|
copy.setItemMeta(meta);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- identity ---
|
||||||
|
|
||||||
|
public static boolean isCamera(ItemStack item) {
|
||||||
|
return hasMarker(item, Main.getInstance().cameraMarker);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isFilm(ItemStack item) {
|
||||||
|
return hasMarker(item, Main.getInstance().filmMarker);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A photo is a filled map carrying our picture id. */
|
||||||
|
public static boolean isPhoto(ItemStack item) {
|
||||||
|
if (item == null || item.getType() != Material.FILLED_MAP || !item.hasItemMeta()) return false;
|
||||||
|
return item.getItemMeta()
|
||||||
|
.getPersistentDataContainer()
|
||||||
|
.has(Main.getInstance().pictureIdFlag, PersistentDataType.STRING);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loaded film on a camera, or 0 if the item is not a camera. */
|
||||||
|
public static int getFilmCount(ItemStack item) {
|
||||||
|
if (!isCamera(item)) return 0;
|
||||||
|
Integer v = item.getItemMeta()
|
||||||
|
.getPersistentDataContainer()
|
||||||
|
.get(Main.getInstance().filmCountKey, PersistentDataType.INTEGER);
|
||||||
|
return v == null ? 0 : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- internals ---
|
||||||
|
|
||||||
|
private static boolean hasMarker(ItemStack item, NamespacedKey key) {
|
||||||
|
if (item == null || item.getType() != Material.PLAYER_HEAD || !item.hasItemMeta()) return false;
|
||||||
|
PersistentDataContainer pdc = item.getItemMeta().getPersistentDataContainer();
|
||||||
|
return pdc.has(key, PersistentDataType.BYTE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void applyCameraLore(ItemMeta meta, int count) {
|
||||||
|
meta.lore(List.of(
|
||||||
|
Component.text(
|
||||||
|
"Film: " + count + " / " + MAX_FILM,
|
||||||
|
count > 0 ? NamedTextColor.YELLOW : NamedTextColor.RED)
|
||||||
|
.decoration(TextDecoration.ITALIC, false),
|
||||||
|
Component.text("Rechtsklick: Foto aufnehmen", NamedTextColor.DARK_GRAY)
|
||||||
|
.decoration(TextDecoration.ITALIC, false)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overrides the {@code PLAYER_HEAD}'s default head-equippable component so the item cannot be worn:
|
||||||
|
* the slot is moved to {@code SADDLE} (no player slot accepts it, so the helmet slot rejects it —
|
||||||
|
* blocking right-click, shift-click and dispenser equipping) and right-click swap is disabled.
|
||||||
|
*/
|
||||||
|
private static void makeUnwearable(ItemMeta meta) {
|
||||||
|
EquippableComponent eq = meta.getEquippable();
|
||||||
|
eq.setSlot(EquipmentSlot.SADDLE);
|
||||||
|
eq.setSwappable(false);
|
||||||
|
meta.setEquippable(eq);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void applyHead(SkullMeta meta, String seed, String textureB64) {
|
||||||
|
if (textureB64 == null || textureB64.isBlank()) return;
|
||||||
|
UUID synthetic = UUID.nameUUIDFromBytes(seed.getBytes(StandardCharsets.UTF_8));
|
||||||
|
PlayerProfile profile = Bukkit.createProfileExact(synthetic, null);
|
||||||
|
profile.setProperty(new ProfileProperty("textures", textureB64));
|
||||||
|
meta.setPlayerProfile(profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.survival;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
|
import org.bukkit.Sound;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.block.Action;
|
||||||
|
import org.bukkit.event.player.PlayerInteractEvent;
|
||||||
|
import org.bukkit.inventory.EquipmentSlot;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a photo when a player right-clicks while holding a camera. Consumes one loaded film per shot;
|
||||||
|
* with no film loaded it gives a short fail feedback. Guards against the well-known double-fire of
|
||||||
|
* {@link PlayerInteractEvent} (off-hand fire + air/block) via a hand filter and a per-player cooldown.
|
||||||
|
*/
|
||||||
|
public class CameraListener implements Listener {
|
||||||
|
|
||||||
|
private static final long COOLDOWN_MILLIS = 500;
|
||||||
|
private final Map<UUID, Long> lastUse = new HashMap<>();
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onInteract(PlayerInteractEvent event) {
|
||||||
|
if (event.getHand() != EquipmentSlot.HAND) return;
|
||||||
|
Action action = event.getAction();
|
||||||
|
if (action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK) return;
|
||||||
|
|
||||||
|
ItemStack inHand = event.getItem();
|
||||||
|
if (!CameraItems.isCamera(inHand)) return;
|
||||||
|
|
||||||
|
Player player = event.getPlayer();
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (now - lastUse.getOrDefault(player.getUniqueId(), 0L) < COOLDOWN_MILLIS) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastUse.put(player.getUniqueId(), now);
|
||||||
|
|
||||||
|
event.setCancelled(true);
|
||||||
|
|
||||||
|
int film = CameraItems.getFilmCount(inHand);
|
||||||
|
if (film <= 0) {
|
||||||
|
// Dry "empty shutter" click — no film loaded.
|
||||||
|
player.playSound(player.getLocation(), Sound.BLOCK_DISPENSER_FAIL, 1f, 1.2f);
|
||||||
|
player.sendActionBar(Component.text("Kein Film geladen!", NamedTextColor.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only charge film if the shot was actually accepted (renderer ready, not queue-rejected).
|
||||||
|
if (PhotoService.takePhoto(player)) {
|
||||||
|
player.getInventory().setItemInMainHand(CameraItems.withFilmCount(inHand, film - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.survival;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.Main;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.Keyed;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.NamespacedKey;
|
||||||
|
import org.bukkit.Sound;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.inventory.CraftItemEvent;
|
||||||
|
import org.bukkit.event.inventory.PrepareItemCraftEvent;
|
||||||
|
import org.bukkit.inventory.CraftingInventory;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.Recipe;
|
||||||
|
import org.bukkit.inventory.meta.MapMeta;
|
||||||
|
import org.bukkit.persistence.PersistentDataType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the two dynamic recipes whose inputs carry variable NBT: <em>load film</em>
|
||||||
|
* (camera + film → camera+1) and <em>copy photo</em> (photo + film → 2 photos sharing the same map).
|
||||||
|
* {@link PrepareItemCraftEvent} computes the result preview and validates the loose
|
||||||
|
* {@code MaterialChoice} matches; {@link CraftItemEvent} performs the consumption/doubling manually
|
||||||
|
* (the vanilla machinery cannot, given the variable-NBT ingredients) and blocks shift-click and any
|
||||||
|
* attempt to leak our custom items into other recipes.
|
||||||
|
*/
|
||||||
|
public class CraftingListener implements Listener {
|
||||||
|
|
||||||
|
private enum Kind {
|
||||||
|
LOAD,
|
||||||
|
COPY,
|
||||||
|
NONE
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scanned crafting grid: the relevant items and whether any of our marked items are present. */
|
||||||
|
private record Scan(Kind kind, ItemStack camera, ItemStack film, ItemStack photo, boolean hasOurItems) {}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onPrepare(PrepareItemCraftEvent event) {
|
||||||
|
CraftingInventory inv = event.getInventory();
|
||||||
|
Scan scan = scan(inv.getMatrix());
|
||||||
|
|
||||||
|
switch (scan.kind()) {
|
||||||
|
case LOAD -> {
|
||||||
|
int count = CameraItems.getFilmCount(scan.camera());
|
||||||
|
inv.setResult(
|
||||||
|
count >= CameraItems.MAX_FILM ? null : CameraItems.withFilmCount(scan.camera(), count + 1));
|
||||||
|
}
|
||||||
|
case COPY -> inv.setResult(buildPhotoCopy(scan.photo()));
|
||||||
|
case NONE -> {
|
||||||
|
// Block our loose MaterialChoice recipes from matching garbage, and stop our items
|
||||||
|
// from being consumed by any unrelated (vanilla) recipe.
|
||||||
|
if (isDynamicRecipe(event.getRecipe()) || scan.hasOurItems()) {
|
||||||
|
inv.setResult(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onCraft(CraftItemEvent event) {
|
||||||
|
CraftingInventory inv = event.getInventory();
|
||||||
|
Scan scan = scan(inv.getMatrix());
|
||||||
|
|
||||||
|
if (scan.kind() == Kind.NONE) {
|
||||||
|
// Defensive: never let our items be consumed by another recipe.
|
||||||
|
if (scan.hasOurItems()) event.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.setCancelled(true);
|
||||||
|
Player player = (Player) event.getWhoClicked();
|
||||||
|
|
||||||
|
if (event.isShiftClick()) {
|
||||||
|
player.sendActionBar(Component.text("Bitte einzeln herstellen.", NamedTextColor.YELLOW));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ItemStack result;
|
||||||
|
ItemStack[] matrix = inv.getMatrix();
|
||||||
|
if (scan.kind() == Kind.LOAD) {
|
||||||
|
int count = CameraItems.getFilmCount(scan.camera());
|
||||||
|
if (count >= CameraItems.MAX_FILM) return;
|
||||||
|
result = CameraItems.withFilmCount(scan.camera(), count + 1);
|
||||||
|
consumeFirst(matrix, CameraItems::isCamera);
|
||||||
|
consumeFirst(matrix, CameraItems::isFilm);
|
||||||
|
} else { // COPY
|
||||||
|
result = buildPhotoCopy(scan.photo());
|
||||||
|
if (result == null) return;
|
||||||
|
consumeFirst(matrix, CameraItems::isFilm); // original photo stays
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply on the next tick so the click settles before we mutate the grid and hand over the item.
|
||||||
|
ItemStack finalResult = result;
|
||||||
|
Bukkit.getScheduler().runTask(Main.getInstance(), () -> {
|
||||||
|
inv.setMatrix(matrix);
|
||||||
|
give(player, finalResult);
|
||||||
|
player.playSound(player.getLocation(), Sound.UI_TOAST_IN, 0.7f, 1.4f);
|
||||||
|
player.updateInventory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
private static Scan scan(ItemStack[] matrix) {
|
||||||
|
int cameras = 0, films = 0, photos = 0, others = 0;
|
||||||
|
ItemStack camera = null, film = null, photo = null;
|
||||||
|
for (ItemStack it : matrix) {
|
||||||
|
if (it == null || it.getType().isAir()) continue;
|
||||||
|
if (CameraItems.isCamera(it)) {
|
||||||
|
cameras++;
|
||||||
|
camera = it;
|
||||||
|
} else if (CameraItems.isFilm(it)) {
|
||||||
|
films++;
|
||||||
|
film = it;
|
||||||
|
} else if (CameraItems.isPhoto(it)) {
|
||||||
|
photos++;
|
||||||
|
photo = it;
|
||||||
|
} else others++;
|
||||||
|
}
|
||||||
|
boolean ours = cameras + films + photos > 0;
|
||||||
|
Kind kind = Kind.NONE;
|
||||||
|
if (others == 0) {
|
||||||
|
if (cameras == 1 && films == 1 && photos == 0) kind = Kind.LOAD;
|
||||||
|
else if (photos == 1 && films == 1 && cameras == 0) kind = Kind.COPY;
|
||||||
|
}
|
||||||
|
return new Scan(kind, camera, film, photo, ours);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isDynamicRecipe(Recipe recipe) {
|
||||||
|
if (!(recipe instanceof Keyed keyed)) return false;
|
||||||
|
NamespacedKey key = keyed.getKey();
|
||||||
|
return SurvivalRecipes.LOAD.equals(key) || SurvivalRecipes.COPY.equals(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A second photo referencing the same {@link org.bukkit.map.MapView} and picture id as {@code photo}. */
|
||||||
|
private static ItemStack buildPhotoCopy(ItemStack photo) {
|
||||||
|
MapMeta src = (MapMeta) photo.getItemMeta();
|
||||||
|
if (!src.hasMapView() || src.getMapView() == null) return null;
|
||||||
|
ItemStack copy = new ItemStack(Material.FILLED_MAP);
|
||||||
|
MapMeta dst = (MapMeta) copy.getItemMeta();
|
||||||
|
dst.setMapView(src.getMapView());
|
||||||
|
String pid = src.getPersistentDataContainer().get(Main.getInstance().pictureIdFlag, PersistentDataType.STRING);
|
||||||
|
if (pid != null) {
|
||||||
|
dst.getPersistentDataContainer().set(Main.getInstance().pictureIdFlag, PersistentDataType.STRING, pid);
|
||||||
|
}
|
||||||
|
copy.setItemMeta(dst);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void consumeFirst(ItemStack[] matrix, Predicate<ItemStack> pred) {
|
||||||
|
for (int i = 0; i < matrix.length; i++) {
|
||||||
|
ItemStack it = matrix[i];
|
||||||
|
if (it != null && !it.getType().isAir() && pred.test(it)) {
|
||||||
|
int amt = it.getAmount();
|
||||||
|
if (amt <= 1) matrix[i] = null;
|
||||||
|
else {
|
||||||
|
it.setAmount(amt - 1);
|
||||||
|
matrix[i] = it;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void give(Player player, ItemStack item) {
|
||||||
|
ItemStack cursor = player.getItemOnCursor();
|
||||||
|
if (cursor.getType().isAir()) {
|
||||||
|
player.setItemOnCursor(item);
|
||||||
|
} else {
|
||||||
|
player.getInventory().addItem(item).values().forEach(left -> player.getWorld()
|
||||||
|
.dropItemNaturally(player.getLocation(), left));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.survival;
|
||||||
|
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.player.PlayerJoinEvent;
|
||||||
|
|
||||||
|
/** Unlocks the survival recipes in each player's recipe book on join (idempotent). */
|
||||||
|
public class JoinListener implements Listener {
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onJoin(PlayerJoinEvent event) {
|
||||||
|
event.getPlayer().discoverRecipes(SurvivalRecipes.allKeys());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.survival;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.Main;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.RenderManager;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.render.DefaultScreenRenderer;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.render.RenderJob;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.render.Resolution;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.utils.MapImageDither;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.utils.MapManager;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.util.UUID;
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.Particle;
|
||||||
|
import org.bukkit.Sound;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.meta.MapMeta;
|
||||||
|
import org.bukkit.map.MapView;
|
||||||
|
import org.bukkit.persistence.PersistentDataType;
|
||||||
|
import org.bukkit.util.Vector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a photo of a player's current view and delivers it as a developing {@code FILLED_MAP},
|
||||||
|
* with a shutter sound + flash particles on capture and a chime when the picture finishes developing.
|
||||||
|
* Heavy work is funneled through {@link RenderManager} (per-player single slot, global concurrency
|
||||||
|
* limit + queue, bounded low-priority CPU). Shared by the camera item (survival) and the
|
||||||
|
* {@code /pixelPic} debug command. Film accounting is the caller's responsibility.
|
||||||
|
*/
|
||||||
|
public final class PhotoService {
|
||||||
|
|
||||||
|
/** Render result carried back to the main thread: PNG source + dithered map indices. */
|
||||||
|
private record RenderOutput(BufferedImage image, byte[] indices) {}
|
||||||
|
|
||||||
|
private PhotoService() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures and delivers a photo. Returns {@code false} (with action-bar feedback) if the renderer
|
||||||
|
* is unavailable or the request is rejected by the queue — callers should not charge film then.
|
||||||
|
*/
|
||||||
|
public static boolean takePhoto(Player player) {
|
||||||
|
Main plugin = Main.getInstance();
|
||||||
|
DefaultScreenRenderer renderer = plugin.getScreenRenderer();
|
||||||
|
if (renderer == null) {
|
||||||
|
player.sendActionBar(
|
||||||
|
Component.text("PixelPics ist nicht einsatzbereit (kein Resource-Pack).", NamedTextColor.RED));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderManager manager = plugin.getRenderManager();
|
||||||
|
UUID user = player.getUniqueId();
|
||||||
|
RenderManager.Outcome outcome = manager.tryReserve(user);
|
||||||
|
if (outcome != RenderManager.Outcome.ACCEPTED) {
|
||||||
|
player.sendActionBar(Component.text(
|
||||||
|
outcome == RenderManager.Outcome.USER_BUSY
|
||||||
|
? "Deine letzte Aufnahme wird noch entwickelt …"
|
||||||
|
: "Zu viele Aufnahmen gerade — versuch es gleich erneut.",
|
||||||
|
NamedTextColor.RED));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean dispatched = false;
|
||||||
|
try {
|
||||||
|
Resolution resolution = new Resolution(Resolution.Pixels._128P, Resolution.AspectRatio._1_1);
|
||||||
|
|
||||||
|
// Capture the world snapshot on the main thread (the moment of the shot).
|
||||||
|
RenderJob job = renderer.prepare(player.getEyeLocation(), resolution, user);
|
||||||
|
|
||||||
|
// Hand the map over immediately, showing blank "film"; it develops once the render is ready.
|
||||||
|
ImageMapRenderer mapRenderer = new ImageMapRenderer();
|
||||||
|
MapView mapView = Bukkit.createMap(player.getWorld());
|
||||||
|
int id = mapView.getId();
|
||||||
|
MapManager.attachView(mapView, mapRenderer);
|
||||||
|
|
||||||
|
ItemStack map = new ItemStack(Material.FILLED_MAP, 1);
|
||||||
|
MapMeta meta = (MapMeta) map.getItemMeta();
|
||||||
|
meta.getPersistentDataContainer()
|
||||||
|
.set(
|
||||||
|
plugin.pictureIdFlag,
|
||||||
|
PersistentDataType.STRING,
|
||||||
|
UUID.randomUUID().toString());
|
||||||
|
meta.setMapView(mapView);
|
||||||
|
map.setItemMeta(meta);
|
||||||
|
player.getInventory().addItem(map);
|
||||||
|
|
||||||
|
// Feedback is sound + particles only; no action-bar text on a normal shot.
|
||||||
|
playShutter(player);
|
||||||
|
|
||||||
|
// Trace + dither off-thread (queued), then develop on the main thread. The cancel token is
|
||||||
|
// tripped by the watchdog on timeout; we bail out before the (now pointless) dithering.
|
||||||
|
manager.dispatch(
|
||||||
|
user,
|
||||||
|
cancelled -> {
|
||||||
|
BufferedImage image = renderer.execute(job, cancelled);
|
||||||
|
if (cancelled.get()) return null;
|
||||||
|
return new RenderOutput(image, MapImageDither.dither(image));
|
||||||
|
},
|
||||||
|
out -> {
|
||||||
|
MapManager.saveImage(out.image(), id);
|
||||||
|
MapManager.saveIndices(out.indices(), id);
|
||||||
|
mapRenderer.develop(out.indices());
|
||||||
|
player.playSound(player.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 0.8f, 1.2f);
|
||||||
|
},
|
||||||
|
() -> player.sendActionBar(Component.text("Rendern fehlgeschlagen.", NamedTextColor.RED)));
|
||||||
|
dispatched = true;
|
||||||
|
} finally {
|
||||||
|
// prepare()/map setup threw before handing off — free the reserved slot.
|
||||||
|
if (!dispatched) manager.release(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void playShutter(Player player) {
|
||||||
|
player.playSound(player.getLocation(), Sound.BLOCK_VAULT_OPEN_SHUTTER, 1f, 1.4f);
|
||||||
|
Location eye = player.getEyeLocation();
|
||||||
|
Vector forward = eye.getDirection().normalize().multiply(1.0);
|
||||||
|
Location flash = eye.clone().add(forward);
|
||||||
|
player.getWorld().spawnParticle(Particle.END_ROD, flash, 12, 0.2, 0.2, 0.2, 0.01);
|
||||||
|
player.getWorld().spawnParticle(Particle.FIREWORK, flash, 6, 0.1, 0.1, 0.1, 0.02);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.survival;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.Main;
|
||||||
|
import java.util.List;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.NamespacedKey;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.RecipeChoice;
|
||||||
|
import org.bukkit.inventory.ShapedRecipe;
|
||||||
|
import org.bukkit.inventory.ShapelessRecipe;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers the four survival recipes and makes them discoverable in the recipe book. Camera and
|
||||||
|
* film have fixed results; load-film and copy-photo use {@code MaterialChoice} ingredients (their
|
||||||
|
* inputs carry variable NBT) and their real results are computed dynamically in {@link CraftingListener}
|
||||||
|
* — the registered result here is only the book/preview icon.
|
||||||
|
*/
|
||||||
|
public final class SurvivalRecipes {
|
||||||
|
|
||||||
|
public static final NamespacedKey CAMERA = key("camera_recipe");
|
||||||
|
public static final NamespacedKey FILM = key("film_recipe");
|
||||||
|
public static final NamespacedKey LOAD = key("load_recipe");
|
||||||
|
public static final NamespacedKey COPY = key("copy_recipe");
|
||||||
|
|
||||||
|
private SurvivalRecipes() {}
|
||||||
|
|
||||||
|
private static NamespacedKey key(String name) {
|
||||||
|
return new NamespacedKey(Main.getInstance(), name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<NamespacedKey> allKeys() {
|
||||||
|
return List.of(CAMERA, FILM, LOAD, COPY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void register() {
|
||||||
|
unregister();
|
||||||
|
|
||||||
|
// Camera
|
||||||
|
ShapedRecipe camera = new ShapedRecipe(CAMERA, CameraItems.createCamera(0));
|
||||||
|
camera.shape("IDI", "GLG", "IRI");
|
||||||
|
camera.setIngredient('I', Material.IRON_INGOT);
|
||||||
|
camera.setIngredient('D', Material.DIAMOND);
|
||||||
|
camera.setIngredient('G', Material.GLASS_PANE);
|
||||||
|
camera.setIngredient('L', Material.ENDER_EYE);
|
||||||
|
camera.setIngredient('R', Material.REDSTONE);
|
||||||
|
Bukkit.addRecipe(camera, false);
|
||||||
|
|
||||||
|
// Film roll
|
||||||
|
ShapedRecipe film = new ShapedRecipe(FILM, CameraItems.createFilm());
|
||||||
|
film.shape(" P ", "PIP", " P ");
|
||||||
|
film.setIngredient('P', Material.PAPER);
|
||||||
|
film.setIngredient('I', Material.INK_SAC);
|
||||||
|
Bukkit.addRecipe(film, false);
|
||||||
|
|
||||||
|
// Load film: camera + film
|
||||||
|
ShapelessRecipe load = new ShapelessRecipe(LOAD, CameraItems.createCamera(1));
|
||||||
|
load.addIngredient(new RecipeChoice.MaterialChoice(Material.PLAYER_HEAD));
|
||||||
|
load.addIngredient(new RecipeChoice.MaterialChoice(Material.PLAYER_HEAD));
|
||||||
|
Bukkit.addRecipe(load, false);
|
||||||
|
|
||||||
|
// Copy photo: photo + film
|
||||||
|
ShapelessRecipe copy = new ShapelessRecipe(COPY, new ItemStack(Material.FILLED_MAP));
|
||||||
|
copy.addIngredient(new RecipeChoice.MaterialChoice(Material.FILLED_MAP));
|
||||||
|
copy.addIngredient(new RecipeChoice.MaterialChoice(Material.PLAYER_HEAD));
|
||||||
|
Bukkit.addRecipe(copy, false);
|
||||||
|
|
||||||
|
// Cover /reload while players are online; fresh joins are handled by JoinListener.
|
||||||
|
Bukkit.getOnlinePlayers().forEach(p -> p.discoverRecipes(allKeys()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void unregister() {
|
||||||
|
for (NamespacedKey k : allKeys()) {
|
||||||
|
Bukkit.removeRecipe(k, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,46 +1,119 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.utils;
|
package eu.mhsl.minecraft.pixelpics.utils;
|
||||||
|
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
import org.bukkit.map.MapCanvas;
|
import org.bukkit.map.MapCanvas;
|
||||||
import org.bukkit.map.MapRenderer;
|
import org.bukkit.map.MapRenderer;
|
||||||
import org.bukkit.map.MapView;
|
import org.bukkit.map.MapView;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
import java.awt.image.BufferedImage;
|
/**
|
||||||
|
* Draws precomputed map palette indices onto the canvas. Supports a Polaroid-style "developing"
|
||||||
|
* animation: the picture dissolves in over {@link #DEVELOP_MILLIS} once the render is ready, like an
|
||||||
|
* old instant camera. Persisted maps are drawn instantly without animation.
|
||||||
|
*
|
||||||
|
* <p>{@code render()} is invoked by Bukkit each tick for viewing players, which drives the animation;
|
||||||
|
* progress is based on wall-clock time so it is correct even if the player looks away and back.
|
||||||
|
*/
|
||||||
public class ImageMapRenderer extends MapRenderer {
|
public class ImageMapRenderer extends MapRenderer {
|
||||||
public static final int MAP_SIZE = 128;
|
|
||||||
private final BufferedImage image;
|
|
||||||
private boolean alreadyRendered = false;
|
|
||||||
|
|
||||||
|
public static final int MAP_SIZE = MapImageDither.SIZE;
|
||||||
|
private static final long DEVELOP_MILLIS = 3000;
|
||||||
|
|
||||||
|
private static byte filmIndex = 0;
|
||||||
|
private static boolean filmResolved = false;
|
||||||
|
|
||||||
|
private volatile byte[] indices; // MAP_SIZE*MAP_SIZE, null while still rendering
|
||||||
|
private final boolean animate;
|
||||||
|
private volatile long developStart = 0; // 0 until indices are available
|
||||||
|
private boolean finished = false;
|
||||||
|
private boolean blankDrawn = false;
|
||||||
|
|
||||||
|
/** Persisted/instant map: draw the full picture immediately, no animation. */
|
||||||
|
public ImageMapRenderer(byte[] indices) {
|
||||||
|
this.indices = indices;
|
||||||
|
this.animate = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fresh capture from a rendered image: dithers immediately, then develops. */
|
||||||
public ImageMapRenderer(BufferedImage image) {
|
public ImageMapRenderer(BufferedImage image) {
|
||||||
this(image, 0, 0);
|
this(MapImageDither.dither(image));
|
||||||
}
|
}
|
||||||
|
|
||||||
public ImageMapRenderer(BufferedImage image, int x, int y) {
|
/** Pending capture: shows blank film until {@link #develop} supplies the picture. */
|
||||||
this.image = this.recalculateInput(image, x, y);
|
public ImageMapRenderer() {
|
||||||
|
this.indices = null;
|
||||||
|
this.animate = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private BufferedImage recalculateInput(BufferedImage input, int x, int y) {
|
public byte[] getIndices() {
|
||||||
if (x * MAP_SIZE > input.getWidth() || y * MAP_SIZE > input.getHeight())
|
return indices;
|
||||||
throw new RuntimeException(String.format("Input image mus match a multiple of x and y with %d", MAP_SIZE));
|
}
|
||||||
|
|
||||||
int x1 = (int) (double) (x * MAP_SIZE);
|
/** Supplies the finished picture and starts the developing animation. */
|
||||||
int y1 = (int) (double) (y * MAP_SIZE);
|
public void develop(byte[] indices) {
|
||||||
|
this.indices = indices;
|
||||||
int x2 = (int) (double) Math.min(input.getWidth(), ((x + 1) * MAP_SIZE));
|
this.developStart = System.currentTimeMillis();
|
||||||
int y2 = (int) (double) Math.min(input.getHeight(), ((y + 1) * MAP_SIZE));
|
this.finished = false;
|
||||||
|
|
||||||
if (x2 - x1 <= 0 || y2 - y1 <= 0)
|
|
||||||
throw new RuntimeException("Invalid Image dimensions!");
|
|
||||||
|
|
||||||
return input.getSubimage(x1, y1, x2 - x1, y2 - y1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void render(@NotNull MapView map, @NotNull MapCanvas canvas, @NotNull Player player) {
|
public void render(@NotNull MapView map, @NotNull MapCanvas canvas, @NotNull Player player) {
|
||||||
if(this.alreadyRendered) return;
|
if (finished) return;
|
||||||
canvas.drawImage(0, 0, this.image);
|
byte[] data = this.indices;
|
||||||
this.alreadyRendered = true;
|
|
||||||
|
// Still rendering: show a blank "film" once.
|
||||||
|
if (data == null) {
|
||||||
|
if (!blankDrawn) {
|
||||||
|
fill(canvas, film());
|
||||||
|
blankDrawn = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!animate) {
|
||||||
|
blit(canvas, data, 1.0);
|
||||||
|
finished = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
double progress = (System.currentTimeMillis() - developStart) / (double) DEVELOP_MILLIS;
|
||||||
|
blit(canvas, data, progress);
|
||||||
|
if (progress >= 1.0) finished = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draws revealed pixels at the given progress; unrevealed pixels stay film-colored. */
|
||||||
|
private void blit(MapCanvas canvas, byte[] data, double progress) {
|
||||||
|
byte film = film();
|
||||||
|
for (int y = 0; y < MAP_SIZE; y++) {
|
||||||
|
for (int x = 0; x < MAP_SIZE; x++) {
|
||||||
|
byte value = (progress >= 1.0 || revealThreshold(x, y) <= progress) ? data[y * MAP_SIZE + x] : film;
|
||||||
|
canvas.setPixel(x, y, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
private void fill(MapCanvas canvas, byte value) {
|
||||||
|
for (int y = 0; y < MAP_SIZE; y++) {
|
||||||
|
for (int x = 0; x < MAP_SIZE; x++) canvas.setPixel(x, y, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-pixel reveal time in [0,1]: mostly noise plus a gentle top-to-bottom sweep for an organic develop. */
|
||||||
|
private double revealThreshold(int x, int y) {
|
||||||
|
int h = (x * 374761393 + y * 668265263);
|
||||||
|
h = (h ^ (h >>> 13)) * 1274126177;
|
||||||
|
double noise = ((h >>> 8) & 0xFFFF) / 65535.0;
|
||||||
|
double sweep = y / (double) (MAP_SIZE - 1);
|
||||||
|
return Math.min(0.999, noise * 0.7 + sweep * 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte film() {
|
||||||
|
if (!filmResolved) {
|
||||||
|
filmIndex = MapColorPalette.mapIndex(MapColorPalette.nearestPos(206, 202, 190));
|
||||||
|
filmResolved = true;
|
||||||
|
}
|
||||||
|
return filmIndex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package eu.mhsl.minecraft.pixelpics.utils;
|
||||||
|
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import org.bukkit.map.MapPalette;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The set of usable Minecraft map colors, with nearest-color matching in CIELAB (perceptual) space.
|
||||||
|
* Built once from {@link MapPalette}. Matching in Lab rather than RGB keeps hues correct (greens stay
|
||||||
|
* green instead of collapsing to gray).
|
||||||
|
*/
|
||||||
|
public final class MapColorPalette {
|
||||||
|
|
||||||
|
private static volatile boolean initialized = false;
|
||||||
|
private static byte[] indices;
|
||||||
|
private static int[] rgb;
|
||||||
|
private static double[][] lab;
|
||||||
|
|
||||||
|
private MapColorPalette() {}
|
||||||
|
|
||||||
|
// MapPalette.getColor(byte) is deprecated for removal with no replacement for enumerating the
|
||||||
|
// index->colour palette; suppress until the API offers an alternative.
|
||||||
|
@SuppressWarnings("removal")
|
||||||
|
private static synchronized void ensure() {
|
||||||
|
if (initialized) return;
|
||||||
|
List<Byte> idx = new ArrayList<>();
|
||||||
|
List<Integer> rgbs = new ArrayList<>();
|
||||||
|
List<double[]> labs = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 256; i++) {
|
||||||
|
Color c;
|
||||||
|
try {
|
||||||
|
c = MapPalette.getColor((byte) i);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c.getAlpha() < 255) continue; // skip transparent slots
|
||||||
|
idx.add((byte) i);
|
||||||
|
rgbs.add((c.getRed() << 16) | (c.getGreen() << 8) | c.getBlue());
|
||||||
|
labs.add(rgbToLab(c.getRed(), c.getGreen(), c.getBlue()));
|
||||||
|
}
|
||||||
|
indices = new byte[idx.size()];
|
||||||
|
rgb = new int[idx.size()];
|
||||||
|
lab = new double[idx.size()][];
|
||||||
|
for (int i = 0; i < idx.size(); i++) {
|
||||||
|
indices[i] = idx.get(i);
|
||||||
|
rgb[i] = rgbs.get(i);
|
||||||
|
lab[i] = labs.get(i);
|
||||||
|
}
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int size() {
|
||||||
|
ensure();
|
||||||
|
return indices.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index into the internal arrays of the perceptually nearest palette color. */
|
||||||
|
public static int nearestPos(int r, int g, int b) {
|
||||||
|
ensure();
|
||||||
|
double[] target = rgbToLab(r, g, b);
|
||||||
|
double best = Double.MAX_VALUE;
|
||||||
|
int bestPos = 0;
|
||||||
|
for (int i = 0; i < lab.length; i++) {
|
||||||
|
double[] l = lab[i];
|
||||||
|
double dl = target[0] - l[0], da = target[1] - l[1], db = target[2] - l[2];
|
||||||
|
double d = dl * dl + da * da + db * db;
|
||||||
|
if (d < best) {
|
||||||
|
best = d;
|
||||||
|
bestPos = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestPos;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte mapIndex(int pos) {
|
||||||
|
return indices[pos];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int mapRgb(int pos) {
|
||||||
|
return rgb[pos];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double[] rgbToLab(int r, int g, int b) {
|
||||||
|
double rl = ColorUtil.toLinear(r), gl = ColorUtil.toLinear(g), bl = ColorUtil.toLinear(b);
|
||||||
|
// sRGB -> XYZ (D65)
|
||||||
|
double x = rl * 0.4124 + gl * 0.3576 + bl * 0.1805;
|
||||||
|
double y = rl * 0.2126 + gl * 0.7152 + bl * 0.0722;
|
||||||
|
double z = rl * 0.0193 + gl * 0.1192 + bl * 0.9505;
|
||||||
|
double fx = pivotXyz(x / 0.95047);
|
||||||
|
double fy = pivotXyz(y);
|
||||||
|
double fz = pivotXyz(z / 1.08883);
|
||||||
|
return new double[] {116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double pivotXyz(double t) {
|
||||||
|
return t > 0.008856 ? Math.cbrt(t) : (7.787 * t + 16.0 / 116.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user