Compare commits

...
45 Commits
Author SHA1 Message Date
MineTec 1797436519 simplify the usability and hardening code: main-thread hop and async saved-data scan shared (MainThread.run, MapUsageScan.diskAsync), render settings passed as a record and applied by one applyConfig, rejections counted in tryReserve, pack folder scanned once per phase with the loader's zip order, admin subcommand table, shared number parsing and timing text, dead overloads removed, comments thinned 2026-11-05 20:16:59 +01:00
MineTec 57a3a06cdf /pixelpic commands: help for everyone (how camera, film and photos work), admin info (versions, pack and dump state, coverage, queue, photo stats, storage), photo [player] with phase timings, give <player> camera|film [amount], reload (config, render limits live, pack and models), tab completion by permission, clear german errors for wrong arguments, command registered lowercase (/pixelPic never matched for players) with alias pixelpics 2026-11-05 18:57:11 +01:00
MineTec 492bcb3a39 automatic setup off the main thread: the vanilla resource pack is downloaded from mojang when none is present (client jar of the server version, sha1 and size from the manifest, https only, timeouts, size limit) and replaced after a minecraft update (version stamp or vanilla-<version>.zip, old pack keeps rendering until then and goes to backup/), photos refused with a clear message until ready, packs of other versions left out, coverage check of block states and item definitions, entity dump version checked against the server (outdated override ignored), new config options added to existing configs, pack script stamps and verifies 2026-11-05 18:57:02 +01:00
MineTec be94c33ea6 render stats and live limits: photo counters and phase timings (queue, snapshot, trace, dither), running/queued load, render limits applicable to the running pool (trace parallelism, concurrent renders, queue, timeout), debug log switch with stack traces of failed renders 2026-11-05 18:56:39 +01:00
MineTec a082271f28 camera fluid check never loads the chunk at the eye (an eye in an unloaded chunk loaded it synchronously in prepare: 204 ms, now 13 ms) 2026-11-05 18:21:47 +01:00
MineTec c75c7a4b60 photo storage failures handled: a shot is refused (film kept) while the image folder is not writable, a photo whose png cannot be saved fails like a render error (film back) instead of becoming a vanilla map after the restart, png/index files written atomically (tmp + move, no truncated png after a crash), one-line warnings instead of imageio stack traces, unreadable png warned 2026-11-05 17:35:00 +01:00
MineTec bf192588a9 crash recovery refunds only photos found in the saved data (async scan): after a crash that rolled the shot back to the last save the camera still had the film and the refund duplicated it, a failed scan refunds all, jobs stay in pending.yml until decided 2026-11-05 17:13:40 +01:00
MineTec 3a064a5ffa photos in item frames are read from their renderer or index cache without loading the map view (a wall of 1000 frames with not yet loaded photos took 830 ms on the main thread, now 490 ms cold / 60 ms warm) 2026-11-05 17:03:47 +01:00
MineTec faeda0e0da nether and end skies: the nether has no sky (camera biome fog_color) and fogs like vanilla (10 to 96 blocks), the end shows vanilla's textured sky box and fogs towards its fog colour (both showed the overworld day sky with sun and clouds) 2026-11-05 16:57:44 +01:00
MineTec 5b4f879158 queued photos hold their chunks (reference-counted plugin tickets on the loaded chunks of the view, released after the snapshot or on failure): a photographer who teleported, changed dimension or logged out while the photo waited longer than the unload delay got a partly empty picture 2026-11-05 16:45:46 +01:00
MineTec 3339c45dda render jobs take their world snapshots one per tick (a burst or max-concurrent > 1 no longer stacks several snapshots into one tick), trace threads numbered (all were named -0) 2026-11-05 16:37:18 +01:00
MineTec 0b781c3538 block/water rendering fixes: weighted blockstate variants picked per position like vanilla (Mth.getSeed + model random, multipart reseed, seed 42 for displays, falling blocks by start pos, block items keep the first variant), face textures are slot refs (heavy_core was invisible), missing textures as missingno, animated textures by mcmeta frame size and first frame, deterministic resource pack priority, full-height snapshot so the sky light under water counts the whole water column, camera surroundings captured for the water fog blend, underwater light applied per depth instead of per ray, camera fluid determined once in prepare, translucent layer limit 8 / 1/255 (3rd water face no longer opaque), rgb attribute modifiers for water_fog_color, pptest fix_* scenes 2026-11-05 16:06:27 +01:00
MineTec 291d51891f rendering fixes: rebaked cubes keep unshaded/additive (end portal/gateway in displays), piston heads no longer skulls (shared block-entity material source), armor/horse armor/carpets/saddles from the equippable component (custom assets, trader llama carpet), inverse-transpose normals for non-uniformly scaled displays, later layer wins on equal depth like vanilla, block tints exactly like vanilla blockcolors (redstone table, stems by age, untinted unknown blocks), pose visibility not forced, pptest scenes tint_blocks/equip_assets/display_fixes 2026-11-05 13:18:04 +01:00
MineTec 4be03afa3f photos named via item_name instead of custom_name so the recipe book fills the copy recipe, copies keep an anvil name 2026-11-05 13:18:04 +01:00
MineTec e364d76da0 model dump: instance getTextureLocation preferred over static overloads (shulker texture was missing), boat water patch posed with its unit state, painting skipped (no model) 2026-11-05 13:18:04 +01:00
MineTec a890370cc5 memory and main-thread fixes: ray map computed per ray instead of stored, photos take their world snapshot only when the render starts instead of while queued, biome tint blend cached per photo with the biome height in the key (was global, unbounded and stale across photos, worlds and 3d biomes), item definitions cached per id and context with the day only for local_time models, one date per photo for christmas chests and local_time, model chains cut at the depth limit not cached, bake failures logged per photo, no sync chunk loads for end gateway neighbours and spawner display mobs, a broken datapack variant file skips only itself 2026-11-05 11:52:43 +01:00
MineTec e08bfc81f3 skins: only textures.minecraft.net texture urls are fetched (https, no redirects, 128 KiB cap, png header must be 64x64 or 64x32 before decoding), processed exactly like vanilla processLegacySkin (left arm and leg mirrored per face, notch transparency hack, opaque base layers), one download per url outside any map lock, failures retried with backoff instead of cached forever, lru of 512 skins 2026-11-05 11:52:43 +01:00
MineTec 5d581dc482 item guard: photos cannot be cloned, locked or scaled in the cartography table, the anvil only renames camera, film and photo, the crafter does not craft with them (it handed out the static load/copy placeholders and ate the ingredients), camera and film cannot be placed as heads (the broken skull lost the markers) or put into mount saddle slots by horse inventory or dispenser 2026-11-05 10:44:35 +01:00
MineTec 4c52bc2dca fix survival data loss and lifecycle: crafting consumes the grid atomically in the click (no same-tick dupes), photos that do not fit are dropped like /give, failed or aborted renders give the film back (offline via pending.yml, crash recovery on start) and keep a blank placeholder instead of turning into world maps, shutdown finishes every job synchronously without scheduling after disable and tracing stops on interrupt, renderers detached and reattached across /reload, vanilla map renderer really removed, photos stored per primary world uid with migration and no overwrite on a reset map counter, cleanup only deletes photos unseen for the given days and referenced nowhere in loaded state or saved world and player data, async dither fallback, cooldown map cleared on quit, cleanup permission without op bypass 2026-11-05 04:48:04 +01:00
MineTec a3fbe122e3 final comment sweep: section separators, signature-only javadocs and history notes removed, stale comments corrected (sign/contents model lookup, inverted entity cubes, item-bounds use, datapack folder search, biome tint argb, water-only overlay faces, beacon first section, mushroom-cow instanceof example, legacy skin arm copy, elytra and pack-detection notes in the scripts, pptest scene descriptions), comments only 2026-11-05 03:35:48 +01:00
MineTec 02e052787c simplify tools: shared harness setup (pipeline, block proxy, render job, framing, record copy, label cells, contact sheets) for the standalone renderers and the benchmark, block-entity scenes as name/yaw records, one dump pipeline for mob and avatar renderers with a shared submit helper, transform record and jem/entry/subset helpers, posed block entities from direct layer constants, pptest scenes registered once with settle-tick and resolution tables and put() for all blocks, jdk detection and full error log in the dump script, pptest jar rebuilt instead of updated, gradle task printing the tool classpath, comments trimmed 2026-11-05 03:24:30 +01:00
MineTec b242adb6d2 simplify survival, map and plugin code: map palette built once in a static initializer instead of a synchronized check per dither lookup, film colour as a constant, one blit for blank film and develop, shared crafting result for prepare and craft, unused photo scan state and dead null checks removed, non-italic text helper for item names, pdc getOrDefault, private map file paths, single map-init attach path, listener registration in one loop, unused image constructor removed (pptest dithers itself), comments trimmed 2026-11-05 02:28:46 +01:00
MineTec 44f2b3c59c simplify asset code: unread average colour and constant model fields dropped (bake returns the element list, shared empty model), baked elements cached per blockstate variant and parent models cached while flattening, one element constructor plus withFaces, blockstate box/face/axis rotation in one pass instead of per-step x/y copies, face rotation normalised once, block state parsing inlined into the resolver, shared json read and image-to-grid helpers, path concatenation instead of format, dead zip directory probe and unused dto/font members removed, comments trimmed 2026-11-05 01:32:46 +01:00
MineTec 7f2520a52d simplify cem entity, block-entity and item code: entity bake split into body texture, vanilla placement and ground snap, part frames only for entities holding items, entity info looked up once, identical toggles-only pose fallback removed, shared model-space and bake-to-cubes helpers, model cube without unread origin/inflate, tinted copy helper, plains tint shared with the biome provider, item bounds via the rendered entity aabb, element rotation via rotationAround, special transformations accumulated while picking, precomputed end portal layer constants and gateway face set, sign text areas as records, dump info parsing split out, unused loader size removed, comments trimmed 2026-11-05 00:57:17 +01:00
MineTec cbc859b565 simplify entity, tint and util code: shared direction helpers (axis, of, opposite), one inverted-face test and frac for block and entity intersectors, element rotate/unrotate without allocating copies, entity scene without the per-entity list and with a single child push, cube corners and hit normal without temp arrays, tint channel cached per material, redstone power from the block data, shared leash faces and frame cards, texture ops on colour helpers, simpler text and chest/shulker/statue texture code, dead helper and dead null checks removed, comments trimmed 2026-11-05 00:20:32 +01:00
MineTec 2a3db3d7aa simplify snapshot code: frustum bounds computed once per photo and shared by all builders, cached per-class reflection handles via a shared helper (getHandle, standing eye height, map data and decorations), one facing yaw for wall and standing block entities, shared banner pattern/skin/copper stage/dye helpers, shield state via the builder, map view read once per frame, leash endpoints without temp arrays, package-private item/map helpers, fully qualified names imported, dead checks removed, comments trimmed 2026-11-04 23:41:05 +01:00
MineTec bc07631fec simplify raytracer, fluid, sky and render code: air fog moved into the camera medium, sky colour computed only when read, single ao neighbour lookups, culling test before tint and ao, allocation-free fluid heights and flow angle, one fluid model lookup per cell, shared normal rotation and face fractions in the element intersector, unified side-face table, dead overloads/interface/enums and convenience constructors removed, comments trimmed 2026-11-04 22:53:25 +01:00
MineTec 4348b5fca0 vanilla fluid rendering: water and lava as fluid polygons with level/neighbour corner heights, flow textures, double-sided and overlay faces, texture alpha, opaque lava, waterlogged blocks and seagrass/kelp/bubble columns hold water, underwater and lava fog from biome water_fog attributes with gaussian biome blend, fogged sky disc, 6/7 fov in fluids, sky light under water also for underwater cameras, fluid test scenes 2026-11-04 22:07:02 +01:00
MineTec 9093c1ccfb christmas chest textures (24-26 december, chests and trapped chests, local_time item models, pixelpics.testDate test property), chest items from the item definition texture, shield banner patterns, item displays turned 180° like vanilla, dropped item lift from the ground model bounds, baby held-item offset, map decorations on framed vanilla maps (banners, frames, markers; none on photo maps), inverted block elements in items and displays (spawner, vault inner faces), deterministic test scenes, items/xmas/marker test scenes 2026-11-04 21:15:38 +01:00
MineTec 9e48e35680 block-entity contents from the decompiled renderers: enchanting table book (closed, open towards a nearby photographer), campfire and shelf items, vault display item, copper golem statues (poses, weathering), spawner and trial spawner mobs, beacon beam with glass colours, end portal and gateway star field, block-entity parts in block displays (lectern book, bell), outer item-model transformations for special models, contents test scenes 2026-11-04 20:15:40 +01:00
MineTec 318a309580 rescale rotated block elements like vanilla (cross plants, amethyst, campfire, fire, raised rails), euler x/y/z element rotations (hanging sign chains), inverted inner-face elements (spawner, vault), vanilla-like water: single surface blend with depth sky light instead of path-length density, water faces against glass/ice/leaves, block geometry and water test scenes 2026-11-04 19:25:17 +01:00
MineTec 2c2e9e2e32 render emissive eye/glow layers, per-model vanilla render types (cutout vs translucent, unshaded), sheep undercoat and vanilla wool colour, angry wolves, tipped arrow and invulnerable wither textures, small armor stand armor textures, elytra rest pose, vanilla mob arm poses (piglins, illagers), layer test scenes 2026-11-04 18:59:14 +01:00
MineTec 45d033bd89 entity state poses from the vanilla dump: sleeping/sitting/crouching/swimming/gliding, fox/cat/panda/parrot/bat/camel/sniffer poses with pose transforms, per-sub-model poses (baby pivots), armor-stand part poses and body pitch via rotation bindings, fish in water, beds, shoulder parrots, pose test scenes 2026-11-04 17:51:01 +01:00
MineTec 6660ebe557 pinhole camera ray map like vanilla (vertical fov 70, horizontal from aspect, pixel centres, no roll) instead of yaw/pitch corner rotation that squashed photos vertically by cos 35, shared buildRayMap in test tools, camera projection test scenes 2026-11-04 17:07:19 +01:00
MineTec 88bae41a03 primitive chunk table in world snapshot instead of colliding long-keyed hashmap, bounded cache for tinted/composited mob and banner textures, vanilla dump with nested model json, village benchmark and perf test scenes 2026-11-04 16:59:42 +01:00
MineTec b5baffebbd entity bvh with exact old tie order and allocation-free entity/element intersectors, entity benchmark harness and perf test scenes 2026-11-04 16:42:08 +01:00
MineTec 2d56a1910c render item frame contents with the item geometry like vanilla ItemFrameRenderer (fixed context, 8 rotations, all facings), frame block models, map contents incl. pixelpics photos, invisible frames, head skins/banner patterns from item meta 2026-11-04 16:03:34 +01:00
MineTec a743dc32c7 derive default layers, armor wearers, arms/chest pose flags and mob variant textures from the vanilla dump, drop the hardcoded lists 2026-11-04 15:48:03 +01:00
MineTec 022b080fce render all block entities from the dumped vanilla models with decompiled renderer poses, drop the cem block-entity path 2026-11-04 15:13:55 +01:00
MineTec c66ebaa942 render cat/wolf collars, charged creeper and wither armor energy swirl (additive), fox/dolphin mouth items 2026-11-04 14:45:57 +01:00
MineTec fbea9dfe08 match vanilla arrow/trident orientation incl. arrow impact shake, add projectile test scenes 2026-11-04 14:26:21 +01:00
MineTec addebffba1 add tools/build-vanilla-pack.sh to build the runtime resource pack from Mojang's client jar 2026-11-04 14:22:27 +01:00
MineTec a0b7ec3a90 apply vanilla uvlock under blockstate x/y rotations and face zero-thickness elements towards the ray
x rotations without uvlock already sampled the model-space element like
vanilla (lying logs/pillars, vertical observers/dispensers verified against
FaceBakery for every blockstate variant). The remaining divergence was
uvlock: faces kept model-extent UVs and were sampled in world orientation,
so top stairs, ceiling buttons, fences/walls and ceiling vines showed
shifted or rotated textures. Rotated uvlocked elements now also go through
the model-space path, with each face's final UVs mapped by vanilla's
BlockMath face transformation (inverted, as in FaceBakery).

Zero-thickness elements (vine, lichen) always reported their low face;
pick the face turned towards the ray instead.

Edge-case test plugin: xrot/xrot2 scenes (lying pillars, vertical
observers/dispensers/droppers, top stairs, ceiling buttons).
2026-11-04 14:11:40 +01:00
MineTec 5852d4b367 render chests from the dumped vanilla models (single, double, trapped, ender, copper) 2026-11-04 13:57:35 +01:00
MineTec 9a6c8b538d alpha-blend translucent block and entity texels (stained glass, slime skin, text backgrounds) 2026-11-04 13:49:45 +01:00
125 changed files with 14624 additions and 4738 deletions

No files matched your search

+1 -1
View File
@@ -169,4 +169,4 @@ gradle-app.setting
# End of https://www.toptal.com/developers/gitignore/api/java,intellij,gradle # End of https://www.toptal.com/developers/gitignore/api/java,intellij,gradle
/local.gradle /local.gradle
+6
View File
@@ -60,3 +60,9 @@ spotless {
if (file("local.gradle").exists()) { if (file("local.gradle").exists()) {
apply from: "local.gradle" apply from: "local.gradle"
} }
// Classpath for the standalone tools (tools/*.java harnesses, tools/edgecase-plugin/build.sh).
tasks.register('printCompileClasspath') {
def cp = sourceSets.main.compileClasspath
doLast { println cp.asPath }
}
@@ -1,49 +1,50 @@
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.listeners.OnMapInitialize;
import eu.mhsl.minecraft.pixelpics.render.RenderManager; 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.tint.BiomeTintProvider; import eu.mhsl.minecraft.pixelpics.setup.SetupManager;
import eu.mhsl.minecraft.pixelpics.survival.CameraListener; import eu.mhsl.minecraft.pixelpics.survival.CameraListener;
import eu.mhsl.minecraft.pixelpics.survival.CraftingListener; import eu.mhsl.minecraft.pixelpics.survival.CraftingListener;
import eu.mhsl.minecraft.pixelpics.survival.ItemGuardListener;
import eu.mhsl.minecraft.pixelpics.survival.JoinListener; import eu.mhsl.minecraft.pixelpics.survival.JoinListener;
import eu.mhsl.minecraft.pixelpics.survival.PendingPhotos;
import eu.mhsl.minecraft.pixelpics.survival.SurvivalRecipes; import eu.mhsl.minecraft.pixelpics.survival.SurvivalRecipes;
import eu.mhsl.minecraft.pixelpics.utils.DebugLog;
import eu.mhsl.minecraft.pixelpics.utils.MapColorPalette; import eu.mhsl.minecraft.pixelpics.utils.MapColorPalette;
import eu.mhsl.minecraft.pixelpics.utils.MapManager;
import java.io.File; import java.io.File;
import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
import org.bukkit.command.CommandSender;
import org.bukkit.command.PluginCommand;
import org.bukkit.configuration.Configuration;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.event.Listener;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
public final class Main extends JavaPlugin { public final class Main extends JavaPlugin {
private static Main instance; private static Main instance;
private ResourcePack resourcePack; private SetupManager setup;
private DefaultScreenRenderer screenRenderer;
private RenderManager renderManager; private RenderManager renderManager;
private PendingPhotos pendingPhotos;
// PDC keys: photo picture id (STRING), camera/film markers (BYTE), loaded film on a camera (INTEGER)
public final NamespacedKey pictureIdFlag = new NamespacedKey(this, "imageid"); public final NamespacedKey pictureIdFlag = new NamespacedKey(this, "imageid");
/** Marks a {@code PLAYER_HEAD} as a camera (BYTE 1). */
public final NamespacedKey cameraMarker = new NamespacedKey(this, "camera"); 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"); 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"); public final NamespacedKey filmMarker = new NamespacedKey(this, "film");
@Override @Override
@@ -51,20 +52,71 @@ public final class Main extends JavaPlugin {
instance = this; instance = this;
saveDefaultConfig(); saveDefaultConfig();
initRenderManager(); RenderManager.Settings settings = applyConfig();
MapManager.init(getServer().getWorlds().getFirst().getUID());
pendingPhotos = new PendingPhotos(getDataFolder());
pendingPhotos.recoverAbandoned();
renderManager = new RenderManager(this, settings);
getLogger().info("Render pool: " + settings + ".");
Bukkit.getPluginManager().registerEvents(new OnMapInitialize(), this); PluginCommand command = Objects.requireNonNull(getCommand("pixelpic"));
Objects.requireNonNull(Bukkit.getPluginCommand("pixelPic")).setExecutor(new PixelPicsCommand()); PixelPicsCommand executor = new PixelPicsCommand();
command.setExecutor(executor);
Bukkit.getPluginManager().registerEvents(new CameraListener(), this); command.setTabCompleter(executor);
Bukkit.getPluginManager().registerEvents(new CraftingListener(), this); for (Listener listener : List.of(
Bukkit.getPluginManager().registerEvents(new JoinListener(), this); new OnMapInitialize(),
new CameraListener(),
new CraftingListener(),
new ItemGuardListener(),
new JoinListener())) {
Bukkit.getPluginManager().registerEvents(listener, this);
}
SurvivalRecipes.register(); SurvivalRecipes.register();
MapManager.reattachAfterReload(OnMapInitialize::attach);
initRenderer(); // Initialise the map palette on the main thread (MapPalette) before any off-thread dithering.
MapColorPalette.size();
setup = new SetupManager(this);
setup.start(null);
Bukkit.getScheduler().runTaskTimer(this, () -> setup.closeRetired(false), 100, 100);
} }
private void initRenderManager() { /**
* Brings an existing config.yml up to date when this version added options: the file is rewritten from the
* bundled config.yml (layout and comments) with all of the admin's values (and their own comments) kept.
*/
private void addNewConfigOptions() {
FileConfiguration config = getConfig();
Configuration defaults = config.getDefaults();
if (defaults == null) return;
List<String> added = defaults.getKeys(true).stream()
.filter(k -> !defaults.isConfigurationSection(k) && !config.isSet(k))
.toList();
if (added.isEmpty()) return;
YamlConfiguration merged = new YamlConfiguration();
try (InputStream in = Objects.requireNonNull(getResource("config.yml"));
Reader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) {
merged.load(reader);
for (String key : config.getKeys(true)) {
if (config.isConfigurationSection(key)) continue;
merged.set(key, config.get(key));
List<String> comments = config.getComments(key);
if (!comments.isEmpty()) merged.setComments(key, comments);
}
merged.save(new File(getDataFolder(), "config.yml"));
reloadConfig();
getLogger().info("Added new options to config.yml (values kept): " + String.join(", ", added) + ".");
} catch (IOException | InvalidConfigurationException e) {
getLogger()
.warning("Could not add the new options to config.yml (" + e.getMessage()
+ "); their defaults apply: " + String.join(", ", added) + ".");
}
}
/** Brings config.yml up to date, applies the debug switch and returns the render limits. */
private RenderManager.Settings applyConfig() {
addNewConfigOptions();
DebugLog.configure(getLogger(), getConfig().getBoolean("debug", false));
int cores = Runtime.getRuntime().availableProcessors(); int cores = Runtime.getRuntime().availableProcessors();
int threads = getConfig().getInt("render.threads", 0); int threads = getConfig().getInt("render.threads", 0);
if (threads <= 0) threads = Math.max(1, cores - 2); if (threads <= 0) threads = Math.max(1, cores - 2);
@@ -72,72 +124,17 @@ public final class Main extends JavaPlugin {
int maxConcurrent = Math.max(1, getConfig().getInt("render.max-concurrent", 2)); int maxConcurrent = Math.max(1, getConfig().getInt("render.max-concurrent", 2));
int queueSize = Math.max(0, getConfig().getInt("render.queue-size", 8)); int queueSize = Math.max(0, getConfig().getInt("render.queue-size", 8));
int timeoutSeconds = Math.max(1, getConfig().getInt("render.timeout-seconds", 30)); int timeoutSeconds = Math.max(1, getConfig().getInt("render.timeout-seconds", 30));
return new RenderManager.Settings(threads, maxConcurrent, queueSize, timeoutSeconds);
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() { /** {@code /pixelpic reload}: config, render limits (live), resource pack and models. False while a setup runs. */
File resourcePackDir = new File(getDataFolder(), "resourcepack"); public boolean reload(CommandSender requester) {
if (!resourcePackDir.exists() && !resourcePackDir.mkdirs()) { if (setup.running()) return false;
getLogger().warning("Could not create resource pack directory: " + resourcePackDir); reloadConfig();
} RenderManager.Settings settings = applyConfig();
renderManager.configure(settings);
Optional<ResourcePack> pack = ResourcePackLoader.load(resourcePackDir, getLogger()); getLogger().info("Reloaded config.yml: render pool " + settings + ", debug " + DebugLog.enabled() + ".");
if (pack.isEmpty()) { return setup.start(requester);
getLogger()
.severe("No resource pack found in " + resourcePackDir.getPath()
+ " — place a vanilla resource pack (directory with assets/minecraft/... or a .zip) there. "
+ "/pixelPic is disabled until a pack is available.");
return;
}
this.resourcePack = pack.get();
AssetReader reader = new AssetReader(resourcePack);
TextureCache textures = new TextureCache(resourcePack);
BlockModelRegistry registry = new BlockModelRegistry(reader, textures);
BiomeTintProvider tintProvider = new BiomeTintProvider(textures);
CemModelLoader cemLoader = new CemModelLoader();
try (InputStream in = getResource("cem/cem_template_models.json")) {
int n = in == null ? 0 : cemLoader.load(in, getLogger());
getLogger().info("Loaded " + n + " CEM entity models.");
} catch (Exception e) {
getLogger().severe("Failed to load CEM entity models: " + e.getMessage());
}
// Vanilla models dumped from the client jar (tools/VanillaModelDump.java) override the CEM set: an
// optional newer dump in the data folder (no recompile on MC updates), else the bundled copy.
File dump = new File(getDataFolder(), "vanilla_entity_models.json");
try (InputStream in =
dump.isFile() ? new java.io.FileInputStream(dump) : getResource("cem/vanilla_entity_models.json")) {
if (in != null) {
int n = cemLoader.load(in, getLogger());
getLogger()
.info("Loaded vanilla entity models" + (dump.isFile() ? " from " + dump.getName() : "") + " ("
+ n + " models total).");
}
} catch (Exception e) {
getLogger().severe("Failed to load vanilla entity models: " + e.getMessage());
}
SkinCache skinCache = new SkinCache();
BitmapFont font = FontLoader.load(resourcePack, textures, getLogger());
getLogger().info("Loaded sign font (" + (font.isEmpty() ? "no glyphs — text disabled" : "ok") + ").");
CemBaker entityBaker = new CemBaker(cemLoader, textures, skinCache);
BlockEntityBaker blockEntityBaker = new BlockEntityBaker(cemLoader, textures, skinCache, font);
this.screenRenderer = new DefaultScreenRenderer(
registry,
tintProvider,
textures,
entityBaker,
blockEntityBaker,
getLogger(),
renderManager.tracePool());
// Warm the map palette on the main thread so off-thread dithering never triggers its first init.
MapColorPalette.size();
getLogger().info("PixelPics renderer initialized with resource pack assets.");
} }
@Override @Override
@@ -145,20 +142,28 @@ public final class Main extends JavaPlugin {
SurvivalRecipes.unregister(); SurvivalRecipes.unregister();
if (renderManager != null) { if (renderManager != null) {
renderManager.shutdown(); renderManager.shutdown();
renderManager = null;
} }
if (resourcePack != null) { MapManager.detachAll();
resourcePack.close(); if (setup != null) {
resourcePack = null; setup.close();
setup = null;
} }
renderManager = null;
} }
/** The renderer, or {@code null} when no resource pack is available (degraded mode). */ /** The renderer, or {@code null} while it is set up or when no resource pack is available. */
public DefaultScreenRenderer getScreenRenderer() { public DefaultScreenRenderer getScreenRenderer() {
return this.screenRenderer; return setup == null ? null : setup.renderer();
}
public SetupManager getSetup() {
return setup;
}
public PendingPhotos getPendingPhotos() {
return this.pendingPhotos;
} }
/** The render queue/limiter; always available after {@code onEnable}. */
public RenderManager getRenderManager() { public RenderManager getRenderManager() {
return this.renderManager; return this.renderManager;
} }
@@ -1,36 +1,34 @@
package eu.mhsl.minecraft.pixelpics.assets; package eu.mhsl.minecraft.pixelpics.assets;
/** /** Pack-relative asset paths in the vanilla layout {@code assets/<namespace>/<category>/<path>.<ext>}. */
* Builds pack-relative asset paths from namespaced ids, following the vanilla layout
* {@code assets/<namespace>/<category>/<path>.<ext>}.
*/
public final class AssetPaths { public final class AssetPaths {
private AssetPaths() {} private AssetPaths() {}
/** {@code assets/<ns>/blockstates/<name>.json} for a plain block name (no {@code block/} prefix). */ /** For a plain block name such as {@code oak_stairs}. */
public static String blockState(String blockName) { public static String blockState(String blockName) {
return String.format( return "assets/" + ResourceLocation.DEFAULT_NAMESPACE + "/blockstates/" + blockName.toLowerCase() + ".json";
"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}. */ /** The id path includes the folder, e.g. {@code block/stone}. */
public static String model(ResourceLocation id) { public static String model(ResourceLocation id) {
return String.format("assets/%s/models/%s.json", id.namespace(), id.path()); return path(id, "models", ".json");
} }
/** {@code assets/<ns>/textures/<path>.png}. The id path already contains e.g. {@code block/stone}. */
public static String texture(ResourceLocation id) { public static String texture(ResourceLocation id) {
return String.format("assets/%s/textures/%s.png", id.namespace(), id.path()); return path(id, "textures", ".png");
} }
/** {@code assets/<ns>/textures/<path>.png.mcmeta} animation metadata, if present. */ /** Animation metadata of a texture. */
public static String textureMeta(ResourceLocation id) { public static String textureMeta(ResourceLocation id) {
return texture(id) + ".mcmeta"; 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) { public static String font(ResourceLocation id) {
return String.format("assets/%s/font/%s.json", id.namespace(), id.path()); return path(id, "font", ".json");
}
private static String path(ResourceLocation id, String category, String extension) {
return "assets/" + id.namespace() + "/" + category + "/" + id.path() + extension;
} }
} }
@@ -5,10 +5,9 @@ import com.google.gson.JsonObject;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Optional; import java.util.Optional;
import java.util.function.Function;
/** /** Reads JSON assets from a {@link ResourcePack}; unreadable or malformed files are empty. */
* Thin convenience layer over a {@link ResourcePack} for reading JSON assets.
*/
public final class AssetReader { public final class AssetReader {
private final ResourcePack pack; private final ResourcePack pack;
@@ -19,20 +18,17 @@ public final class AssetReader {
} }
public <T> Optional<T> readJson(String path, Class<T> type) { public <T> Optional<T> readJson(String path, Class<T> type) {
return pack.read(path).flatMap(bytes -> { return read(path, text -> gson.fromJson(text, type));
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) { public Optional<JsonObject> readJsonObject(String path) {
return read(path, text -> JsonParser.parseString(text).getAsJsonObject());
}
private <T> Optional<T> read(String path, Function<String, T> parser) {
return pack.read(path).flatMap(bytes -> { return pack.read(path).flatMap(bytes -> {
try { try {
return Optional.of(JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)) return Optional.ofNullable(parser.apply(new String(bytes, StandardCharsets.UTF_8)));
.getAsJsonObject());
} catch (Exception e) { } catch (Exception e) {
return Optional.empty(); return Optional.empty();
} }
@@ -0,0 +1,94 @@
package eu.mhsl.minecraft.pixelpics.assets;
import java.util.EnumSet;
import org.bukkit.Material;
import org.bukkit.block.data.type.Chest;
import org.bukkit.block.data.type.CopperGolemStatue;
import org.bukkit.block.data.type.HangingSign;
import org.bukkit.block.data.type.Sign;
import org.bukkit.block.data.type.Skull;
import org.bukkit.block.data.type.WallHangingSign;
import org.bukkit.block.data.type.WallSign;
import org.bukkit.block.data.type.WallSkull;
/**
* The block families drawn by block-entity renderers, shared by the block-model registry and the block-entity
* snapshot. Matched by the material's BlockData type ({@link Material#data}, needs no live registry) where it is
* unique, otherwise by name (banners, shulker boxes).
*/
public final class BlockEntityBlocks {
private BlockEntityBlocks() {}
// No block-model geometry, drawn only by the entity scene (classify also covers kinds that keep their model).
private static final EnumSet<Material> NO_BLOCK_MODEL = EnumSet.noneOf(Material.class);
static {
for (Material m : Material.values()) {
if (isSign(m)
|| isBanner(m)
|| isShulkerBox(m)
|| isHead(m)
|| isChest(m)
|| m == Material.ENDER_CHEST
|| m == Material.CONDUIT
|| m == Material.DECORATED_POT
|| m == Material.BELL
|| isCopperGolemStatue(m)
|| m == Material.END_PORTAL
|| m == Material.END_GATEWAY) {
NO_BLOCK_MODEL.add(m);
}
}
}
public static boolean hasNoBlockModel(Material m) {
return NO_BLOCK_MODEL.contains(m);
}
/** Chest, trapped chest and the copper chests (not the ender chest). */
public static boolean isChest(Material m) {
return m.data == Chest.class;
}
public static boolean isCopperChest(Material m) {
return isChest(m) && m.name().endsWith("COPPER_CHEST");
}
/** Mob heads and skulls, standing or on a wall (not the piston head). */
public static boolean isHead(Material m) {
return m.data == Skull.class || isWallHead(m);
}
public static boolean isWallHead(Material m) {
return m.data == WallSkull.class;
}
public static boolean isSign(Material m) {
return m.data == Sign.class || m.data == WallSign.class || isHangingSign(m);
}
public static boolean isWallSign(Material m) {
return m.data == WallSign.class;
}
public static boolean isHangingSign(Material m) {
return m.data == HangingSign.class || m.data == WallHangingSign.class;
}
public static boolean isBanner(Material m) {
return m.name().endsWith("_BANNER");
}
public static boolean isWallBanner(Material m) {
return m.name().endsWith("_WALL_BANNER");
}
public static boolean isShulkerBox(Material m) {
return m.name().endsWith("SHULKER_BOX");
}
public static boolean isCopperGolemStatue(Material m) {
return m.data == CopperGolemStatue.class;
}
}
@@ -1,40 +1,62 @@
package eu.mhsl.minecraft.pixelpics.assets; 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.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Element; import eu.mhsl.minecraft.pixelpics.assets.model.Element;
import eu.mhsl.minecraft.pixelpics.assets.model.Face; import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel; import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.block.BlockFace;
import org.bukkit.block.data.Bisected;
import org.bukkit.block.data.BlockData; import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.type.Bed;
import org.bukkit.block.data.type.Stairs;
import org.bukkit.block.data.type.TrapDoor;
/** /**
* Top-level entry point that turns a {@link BlockData} into a baked {@link ResolvedModel}, combining * Turns a {@link BlockData} into a baked {@link ResolvedModel} (blockstate -> variants -> flattened models ->
* blockstate resolution, model resolution and geometry baking. Results are cached per BlockData and * elements). Thread-safe; models are cached per BlockData and choice of weighted variants, the baked elements per
* the cache is safe for concurrent access by the parallel renderer. * {@link Variant}.
*
* <p>Weighted variant lists (stone, grass, dirt, sand, … in four rotations; bamboo/chorus/fire parts) are picked
* like vanilla 26.3: blocks in the world with {@code random.setSeed(state.getSeed(pos))} ({@code Mth.getSeed},
* doors/double plants by their lower half, beds by their head; {@code SectionCompiler} →
* {@code ModelBlockRenderer.tesselateBlock}), then {@code WeightedList.getRandomOrThrow} = {@code nextInt(total
* weight)}; multipart draws one {@code nextLong} and re-seeds every part with it ({@code MultiPartModel}). Block
* models outside the world (block displays, minecart blocks; {@code BlockModelResolver}) use the fixed seed 42.
*/ */
public final class BlockModelRegistry { public final class BlockModelRegistry {
private static final int FALLBACK_GREY = 0xFF7F7F7F;
/** {@code BlockModelResolver.MODEL_SEED}: block models drawn outside the world (displays, minecart contents). */
public static final long MODEL_SEED = 42L;
// Combinations of weighted choices beyond this are cached in a map instead of an array.
private static final int MAX_COMBO_ARRAY = 4096;
// Render nothing in the world: hand-only technical blocks, and liquids (vanilla RenderShape.INVISIBLE; drawn by
// render.raytrace.FluidRenderer, block displays of them show nothing like vanilla).
private static final EnumSet<Material> INVISIBLE_MATERIALS = EnumSet.of(
Material.BARRIER,
Material.LIGHT,
Material.STRUCTURE_VOID,
Material.WATER,
Material.LAVA,
Material.BUBBLE_COLUMN);
private final AssetReader reader;
private final TextureCache textures; private final TextureCache textures;
private final BlockStateResolver blockStateResolver; private final BlockStateResolver blockStateResolver;
private final ModelResolver modelResolver; private final ModelResolver modelResolver;
private final ModelBaker baker; private final ModelBaker baker;
private final Map<BlockData, ResolvedModel> cache = new ConcurrentHashMap<>(); private final Map<BlockData, States> cache = new ConcurrentHashMap<>();
private volatile ResolvedModel waterModel; private final Map<ResourceLocation, ResolvedModel> modelCache = new ConcurrentHashMap<>();
private volatile ResolvedModel lavaModel; private final Map<Variant, List<Element>> variantCache = new ConcurrentHashMap<>();
private final AssetReader reader;
/** The asset reader this registry resolves models with (shared by item-model resolution). */
public AssetReader reader() {
return reader;
}
public BlockModelRegistry(AssetReader reader, TextureCache textures) { public BlockModelRegistry(AssetReader reader, TextureCache textures) {
this.reader = reader; this.reader = reader;
@@ -44,147 +66,293 @@ public final class BlockModelRegistry {
this.baker = new ModelBaker(textures); this.baker = new ModelBaker(textures);
} }
public TextureCache textures() {
return textures;
}
public AssetReader reader() {
return reader;
}
/** The model of {@code data} at world position (x, y, z) (picks weighted variants like vanilla's chunks). */
public ResolvedModel get(BlockData data, int x, int y, int z) {
States states = states(data);
if (states.fixed != null) return states.fixed;
return states.pick(this, mthSeed(x + states.seedDx, y + states.seedDy, z + states.seedDz));
}
/** The model of {@code data} outside the world ({@link #MODEL_SEED}): block displays, minecart blocks, … */
public ResolvedModel get(BlockData data) { public ResolvedModel get(BlockData data) {
return cache.computeIfAbsent(data, this::resolve); return get(data, MODEL_SEED);
} }
private ResolvedModel resolve(BlockData data) { /** The model of {@code data} with the weighted variants picked by {@code seed} (vanilla's model random seed). */
public ResolvedModel get(BlockData data, long seed) {
States states = states(data);
return states.fixed != null ? states.fixed : states.pick(this, seed);
}
/**
* The model of {@code data} with the FIRST entry of every weighted list: block items drawn from their blockstate
* (vanilla draws the item model, which for weighted blocks is the first, unrotated variant's model).
*/
public ResolvedModel firstVariants(BlockData data) {
States states = states(data);
return states.fixed != null ? states.fixed : states.model(this, 0);
}
private States states(BlockData data) {
States states = cache.get(data);
return states != null ? states : cache.computeIfAbsent(data, this::resolveStates);
}
/**
* Vanilla {@code BlockState.getSeed(pos)}: {@code Mth.getSeed(pos)}; doors and double plants
* ({@code DoublePlantBlock}: tall grass/flowers/seagrass, small dripleaf, pitcher crop) use their lower half,
* beds their head ({@code AbstractBedBlock}).
*/
public static long seed(BlockData data, int x, int y, int z) {
int[] d = seedOffset(data);
return mthSeed(x + d[0], y + d[1], z + d[2]);
}
/** The position offset of {@link #seed}: lower half of doors/double plants, head of a bed foot. */
private static int[] seedOffset(BlockData data) {
if (data instanceof Bisected b
&& !(data instanceof Stairs)
&& !(data instanceof TrapDoor)
&& b.getHalf() == Bisected.Half.TOP) {
return new int[] {0, -1, 0};
}
if (data instanceof Bed bed && bed.getPart() == Bed.Part.FOOT) {
BlockFace f = bed.getFacing();
return new int[] {f.getModX(), 0, f.getModZ()};
}
return new int[3];
}
/** {@code Mth.getSeed(x, y, z)}, including its int overflow of {@code x * 3129871}. */
static long mthSeed(int x, int y, int z) {
long seed = (long) (x * 3129871) ^ z * 116129781L ^ y;
seed = seed * seed * 42317861L + seed * 11L;
return seed >> 16;
}
/**
* The picked entry of every part, as a mixed-radix index over the parts' list sizes (first part most
* significant): {@code ModelBlockRenderer} seeds the random, {@code WeightedVariants} draws
* {@code nextInt(totalWeight)}, {@code MultiPartModel} first draws a {@code nextLong} and re-seeds with it before
* every part.
*/
static long comboIndex(boolean multipart, BlockStateResolver.Choice[] parts, long seed) {
if (!multipart) {
// Hot path (stone, grass, dirt, … in every chunk): one list, the first draw of a fresh seed.
BlockStateResolver.Choice c = parts[0];
return c.index(Lcg.firstNextInt(seed, c.totalWeight()));
}
Lcg random = new Lcg(seed);
long partSeed = multipart ? random.nextLong() : 0;
long index = 0;
for (BlockStateResolver.Choice c : parts) {
if (multipart) random.setSeed(partSeed);
// WeightedVariants draws even for a one-entry list, SingleVariant does not: the same pick either way.
int pick = c.variants().size() == 1 ? 0 : c.index(random.nextInt(c.totalWeight()));
index = index * c.variants().size() + pick;
}
return index;
}
/** Vanilla's {@code LegacyRandomSource}/{@code SingleThreadedRandomSource} (java.util.Random's LCG). */
private static final class Lcg {
private static final long MULTIPLIER = 0x5DEECE66DL;
private static final long MASK = (1L << 48) - 1;
private long seed;
Lcg(long seed) {
setSeed(seed);
}
void setSeed(long seed) {
this.seed = (seed ^ MULTIPLIER) & MASK;
}
/** {@code setSeed(seed); nextInt(bound)} without an instance. */
static int firstNextInt(long seed, int bound) {
long s = (seed ^ MULTIPLIER) & MASK;
s = s * MULTIPLIER + 11L & MASK;
int sample = (int) (s >> 17);
if ((bound & bound - 1) == 0) return (int) ((long) bound * sample >> 31);
int modulo = sample % bound;
while (sample - modulo + (bound - 1) < 0) {
s = s * MULTIPLIER + 11L & MASK;
sample = (int) (s >> 17);
modulo = sample % bound;
}
return modulo;
}
private int next(int bits) {
seed = seed * MULTIPLIER + 11L & MASK;
return (int) (seed >> 48 - bits);
}
/** {@code BitRandomSource.nextInt(bound)}. */
int nextInt(int bound) {
if ((bound & bound - 1) == 0) return (int) ((long) bound * next(31) >> 31);
int sample, modulo;
do {
sample = next(31);
modulo = sample % bound;
} while (sample - modulo + (bound - 1) < 0);
return modulo;
}
long nextLong() {
int upper = next(32);
int lower = next(32);
return ((long) upper << 32) + lower;
}
}
/**
* The model choices of one BlockData: {@code fixed} when nothing is random (every choice has one entry),
* else the choices and the models per combination of picked entries (built on first use).
*/
private static final class States {
final ResolvedModel fixed;
final BlockData data;
final boolean multipart;
final BlockStateResolver.Choice[] parts;
final ResolvedModel[] combos; // index = mixed radix over the parts' picks; null when too many
final Map<Long, ResolvedModel> comboMap;
final int seedDx, seedDy, seedDz; // seedOffset of data
States(ResolvedModel fixed) {
this.seedDx = this.seedDy = this.seedDz = 0;
this.fixed = fixed;
this.data = null;
this.multipart = false;
this.parts = null;
this.combos = null;
this.comboMap = null;
}
States(BlockData data, BlockStateResolver.StateModel model) {
int[] d = seedOffset(data);
this.seedDx = d[0];
this.seedDy = d[1];
this.seedDz = d[2];
this.fixed = null;
this.data = data;
this.multipart = model.multipart();
this.parts = model.parts().toArray(BlockStateResolver.Choice[]::new);
long count = 1;
for (BlockStateResolver.Choice c : parts)
count = Math.min(count * c.variants().size(), Long.MAX_VALUE / 64);
this.combos = count <= MAX_COMBO_ARRAY ? new ResolvedModel[(int) count] : null;
this.comboMap = combos == null ? new ConcurrentHashMap<>() : null;
}
ResolvedModel pick(BlockModelRegistry registry, long seed) {
return model(registry, comboIndex(multipart, parts, seed));
}
/** The model of a combination index (0 = the first entry of every list). */
ResolvedModel model(BlockModelRegistry registry, long index) {
if (combos != null) {
ResolvedModel m = combos[(int) index];
if (m == null) combos[(int) index] = m = registry.resolve(data, variants(index)); // immutable, racy ok
return m;
}
long key = index;
return comboMap.computeIfAbsent(key, k -> registry.resolve(data, variants(k)));
}
/** The picked variant of every part for a combination index. */
private List<Variant> variants(long index) {
Variant[] picked = new Variant[parts.length];
for (int i = parts.length - 1; i >= 0; i--) {
int n = parts[i].variants().size();
picked[i] = parts[i].variants().get((int) (index % n));
index /= n;
}
return List.of(picked);
}
}
/**
* A block model by id (e.g. {@code block/item_frame_map}) without a blockstate, unrotated: for models vanilla
* draws from a fake block state (item frames). Empty when the model is missing.
*/
public ResolvedModel model(ResourceLocation modelId) {
return modelCache.computeIfAbsent(modelId, id -> {
List<Element> elements = bake(new Variant(id, 0, 0, false));
return new ResolvedModel(elements, !elements.isEmpty());
});
}
private List<Element> bake(Variant variant) {
return variantCache.computeIfAbsent(variant, v -> baker.bake(modelResolver.resolve(v.model()), v));
}
private States resolveStates(BlockData data) {
if (INVISIBLE_MATERIALS.contains(data.getMaterial())) return new States(ResolvedModel.EMPTY);
BlockStateResolver.StateModel model = blockStateResolver.resolve(data);
boolean random = false;
for (BlockStateResolver.Choice c : model.parts()) random |= c.variants().size() > 1;
if (random) return new States(data, model);
List<Variant> variants = new ArrayList<>(model.parts().size());
for (BlockStateResolver.Choice c : model.parts())
variants.add(c.variants().getFirst());
return new States(resolve(data, variants));
}
/** The model of {@code data} from the picked {@code variants} (one per part). */
private ResolvedModel resolve(BlockData data, List<Variant> variants) {
Material material = data.getMaterial(); Material material = data.getMaterial();
if (material == Material.WATER) return water(); List<Element> elements;
if (material == Material.LAVA) return lava(); if (variants.size() == 1) {
// Technical blocks that are invisible in the world (their textures only show held in hand). elements = bake(variants.getFirst()); // shared with other states of the same variant; never mutated
if (INVISIBLE_MATERIALS.contains(material)) { } else {
return new ResolvedModel(List.of(), 0, 0, 0, false, false); elements = new ArrayList<>();
for (Variant variant : variants) elements.addAll(bake(variant));
} }
if (!elements.isEmpty()) return new ResolvedModel(elements, true);
List<Variant> variants = blockStateResolver.resolve(data); // Block entities (builtin/entity models) are drawn by the entity scene: no grey cube in front of them.
if (BlockEntityBlocks.hasNoBlockModel(material)) return ResolvedModel.EMPTY;
List<Element> elements = new ArrayList<>(); FlatModel last = variants.isEmpty()
AverageColor.Accumulator avgColor = new AverageColor.Accumulator(); ? null
FlatModel lastFlat = null; : modelResolver.resolve(variants.getLast().model());
return new ResolvedModel(List.of(solidCube(particleColor(last))), false);
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). */ /** A full cube whose faces sample one solid colour. */
private static final EnumSet<Material> INVISIBLE_MATERIALS = buildInvisibleMaterials(); private static Element solidCube(int color) {
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}}; int[][] tex = {{color}};
Face[] faces = new Face[6]; Face[] faces = new Face[6];
for (Direction d : Direction.values()) { for (Direction d : Direction.values()) faces[d.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, -1);
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);
}
return new Element(new double[] {0, 0, 0}, new double[] {1, 1, 1}, faces, null, -1, 0, false);
} }
private int fallbackColor(FlatModel flat) { /** Average colour of the model's particle texture (texels with alpha > 16), else grey. */
if (flat != null && flat.textures() != null) { private int particleColor(FlatModel flat) {
String particle = flat.textures().get("particle"); String particle = flat == null ? null : flat.textures().get("particle");
if (particle != null && !particle.startsWith("#")) { if (particle == null || particle.startsWith("#")) return FALLBACK_GREY;
int[][] tex = textures.get(ResourceLocation.parse(particle)).orElse(null); int[][] tex = textures.get(ResourceLocation.parse(particle)).orElse(null);
if (tex != null) return AverageColor.of(tex); if (tex == null) return FALLBACK_GREY;
long r = 0, g = 0, b = 0;
int count = 0;
for (int[] row : tex) {
for (int argb : row) {
if (!ColorUtil.opaque(argb)) continue;
r += ColorUtil.red(argb);
g += ColorUtil.green(argb);
b += ColorUtil.blue(argb);
count++;
} }
} }
return 0xFF7F7F7F; if (count == 0) return FALLBACK_GREY;
} return ColorUtil.argb(0xFF, (int) (r / count), (int) (g / count), (int) (b / count));
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);
} }
} }
@@ -1,40 +0,0 @@
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;
}
}
@@ -4,28 +4,64 @@ import com.google.gson.JsonArray;
import com.google.gson.JsonElement; import com.google.gson.JsonElement;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import org.bukkit.block.data.BlockData; import org.bukkit.block.data.BlockData;
/** /**
* Resolves a {@link BlockData} to the list of model variants vanilla would render, by reading the * Resolves a {@link BlockData} to the model choices of its {@code blockstates/<name>.json}: the matching entry of
* block's {@code blockstates/<name>.json} (either {@code variants} or {@code multipart}). * {@code variants}, or every applying {@code multipart} case. Each choice is a weighted list (vanilla
* {@code WeightedVariants}; a single object = one entry); {@link BlockModelRegistry} picks per position.
*/ */
public final class BlockStateResolver { public final class BlockStateResolver {
/** A weighted variant list (vanilla {@code WeightedVariants}); a single variant has one entry. */
public record Choice(List<Variant> variants, int[] weights, int totalWeight) {
/** {@code WeightedList.Selector.get}: the entry whose weight range contains {@code selection}. */
public int index(int selection) {
for (int i = 0; i < weights.length; i++) {
selection -= weights[i];
if (selection < 0) return i;
}
return weights.length - 1;
}
}
/**
* The model of a block state: {@code multipart} (vanilla {@code MultiPartModel}: every part picks with the
* same seed) or a single {@code variants} entry ({@code parts} has one choice); no parts = no model.
*/
public record StateModel(boolean multipart, List<Choice> parts) {
static final StateModel NONE = new StateModel(false, List.of());
}
private final AssetReader reader; private final AssetReader reader;
public BlockStateResolver(AssetReader reader) { public BlockStateResolver(AssetReader reader) {
this.reader = reader; this.reader = reader;
} }
public List<Variant> resolve(BlockData data) { public StateModel resolve(BlockData data) {
String name = BlockStateProperties.blockName(data); // e.g. minecraft:oak_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]
Map<String, String> props = BlockStateProperties.properties(data); String state = data.getAsString(false);
int open = state.indexOf('[');
String id = open < 0 ? state : state.substring(0, open);
String name = id.substring(id.indexOf(':') + 1).trim();
Map<String, String> props = new HashMap<>();
int close = state.lastIndexOf(']');
if (open >= 0 && close > open) {
for (String pair : state.substring(open + 1, close).split(",")) {
int eq = pair.indexOf('=');
if (eq >= 0)
props.put(
pair.substring(0, eq).trim(), pair.substring(eq + 1).trim());
}
}
JsonObject root = reader.readJsonObject(AssetPaths.blockState(name)).orElse(null); JsonObject root = reader.readJsonObject(AssetPaths.blockState(name)).orElse(null);
if (root == null) return List.of(); if (root == null) return StateModel.NONE;
if (root.has("variants")) { if (root.has("variants")) {
return resolveVariants(root.getAsJsonObject("variants"), props); return resolveVariants(root.getAsJsonObject("variants"), props);
@@ -33,30 +69,30 @@ public final class BlockStateResolver {
if (root.has("multipart")) { if (root.has("multipart")) {
return resolveMultipart(root.getAsJsonArray("multipart"), props); return resolveMultipart(root.getAsJsonArray("multipart"), props);
} }
return List.of(); return StateModel.NONE;
} }
private List<Variant> resolveVariants(JsonObject variants, Map<String, String> props) { private static StateModel resolveVariants(JsonObject variants, Map<String, String> props) {
for (Map.Entry<String, JsonElement> entry : variants.entrySet()) { for (Map.Entry<String, JsonElement> entry : variants.entrySet()) {
if (variantKeyMatches(entry.getKey(), props)) { if (variantKeyMatches(entry.getKey(), props)) {
return List.of(parseVariant(firstOf(entry.getValue()))); return new StateModel(false, List.of(choice(entry.getValue())));
} }
} }
return List.of(); return StateModel.NONE;
} }
private List<Variant> resolveMultipart(JsonArray multipart, Map<String, String> props) { private static StateModel resolveMultipart(JsonArray multipart, Map<String, String> props) {
List<Variant> result = new ArrayList<>(); List<Choice> result = new ArrayList<>();
for (JsonElement caseEl : multipart) { for (JsonElement caseEl : multipart) {
JsonObject caseObj = caseEl.getAsJsonObject(); JsonObject caseObj = caseEl.getAsJsonObject();
if (caseObj.has("when") && !whenMatches(caseObj.get("when"), props)) continue; if (caseObj.has("when") && !whenMatches(caseObj.get("when"), props)) continue;
result.add(parseVariant(firstOf(caseObj.get("apply")))); result.add(choice(caseObj.get("apply")));
} }
return result; return new StateModel(true, result);
} }
/** A variant key like {@code facing=east,half=bottom} matches when every pair holds for the block. */ /** A variant key like {@code facing=east,half=bottom} matches when every pair holds. */
private boolean variantKeyMatches(String key, Map<String, String> props) { private static boolean variantKeyMatches(String key, Map<String, String> props) {
if (key.isEmpty()) return true; if (key.isEmpty()) return true;
for (String pair : key.split(",")) { for (String pair : key.split(",")) {
int eq = pair.indexOf('='); int eq = pair.indexOf('=');
@@ -68,7 +104,7 @@ public final class BlockStateResolver {
return true; return true;
} }
private boolean whenMatches(JsonElement when, Map<String, String> props) { private static boolean whenMatches(JsonElement when, Map<String, String> props) {
JsonObject obj = when.getAsJsonObject(); JsonObject obj = when.getAsJsonObject();
if (obj.has("OR")) { if (obj.has("OR")) {
for (JsonElement sub : obj.getAsJsonArray("OR")) { for (JsonElement sub : obj.getAsJsonArray("OR")) {
@@ -82,7 +118,6 @@ public final class BlockStateResolver {
} }
return true; return true;
} }
// Implicit AND over property conditions.
for (Map.Entry<String, JsonElement> e : obj.entrySet()) { for (Map.Entry<String, JsonElement> e : obj.entrySet()) {
if (!valueMatches(props.get(e.getKey()), e.getValue().getAsString())) return false; if (!valueMatches(props.get(e.getKey()), e.getValue().getAsString())) return false;
} }
@@ -90,7 +125,7 @@ public final class BlockStateResolver {
} }
/** A condition value may be an OR list like {@code "north|south"}. */ /** A condition value may be an OR list like {@code "north|south"}. */
private boolean valueMatches(String actual, String expected) { private static boolean valueMatches(String actual, String expected) {
if (actual == null) return false; if (actual == null) return false;
for (String option : expected.split("\\|")) { for (String option : expected.split("\\|")) {
if (option.equals(actual)) return true; if (option.equals(actual)) return true;
@@ -98,14 +133,24 @@ public final class BlockStateResolver {
return false; return false;
} }
private JsonObject firstOf(JsonElement element) { /** A variant object or a weighted list of them ({@code "weight"}, default 1). */
if (element.isJsonArray()) { private static Choice choice(JsonElement element) {
return element.getAsJsonArray().get(0).getAsJsonObject(); if (!element.isJsonArray())
return new Choice(List.of(parseVariant(element.getAsJsonObject())), new int[] {1}, 1);
JsonArray array = element.getAsJsonArray();
List<Variant> variants = new ArrayList<>(array.size());
int[] weights = new int[array.size()];
int total = 0;
for (int i = 0; i < array.size(); i++) {
JsonObject obj = array.get(i).getAsJsonObject();
variants.add(parseVariant(obj));
weights[i] = obj.has("weight") ? Math.max(1, obj.get("weight").getAsInt()) : 1;
total += weights[i];
} }
return element.getAsJsonObject(); return new Choice(List.copyOf(variants), weights, total);
} }
private Variant parseVariant(JsonObject obj) { private static Variant parseVariant(JsonObject obj) {
ResourceLocation model = ResourceLocation.parse(obj.get("model").getAsString()); ResourceLocation model = ResourceLocation.parse(obj.get("model").getAsString());
int x = obj.has("x") ? obj.get("x").getAsInt() : 0; int x = obj.has("x") ? obj.get("x").getAsInt() : 0;
int y = obj.has("y") ? obj.get("y").getAsInt() : 0; int y = obj.has("y") ? obj.get("y").getAsInt() : 0;
@@ -3,10 +3,7 @@ package eu.mhsl.minecraft.pixelpics.assets;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
/** /** Several packs as one; the first pack containing an asset wins. */
* 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 { public final class CompositeResourcePack implements ResourcePack {
private final List<ResourcePack> packs; private final List<ResourcePack> packs;
@@ -5,10 +5,7 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Optional; import java.util.Optional;
/** /** A {@link ResourcePack} backed by a directory (the one containing {@code assets/}). */
* A {@link ResourcePack} backed by a directory on disk. The root typically contains the
* {@code assets/} folder.
*/
public final class DirectoryResourcePack implements ResourcePack { public final class DirectoryResourcePack implements ResourcePack {
private final Path root; private final Path root;
@@ -19,7 +16,7 @@ public final class DirectoryResourcePack implements ResourcePack {
private Optional<Path> resolve(String path) { private Optional<Path> resolve(String path) {
Path resolved = root.resolve(path).normalize(); Path resolved = root.resolve(path).normalize();
// Guard against path traversal outside the pack root. // No path traversal outside the pack root.
if (!resolved.startsWith(root)) return Optional.empty(); if (!resolved.startsWith(root)) return Optional.empty();
return Optional.of(resolved); return Optional.of(resolved);
} }
@@ -42,7 +39,5 @@ public final class DirectoryResourcePack implements ResourcePack {
} }
@Override @Override
public void close() { public void close() {}
// nothing to release
}
} }
@@ -5,8 +5,8 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* A model with its parent chain flattened: textures merged (child wins) and the nearest non-empty * A model with its parent chain flattened: textures merged (child wins), elements from the nearest model that has
* {@code elements} list selected (vanilla does not merge elements across parents). * any (vanilla does not merge elements across parents).
*/ */
public record FlatModel(Map<String, String> textures, List<ModelFileDto.ElementDto> elements) { public record FlatModel(Map<String, String> textures, List<ModelFileDto.ElementDto> elements) {
@@ -1,13 +1,13 @@
package eu.mhsl.minecraft.pixelpics.assets; package eu.mhsl.minecraft.pixelpics.assets;
import java.awt.image.BufferedImage;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
/** /**
* Thread-safe cache of ARGB pixel grids keyed by {@code K}. Subclasses implement {@link #load} to * Thread-safe cache of ARGB pixel grids ({@code [y][x]}, top-left origin). Failed loads ({@link #load} returns null)
* produce the grid (or {@code null} on failure); a failure is cached as a sentinel so it is not * are cached too and never retried.
* retried, and {@link #get} returns empty for it. The grid is indexed {@code [y][x]}, top-left origin.
*/ */
public abstract class ImageCache<K> { public abstract class ImageCache<K> {
@@ -15,7 +15,6 @@ public abstract class ImageCache<K> {
private final Map<K, int[][]> cache = new ConcurrentHashMap<>(); 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); protected abstract int[][] load(K key);
public Optional<int[][]> get(K key) { public Optional<int[][]> get(K key) {
@@ -23,6 +22,13 @@ public abstract class ImageCache<K> {
return result == MISSING ? Optional.empty() : Optional.of(result); return result == MISSING ? Optional.empty() : Optional.of(result);
} }
/** The {@code w×h} pixels of an image at ({@code x0}, {@code y0}) as ARGB rows. */
protected static int[][] pixels(BufferedImage img, int x0, int y0, int w, int h) {
int[][] out = new int[h][];
for (int y = 0; y < h; y++) out[y] = img.getRGB(x0, y0 + y, w, 1, null, 0, w);
return out;
}
private int[][] loadOrSentinel(K key) { private int[][] loadOrSentinel(K key) {
int[][] loaded = load(key); int[][] loaded = load(key);
return loaded == null ? MISSING : loaded; return loaded == null ? MISSING : loaded;
@@ -1,7 +1,6 @@
package eu.mhsl.minecraft.pixelpics.assets; package eu.mhsl.minecraft.pixelpics.assets;
import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto; 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.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Element; import eu.mhsl.minecraft.pixelpics.assets.model.Element;
import eu.mhsl.minecraft.pixelpics.assets.model.Face; import eu.mhsl.minecraft.pixelpics.assets.model.Face;
@@ -10,11 +9,8 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* Bakes a {@link FlatModel} plus a blockstate {@link Variant} into intersectable {@link Element} * Bakes a {@link FlatModel} plus a blockstate {@link Variant} into {@link Element} boxes. Blockstate x/y rotations
* boxes. Texture variables are resolved against the model's merged texture map; default UVs follow * (90° steps) are applied to box, faces and the element's own rotation; rotated elements keep their model-space copy.
* 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 { public final class ModelBaker {
@@ -24,26 +20,19 @@ public final class ModelBaker {
this.textures = textures; this.textures = textures;
} }
public record BakedGeometry(List<Element> elements, int averageColor, boolean hasGeometry) {} public List<Element> bake(FlatModel model, Variant variant) {
if (!model.hasElements()) return List.of();
public BakedGeometry bake(FlatModel model, Variant variant) { int xSteps = Math.floorMod(variant.x() / 90, 4);
if (!model.hasElements()) { int ySteps = Math.floorMod(variant.y() / 90, 4);
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<>(); List<Element> baked = new ArrayList<>();
AverageColor.Accumulator avgColor = new AverageColor.Accumulator();
for (ModelFileDto.ElementDto dto : model.elements()) { for (ModelFileDto.ElementDto dto : model.elements()) {
if (dto.from == null || dto.to == null) continue; 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[] 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}; 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]; Face[] faces = new Face[6];
if (dto.faces != null) { if (dto.faces != null) {
for (Map.Entry<String, ModelFileDto.FaceDto> e : dto.faces.entrySet()) { for (Map.Entry<String, ModelFileDto.FaceDto> e : dto.faces.entrySet()) {
@@ -54,92 +43,81 @@ public final class ModelBaker {
} }
} }
// Element's own rotation (origin in 0..1, axis index, radians).
double[] rotOrigin = null; double[] rotOrigin = null;
int rotAxis = -1; int rotAxis = -1;
double rotAngle = 0; double rotAngle = 0;
double[] rotMatrix = null;
boolean rescale = false; boolean rescale = false;
if (dto.rotation != null && dto.rotation.angle != 0 && dto.rotation.origin != null) { ModelFileDto.RotationDto rot = dto.rotation;
rotOrigin = new double[] { if (rot != null && rot.origin != null) {
dto.rotation.origin[0] / 16.0, dto.rotation.origin[1] / 16.0, dto.rotation.origin[2] / 16.0 if (rot.axis == null && (rot.x != null || rot.y != null || rot.z != null)) {
}; rotMatrix = eulerXyz(rot.x, rot.y, rot.z);
rotAxis = axisIndex(dto.rotation.axis); if (rotMatrix != null) rotAxis = Element.AXIS_MATRIX;
rotAngle = Math.toRadians(dto.rotation.angle); } else if (rot.angle != 0) {
rescale = dto.rotation.rescale; rotAxis = axisIndex(rot.axis);
} rotAngle = Math.toRadians(rot.angle);
Element modelElement = new Element(from, to, faces, rotOrigin, rotAxis, rotAngle, rescale);
// Apply blockstate variant rotation (90-degree steps) to box + faces + element rotation.
for (int i = 0; i < xSteps; i++) {
double[][] r = rotateBoxX(from, to);
from = r[0];
to = r[1];
faces = rotateFacesX(faces);
if (rotAxis >= 0) {
rotOrigin = rotatePointX(rotOrigin);
AxisRotation na = rotateAxisX(rotAxis);
rotAxis = na.axis();
if (na.flip()) rotAngle = -rotAngle;
} }
}
for (int i = 0; i < ySteps; i++) {
double[][] r = rotateBoxY(from, to);
from = r[0];
to = r[1];
faces = rotateFacesY(faces);
if (rotAxis >= 0) { if (rotAxis >= 0) {
rotOrigin = rotatePointY(rotOrigin); rotOrigin = new double[] {rot.origin[0] / 16.0, rot.origin[1] / 16.0, rot.origin[2] / 16.0};
AxisRotation na = rotateAxisY(rotAxis); rescale = rot.rescale;
rotAxis = na.axis();
if (na.flip()) rotAngle = -rotAngle;
} }
} }
// Without uvlock, textures turn with the model (vanilla): keep the model-space element so the Element modelElement =
// intersectors can sample it exactly. With uvlock they stay world-aligned (the rotated faces). new Element(from, to, faces, rotOrigin, rotAxis, rotAngle, rotMatrix, rescale, null, 0, 0);
Element unrotated = (!variant.uvlock() && (xSteps != 0 || ySteps != 0)) ? modelElement : null;
baked.add(new Element(from, to, faces, rotOrigin, rotAxis, rotAngle, rescale, unrotated, xSteps, ySteps));
// Accumulate average color from the element's face textures. // Rotated variants keep the model-space element; intersectors hit it with the inverse-rotated ray (as
for (Face f : faces) { // vanilla's FaceBakery, textures turn with the model). Under uvlock its faces get a UV transform instead.
if (f == null) continue; // The world box is min/max-normalised, so only the model-space copy of an inverted element stays inverted.
avgColor.add(AverageColor.of(f.texture)); Element unrotated = null;
if (xSteps != 0 || ySteps != 0) {
unrotated = variant.uvlock() ? uvLocked(modelElement, xSteps, ySteps) : modelElement;
double[][] box = minMax(rotatePoint(from, xSteps, ySteps), rotatePoint(to, xSteps, ySteps));
from = box[0];
to = box[1];
Face[] rotated = new Face[6];
for (Direction d : Direction.values()) {
rotated[d.rotateX(xSteps).rotateY(ySteps).ordinal()] = faces[d.ordinal()];
}
faces = rotated;
if (rotAxis >= 0) rotOrigin = rotatePoint(rotOrigin, xSteps, ySteps);
if (rotAxis >= 0 && rotAxis < Element.AXIS_MATRIX) {
Direction axis =
Direction.of(rotAxis, false).rotateX(xSteps).rotateY(ySteps);
rotAxis = axis.axis;
if (axis.nx + axis.ny + axis.nz < 0) rotAngle = -rotAngle;
}
} }
baked.add(new Element(
from,
to,
faces,
rotOrigin,
rotAxis,
rotAngle,
rotMatrix == null ? null : conjugate(rotMatrix, xSteps, ySteps),
rescale,
unrotated,
xSteps,
ySteps));
} }
return baked;
return new BakedGeometry(baked, avgColor.average(0xFF7F7F7F), !baked.isEmpty());
} }
private Face buildFace( private Face buildFace(
Direction dir, ModelFileDto.FaceDto dto, double[] from, double[] to, Map<String, String> textureVars) { Direction dir, ModelFileDto.FaceDto dto, double[] from, double[] to, Map<String, String> textureVars) {
int[][] tex = resolveTexture(dto.texture, textureVars); int[][] tex = resolveTexture(dto.texture, textureVars);
if (tex == null) return null; if (tex == null) return null;
double[] uv = dto.uv != null && dto.uv.length == 4
double u1, v1, u2, v2; ? new double[] {dto.uv[0] / 16.0, dto.uv[1] / 16.0, dto.uv[2] / 16.0, dto.uv[3] / 16.0}
if (dto.uv != null && dto.uv.length == 4) { : defaultUv(dir, from, to);
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; int tint = dto.tintindex != null ? dto.tintindex : -1;
return new Face(tex, u1, v1, u2, v2, dto.rotation, tint); return new Face(tex, uv[0], uv[1], uv[2], uv[3], dto.rotation, tint);
} }
/** /** Vanilla {@code BlockElement.uvsByFace}, normalised (texture v is top-down). */
* Default UV (normalized 0..1) from the element extents, matching vanilla. Texture V is top-down, private static double[] defaultUv(Direction dir, double[] f, double[] t) {
* 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) { return switch (dir) {
// Vanilla BlockElement.uvsByFace (u/v in 0..1).
case DOWN -> new double[] {f[0], 1 - t[2], t[0], 1 - f[2]}; case DOWN -> new double[] {f[0], 1 - t[2], t[0], 1 - f[2]};
case UP -> new double[] {f[0], f[2], t[0], t[2]}; case UP -> new double[] {f[0], f[2], t[0], t[2]};
case NORTH -> new double[] {1 - t[0], 1 - t[1], 1 - f[0], 1 - f[1]}; case NORTH -> new double[] {1 - t[0], 1 - t[1], 1 - f[0], 1 - f[1]};
@@ -149,19 +127,109 @@ public final class ModelBaker {
}; };
} }
/**
* Vanilla {@code EulerXYZRotation}: {@code Matrix4f.rotationZYX(z, y, x)} = Rz·Ry·Rx (degrees), row-major;
* null when it is the identity (vanilla then skips the rotation and its rescale).
*/
static double[] eulerXyz(Double xDeg, Double yDeg, Double zDeg) {
double x = Math.toRadians(xDeg == null ? 0 : xDeg);
double y = Math.toRadians(yDeg == null ? 0 : yDeg);
double z = Math.toRadians(zDeg == null ? 0 : zDeg);
if (x == 0 && y == 0 && z == 0) return null;
double cx = Math.cos(x),
sx = Math.sin(x),
cy = Math.cos(y),
sy = Math.sin(y),
cz = Math.cos(z),
sz = Math.sin(z);
double[] rz = {cz, -sz, 0, sz, cz, 0, 0, 0, 1};
double[] ry = {cy, 0, sy, 0, 1, 0, -sy, 0, cy};
double[] rx = {1, 0, 0, 0, cx, -sx, 0, sx, cx};
return mul(mul(rz, ry), rx);
}
/** The element rotation as seen after the blockstate rotation: V·M·Vᵀ (V = Element#rotate of the axes). */
private static double[] conjugate(double[] m, int xSteps, int ySteps) {
double[] v = new double[9];
for (int a = 0; a < 3; a++) {
double[] col = Element.rotate(a == 0 ? 1 : 0, a == 1 ? 1 : 0, a == 2 ? 1 : 0, xSteps, ySteps, false);
v[a] = col[0];
v[3 + a] = col[1];
v[6 + a] = col[2];
}
double[] vt = {v[0], v[3], v[6], v[1], v[4], v[7], v[2], v[5], v[8]};
return mul(mul(v, m), vt);
}
private static double[] mul(double[] a, double[] b) {
double[] r = new double[9];
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++)
r[3 * i + j] = a[3 * i] * b[j] + a[3 * i + 1] * b[3 + j] + a[3 * i + 2] * b[6 + j];
return r;
}
private static Element uvLocked(Element e, int xSteps, int ySteps) {
Face[] locked = new Face[6];
for (Direction d : Direction.values()) {
Face f = e.faces[d.ordinal()];
if (f != null) locked[d.ordinal()] = f.withUvLock(uvLockTransform(d, xSteps, ySteps));
}
return e.withFaces(locked);
}
/**
* Vanilla uvlock ({@code BlockMath.getFaceTransformation}, inverted as in {@code FaceBakery}): the final
* texture coordinates of model face {@code d}, centred on the texture, are mapped by
* {@code M = G2L(d) · R⁻¹ · L2G(D)} where R is the blockstate rotation, D the world face {@code d} turns into,
* and L2G the per-face UV frames (south = identity). Up/down under x rotations follow vanilla's frames (not
* strictly world-aligned). M is one of the 8 symmetries of the unit UV square, as the affine of {@link Face#uvLock}.
*/
static double[] uvLockTransform(Direction d, int xSteps, int ySteps) {
Direction world = d.rotateX(xSteps).rotateY(ySteps);
double[] cu = lockAxis(d, world, 1, 0, xSteps, ySteps);
double[] cv = lockAxis(d, world, 0, 1, xSteps, ySteps);
// u' = ½ + cu.x (u-½) + cv.x (v-½), v' = ½ + cu.y (u-½) + cv.y (v-½)
return new double[] {0.5 - 0.5 * (cu[0] + cv[0]), cu[0], cv[0], 0.5 - 0.5 * (cu[1] + cv[1]), cu[1], cv[1]};
}
private static double[] lockAxis(Direction d, Direction world, double u, double v, int xSteps, int ySteps) {
double[] w = uvFrame(world, u, v, 0, false);
double[] m = Element.unrotate(w[0], w[1], w[2], xSteps, ySteps, false);
return uvFrame(d, m[0], m[1], m[2], true);
}
/** Vanilla {@code BlockMath.VANILLA_UV_TRANSFORM_LOCAL_TO_GLOBAL} (or its inverse) applied to a vector. */
private static double[] uvFrame(Direction d, double x, double y, double z, boolean inverse) {
return switch (d) {
case SOUTH -> new double[] {x, y, z};
case NORTH -> new double[] {-x, y, -z}; // rotY(180°)
case EAST -> inverse ? new double[] {-z, y, x} : new double[] {z, y, -x}; // rotY(90°)
case WEST -> inverse ? new double[] {z, y, -x} : new double[] {-z, y, x}; // rotY(-90°)
case UP -> inverse ? new double[] {x, -z, y} : new double[] {x, z, -y}; // rotX(-90°)
case DOWN -> inverse ? new double[] {x, z, -y} : new double[] {x, -z, y}; // rotX(90°)
};
}
/**
* The texture of a face: like vanilla 26.3 {@code TextureSlots.getMaterial} the face's {@code texture} always
* names a texture slot, with or without the leading {@code #} (heavy_core uses {@code "all"}); slot values
* starting with {@code #} refer to other slots. An unresolved slot or a missing/unreadable texture is the
* magenta-black missing texture ({@link TextureCache#MISSINGNO}), not a hole.
*/
private int[][] resolveTexture(String ref, Map<String, String> vars) { private int[][] resolveTexture(String ref, Map<String, String> vars) {
if (ref == null) return null; if (ref == null || ref.isEmpty()) return null;
String current = ref; String current = ref.startsWith("#") ? ref : "#" + ref;
int guard = 0; int guard = 0;
while (current.startsWith("#") && guard++ < 16) { while (current.startsWith("#") && guard++ < 16) {
current = vars.get(current.substring(1)); current = vars.get(current.substring(1));
if (current == null) return null; if (current == null) return TextureCache.MISSINGNO;
} }
if (current.startsWith("#")) return null; if (current.startsWith("#")) return TextureCache.MISSINGNO;
return textures.get(ResourceLocation.parse(current)).orElse(null); return textures.get(ResourceLocation.parse(current)).orElse(TextureCache.MISSINGNO);
} }
private int axisIndex(String axis) { private static int axisIndex(String axis) {
if (axis == null) return -1; if (axis == null) return -1;
return switch (axis.toLowerCase()) { return switch (axis.toLowerCase()) {
case "x" -> 0; case "x" -> 0;
@@ -171,74 +239,27 @@ public final class ModelBaker {
}; };
} }
// --- 90-degree box rotations around the block center (0.5, 0.5, 0.5) --- /**
* Blockstate rotation of a point about the block centre: x steps, then y steps, both clockwise like vanilla
private double[][] rotateBoxY(double[] from, double[] to) { * (y: north -> east). Re-centred after every step instead of using {@link Element#rotate}: that one adds
// 90 deg clockwise viewed from above (vanilla), see rotatePointY. * {@code (p - 0.5) + 0.5} on the fixed axis, which is not exact for every coordinate.
double[] c1 = rotatePointY(from); */
double[] c2 = rotatePointY(to); private static double[] rotatePoint(double[] p, int xSteps, int ySteps) {
return minMax(c1, c2); double[] r = p;
for (int i = 0; i < xSteps; i++) {
double y = r[1] - 0.5, z = r[2] - 0.5;
r = new double[] {r[0], 0.5 + z, 0.5 - y};
}
for (int i = 0; i < ySteps; i++) {
double x = r[0] - 0.5, z = r[2] - 0.5;
r = new double[] {0.5 - z, r[1], 0.5 + x};
}
return r;
} }
private double[][] rotateBoxX(double[] from, double[] to) { private static double[][] minMax(double[] a, double[] b) {
double[] c1 = rotatePointX(from);
double[] c2 = rotatePointX(to);
return minMax(c1, c2);
}
private double[] rotatePointY(double[] p) {
// Vanilla blockstate "y": clockwise seen from above (north -> east, south -> west), i.e. a rotation
// by -90° about +Y: (dx, dz) -> (-dz, dx). Must match Direction.rotateY used for the faces.
double x = p[0] - 0.5, z = p[2] - 0.5;
return new double[] {0.5 - z, p[1], 0.5 + x};
}
private double[] rotatePointX(double[] p) {
double y = p[1] - 0.5, z = p[2] - 0.5;
return new double[] {p[0], 0.5 + z, 0.5 - y};
}
private double[][] minMax(double[] a, double[] b) {
double[] from = {Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.min(a[2], b[2])}; double[] 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])}; 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}; return new double[][] {from, to};
} }
private Face[] rotateFacesY(Face[] faces) {
Face[] out = new Face[6];
for (Direction d : Direction.values()) {
out[d.rotateY(1).ordinal()] = faces[d.ordinal()];
}
return out;
}
private Face[] rotateFacesX(Face[] faces) {
Face[] out = new Face[6];
for (Direction d : Direction.values()) {
out[d.rotateX(1).ordinal()] = faces[d.ordinal()];
}
return out;
}
// Rotating an element's own rotation axis under a 90-degree block rotation.
// {@code flip} indicates the angle sign should invert.
private record AxisRotation(int axis, boolean flip) {}
private AxisRotation rotateAxisY(int axis) {
// Clockwise Y rotation maps +x -> +z and +z -> -x; the y axis is unchanged.
return switch (axis) {
case 0 -> new AxisRotation(2, false); // x -> z
case 2 -> new AxisRotation(0, true); // z -> -x
default -> new AxisRotation(axis, false);
};
}
private AxisRotation rotateAxisX(int axis) {
// X rotation maps y<->z; the x axis is unchanged.
return switch (axis) {
case 1 -> new AxisRotation(2, true); // y -> z
case 2 -> new AxisRotation(1, false); // z -> y
default -> new AxisRotation(axis, false);
};
}
} }
@@ -1,15 +1,13 @@
package eu.mhsl.minecraft.pixelpics.assets; package eu.mhsl.minecraft.pixelpics.assets;
import com.google.gson.JsonElement;
import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto; import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
/** /** Loads block models and flattens their parent chains into {@link FlatModel}s, cached per model id. */
* Loads block models and flattens their parent chains into {@link FlatModel}s. Results are cached by
* model id.
*/
public final class ModelResolver { public final class ModelResolver {
private static final int MAX_DEPTH = 16; private static final int MAX_DEPTH = 16;
@@ -22,15 +20,14 @@ public final class ModelResolver {
} }
public FlatModel resolve(ResourceLocation modelId) { public FlatModel resolve(ResourceLocation modelId) {
FlatModel cached = cache.get(modelId); return resolve(modelId, 0).model();
if (cached != null) return cached;
FlatModel resolved = resolve(modelId, 0);
cache.put(modelId, resolved);
return resolved;
} }
/** A flattened model; {@code complete} = false when its parent chain was cut at {@link #MAX_DEPTH} (cycle). */
private record Resolved(FlatModel model, boolean complete) {}
/** A texture value: a plain reference, or the {@code sprite} of a 26.x texture object; null otherwise. */ /** A texture value: a plain reference, or the {@code sprite} of a 26.x texture object; null otherwise. */
public static String textureRef(com.google.gson.JsonElement v) { public static String textureRef(JsonElement v) {
if (v == null) return null; if (v == null) return null;
if (v.isJsonPrimitive()) return v.getAsString(); if (v.isJsonPrimitive()) return v.getAsString();
if (v.isJsonObject() && v.getAsJsonObject().has("sprite")) { if (v.isJsonObject() && v.getAsJsonObject().has("sprite")) {
@@ -39,30 +36,43 @@ public final class ModelResolver {
return null; return null;
} }
private FlatModel resolve(ResourceLocation modelId, int depth) { // Parents go through the cache too (shared chains like block/cube_all are parsed once). No computeIfAbsent:
// the recursion would modify the map from inside its own compute. depth only guards against parent cycles; a
// chain cut there is not cached, so a later direct lookup of a model in it does not get the cut version.
private Resolved resolve(ResourceLocation modelId, int depth) {
FlatModel cached = cache.get(modelId);
if (cached != null) return new Resolved(cached, true);
ModelFileDto dto = ModelFileDto dto =
reader.readJson(AssetPaths.model(modelId), ModelFileDto.class).orElse(null); reader.readJson(AssetPaths.model(modelId), ModelFileDto.class).orElse(null);
FlatModel resolved;
boolean complete = true;
if (dto == null) { if (dto == null) {
return new FlatModel(new HashMap<>(), null); resolved = new FlatModel(new HashMap<>(), null);
} } else {
Map<String, String> textures = new HashMap<>();
Map<String, String> textures = new HashMap<>(); List<ModelFileDto.ElementDto> elements = dto.elements;
List<ModelFileDto.ElementDto> elements = dto.elements; if (dto.parent != null && !dto.parent.startsWith("builtin/")) {
if (depth < MAX_DEPTH) {
if (dto.parent != null && depth < MAX_DEPTH && !dto.parent.startsWith("builtin/")) { Resolved parent = resolve(ResourceLocation.parse(dto.parent), depth + 1);
FlatModel parent = resolve(ResourceLocation.parse(dto.parent), depth + 1); complete = parent.complete();
textures.putAll(parent.textures()); textures.putAll(parent.model().textures());
if (elements == null || elements.isEmpty()) { if (elements == null || elements.isEmpty())
elements = parent.elements(); elements = parent.model().elements();
} else {
complete = false;
}
} }
if (dto.textures != null) {
dto.textures.forEach((key, value) -> {
String ref = textureRef(value);
if (ref != null) textures.put(key, ref);
});
}
resolved = new FlatModel(textures, elements);
} }
if (dto.textures != null) { if (!complete) return new Resolved(resolved, false);
dto.textures.forEach((key, value) -> { FlatModel raced = cache.putIfAbsent(modelId, resolved);
String ref = textureRef(value); return new Resolved(raced != null ? raced : resolved, true);
if (ref != null) textures.put(key, ref);
});
}
return new FlatModel(textures, elements);
} }
} }
@@ -1,9 +1,6 @@
package eu.mhsl.minecraft.pixelpics.assets; package eu.mhsl.minecraft.pixelpics.assets;
/** /** A namespaced asset id such as {@code minecraft:block/stone}; lower-cased, namespace defaults to minecraft. */
* 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 record ResourceLocation(String namespace, String path) {
public static final String DEFAULT_NAMESPACE = "minecraft"; public static final String DEFAULT_NAMESPACE = "minecraft";
@@ -13,9 +10,6 @@ public record ResourceLocation(String namespace, String path) {
path = path.toLowerCase(); path = path.toLowerCase();
} }
/**
* Parses a string like {@code minecraft:block/stone} or {@code block/stone}.
*/
public static ResourceLocation parse(String raw) { public static ResourceLocation parse(String raw) {
int colon = raw.indexOf(':'); int colon = raw.indexOf(':');
if (colon < 0) { if (colon < 0) {
@@ -4,22 +4,13 @@ import java.io.Closeable;
import java.util.Optional; import java.util.Optional;
/** /**
* A read-only source of Minecraft assets. Paths are pack-relative and always use {@code /} as a * A read-only source of assets by pack-relative {@code /}-separated path, e.g.
* separator, e.g. {@code assets/minecraft/blockstates/oak_fence.json}. * {@code assets/minecraft/blockstates/oak_fence.json}. Must be safe for concurrent reads (parallel renderer).
*
* <p>Implementations must be safe for concurrent {@link #read} calls, since the renderer accesses
* assets from multiple threads.
*/ */
public interface ResourcePack extends Closeable { 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); Optional<byte[]> read(String path);
/**
* Whether the given asset path exists in this pack.
*/
boolean exists(String path); boolean exists(String path);
@Override @Override
@@ -5,35 +5,60 @@ import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.logging.Logger; import java.util.logging.Logger;
import java.util.stream.Stream; import java.util.stream.Stream;
/** /**
* Builds a {@link ResourcePack} from the plugin's {@code resourcepack/} data folder. The folder may * Builds a {@link ResourcePack} from the plugin's {@code resourcepack/} folder: unpacked packs (the folder itself or
* contain an unpacked pack (a directory with {@code assets/minecraft/...}) and/or one or more * a direct sub-folder with {@code assets/minecraft}) and {@code .zip} packs anywhere below it. Priority (the first
* {@code .zip} packs. Returns an empty optional when nothing usable is found. * pack containing an asset wins) is deterministic, independent of the file system's listing order: the folder
* itself, then its sub-folders sorted by name, then the zips sorted by their path relative to the folder ('/'
* separated, plain {@code String} order: case-sensitive, digits before letters). To let a pack override another,
* give it a name that sorts first (e.g. {@code 0-custom.zip} before {@code vanilla.zip}). The packs are logged in
* that order.
*/ */
public final class ResourcePackLoader { public final class ResourcePackLoader {
/** The marker that identifies a valid pack root: {@code <root>/assets/minecraft}. */
private static final String MARKER = "assets/minecraft"; private static final String MARKER = "assets/minecraft";
private ResourcePackLoader() {} private ResourcePackLoader() {}
private static String relative(Path root, Path path) {
return root.relativize(path).toString().replace(File.separatorChar, '/');
}
/** The {@code .zip} files below {@code root} in load order (see the class doc). */
public static List<Path> zips(Path root) throws IOException {
try (Stream<Path> walk = Files.walk(root)) {
return walk.filter(Files::isRegularFile)
.filter(p -> p.getFileName().toString().toLowerCase().endsWith(".zip"))
.sorted(Comparator.comparing(p -> relative(root, p)))
.toList();
}
}
public static Optional<ResourcePack> load(File resourcePackDir, Logger logger) { public static Optional<ResourcePack> load(File resourcePackDir, Logger logger) {
return load(resourcePackDir, logger, Set.of());
}
/** Like {@link #load(File, Logger)}, skipping the zips in {@code exclude}. */
public static Optional<ResourcePack> load(File resourcePackDir, Logger logger, Set<Path> exclude) {
if (!resourcePackDir.isDirectory()) { if (!resourcePackDir.isDirectory()) {
return Optional.empty(); return Optional.empty();
} }
List<ResourcePack> packs = new ArrayList<>(); 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<>(); List<Path> dirCandidates = new ArrayList<>();
dirCandidates.add(resourcePackDir.toPath()); dirCandidates.add(resourcePackDir.toPath());
File[] children = resourcePackDir.listFiles(File::isDirectory); File[] children = resourcePackDir.listFiles(File::isDirectory);
if (children != null) { if (children != null) {
Arrays.sort(children, Comparator.comparing(File::getName));
for (File child : children) dirCandidates.add(child.toPath()); for (File child : children) dirCandidates.add(child.toPath());
} }
for (Path candidate : dirCandidates) { for (Path candidate : dirCandidates) {
@@ -43,15 +68,15 @@ public final class ResourcePackLoader {
} }
} }
// Zip packs anywhere under the resourcepack folder. try {
try (Stream<Path> walk = Files.walk(resourcePackDir.toPath())) { for (Path zip : zips(resourcePackDir.toPath())) {
List<Path> zips = walk.filter(Files::isRegularFile) if (exclude.contains(zip)) continue;
.filter(p -> p.getFileName().toString().toLowerCase().endsWith(".zip"))
.toList();
for (Path zip : zips) {
try { try {
ZipResourcePack pack = new ZipResourcePack(zip); ZipResourcePack pack = new ZipResourcePack(zip);
if (pack.exists(MARKER + "/blockstates") || pack.exists("pack.mcmeta") || hasAnyBlockstate(pack)) { // Zip entries are files only; the client jar has no pack.mcmeta, so probe two vanilla files.
if (pack.exists("pack.mcmeta")
|| pack.exists(AssetPaths.blockState("stone"))
|| pack.exists(AssetPaths.blockState("dirt"))) {
packs.add(pack); packs.add(pack);
logger.info("Loaded resource pack zip: " + zip); logger.info("Loaded resource pack zip: " + zip);
} else { } else {
@@ -69,9 +94,4 @@ public final class ResourcePackLoader {
if (packs.size() == 1) return Optional.of(packs.getFirst()); if (packs.size() == 1) return Optional.of(packs.getFirst());
return Optional.of(new CompositeResourcePack(packs)); 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"));
}
} }
@@ -1,72 +1,233 @@
package eu.mhsl.minecraft.pixelpics.assets; package eu.mhsl.minecraft.pixelpics.assets;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URI; import java.net.URI;
import java.net.URL; import java.util.Iterator;
import java.net.URLConnection; import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import javax.imageio.ImageIO; import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
/** /**
* Downloads and caches player skin textures (by URL) as ARGB pixel grids. Legacy 64x32 skins are * Player skins by profile texture URL as 64x64 ARGB grids, processed like vanilla's
* converted to the modern 64x64 layout (so the model's overlay/second-layer bones map correctly). * {@code SkinTextureDownloader.processLegacySkin} (legacy 64x32 skins converted). Downloaded off the main thread, from
* Downloads happen off the main thread (from the entity baking step) and are cached. * the baking step.
*
* <p>Profile textures of heads and NPCs are not signed, so the URL is untrusted: only Mojang's texture server is
* contacted ({@code http(s)://textures.minecraft.net/texture/…}, fetched over https without redirects), the
* download is capped at {@link #MAX_BYTES} and the PNG header must declare 64x64 or 64x32 (the only sizes vanilla
* accepts) before anything is decoded. Concurrent requests for one URL share a single download; other URLs are not
* blocked meanwhile. Failed downloads are retried after a growing backoff. The cache keeps the {@link #CAPACITY}
* most recently used skins.
*/ */
public final class SkinCache extends ImageCache<String> { public final class SkinCache {
@Override static final String HOST = "textures.minecraft.net";
static final int MAX_BYTES = 128 * 1024;
private static final int CAPACITY = 512;
private static final int TIMEOUT_MILLIS = 5000;
private static final long FIRST_BACKOFF_MILLIS = 60_000;
private static final long MAX_BACKOFF_MILLIS = 60 * 60_000;
private record Failure(long retryAt, long backoff) {}
private final Map<String, int[][]> skins = new LinkedHashMap<>(64, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, int[][]> eldest) {
return size() > CAPACITY;
}
};
private final Map<String, Failure> failures = new LinkedHashMap<>();
private final Map<String, CompletableFuture<int[][]>> downloads = new ConcurrentHashMap<>();
/** The skin at {@code url}, or empty (no/disallowed URL, download failed or backing off, invalid image). */
public Optional<int[][]> get(String url) { public Optional<int[][]> get(String url) {
if (url == null || url.isEmpty()) return Optional.empty(); String fetch = httpsUrl(url);
return super.get(url); if (fetch == null) return Optional.empty();
synchronized (this) {
int[][] cached = skins.get(fetch);
if (cached != null) return Optional.of(cached);
Failure failed = failures.get(fetch);
if (failed != null && System.currentTimeMillis() < failed.retryAt()) return Optional.empty();
}
CompletableFuture<int[][]> mine = new CompletableFuture<>();
CompletableFuture<int[][]> running = downloads.putIfAbsent(fetch, mine);
if (running != null) return Optional.ofNullable(running.join());
int[][] skin = null;
try {
synchronized (this) {
skin = skins.get(fetch); // finished between the cache check and putIfAbsent
}
if (skin == null) skin = load(fetch);
} finally {
synchronized (this) {
if (skin != null) {
skins.put(fetch, skin);
failures.remove(fetch);
} else {
Failure before = failures.get(fetch);
long backoff =
before == null ? FIRST_BACKOFF_MILLIS : Math.min(MAX_BACKOFF_MILLIS, before.backoff() * 2);
failures.put(fetch, new Failure(System.currentTimeMillis() + backoff, backoff));
pruneFailures();
}
}
downloads.remove(fetch);
mine.complete(skin);
}
return Optional.ofNullable(skin);
} }
@Override /** Drops failures whose backoff ran out, and the oldest ones beyond the capacity. */
protected int[][] load(String url) { private void pruneFailures() {
long now = System.currentTimeMillis();
Iterator<Failure> it = failures.values().iterator();
while (it.hasNext()) {
if (it.next().retryAt() <= now || failures.size() > CAPACITY) it.remove();
}
}
/**
* The https form of a texture URL on {@link #HOST} ({@code /texture/<hash>}, Mojang profiles use http), or null
* for any other URL.
*/
static String httpsUrl(String url) {
if (url == null || url.isEmpty()) return null;
try { try {
URL u = URI.create(url).toURL(); URI u = new URI(url);
URLConnection conn = u.openConnection(); String scheme = u.getScheme() == null ? "" : u.getScheme().toLowerCase(Locale.ROOT);
conn.setConnectTimeout(5000); if (!scheme.equals("http") && !scheme.equals("https")) return null;
conn.setReadTimeout(5000); if (u.getRawUserInfo() != null || u.getPort() != -1 || u.getRawQuery() != null) return null;
conn.setRequestProperty("User-Agent", "PixelPics"); if (u.getHost() == null || !u.getHost().equalsIgnoreCase(HOST)) return null;
BufferedImage img; String path = u.getRawPath();
try (InputStream in = conn.getInputStream()) { if (path == null || !path.matches("/texture/[0-9a-zA-Z]+")) return null;
img = ImageIO.read(in); return "https://" + HOST + path;
}
if (img == null) return null;
return toModern(img);
} catch (Exception e) { } catch (Exception e) {
return null; return null;
} }
} }
/** Reads the image to a 64x64 grid, converting the legacy 64x32 layout (mirrored arm/leg) if needed. */ private static int[][] load(String url) {
private int[][] toModern(BufferedImage img) { try {
int w = img.getWidth(); HttpURLConnection conn = (HttpURLConnection) URI.create(url).toURL().openConnection();
int h = img.getHeight(); try {
int[][] out = new int[64][64]; conn.setInstanceFollowRedirects(false);
for (int y = 0; y < Math.min(h, 64); y++) { conn.setConnectTimeout(TIMEOUT_MILLIS);
for (int x = 0; x < Math.min(w, 64); x++) { conn.setReadTimeout(TIMEOUT_MILLIS);
out[y][x] = img.getRGB(x, y); conn.setRequestProperty("User-Agent", "PixelPics");
if (conn.getResponseCode() / 100 != 2) return null;
if (conn.getContentLengthLong() > MAX_BYTES) return null;
byte[] data;
try (InputStream in = conn.getInputStream()) {
data = readLimited(in);
}
return data == null ? null : decode(data);
} finally {
conn.disconnect();
} }
} catch (IOException | RuntimeException e) {
return null;
} }
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. */ /** At most {@link #MAX_BYTES} bytes of {@code in}; null when there are more. */
private void copyMirrored(BufferedImage img, int[][] out, int srcX, int srcY, int dstX, int dstY) { static byte[] readLimited(InputStream in) throws IOException {
for (int y = 0; y < 12 && srcY + y < img.getHeight(); y++) { ByteArrayOutputStream out = new ByteArrayOutputStream();
for (int x = 0; x < 16 && srcX + x < img.getWidth(); x++) { byte[] buf = new byte[8192];
int px = img.getRGB(srcX + (15 - x), srcY + y); int total = 0;
int ox = dstX + x, oy = dstY + y; for (int n; (n = in.read(buf)) != -1; ) {
if (ox < 64 && oy < 64) out[oy][ox] = px; total += n;
if (total > MAX_BYTES) return null;
out.write(buf, 0, n);
}
return out.toByteArray();
}
/** Decodes a PNG skin whose header declares 64x64 or 64x32 (checked before decoding); null otherwise. */
static int[][] decode(byte[] png) throws IOException {
try (ImageInputStream in = ImageIO.createImageInputStream(new ByteArrayInputStream(png))) {
Iterator<ImageReader> readers = ImageIO.getImageReadersByFormatName("png");
if (in == null || !readers.hasNext()) return null;
ImageReader reader = readers.next();
try {
reader.setInput(in, true, true);
int w = reader.getWidth(0), h = reader.getHeight(0);
if (w != 64 || (h != 64 && h != 32)) return null; // vanilla discards other sizes
BufferedImage img = reader.read(0);
int[][] argb = new int[h][];
for (int y = 0; y < h; y++) argb[y] = img.getRGB(0, y, w, 1, null, 0, w);
return processSkin(argb);
} finally {
reader.dispose();
} }
} }
} }
/**
* SkinTextureDownloader.processLegacySkin (26.3) on a 64x64 or 64x32 ARGB grid: a legacy skin is extended to
* 64x64 with the left arm and leg made from the right ones (each face mirrored in place), its hat layer gets the
* "Notch transparency hack", and the base layers are made opaque.
*/
static int[][] processSkin(int[][] src) {
boolean legacy = src.length == 32;
int[][] img = new int[64][64];
for (int y = 0; y < src.length; y++) System.arraycopy(src[y], 0, img[y], 0, 64);
if (legacy) {
copyRect(img, 4, 16, 16, 32, 4, 4);
copyRect(img, 8, 16, 16, 32, 4, 4);
copyRect(img, 0, 20, 24, 32, 4, 12);
copyRect(img, 4, 20, 16, 32, 4, 12);
copyRect(img, 8, 20, 8, 32, 4, 12);
copyRect(img, 12, 20, 16, 32, 4, 12);
copyRect(img, 44, 16, -8, 32, 4, 4);
copyRect(img, 48, 16, -8, 32, 4, 4);
copyRect(img, 40, 20, 0, 32, 4, 12);
copyRect(img, 44, 20, -8, 32, 4, 12);
copyRect(img, 48, 20, -16, 32, 4, 12);
copyRect(img, 52, 20, -8, 32, 4, 12);
}
setNoAlpha(img, 0, 0, 32, 16);
if (legacy) notchTransparencyHack(img, 32, 0, 64, 32);
setNoAlpha(img, 0, 16, 64, 32);
setNoAlpha(img, 16, 48, 48, 64);
return img;
}
/** NativeImage.copyRect(startX, startY, offsetX, offsetY, sizeX, sizeY, swapX = true, swapY = false). */
private static void copyRect(int[][] img, int sx, int sy, int offX, int offY, int w, int h) {
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
img[sy + offY + y][sx + offX + (w - 1 - x)] = img[sy + y][sx + x];
}
}
}
/** Clears the alpha of the whole region only if every texel in it has alpha >= 128 (doNotchTransparencyHack). */
private static void notchTransparencyHack(int[][] img, int x0, int y0, int x1, int y1) {
for (int y = y0; y < y1; y++) {
for (int x = x0; x < x1; x++) {
if ((img[y][x] >>> 24) < 128) return;
}
}
for (int y = y0; y < y1; y++) {
for (int x = x0; x < x1; x++) img[y][x] &= 0xFFFFFF;
}
}
private static void setNoAlpha(int[][] img, int x0, int y0, int x1, int y1) {
for (int y = y0; y < y1; y++) {
for (int x = x0; x < x1; x++) img[y][x] |= 0xFF000000;
}
}
} }
@@ -1,26 +1,41 @@
package eu.mhsl.minecraft.pixelpics.assets; package eu.mhsl.minecraft.pixelpics.assets;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import javax.imageio.ImageIO; import javax.imageio.ImageIO;
/** /**
* Loads and caches block textures as raw ARGB pixel grids. Textures are stored unflipped (vanilla * Pack textures as ARGB grids by id (unflipped; the UV origin is top-left like vanilla). Animated textures (a
* UV origin is top-left); all orientation is handled by the UV math in the intersector. * {@code .mcmeta} with an {@code animation} section) keep the frame vanilla shows first.
*
* <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> { public final class TextureCache extends ImageCache<ResourceLocation> {
/**
* Vanilla {@code MissingTextureAtlasSprite} (16x16): magenta #F800F8 / black quarters, black top-left. Shared:
* never mutate.
*/
public static final int[][] MISSINGNO = missingno();
private final ResourcePack pack; private final ResourcePack pack;
public TextureCache(ResourcePack pack) { public TextureCache(ResourcePack pack) {
this.pack = pack; this.pack = pack;
} }
private static int[][] missingno() {
int[][] t = new int[16][16];
for (int y = 0; y < 16; y++) {
for (int x = 0; x < 16; x++) t[y][x] = (y < 8 ^ x < 8) ? 0xFFF800F8 : 0xFF000000;
}
return t;
}
@Override @Override
protected int[][] load(ResourceLocation id) { protected int[][] load(ResourceLocation id) {
var bytes = pack.read(AssetPaths.texture(id)); var bytes = pack.read(AssetPaths.texture(id));
@@ -35,17 +50,77 @@ public final class TextureCache extends ImageCache<ResourceLocation> {
int width = img.getWidth(); int width = img.getWidth();
int height = img.getHeight(); int height = img.getHeight();
// Reduce animated strips to the first frame — but only a REAL animation (has a .mcmeta); a tall JsonObject animation = animation(id);
// sprite without one (e.g. a 64×128 entity texture) is a full texture, not a frame strip. if (animation == null) return pixels(img, 0, 0, width, height);
boolean animated = height > width && height % width == 0 && pack.exists(AssetPaths.textureMeta(id)); try {
int frameHeight = animated ? width : height; return firstFrame(img, animation, width, height);
} catch (RuntimeException e) {
int[][] pixels = new int[frameHeight][width]; return null; // unreadable animation metadata: vanilla uses the missing texture
for (int y = 0; y < frameHeight; y++) {
for (int x = 0; x < width; x++) {
pixels[y][x] = img.getRGB(x, y);
}
} }
return pixels; }
/** The {@code animation} section of the texture's {@code .mcmeta}, or null (none, or not parseable). */
private JsonObject animation(ResourceLocation id) {
Optional<byte[]> meta = pack.read(AssetPaths.textureMeta(id));
if (meta.isEmpty()) return null;
try {
JsonElement root = JsonParser.parseString(new String(meta.get(), StandardCharsets.UTF_8));
if (!root.isJsonObject()) return null;
JsonElement anim = root.getAsJsonObject().get("animation");
return anim != null && anim.isJsonObject() ? anim.getAsJsonObject() : null;
} catch (RuntimeException e) {
return null;
}
}
/**
* The frame vanilla's animation starts with (26.3 {@code SpriteResourceLoader} + {@code SpriteContents}): frame
* size from {@code width}/{@code height} (each defaulting to the image's; both absent = the smaller image side),
* frames numbered row by row; the first valid entry of {@code frames} (index in range, time > 0), else frame 0
* (also with at most one valid entry: then vanilla does not animate and the atlas keeps the top-left frame).
* An image that is not a multiple of the frame size is the missing texture in vanilla (null here).
*/
private static int[][] firstFrame(BufferedImage img, JsonObject anim, int width, int height) {
Integer fw = positiveInt(anim, "width"), fh = positiveInt(anim, "height");
int frameW, frameH;
if (fw != null) {
frameW = fw;
frameH = fh != null ? fh : height;
} else if (fh != null) {
frameW = width;
frameH = fh;
} else {
frameW = frameH = Math.min(width, height);
}
if (width % frameW != 0 || height % frameH != 0) return null;
int perRow = width / frameW;
int total = perRow * (height / frameH);
int defaultTime = anim.has("frametime") ? anim.get("frametime").getAsInt() : 1;
int index = 0, valid = 0;
JsonElement frames = anim.get("frames");
if (frames != null && frames.isJsonArray()) {
int first = 0;
for (JsonElement f : (JsonArray) frames) {
int i, time = defaultTime;
if (f.isJsonObject()) {
JsonObject o = f.getAsJsonObject();
i = o.get("index").getAsInt();
if (o.has("time")) time = o.get("time").getAsInt();
} else {
i = f.getAsInt();
}
if (time > 0 && i >= 0 && i < total && valid++ == 0) first = i;
}
// One valid frame or none: no animation, the atlas keeps the top-left frame.
if (valid > 1) index = first;
}
return pixels(img, index % perRow * frameW, index / perRow * frameH, frameW, frameH);
}
private static Integer positiveInt(JsonObject o, String key) {
if (!o.has(key)) return null;
int v = o.get(key).getAsInt();
if (v <= 0) throw new IllegalArgumentException(key + " must be positive");
return v;
} }
} }
@@ -1,7 +1,4 @@
package eu.mhsl.minecraft.pixelpics.assets; package eu.mhsl.minecraft.pixelpics.assets;
/** /** A blockstate variant: model, {@code x}/{@code y} rotation in degrees (multiples of 90) and {@code uvlock}. */
* 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) {} public record Variant(ResourceLocation model, int x, int y, boolean uvlock) {}
@@ -9,12 +9,7 @@ import java.util.Optional;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipFile; import java.util.zip.ZipFile;
/** /** A {@link ResourcePack} backed by a {@code .zip}; {@link ZipFile} allows concurrent reads. */
* 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 { public final class ZipResourcePack implements ResourcePack {
private final ZipFile zipFile; private final ZipFile zipFile;
@@ -4,10 +4,7 @@ import com.google.gson.JsonElement;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
/** /** Gson binding of a block model JSON; coordinates and UVs in 0..16. */
* Gson-bound representation of a vanilla block model JSON
* ({@code assets/minecraft/models/block/*.json}).
*/
public class ModelFileDto { public class ModelFileDto {
public String parent; public String parent;
// Values are texture refs ("block/stone", "#side") or, since 26.x, objects {"sprite": …, "force_translucent": …}. // Values are texture refs ("block/stone", "#side") or, since 26.x, objects {"sprite": …, "force_translucent": …}.
@@ -15,24 +12,26 @@ public class ModelFileDto {
public List<ElementDto> elements; public List<ElementDto> elements;
public static class ElementDto { public static class ElementDto {
public double[] from; // 0..16 public double[] from;
public double[] to; // 0..16 public double[] to;
public RotationDto rotation; // optional public RotationDto rotation;
public Map<String, FaceDto> faces; // keys: down/up/north/south/west/east public Map<String, FaceDto> faces; // keyed by direction name
} }
public static class FaceDto { public static class FaceDto {
public double[] uv; // optional, 0..16 (x1,y1,x2,y2) public double[] uv; // u1, v1, u2, v2; default from the element extents
public String texture; // e.g. "#side" or "minecraft:block/oak_planks" public String texture; // e.g. "#side" or "minecraft:block/oak_planks"
public Integer tintindex; public Integer tintindex;
public String cullface; // ignored by the renderer
public int rotation; // 0/90/180/270 public int rotation; // 0/90/180/270
} }
public static class RotationDto { public static class RotationDto {
public double[] origin; // 0..16 public double[] origin;
public String axis; // "x" | "y" | "z" public String axis;
public double angle; // -45..45 in 22.5 steps public double angle; // degrees
// Since 26.x alternatively Euler angles in degrees (CuboidRotation.EulerXYZRotation, Rz·Ry·Rx), e.g. the
// chains of template_hanging_sign_rot_3.
public Double x, y, z;
public boolean rescale; public boolean rescale;
} }
} }
@@ -2,24 +2,19 @@ package eu.mhsl.minecraft.pixelpics.assets.font;
import java.util.Map; import java.util.Map;
/** /** Glyphs and advances of the pack's bitmap font (see {@link FontLoader}); immutable. */
* 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 { public final class BitmapFont {
private final Map<Integer, Glyph> glyphs; private final Map<Integer, Glyph> glyphs;
private final Map<Integer, Integer> spaceAdvances; private final Map<Integer, Integer> spaceAdvances;
private final int maxAscent; private final int maxAscent;
private final int maxDescent; private final int lineHeight;
public BitmapFont(Map<Integer, Glyph> glyphs, Map<Integer, Integer> spaceAdvances, int maxAscent, int maxDescent) { public BitmapFont(Map<Integer, Glyph> glyphs, Map<Integer, Integer> spaceAdvances, int maxAscent, int maxDescent) {
this.glyphs = glyphs; this.glyphs = glyphs;
this.spaceAdvances = spaceAdvances; this.spaceAdvances = spaceAdvances;
this.maxAscent = maxAscent; this.maxAscent = maxAscent;
this.maxDescent = maxDescent; this.lineHeight = maxAscent + maxDescent;
} }
/** The glyph for a codepoint, or null when no bitmap provider supplies it (e.g. CJK via unihex). */ /** The glyph for a codepoint, or null when no bitmap provider supplies it (e.g. CJK via unihex). */
@@ -35,19 +30,14 @@ public final class BitmapFont {
return g != null ? g.advance() : 0; return g != null ? g.advance() : 0;
} }
/** Largest baseline offset across all glyphs (font px) — the common baseline for a rendered line. */ /** Largest ascent of all glyphs (font px): the common baseline of a line. */
public int maxAscent() { public int maxAscent() {
return maxAscent; return maxAscent;
} }
/** Largest below-baseline extent across all glyphs (font px). */ /** Largest ascent + largest descent (font px). */
public int maxDescent() {
return maxDescent;
}
/** Total vertical extent of one line (ascent + descent, font px). */
public int lineHeight() { public int lineHeight() {
return maxAscent + maxDescent; return lineHeight;
} }
public boolean isEmpty() { public boolean isEmpty() {
@@ -8,6 +8,8 @@ import eu.mhsl.minecraft.pixelpics.assets.AssetPaths;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation; import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import eu.mhsl.minecraft.pixelpics.assets.ResourcePack; import eu.mhsl.minecraft.pixelpics.assets.ResourcePack;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache; import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
@@ -17,19 +19,15 @@ import java.util.Set;
import java.util.logging.Logger; import java.util.logging.Logger;
/** /**
* Builds a {@link BitmapFont} from a resource pack's {@code font/default.json}. Resolves {@code reference} * Builds a {@link BitmapFont} from the pack's {@code font/default.json}: {@code reference}, {@code bitmap} and
* providers recursively, parses {@code bitmap} providers into per-codepoint {@link Glyph}s (cell size and * {@code space} providers; {@code unihex}/{@code legacy_unicode}/{@code ttf} are skipped (CJK etc. render as
* per-glyph pixel width derived from the PNG), and {@code space} providers into advance overrides. * gaps). Earlier providers win, like vanilla.
* {@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 { public final class FontLoader {
private FontLoader() {} private FontLoader() {}
/** Loads {@code minecraft:default}. Returns an empty font (no glyphs) when the pack has no font assets. */ /** {@code minecraft:default}; a font without glyphs when the pack has no font assets. */
public static BitmapFont load(ResourcePack pack, TextureCache textures, Logger log) { public static BitmapFont load(ResourcePack pack, TextureCache textures, Logger log) {
Builder b = new Builder(pack, textures, log); Builder b = new Builder(pack, textures, log);
b.loadFont(ResourceLocation.parse("minecraft:default"), new HashSet<>()); b.loadFont(ResourceLocation.parse("minecraft:default"), new HashSet<>());
@@ -57,10 +55,9 @@ public final class FontLoader {
void loadFont(ResourceLocation id, Set<String> visited) { void loadFont(ResourceLocation id, Set<String> visited) {
String path = AssetPaths.font(id); String path = AssetPaths.font(id);
if (!visited.add(path)) return; // cycle guard if (!visited.add(path)) return;
Optional<byte[]> bytes = pack.read(path); Optional<byte[]> bytes = pack.read(path);
// Packs built from just the textures/models lack font/*.json — use the bundled vanilla font // Packs without font/*.json: the bundled vanilla definitions (glyph sheets still come from the pack).
// definitions (tiny JSON; the glyph sheets still come from the pack's textures/font/).
if (bytes.isEmpty()) bytes = bundled(path); if (bytes.isEmpty()) bytes = bundled(path);
if (bytes.isEmpty()) return; if (bytes.isEmpty()) return;
JsonObject root; JsonObject root;
@@ -68,7 +65,7 @@ public final class FontLoader {
root = JsonParser.parseString(new String(bytes.get(), StandardCharsets.UTF_8)) root = JsonParser.parseString(new String(bytes.get(), StandardCharsets.UTF_8))
.getAsJsonObject(); .getAsJsonObject();
} catch (Exception e) { } catch (Exception e) {
if (log != null) log.warning("PixelPics: failed to parse font " + path + ": " + e.getMessage()); log.warning("PixelPics: failed to parse font " + path + ": " + e.getMessage());
return; return;
} }
JsonArray providers = root.getAsJsonArray("providers"); JsonArray providers = root.getAsJsonArray("providers");
@@ -80,11 +77,11 @@ public final class FontLoader {
} }
private Optional<byte[]> bundled(String path) { private Optional<byte[]> bundled(String path) {
try (java.io.InputStream in = FontLoader.class.getResourceAsStream("/fallback/" + path)) { try (InputStream in = FontLoader.class.getResourceAsStream("/fallback/" + path)) {
if (in == null) return Optional.empty(); if (in == null) return Optional.empty();
if (log != null) log.info("PixelPics: resource pack has no " + path + ", using the bundled copy."); log.info("PixelPics: resource pack has no " + path + ", using the bundled copy.");
return Optional.of(in.readAllBytes()); return Optional.of(in.readAllBytes());
} catch (java.io.IOException e) { } catch (IOException e) {
return Optional.empty(); return Optional.empty();
} }
} }
@@ -97,9 +94,7 @@ public final class FontLoader {
} }
case "space" -> space(p); case "space" -> space(p);
case "bitmap" -> bitmap(p); case "bitmap" -> bitmap(p);
default -> { default -> {}
/* unihex, legacy_unicode, ttf, … — out of scope */
}
} }
} }
@@ -123,7 +118,7 @@ public final class FontLoader {
if (file.endsWith(".png")) file = file.substring(0, file.length() - 4); if (file.endsWith(".png")) file = file.substring(0, file.length() - 4);
int[][] tex = textures.get(ResourceLocation.parse(file)).orElse(null); int[][] tex = textures.get(ResourceLocation.parse(file)).orElse(null);
if (tex == null || tex.length == 0) { if (tex == null || tex.length == 0) {
if (log != null) log.warning("PixelPics: font bitmap missing: " + file); log.warning("PixelPics: font bitmap missing: " + file);
return; return;
} }
int imgH = tex.length; int imgH = tex.length;
@@ -132,9 +127,8 @@ public final class FontLoader {
JsonArray rows = p.getAsJsonArray("chars"); JsonArray rows = p.getAsJsonArray("chars");
int nRows = rows.size(); int nRows = rows.size();
if (nRows == 0) return; if (nRows == 0) return;
int nCols = rows.get(0) String firstRow = rows.get(0).getAsString();
.getAsString() int nCols = firstRow.codePointCount(0, firstRow.length());
.codePointCount(0, rows.get(0).getAsString().length());
if (nCols == 0) return; if (nCols == 0) return;
int cellW = imgW / nCols; int cellW = imgW / nCols;
int cellH = imgH / nRows; int cellH = imgH / nRows;
@@ -153,7 +147,7 @@ public final class FontLoader {
int srcX = thisCol * cellW; int srcX = thisCol * cellW;
int srcY = r * cellH; int srcY = r * cellH;
int glyphPx = rightmostOpaqueColumn(tex, srcX, srcY, cellW, cellH) + 1; int glyphPx = rightmostOpaqueColumn(tex, srcX, srcY, cellW, cellH) + 1;
if (glyphPx <= 0) continue; // blank glyph (handled by space provider) if (glyphPx <= 0) continue; // blank: spaces come from the space provider
int advance = (int) (glyphPx * scale + 0.5) + 1; int advance = (int) (glyphPx * scale + 0.5) + 1;
glyphs.put(cp, new Glyph(tex, srcX, srcY, cellW, cellH, glyphPx, height, ascent, advance)); glyphs.put(cp, new Glyph(tex, srcX, srcY, cellW, cellH, glyphPx, height, ascent, advance));
maxAscent = Math.max(maxAscent, ascent); maxAscent = Math.max(maxAscent, ascent);
@@ -162,13 +156,13 @@ public final class FontLoader {
} }
} }
/** Rightmost column index (0-based, relative to the cell) containing an opaque pixel, or -1 if blank. */ /** Rightmost cell column with a non-transparent pixel, -1 when blank. */
private static int rightmostOpaqueColumn(int[][] tex, int srcX, int srcY, int cellW, int cellH) { private static int rightmostOpaqueColumn(int[][] tex, int srcX, int srcY, int cellW, int cellH) {
int last = -1; int last = -1;
for (int x = 0; x < cellW; x++) { for (int x = 0; x < cellW; x++) {
for (int y = 0; y < cellH; y++) { for (int y = 0; y < cellH; y++) {
int argb = tex[srcY + y][srcX + x]; int argb = tex[srcY + y][srcX + x];
if (((argb >>> 24) & 0xFF) != 0) { if (argb >>> 24 != 0) {
last = x; last = x;
break; break;
} }
@@ -1,19 +1,9 @@
package eu.mhsl.minecraft.pixelpics.assets.font; package eu.mhsl.minecraft.pixelpics.assets.font;
/** /**
* A single bitmap-font glyph: a rectangular cell {@code [srcX, srcX+cellW) × [srcY, srcY+cellH)} inside * A bitmap-font glyph: its cell in the provider sheet {@code tex}, the drawn width {@code glyphPx} (up to the
* its provider PNG ({@code tex}, ARGB top-left), the number of opaque pixel columns to actually draw * rightmost non-transparent column), and {@code height}, {@code ascent} and {@code advance} in font px. Providers
* ({@code glyphPx}, measured to the rightmost non-transparent column), the rendered {@code height} and * differ in cell size and ascent (ascii 8/7, accented 12/10), so lines align glyphs on a common baseline.
* {@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( public record Glyph(
int[][] tex, int srcX, int srcY, int cellW, int cellH, int glyphPx, int height, int ascent, int advance) { 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;
}
}
@@ -1,47 +0,0 @@
package eu.mhsl.minecraft.pixelpics.assets.model;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
/**
* Computes the average opaque color of a texture, used as the 100% coverage fallback.
*/
public final class AverageColor {
private AverageColor() {}
/** Average ARGB over pixels with alpha &gt; 16; returns opaque gray when fully transparent/empty. */
public static int of(int[][] texture) {
long r = 0, g = 0, b = 0;
int count = 0;
for (int[] row : texture) {
for (int argb : row) {
if (ColorUtil.alpha(argb) <= 16) continue;
r += ColorUtil.red(argb);
g += ColorUtil.green(argb);
b += ColorUtil.blue(argb);
count++;
}
}
if (count == 0) return 0xFF7F7F7F;
return ColorUtil.argb(0xFF, (int) (r / count), (int) (g / count), (int) (b / count));
}
/** Mutable accumulator that averages a set of already-opaque ARGB colors (e.g. per-face/per-variant). */
public static final class Accumulator {
private long r, g, b;
private int count;
public void add(int argb) {
r += ColorUtil.red(argb);
g += ColorUtil.green(argb);
b += ColorUtil.blue(argb);
count++;
}
/** Opaque average of the added colors, or {@code fallback} if none were added. */
public int average(int fallback) {
if (count == 0) return fallback;
return ColorUtil.argb(0xFF, (int) (r / count), (int) (g / count), (int) (b / count));
}
}
}
@@ -1,9 +1,6 @@
package eu.mhsl.minecraft.pixelpics.assets.model; package eu.mhsl.minecraft.pixelpics.assets.model;
/** /** The six block face directions with unit normals and the blockstate 90° rotations. */
* 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 { public enum Direction {
DOWN(0, -1, 0), DOWN(0, -1, 0),
UP(0, 1, 0), UP(0, 1, 0),
@@ -12,11 +9,34 @@ public enum Direction {
WEST(-1, 0, 0), WEST(-1, 0, 0),
EAST(1, 0, 0); EAST(1, 0, 0);
public final int nx, ny, nz; public final int nx, ny, nz;
/** The axis of the normal: 0 = x, 1 = y, 2 = z. */
public final int axis;
Direction(int nx, int ny, int nz) { Direction(int nx, int ny, int nz) {
this.nx = nx; this.nx = nx;
this.ny = ny; this.ny = ny;
this.nz = nz; this.nz = nz;
this.axis = nx != 0 ? 0 : ny != 0 ? 1 : 2;
}
/** The face of a box on {@code axis}: the low-coordinate one (WEST/DOWN/NORTH) when {@code negative}. */
public static Direction of(int axis, boolean negative) {
return switch (axis) {
case 0 -> negative ? WEST : EAST;
case 1 -> negative ? DOWN : UP;
default -> negative ? NORTH : SOUTH;
};
}
public Direction opposite() {
return switch (this) {
case DOWN -> UP;
case UP -> DOWN;
case NORTH -> SOUTH;
case SOUTH -> NORTH;
case WEST -> EAST;
case EAST -> WEST;
};
} }
public static Direction fromName(String name) { public static Direction fromName(String name) {
@@ -31,9 +51,9 @@ public enum Direction {
}; };
} }
/** Rotate this direction by {@code steps * 90} degrees around the Y axis (clockwise from above). */ /** Rotated by {@code steps}·90° about Y, clockwise seen from above (vanilla blockstate {@code y}). */
public Direction rotateY(int steps) { public Direction rotateY(int steps) {
steps = ((steps % 4) + 4) % 4; steps = Math.floorMod(steps, 4);
Direction d = this; Direction d = this;
for (int i = 0; i < steps; i++) { for (int i = 0; i < steps; i++) {
d = switch (d) { d = switch (d) {
@@ -41,15 +61,14 @@ public enum Direction {
case EAST -> SOUTH; case EAST -> SOUTH;
case SOUTH -> WEST; case SOUTH -> WEST;
case WEST -> NORTH; case WEST -> NORTH;
// up/down unchanged
default -> d;}; default -> d;};
} }
return d; return d;
} }
/** Rotate this direction by {@code steps * 90} degrees around the X axis. */ /** Rotated by {@code steps}·90° about X (vanilla blockstate {@code x}). */
public Direction rotateX(int steps) { public Direction rotateX(int steps) {
steps = ((steps % 4) + 4) % 4; steps = Math.floorMod(steps, 4);
Direction d = this; Direction d = this;
for (int i = 0; i < steps; i++) { for (int i = 0; i < steps; i++) {
d = switch (d) { d = switch (d) {
@@ -57,7 +76,6 @@ public enum Direction {
case NORTH -> DOWN; case NORTH -> DOWN;
case DOWN -> SOUTH; case DOWN -> SOUTH;
case SOUTH -> UP; case SOUTH -> UP;
// east/west unchanged
default -> d;}; default -> d;};
} }
return d; return d;
@@ -1,40 +1,43 @@
package eu.mhsl.minecraft.pixelpics.assets.model; 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 * A box of a baked block model in block units (0..1 inside the cell); faces indexed by {@link Direction#ordinal()},
* indexed by {@link Direction#ordinal()} and may be {@code null} when absent. * null when absent. {@code from > to} on an axis marks an inverted element (inward-facing faces, like vanilla).
*
* <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 class Element {
public final double[] from; // length 3, 0..1 public final double[] from;
public final double[] to; // length 3, 0..1 public final double[] to;
public final Face[] faces; // length 6, indexed by Direction.ordinal() public final Face[] faces;
// Element rotation (0..1 origin), null/zero when axis-aligned. // Own element rotation about rotOrigin: single axis (0=x, 1=y, 2=z) by rotAngleRad, or AXIS_MATRIX for a
public final double[] rotOrigin; // length 3, 0..1, may be null // general (Euler x/y/z) rotation in rotMatrix; rotAxis -1 and rotOrigin null when unrotated.
public final int rotAxis; // 0=x,1=y,2=z, -1 when none public final double[] rotOrigin;
public final double rotAngleRad; // radians public final int rotAxis;
public final double rotAngleRad;
public final boolean rescale; public final boolean rescale;
// Blockstate rotation WITHOUT uvlock: the model-space element and the 90° steps (x first, then y) that public static final int AXIS_MATRIX = 3;
// turn it into this world-space one. Textures stay bound to the model, so intersectors hit the
// unrotated element with the inverse-rotated ray. Null/0 when unrotated or uvlocked. // Precomputed for the intersector's hot path: cos/sin of +angle (local -> block) and -angle (block -> local).
public final double rotCos, rotSin, invRotCos, invRotSin;
// Row-major 3x3 local -> block and its transpose, only for AXIS_MATRIX.
public final double[] rotMatrix, invRotMatrix;
// Vanilla rescale (CuboidRotation.computeRescale): scale per LOCAL axis by 1 / max|component of R·axis| before
// rotating, so a tilted cross plane still spans the block. Stored as reciprocals (block -> local); null without.
public final double[] invScale;
// Blockstate rotation: the model-space element and the 90° steps (x first, then y) that turn it into this
// world-space one; null/0 when unrotated. Intersectors hit the unrotated element with the inverse-rotated ray.
public final Element unrotated; public final Element unrotated;
public final int xSteps; public final int xSteps;
public final int ySteps; public final int ySteps;
public Element( /** An unrotated box. */
double[] from, public Element(double[] from, double[] to, Face[] faces) {
double[] to, this(from, to, faces, null, -1, 0, null, false, null, 0, 0);
Face[] faces,
double[] rotOrigin,
int rotAxis,
double rotAngleRad,
boolean rescale) {
this(from, to, faces, rotOrigin, rotAxis, rotAngleRad, rescale, null, 0, 0);
} }
public Element( public Element(
@@ -44,6 +47,7 @@ public final class Element {
double[] rotOrigin, double[] rotOrigin,
int rotAxis, int rotAxis,
double rotAngleRad, double rotAngleRad,
double[] rotMatrix,
boolean rescale, boolean rescale,
Element unrotated, Element unrotated,
int xSteps, int xSteps,
@@ -58,10 +62,61 @@ public final class Element {
this.rotAxis = rotAxis; this.rotAxis = rotAxis;
this.rotAngleRad = rotAngleRad; this.rotAngleRad = rotAngleRad;
this.rescale = rescale; this.rescale = rescale;
this.rotCos = Math.cos(rotAngleRad);
this.rotSin = Math.sin(rotAngleRad);
this.invRotCos = Math.cos(-rotAngleRad);
this.invRotSin = Math.sin(-rotAngleRad);
this.rotMatrix = rotAxis == AXIS_MATRIX ? rotMatrix : null;
this.invRotMatrix = this.rotMatrix == null ? null : transpose(this.rotMatrix);
this.invScale = rescale && !isAxisAligned() ? inverseRescale(linearRotation()) : null;
}
/** This element with other faces (rotation and blockstate rotation kept). */
public Element withFaces(Face[] faces) {
return new Element(
from, to, faces, rotOrigin, rotAxis, rotAngleRad, rotMatrix, rescale, unrotated, xSteps, ySteps);
}
/** The rotation as a row-major 3x3 (local -> block), identity when axis-aligned. */
public double[] linearRotation() {
if (rotMatrix != null) return rotMatrix.clone();
double c = rotCos, s = rotSin;
return switch (isAxisAligned() ? -1 : rotAxis) {
case 0 -> new double[] {1, 0, 0, 0, c, -s, 0, s, c};
case 1 -> new double[] {c, 0, s, 0, 1, 0, -s, 0, c};
case 2 -> new double[] {c, -s, 0, s, c, 0, 0, 0, 1};
default -> new double[] {1, 0, 0, 0, 1, 0, 0, 0, 1};
};
}
/** Reciprocal rescale per local axis: max|component of R·axis| (column a of R). */
private static double[] inverseRescale(double[] r) {
double[] inv = new double[3];
for (int a = 0; a < 3; a++) {
inv[a] = Math.max(Math.max(Math.abs(r[a]), Math.abs(r[3 + a])), Math.abs(r[6 + a]));
}
return inv;
}
/** The rescale factors per local axis (local -> block), {1,1,1} without rescale. */
public double[] rescaleFactors() {
if (invScale == null) return new double[] {1, 1, 1};
return new double[] {1 / invScale[0], 1 / invScale[1], 1 / invScale[2]};
}
private static double[] transpose(double[] m) {
return new double[] {m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8]};
} }
/** Model space -> world: vanilla blockstate rotation (x steps, then y steps; clockwise about the centre). */ /** Model space -> world: vanilla blockstate rotation (x steps, then y steps; clockwise about the centre). */
public static double[] rotate(double x, double y, double z, int xSteps, int ySteps, boolean point) { public static double[] rotate(double x, double y, double z, int xSteps, int ySteps, boolean point) {
double[] out = new double[3];
rotate(out, x, y, z, xSteps, ySteps, point);
return out;
}
/** {@link #rotate} into {@code out} (allocation-free). */
public static void rotate(double[] out, double x, double y, double z, int xSteps, int ySteps, boolean point) {
double c = point ? 0.5 : 0; double c = point ? 0.5 : 0;
double dx = x - c, dy = y - c, dz = z - c; double dx = x - c, dy = y - c, dz = z - c;
for (int i = 0; i < xSteps; i++) { for (int i = 0; i < xSteps; i++) {
@@ -74,16 +129,25 @@ public final class Element {
dx = nx; dx = nx;
dz = nz; dz = nz;
} }
return new double[] {dx + c, dy + c, dz + c}; out[0] = dx + c;
out[1] = dy + c;
out[2] = dz + c;
} }
/** World -> model space: the inverse of {@link #rotate}. */ /** World -> model space: the inverse of {@link #rotate}. */
public static double[] unrotate(double x, double y, double z, int xSteps, int ySteps, boolean point) { public static double[] unrotate(double x, double y, double z, int xSteps, int ySteps, boolean point) {
double[] p = rotate(x, y, z, 0, (4 - ySteps) % 4, point); double[] out = new double[3];
return rotate(p[0], p[1], p[2], (4 - xSteps) % 4, 0, point); unrotate(out, x, y, z, xSteps, ySteps, point);
return out;
}
/** {@link #unrotate} into {@code out}. */
public static void unrotate(double[] out, double x, double y, double z, int xSteps, int ySteps, boolean point) {
rotate(out, x, y, z, 0, (4 - ySteps) % 4, point);
rotate(out, out[0], out[1], out[2], (4 - xSteps) % 4, 0, point);
} }
public boolean isAxisAligned() { public boolean isAxisAligned() {
return rotAxis < 0 || rotAngleRad == 0.0; return rotAxis < 0 || (rotAxis != AXIS_MATRIX && rotAngleRad == 0.0);
} }
} }
@@ -1,34 +1,47 @@
package eu.mhsl.minecraft.pixelpics.assets.model; package eu.mhsl.minecraft.pixelpics.assets.model;
/** /** A textured face of an {@link Element}; UVs in 0..1 with a top-left origin (v down), like vanilla. */
* 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 class Face {
public final int[][] texture; // [y][x] ARGB, top-left origin public final int[][] texture; // [y][x] ARGB, top-left origin
public final double u1, v1, u2, v2; public final double u1, v1, u2, v2;
public final int rotation; // 0/90/180/270, applied to the sampled UV public final int rotation; // 0/90/180/270 (normalised), applied to the sampled UV
public final int tintIndex; // -1 = no tint public final int tintIndex; // -1 = no tint
// uvlock: affine applied to the final texture (u, v) as {a, b, c, d, e, f} with u' = a + b*u + c*v and
// v' = d + e*u + f*v (one of the 8 symmetries of the unit square), null when not locked.
public final double[] uvLock;
public Face(int[][] texture, double u1, double v1, double u2, double v2, int rotation, int tintIndex) { public Face(int[][] texture, double u1, double v1, double u2, double v2, int rotation, int tintIndex) {
this(texture, u1, v1, u2, v2, rotation, tintIndex, null);
}
public Face(
int[][] texture, double u1, double v1, double u2, double v2, int rotation, int tintIndex, double[] uvLock) {
this.uvLock = uvLock;
this.texture = texture; this.texture = texture;
this.u1 = u1; this.u1 = u1;
this.v1 = v1; this.v1 = v1;
this.u2 = u2; this.u2 = u2;
this.v2 = v2; this.v2 = v2;
this.rotation = rotation; this.rotation = Math.floorMod(rotation, 360);
this.tintIndex = tintIndex; this.tintIndex = tintIndex;
} }
/** /** This face with another texture and tint index (UVs, rotation and uvlock kept). */
* Samples the ARGB pixel for a position within the face, where {@code (s, t)} are in 0..1 across public Face withTexture(int[][] texture, int tintIndex) {
* the face's two in-plane axes (s = horizontal, t = vertical, top-left origin). return new Face(texture, u1, v1, u2, v2, rotation, tintIndex, uvLock);
*/ }
/** This face with its final texture coordinates transformed by a uvlock affine (see {@link #uvLock}). */
public Face withUvLock(double[] lock) {
return new Face(texture, u1, v1, u2, v2, rotation, tintIndex, lock);
}
/** The ARGB texel at {@code (s, t)} in 0..1 across the face (s horizontal, t vertical, top-left origin). */
public int sample(double s, double t) { public int sample(double s, double t) {
// Apply face rotation by rotating the (s,t) lookup.
double rs = s, rt = t; double rs = s, rt = t;
switch (((rotation % 360) + 360) % 360) { switch (rotation) {
case 90 -> { case 90 -> {
rs = t; rs = t;
rt = 1.0 - s; rt = 1.0 - s;
@@ -48,6 +61,11 @@ public final class Face {
double u = u1 + (u2 - u1) * rs; double u = u1 + (u2 - u1) * rs;
double v = v1 + (v2 - v1) * rt; double v = v1 + (v2 - v1) * rt;
if (uvLock != null) {
double lu = uvLock[0] + uvLock[1] * u + uvLock[2] * v;
v = uvLock[3] + uvLock[4] * u + uvLock[5] * v;
u = lu;
}
int h = texture.length; int h = texture.length;
if (h == 0) return 0; if (h == 0) return 0;
@@ -3,34 +3,18 @@ package eu.mhsl.minecraft.pixelpics.assets.model;
import java.util.List; import java.util.List;
/** /**
* A fully baked, intersectable block model: a list of {@link Element} boxes plus rendering hints. * A baked block model. {@code hasGeometry} is false for empty models and for the solid fallback cube of blocks
* * without model elements. Deliberately not a record: callers key caches on identity (one instance per BlockData).
* <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 class ResolvedModel {
public static final ResolvedModel EMPTY = new ResolvedModel(List.of(), false);
public final List<Element> elements; 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 final boolean hasGeometry;
public ResolvedModel( public ResolvedModel(List<Element> elements, boolean hasGeometry) {
List<Element> elements,
int averageColor,
double transparency,
double reflection,
boolean occluding,
boolean hasGeometry) {
this.elements = elements; this.elements = elements;
this.averageColor = averageColor;
this.transparency = transparency;
this.reflection = reflection;
this.occluding = occluding;
this.hasGeometry = hasGeometry; this.hasGeometry = hasGeometry;
} }
} }
@@ -1,20 +1,58 @@
package eu.mhsl.minecraft.pixelpics.commands; package eu.mhsl.minecraft.pixelpics.commands;
import eu.mhsl.minecraft.pixelpics.Main;
import eu.mhsl.minecraft.pixelpics.render.RenderManager;
import eu.mhsl.minecraft.pixelpics.render.RenderStats;
import eu.mhsl.minecraft.pixelpics.setup.SetupManager;
import eu.mhsl.minecraft.pixelpics.survival.CameraItems;
import eu.mhsl.minecraft.pixelpics.survival.PhotoService; import eu.mhsl.minecraft.pixelpics.survival.PhotoService;
import eu.mhsl.minecraft.pixelpics.utils.DebugLog;
import eu.mhsl.minecraft.pixelpics.utils.MainThread;
import eu.mhsl.minecraft.pixelpics.utils.MapManager; import eu.mhsl.minecraft.pixelpics.utils.MapManager;
import eu.mhsl.minecraft.pixelpics.utils.MapUsageScan;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Set; import java.util.Set;
import java.util.stream.Stream;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.bukkit.command.TabExecutor;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
public class PixelPicsCommand implements CommandExecutor { /** {@code /pixelpic help} for everyone, the other subcommands need {@code pixelpic.admin}. Messages are German. */
public class PixelPicsCommand implements TabExecutor {
private static final String ADMIN = "pixelpic.admin";
private static final int DEFAULT_CLEANUP_DAYS = 30; private static final int DEFAULT_CLEANUP_DAYS = 30;
private static final int MAX_GIVE = 64;
/** An admin subcommand as listed by {@code help}. */
private record Sub(String name, String params, String help) {}
private static final List<Sub> ADMIN_SUBS = List.of(
new Sub("info", "", "Status, Versionen, Resource Pack, Warteschlange, Statistik"),
new Sub("photo", " [Spieler]", "Testfoto ohne Kamera und Film, mit Zeitmessung"),
new Sub("give", " <Spieler> <camera|film> [Anzahl]", "Kamera oder Filmrollen geben"),
new Sub("reload", "", "config.yml, Resource Pack und Modelle neu laden"),
new Sub(
"cleanup",
" [confirm] [Tage]",
"ungenutzte Fotos löschen (ohne confirm nur Vorschau, Standard " + DEFAULT_CLEANUP_DAYS
+ " Tage)"));
private static final List<String> ADMIN_SUBCOMMANDS =
ADMIN_SUBS.stream().map(Sub::name).toList();
private static boolean cleanupRunning = false;
@Override @Override
public boolean onCommand( public boolean onCommand(
@@ -22,49 +60,362 @@ public class PixelPicsCommand implements CommandExecutor {
@NotNull Command command, @NotNull Command command,
@NotNull String label, @NotNull String label,
@NotNull String @NotNull [] args) { @NotNull String @NotNull [] args) {
if (args.length >= 1 && args[0].equalsIgnoreCase("cleanup")) { String sub = args.length == 0 ? "help" : args[0].toLowerCase(Locale.ROOT);
return cleanup(sender, args); if (sub.equals("help") || sub.equals("?")) {
} help(sender, label);
if (!(sender instanceof Player player)) {
sender.sendMessage(
Component.text("Dieser Command kann nur von einem Spieler ausgeführt werden!", NamedTextColor.RED));
return true; return true;
} }
if (args.length > 0) return false; if (!ADMIN_SUBCOMMANDS.contains(sub)) {
error(sender, "Unbekannter Unterbefehl „" + args[0] + "“. Übersicht: /" + label + " help");
// Debug shortcut: render a photo from the player's view without needing a camera or film.
if (!player.hasPermission("pixelpic.admin")) {
player.sendActionBar(
Component.text("Dafür fehlt dir die Berechtigung — nutze eine Kamera.", NamedTextColor.RED));
return true; return true;
} }
if (!sender.hasPermission(ADMIN)) {
PhotoService.takePhoto(player); error(sender, "Dafür fehlt dir die Berechtigung (" + ADMIN + ").");
return true;
}
switch (sub) {
case "info" -> info(sender);
case "photo" -> photo(sender, label, args);
case "give" -> give(sender, label, args);
case "reload" -> reload(sender);
case "cleanup" -> cleanup(sender, label, args);
default -> throw new IllegalStateException(sub);
}
return true; return true;
} }
/** {@code /pixelPic cleanup [days]} dry-run; {@code /pixelPic cleanup confirm [days]} deletes orphans. */ @Override
private boolean cleanup(CommandSender sender, String[] args) { public List<String> onTabComplete(
if (!sender.hasPermission("pixelpic.admin") && !sender.isOp()) { @NotNull CommandSender sender,
sender.sendMessage( @NotNull Command command,
Component.text("Dafür fehlt dir die Berechtigung (pixelpic.admin).", NamedTextColor.RED)); @NotNull String label,
return true; @NotNull String @NotNull [] args) {
boolean admin = sender.hasPermission(ADMIN);
List<String> options = new ArrayList<>();
if (args.length == 1) {
options.add("help");
if (admin) options.addAll(ADMIN_SUBCOMMANDS);
} else if (admin) {
String sub = args[0].toLowerCase(Locale.ROOT);
int i = args.length - 1;
switch (sub) {
case "photo" -> {
if (i == 1) options.addAll(playerNames());
}
case "give" -> {
if (i == 1) {
options.addAll(playerNames());
options.add("@a");
} else if (i == 2) {
options.addAll(List.of("camera", "film"));
} else if (i == 3) {
options.addAll(List.of("1", "8", "16", "64"));
}
}
case "cleanup" -> {
if (i <= 2) {
if (Stream.of(args).noneMatch("confirm"::equalsIgnoreCase)) options.add("confirm");
options.add(String.valueOf(DEFAULT_CLEANUP_DAYS));
}
}
default -> {}
}
}
String prefix = args[args.length - 1].toLowerCase(Locale.ROOT);
return options.stream()
.filter(o -> o.toLowerCase(Locale.ROOT).startsWith(prefix))
.toList();
}
private static List<String> playerNames() {
return Bukkit.getOnlinePlayers().stream().map(Player::getName).toList();
}
private static void help(CommandSender sender, String label) {
Main plugin = Main.getInstance();
sender.sendMessage(Component.text(
"PixelPics " + plugin.getPluginMeta().getVersion() + " — Sofortbildkamera", NamedTextColor.GOLD));
for (String line : List.of(
"Kamera craften: Eisen, Diamant, Glasscheiben, Enderauge und Redstone (Rezeptbuch: „Kamera“).",
"Filmrolle craften: 4 Papier und 1 Tintenbeutel (Rezeptbuch: „Filmrolle“).",
"Film laden: Kamera und Filmrolle zusammen ins Crafting-Feld (bis zu " + CameraItems.MAX_FILM
+ " Filme).",
"Foto: Rechtsklick mit der Kamera (1 Film). Das Bild entwickelt sich nach ein paar Sekunden.",
"Kopie: Foto und Filmrolle zusammen ins Crafting-Feld.")) {
sender.sendMessage(Component.text("• " + line, NamedTextColor.GRAY));
}
if (!sender.hasPermission(ADMIN)) return;
sender.sendMessage(Component.text("Admin-Befehle:", NamedTextColor.GOLD));
for (Sub sub : ADMIN_SUBS) {
String command = "/" + label + " " + sub.name();
sender.sendMessage(Component.text(command + sub.params(), NamedTextColor.YELLOW)
.clickEvent(ClickEvent.suggestCommand(command + " "))
.hoverEvent(HoverEvent.showText(Component.text("Klicken zum Einfügen")))
.append(Component.text(" – " + sub.help(), NamedTextColor.GRAY)));
}
}
private static void info(CommandSender sender) {
Main plugin = Main.getInstance();
SetupManager setup = plugin.getSetup();
SetupManager.Report report = setup.report();
String server = Bukkit.getMinecraftVersion();
sender.sendMessage(Component.text(
"PixelPics " + plugin.getPluginMeta().getVersion() + " auf " + Bukkit.getName() + " "
+ Bukkit.getVersion(),
NamedTextColor.GOLD));
String task = setup.task();
if (plugin.getScreenRenderer() == null) {
String status = task != null
? "wird eingerichtet: " + task
: setup.running() ? "wird eingerichtet" : "nicht bereit, Fotos sind deaktiviert";
line(sender, "Status", status, NamedTextColor.RED);
} else {
line(sender, "Status", "bereit" + (task != null ? " (im Hintergrund: " + task + ")" : ""), null);
}
if (setup.problem() != null) line(sender, "Problem", setup.problem(), NamedTextColor.RED);
if (report != null) {
List<String> packs = new ArrayList<>();
for (SetupManager.PackInfo p : report.packs()) {
String v = p.version() == null ? "Version unbekannt" : p.version();
packs.add(p.name() + " (" + v + (p.loaded() ? "" : ", nicht geladen") + ")");
}
line(
sender,
"Resource Pack",
packs.isEmpty() ? "Ordner-Pack" : String.join(", ", packs),
report.packs().stream()
.anyMatch(p -> p.loaded()
&& p.version() != null
&& !p.version().equals(server))
? NamedTextColor.RED
: null);
coverageLine(sender, "Blockzustände", report.blocks());
coverageLine(sender, "Item-Modelle", report.items());
if (!report.fontOk()) line(sender, "Schrift", "fehlt im Pack — Schilder bleiben leer", NamedTextColor.RED);
String dump = report.dumpVersion() == null
? "kein Dump, nur CEM-Vorlagen"
: "Dump " + report.dumpVersion() + " (" + report.dumpSource() + "), " + report.entityModels()
+ " Modelle"
+ (report.datapackVariants() > 0
? ", " + report.datapackVariants() + " Datapack-Varianten"
: "");
boolean dumpOk = server.equals(report.dumpVersion());
line(
sender,
"Entity-Modelle",
dump + (dumpOk ? "" : " — passt nicht zu " + server + ", siehe Server-Log"),
dumpOk ? null : NamedTextColor.RED);
} }
RenderManager manager = plugin.getRenderManager();
RenderManager.Load load = manager.load();
line(
sender,
"Rendering",
load.running() + " laufen, " + load.queued() + " warten (max. " + load.maxConcurrent()
+ " parallel + " + load.queueSize() + " in der Warteschlange, " + load.threads()
+ " Kerne, Timeout " + load.timeoutSeconds() + " s)",
null);
RenderStats stats = manager.stats();
String photos = stats.developedCount() + " entwickelt, " + stats.failedCount() + " fehlgeschlagen, "
+ stats.rejectedCount() + " abgelehnt";
if (stats.developedCount() > 0) {
RenderStats.Timings avg = stats.average();
photos += "; Ø " + seconds(avg.total()) + " (" + phases(avg, "") + "), max. " + seconds(stats.maxTotal())
+ ", Snapshot max. " + stats.maxSnapshot() + " ms";
}
line(sender, "Fotos seit Start", photos, null);
line(sender, "Debug-Logging", DebugLog.enabled() ? "an" : "aus (config.yml: debug)", null);
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
MapManager.Storage storage = MapManager.storage();
String text = storage.photos() + (storage.photos() == 1 ? " Foto, " : " Fotos, ")
+ String.format(Locale.GERMANY, "%.1f MB", storage.bytes() / 1048576.0) + " in "
+ storage.folder();
MainThread.run(plugin, () -> line(sender, "Gespeichert", text, null));
});
}
private static void coverageLine(CommandSender sender, String name, SetupManager.Coverage c) {
String text = (c.total() - c.missing()) + " / " + c.total() + " vorhanden";
if (c.missing() > 0) {
text += ", fehlen z. B.: " + String.join(", ", c.sample()) + " — Pack älter als der Server?";
}
line(sender, " " + name, text, c.missing() > 0 ? NamedTextColor.RED : null);
}
private static void line(CommandSender sender, String key, String value, NamedTextColor valueColor) {
sender.sendMessage(Component.text(key + ": ", NamedTextColor.GRAY)
.append(Component.text(value, valueColor != null ? valueColor : NamedTextColor.WHITE)));
}
private static String phases(RenderStats.Timings t, String snapshotNote) {
return "Warten " + t.queued() + " ms, Snapshot " + t.snapshot() + " ms" + snapshotNote + ", Raytracing "
+ seconds(t.trace()) + ", Dithern " + t.dither() + " ms";
}
private static String seconds(long ms) {
return String.format(Locale.GERMANY, "%.2f s", ms / 1000.0);
}
private static void photo(CommandSender sender, String label, String[] args) {
Player target;
if (args.length >= 2) {
List<Player> found = players(sender, args[1]);
if (found == null) return;
if (found.size() != 1) {
error(sender, "Bitte genau einen Spieler angeben.");
return;
}
target = found.getFirst();
} else if (sender instanceof Player player) {
target = player;
} else {
error(sender, "Von der Konsole aus: /" + label + " photo <Spieler> (fotografiert aus dessen Sicht).");
return;
}
String who = target.equals(sender) ? "" : " aus der Sicht von " + target.getName();
Component refusal = PhotoService.takeDebugPhoto(target, timings -> {
if (timings == null) {
error(sender, "Testfoto" + who + " fehlgeschlagen (Details im Server-Log).");
return;
}
sender.sendMessage(Component.text(
"Testfoto" + who + " entwickelt in " + seconds(timings.total()) + ": "
+ phases(timings, " (Main-Thread)") + ".",
NamedTextColor.GREEN));
});
if (refusal != null) {
sender.sendMessage(refusal);
return;
}
sender.sendMessage(Component.text(
"Testfoto" + who + " ausgelöst (ohne Film), die Karte landet im Inventar von " + target.getName() + ".",
NamedTextColor.GRAY));
}
private static void give(CommandSender sender, String label, String[] args) {
if (args.length < 3) {
error(sender, "Verwendung: /" + label + " give <Spieler> <camera|film> [Anzahl]");
return;
}
List<Player> targets = players(sender, args[1]);
if (targets == null) return;
String kind = args[2].toLowerCase(Locale.ROOT);
boolean camera = kind.equals("camera") || kind.equals("kamera");
if (!camera && !kind.equals("film")) {
error(sender, "Unbekanntes Item „" + args[2] + "“ — möglich sind camera und film.");
return;
}
int amount = args.length >= 4 ? parse(args[3], 1, MAX_GIVE) : 1;
if (amount < 0) {
error(sender, "Die Anzahl muss eine Zahl von 1 bis " + MAX_GIVE + " sein.");
return;
}
for (Player target : targets) {
if (camera) {
for (int i = 0; i < amount; i++) CameraItems.give(target, CameraItems.createCamera(0));
} else {
ItemStack film = CameraItems.createFilm();
film.setAmount(amount);
CameraItems.give(target, film);
}
}
String item = camera
? (amount == 1 ? "Kamera (leer)" : "Kameras (leer)")
: (amount == 1 ? "Filmrolle" : "Filmrollen");
String who = targets.size() == 1 ? targets.getFirst().getName() : targets.size() + " Spieler";
sender.sendMessage(Component.text(amount + " " + item + " an " + who + " gegeben.", NamedTextColor.GREEN));
}
/** Online players for a name or selector, or null after telling the sender why there are none. */
private static List<Player> players(CommandSender sender, String arg) {
if (arg.startsWith("@")) {
List<Player> found = new ArrayList<>();
try {
for (Entity e : Bukkit.selectEntities(sender, arg)) {
if (e instanceof Player p) found.add(p);
}
} catch (IllegalArgumentException e) {
error(sender, "Ungültiger Selektor „" + arg + "“.");
return null;
}
if (found.isEmpty()) {
error(sender, "Der Selektor „" + arg + "“ trifft keinen Spieler.");
return null;
}
return found;
}
Player player = Bukkit.getPlayerExact(arg);
if (player == null) {
error(sender, "Spieler „" + arg + "“ ist nicht online.");
return null;
}
return List.of(player);
}
private static void reload(CommandSender sender) {
Main plugin = Main.getInstance();
if (!plugin.reload(sender)) {
String task = plugin.getSetup().task();
error(
sender,
"PixelPics wird gerade schon eingerichtet" + (task != null ? " (" + task + ")" : "")
+ " — bitte kurz warten.");
return;
}
sender.sendMessage(Component.text(
"config.yml neu geladen; Resource Pack und Modelle werden im Hintergrund neu eingelesen …",
NamedTextColor.GRAY));
}
/** Without {@code confirm} a dry run; deletes photos no item references that nobody saw for {@code days} days. */
private static void cleanup(CommandSender sender, String label, String[] args) {
boolean confirm = false; boolean confirm = false;
int days = DEFAULT_CLEANUP_DAYS; int days = DEFAULT_CLEANUP_DAYS;
for (int i = 1; i < args.length; i++) { for (int i = 1; i < args.length; i++) {
if (args[i].equalsIgnoreCase("confirm")) confirm = true; if (args[i].equalsIgnoreCase("confirm")) {
else { confirm = true;
try { continue;
days = Math.max(0, Integer.parseInt(args[i])); }
} catch (NumberFormatException ignored) { days = parse(args[i], 0, Integer.MAX_VALUE);
} if (days < 0) {
error(
sender,
"„" + args[i] + "“ ist keine gültige Anzahl Tage. Verwendung: /" + label
+ " cleanup [confirm] [Tage]");
return;
} }
} }
if (cleanupRunning) {
sender.sendMessage(Component.text("Es läuft bereits ein Aufräum-Scan.", NamedTextColor.YELLOW));
return;
}
Set<Integer> inUse = MapManager.collectInUseMapIds(); // Loaded state now, saved world and player data off the main thread, loaded state again before deciding.
Set<Integer> liveBefore = MapUsageScan.live();
cleanupRunning = true;
sender.sendMessage(Component.text("Durchsuche Welt- und Spielerdaten nach Fotos …", NamedTextColor.GRAY));
boolean finalConfirm = confirm;
int finalDays = days;
MapUsageScan.diskAsync(Main.getInstance(), (onDisk, failure) -> {
cleanupRunning = false;
if (onDisk == null) {
error(sender, "Konnte nicht alle Weltdaten lesen, es wurde nichts gelöscht: " + failure.getMessage());
return;
}
Set<Integer> inUse = new HashSet<>(liveBefore);
inUse.addAll(onDisk);
inUse.addAll(MapUsageScan.live());
finishCleanup(sender, label, inUse, finalConfirm, finalDays);
});
}
private static void finishCleanup(
CommandSender sender, String label, Set<Integer> inUse, boolean confirm, int days) {
long cutoff = System.currentTimeMillis() - days * 86_400_000L; long cutoff = System.currentTimeMillis() - days * 86_400_000L;
List<MapManager.StoredMap> candidates = MapManager.listStored().stream() List<MapManager.StoredMap> candidates = MapManager.listStored().stream()
.filter(s -> !inUse.contains(s.id()) && s.lastModified() < cutoff) .filter(s -> !inUse.contains(s.id()) && s.lastModified() < cutoff)
@@ -72,28 +423,44 @@ public class PixelPicsCommand implements CommandExecutor {
if (candidates.isEmpty()) { if (candidates.isEmpty()) {
sender.sendMessage(Component.text( sender.sendMessage(Component.text(
"Keine aufräumbaren Aufnahmen gefunden (älter als " + days + " Tage und nicht in Benutzung).", "Keine aufräumbaren Fotos gefunden (nirgends mehr vorhanden und seit " + days
+ " Tagen nicht gesehen).",
NamedTextColor.YELLOW)); NamedTextColor.YELLOW));
return true; return;
} }
if (!confirm) { if (!confirm) {
sender.sendMessage(Component.text( sender.sendMessage(Component.text(
candidates.size() + " Aufnahme(n) könnten gelöscht werden " + "(älter als " + days candidates.size() + " Foto(s) könnten gelöscht werden: in keiner geladenen oder gespeicherten"
+ " Tage, nicht in geladenen Itemframes/Online-Inventaren).", + " Welt, keinem Spielerinventar/Enderchest (auch offline) und keinem Container/Shulker/"
+ "Bündel gefunden und seit " + days + " Tagen nicht geladen oder angesehen.",
NamedTextColor.YELLOW)); NamedTextColor.YELLOW));
sender.sendMessage(Component.text( sender.sendMessage(Component.text(
"Achtung: Maps in lange ungeladenen Bereichen werden hierbei nicht " "Achtung: Nicht erkannt werden Karten in gerade nicht geladenen Welten und in Speichern anderer"
+ "erkannt. Zum Löschen: /pixelPic cleanup confirm " + days, + " Plugins (z. B. Rucksäcke, Auktionshäuser). Zum Löschen: /" + label
+ " cleanup confirm " + days,
NamedTextColor.GRAY)); NamedTextColor.GRAY));
return true; return;
} }
int deleted = 0; int deleted = 0;
for (MapManager.StoredMap s : candidates) { for (MapManager.StoredMap s : candidates) {
if (MapManager.delete(s.id())) deleted++; if (MapManager.delete(s.id())) deleted++;
} }
sender.sendMessage(Component.text(deleted + " Aufnahme(n) gelöscht.", NamedTextColor.GREEN)); sender.sendMessage(Component.text(deleted + " Foto(s) gelöscht.", NamedTextColor.GREEN));
return true; }
/** {@code arg} as a number in {@code [min, max]}, else -1. */
private static int parse(String arg, int min, int max) {
try {
int n = Integer.parseInt(arg);
return n >= min && n <= max ? n : -1;
} catch (NumberFormatException e) {
return -1;
}
}
private static void error(CommandSender sender, String message) {
sender.sendMessage(Component.text(message, NamedTextColor.RED));
} }
} }
@@ -1,31 +1,43 @@
package eu.mhsl.minecraft.pixelpics.listeners; package eu.mhsl.minecraft.pixelpics.listeners;
import eu.mhsl.minecraft.pixelpics.Main;
import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer; import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer;
import eu.mhsl.minecraft.pixelpics.utils.MapImageDither; import eu.mhsl.minecraft.pixelpics.utils.MapImageDither;
import eu.mhsl.minecraft.pixelpics.utils.MapManager; import eu.mhsl.minecraft.pixelpics.utils.MapManager;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import org.bukkit.Bukkit;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
import org.bukkit.event.server.MapInitializeEvent; import org.bukkit.event.server.MapInitializeEvent;
import org.bukkit.map.MapView;
/** Reattaches stored photos to their maps when the server loads them. */
public class OnMapInitialize implements Listener { public class OnMapInitialize implements Listener {
@EventHandler @EventHandler
public void onMapInitialize(MapInitializeEvent event) { public void onMapInitialize(MapInitializeEvent event) {
int mapId = event.getMap().getId(); attach(event.getMap());
}
// Fast path: use the cached dithered indices (no re-quantization). /** Gives a map its stored photo, if it has one. */
public static void attach(MapView view) {
int mapId = view.getId();
if (!MapManager.isManaged(mapId)) return;
MapManager.touch(mapId);
byte[] indices = MapManager.readIndices(mapId); byte[] indices = MapManager.readIndices(mapId);
if (indices != null) { if (indices != null) {
MapManager.attachView(event.getMap(), new ImageMapRenderer(indices)); MapManager.attachView(view, new ImageMapRenderer(indices));
return; return;
} }
// No index cache (yet): show blank film and dither the stored PNG off the main thread.
// Fallback/migration: dither from the stored PNG once, then cache for next time. ImageMapRenderer renderer = new ImageMapRenderer();
BufferedImage image = MapManager.readImage(mapId); MapManager.attachView(view, renderer);
if (image == null) return; Bukkit.getScheduler().runTaskAsynchronously(Main.getInstance(), () -> {
indices = MapImageDither.dither(image); BufferedImage image = MapManager.readImage(mapId);
MapManager.saveIndices(indices, mapId); if (image == null) return;
MapManager.attachView(event.getMap(), new ImageMapRenderer(indices)); byte[] dithered = MapImageDither.dither(image);
MapManager.saveIndices(dithered, mapId);
renderer.show(dithered);
});
} }
} }
@@ -0,0 +1,66 @@
package eu.mhsl.minecraft.pixelpics.render;
import eu.mhsl.minecraft.pixelpics.render.snapshot.SnapshotBuilder;
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.stream.LongStream;
import org.bukkit.Bukkit;
import org.bukkit.World;
import org.bukkit.plugin.Plugin;
/**
* Keeps the chunks of a queued photo loaded until its snapshot is taken, so they do not unload when the photographer
* leaves meanwhile. Plugin chunk tickets are not counted per plugin, hence the reference count. Only chunks loaded at
* the shot get a ticket (a hold never loads one). Main thread only.
*/
public final class ChunkHolds {
private final Plugin plugin;
private final Map<UUID, Map<Long, Integer>> counts = new HashMap<>();
ChunkHolds(Plugin plugin) {
this.plugin = plugin;
}
/** Holds the loaded chunks of {@code area} in {@code world} until {@link Hold#release}. */
public Hold hold(World world, SnapshotBuilder.ChunkArea area) {
Map<Long, Integer> held = counts.computeIfAbsent(world.getUID(), k -> new HashMap<>());
LongStream.Builder keys = LongStream.builder();
for (int cx = area.minCX(); cx <= area.maxCX(); cx++) {
for (int cz = area.minCZ(); cz <= area.maxCZ(); cz++) {
if (!world.isChunkLoaded(cx, cz)) continue;
long key = WorldSnapshot.chunkKey(cx, cz);
if (held.merge(key, 1, Integer::sum) == 1) world.addPluginChunkTicket(cx, cz, plugin);
keys.add(key);
}
}
return new Hold(world.getUID(), keys.build().toArray());
}
/** The chunks of one photo; {@link #release} is idempotent. */
public final class Hold {
private final UUID world;
private final long[] keys;
private boolean released;
private Hold(UUID world, long[] keys) {
this.world = world;
this.keys = keys;
}
public void release() {
if (released) return;
released = true;
Map<Long, Integer> held = counts.get(world);
if (held == null) return;
World w = Bukkit.getWorld(world); // null once unloaded: its tickets are gone with it
for (long key : keys) {
Integer left = held.computeIfPresent(key, (k, c) -> c > 1 ? c - 1 : null);
if (left == null && w != null) w.removePluginChunkTicket((int) (key >> 32), (int) key, plugin);
}
if (held.isEmpty()) counts.remove(world);
}
}
}
@@ -1,8 +1,14 @@
package eu.mhsl.minecraft.pixelpics.render; package eu.mhsl.minecraft.pixelpics.render;
import eu.mhsl.minecraft.pixelpics.utils.DebugLog;
import eu.mhsl.minecraft.pixelpics.utils.MainThread;
import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ForkJoinPool; import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinWorkerThread; import java.util.concurrent.ForkJoinWorkerThread;
@@ -12,12 +18,16 @@ import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.Function; import java.util.function.Function;
import java.util.function.Supplier;
import java.util.logging.Level;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.plugin.Plugin; import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitTask;
/** /**
* Server-friendly scheduling for photo renders. Bounds CPU use and protects the server tick by: * Server-friendly scheduling for photo renders. Bounds CPU use and protects the server tick by:
@@ -26,8 +36,9 @@ import org.bukkit.plugin.Plugin;
* <li><b>Globally:</b> at most {@code maxConcurrent} photos render at once, with up to * <li><b>Globally:</b> at most {@code maxConcurrent} photos render at once, with up to
* {@code queueSize} more waiting; further requests are rejected.</li> * {@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} * <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 * {@link Thread#MIN_PRIORITY} workers instead of fanning out over the common pool.</li>
* never fans out across every core like the default common pool would.</li> * <li><b>Main thread:</b> at most one world snapshot ({@link #onMainThread}) per tick, also when several
* jobs start at once.</li>
* </ul> * </ul>
* *
* <p>Reserve a slot with {@link #tryReserve(UUID)}; if accepted, run the heavy work via * <p>Reserve a slot with {@link #tryReserve(UUID)}; if accepted, run the heavy work via
@@ -42,33 +53,106 @@ public final class RenderManager {
QUEUE_FULL QUEUE_FULL
} }
/** The {@code render.*} limits from config.yml, already normalised. */
public record Settings(int threads, int maxConcurrent, int queueSize, int timeoutSeconds) {
@Override
public String toString() {
return threads + " core(s), max " + maxConcurrent + " concurrent, queue " + queueSize + ", timeout "
+ timeoutSeconds + "s";
}
}
private static final int SHUTDOWN_WAIT_SECONDS = 5;
private final Plugin plugin; private final Plugin plugin;
private final ForkJoinPool tracePool; private final ForkJoinPool tracePool;
private final ThreadPoolExecutor dispatcher; private final ThreadPoolExecutor dispatcher;
private final ScheduledExecutorService watchdog; private final ScheduledExecutorService watchdog;
private final Set<UUID> activeUsers = ConcurrentHashMap.newKeySet(); private final Set<UUID> activeUsers = ConcurrentHashMap.newKeySet();
private final AtomicInteger inFlight = new AtomicInteger(); private final Set<Job<?>> jobs = ConcurrentHashMap.newKeySet();
private final int capacity; private final ConcurrentLinkedQueue<MainTask> mainTasks = new ConcurrentLinkedQueue<>();
private final long timeoutMillis;
public RenderManager(Plugin plugin, int threads, int maxConcurrent, int queueSize, int timeoutSeconds) { private final BukkitTask mainTaskPump;
private final ChunkHolds chunkHolds;
private volatile boolean shuttingDown = false;
private final AtomicInteger inFlight = new AtomicInteger();
private final AtomicInteger running = new AtomicInteger();
private final RenderStats stats = new RenderStats();
private volatile int capacity;
private volatile int maxConcurrent;
private volatile long timeoutMillis;
public RenderManager(Plugin plugin, Settings settings) {
this.plugin = plugin; this.plugin = plugin;
this.capacity = maxConcurrent + queueSize; this.tracePool = new ForkJoinPool(settings.threads(), lowPriorityForkJoinFactory(), null, false);
this.timeoutMillis = Math.max(1L, timeoutSeconds) * 1000L;
this.tracePool = new ForkJoinPool(threads, lowPriorityForkJoinFactory(), null, false);
this.dispatcher = new ThreadPoolExecutor( this.dispatcher = new ThreadPoolExecutor(
maxConcurrent, settings.maxConcurrent(),
maxConcurrent, settings.maxConcurrent(),
30, 30,
TimeUnit.SECONDS, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(), // capacity is enforced by inFlight, not this queue new LinkedBlockingQueue<>(), // capacity is enforced by inFlight, not this queue
lowPriorityThreadFactory()); lowPriorityThreadFactory());
this.dispatcher.allowCoreThreadTimeOut(true); this.dispatcher.allowCoreThreadTimeOut(true);
applyLimits(settings);
this.watchdog = Executors.newSingleThreadScheduledExecutor(runnable -> { this.watchdog = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "PixelPics-render-watchdog"); Thread thread = new Thread(runnable, "PixelPics-render-watchdog");
thread.setDaemon(true); thread.setDaemon(true);
return thread; return thread;
}); });
this.mainTaskPump = Bukkit.getScheduler().runTaskTimer(plugin, this::runNextMainTask, 1, 1);
this.chunkHolds = new ChunkHolds(plugin);
}
/**
* Applies new limits to the running manager ({@code /pixelpic reload}). Running and queued jobs are kept; a smaller
* capacity only rejects new photos until enough are done.
*/
public void configure(Settings settings) {
tracePool.setParallelism(settings.threads());
int max = settings.maxConcurrent();
// ThreadPoolExecutor rejects core > max at every step, so the order depends on the direction.
if (max >= dispatcher.getMaximumPoolSize()) {
dispatcher.setMaximumPoolSize(max);
dispatcher.setCorePoolSize(max);
} else {
dispatcher.setCorePoolSize(max);
dispatcher.setMaximumPoolSize(max);
}
applyLimits(settings);
}
private void applyLimits(Settings settings) {
maxConcurrent = settings.maxConcurrent();
capacity = settings.maxConcurrent() + settings.queueSize();
timeoutMillis = settings.timeoutSeconds() * 1000L;
}
/** Photos rendering now (incl. waiting for their snapshot), photos waiting for a worker, and the limits. */
public record Load(int running, int queued, int maxConcurrent, int queueSize, int threads, long timeoutSeconds) {}
public Load load() {
int total = inFlight.get();
int run = Math.min(running.get(), total);
return new Load(
run,
Math.max(0, total - run),
maxConcurrent,
capacity - maxConcurrent,
tracePool.getParallelism(),
timeoutMillis / 1000);
}
public RenderStats stats() {
return stats;
}
/** True while no job is running, queued or waiting for its callback. */
public boolean isIdle() {
return jobs.isEmpty();
}
public ChunkHolds chunkHolds() {
return chunkHolds;
} }
/** The bounded, low-priority pool the ray tracer must run its parallel work on. */ /** The bounded, low-priority pool the ray tracer must run its parallel work on. */
@@ -78,16 +162,20 @@ public final class RenderManager {
/** Atomically reserves a slot for {@code user}. On {@code ACCEPTED}, you must later release it. */ /** Atomically reserves a slot for {@code user}. On {@code ACCEPTED}, you must later release it. */
public Outcome tryReserve(UUID user) { public Outcome tryReserve(UUID user) {
if (!activeUsers.add(user)) return Outcome.USER_BUSY; if (!activeUsers.add(user)) {
stats.rejected();
return Outcome.USER_BUSY;
}
if (inFlight.incrementAndGet() > capacity) { if (inFlight.incrementAndGet() > capacity) {
inFlight.decrementAndGet(); inFlight.decrementAndGet();
activeUsers.remove(user); activeUsers.remove(user);
stats.rejected();
return Outcome.QUEUE_FULL; return Outcome.QUEUE_FULL;
} }
return Outcome.ACCEPTED; return Outcome.ACCEPTED;
} }
/** Releases a reservation made by {@link #tryReserve}. Safe to call once per accepted reserve. */ /** Releases a reservation made by {@link #tryReserve}; a no-op when {@code user} holds none. */
public void release(UUID user) { public void release(UUID user) {
if (activeUsers.remove(user)) { if (activeUsers.remove(user)) {
inFlight.decrementAndGet(); inFlight.decrementAndGet();
@@ -98,49 +186,150 @@ public final class RenderManager {
* Runs {@code work} off the main thread (honoring the global concurrency limit), then delivers the * 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 * 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 * 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 * once the deadline passes or the plugin shuts down — {@code work} should poll it and bail out
* caller's reservation when done. Requires a prior {@link #tryReserve} success. * cooperatively. Exactly one callback runs per job, also when the plugin is disabled meanwhile
* ({@link #shutdown} runs it synchronously). Releases the caller's reservation when done. Requires a
* prior {@link #tryReserve} success.
*/ */
public <T> void dispatch(UUID user, Function<AtomicBoolean, T> work, Consumer<T> onSuccess, Runnable onFailure) { public <T> void dispatch(UUID user, Function<AtomicBoolean, T> work, Consumer<T> onSuccess, Runnable onFailure) {
Job<T> job = new Job<>(onSuccess, onFailure);
jobs.add(job);
dispatcher.execute(() -> { dispatcher.execute(() -> {
AtomicBoolean cancelled = new AtomicBoolean(false); running.incrementAndGet();
ScheduledFuture<?> deadline = long timeout = timeoutMillis;
watchdog.schedule(() -> cancelled.set(true), timeoutMillis, TimeUnit.MILLISECONDS); AtomicBoolean timedOut = new AtomicBoolean(false);
T result = null; ScheduledFuture<?> deadline = watchdog.schedule(
boolean ok = false; () -> {
timedOut.set(true);
job.cancelled.set(true);
},
timeout,
TimeUnit.MILLISECONDS);
try { try {
result = work.apply(cancelled); T result = job.cancelled.get() ? null : work.apply(job.cancelled);
ok = result != null && !cancelled.get(); if (result != null && !job.cancelled.get()) job.result = result;
} catch (Throwable t) { } catch (Throwable t) {
plugin.getLogger().warning("Render job failed: " + t); if (!shuttingDown) {
plugin.getLogger().warning("Render job failed: " + t);
if (DebugLog.enabled()) plugin.getLogger().log(Level.WARNING, "Render job failure", t);
}
} finally { } finally {
deadline.cancel(false); deadline.cancel(false);
if (cancelled.get()) { running.decrementAndGet();
if (timedOut.get()) {
plugin.getLogger() plugin.getLogger()
.warning( .warning("Render for " + user + " aborted after " + (timeout / 1000) + "s (timeout).");
"Render for " + user + " aborted after " + (timeoutMillis / 1000) + "s (timeout).");
} }
release(user); release(user);
} }
T finalResult = result; // Not scheduled while shutting down or once disabled: shutdown() delivers it then.
boolean finalOk = ok; if (!shuttingDown) MainThread.run(plugin, job::finish);
Bukkit.getScheduler().runTask(plugin, () -> {
if (finalOk) onSuccess.accept(finalResult);
else onFailure.run();
});
}); });
} }
/**
* From a dispatched job's {@code work}: runs {@code task} on the main thread and waits for its result. The tasks of
* all jobs run one per tick in request order, so jobs starting together never stack their snapshots into one long
* tick. Returns null without running {@code task} once {@code cancelled} is set or the worker is interrupted.
*/
public <T> T onMainThread(Supplier<T> task, AtomicBoolean cancelled) {
if (shuttingDown) return null;
CompletableFuture<T> result = new CompletableFuture<>();
mainTasks.add(new MainTask(
() -> {
try {
result.complete(task.get());
} catch (Throwable t) {
result.completeExceptionally(t);
}
},
cancelled));
while (!cancelled.get()) {
try {
return result.get(50, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
// poll cancelled again: shutdown() runs on the main thread, the task may never run
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
cancelled.set(true);
return null;
} catch (ExecutionException e) {
if (e.getCause() instanceof RuntimeException re) throw re;
throw new IllegalStateException(e.getCause());
}
}
return null;
}
/** Every tick: the oldest main-thread task whose job is not cancelled. */
private void runNextMainTask() {
MainTask next;
while ((next = mainTasks.poll()) != null) {
if (next.cancelled.get()) continue; // its worker returns null on its own
next.run.run();
return;
}
}
private record MainTask(Runnable run, AtomicBoolean cancelled) {}
/**
* Stops all rendering without leaving a job undelivered: running and queued jobs are cancelled, the workers get
* a few seconds to wind down, then every job's callback runs here (on the main thread) — {@code onSuccess} for
* jobs that had completed but were not delivered yet, {@code onFailure} for the rest. Nothing is scheduled.
*/
public void shutdown() { public void shutdown() {
shuttingDown = true;
mainTaskPump.cancel();
for (Job<?> job : jobs) job.cancelled.set(true);
mainTasks.clear();
watchdog.shutdownNow(); watchdog.shutdownNow();
dispatcher.shutdownNow(); dispatcher.shutdownNow();
try {
if (!dispatcher.awaitTermination(SHUTDOWN_WAIT_SECONDS, TimeUnit.SECONDS)) {
plugin.getLogger().warning("Render workers did not stop within " + SHUTDOWN_WAIT_SECONDS + "s.");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
tracePool.shutdownNow(); tracePool.shutdownNow();
for (Job<?> job : List.copyOf(jobs)) {
try {
job.finish();
} catch (Throwable t) {
plugin.getLogger().warning("Failed to finish a render job on shutdown: " + t);
}
}
}
/** A dispatched render; {@link #finish} runs exactly one of its callbacks, exactly once. */
private final class Job<T> {
final AtomicBoolean cancelled = new AtomicBoolean(false);
private final AtomicBoolean finished = new AtomicBoolean(false);
private final Consumer<T> onSuccess;
private final Runnable onFailure;
volatile T result; // set once the work completed successfully
Job(Consumer<T> onSuccess, Runnable onFailure) {
this.onSuccess = onSuccess;
this.onFailure = onFailure;
}
void finish() {
if (!finished.compareAndSet(false, true)) return;
jobs.remove(this);
T r = result;
if (r != null) onSuccess.accept(r);
else onFailure.run();
}
} }
private static ForkJoinPool.ForkJoinWorkerThreadFactory lowPriorityForkJoinFactory() { private static ForkJoinPool.ForkJoinWorkerThreadFactory lowPriorityForkJoinFactory() {
AtomicInteger counter = new AtomicInteger();
return pool -> { return pool -> {
ForkJoinWorkerThread thread = ForkJoinPool.defaultForkJoinWorkerThreadFactory.newThread(pool); ForkJoinWorkerThread thread = ForkJoinPool.defaultForkJoinWorkerThreadFactory.newThread(pool);
thread.setName("PixelPics-trace-" + thread.getPoolIndex()); // not getPoolIndex(): it is only assigned once the worker registers, here every name would end in -0
thread.setName("PixelPics-trace-" + counter.incrementAndGet());
thread.setPriority(Thread.MIN_PRIORITY); thread.setPriority(Thread.MIN_PRIORITY);
thread.setDaemon(true); thread.setDaemon(true);
return thread; return thread;
@@ -0,0 +1,69 @@
package eu.mhsl.minecraft.pixelpics.render;
import java.util.concurrent.atomic.AtomicLong;
/** Photo counters and summed phase timings since the start (thread-safe), shown by {@code /pixelpic info}. */
public final class RenderStats {
/**
* Phase durations of one developed photo in ms: waiting for a worker and its snapshot turn, the world snapshot on
* the main thread, ray tracing, dithering to map colours, and the whole time from the shot to the result.
*/
public record Timings(long queued, long snapshot, long trace, long dither, long total) {}
private final AtomicLong developed = new AtomicLong();
private final AtomicLong failed = new AtomicLong();
private final AtomicLong rejected = new AtomicLong();
private final AtomicLong queued = new AtomicLong();
private final AtomicLong snapshot = new AtomicLong();
private final AtomicLong trace = new AtomicLong();
private final AtomicLong dither = new AtomicLong();
private final AtomicLong total = new AtomicLong();
private final AtomicLong maxTotal = new AtomicLong();
private final AtomicLong maxSnapshot = new AtomicLong();
public void developed(Timings t) {
developed.incrementAndGet();
queued.addAndGet(t.queued());
snapshot.addAndGet(t.snapshot());
trace.addAndGet(t.trace());
dither.addAndGet(t.dither());
total.addAndGet(t.total());
maxTotal.accumulateAndGet(t.total(), Math::max);
maxSnapshot.accumulateAndGet(t.snapshot(), Math::max);
}
public void failed() {
failed.incrementAndGet();
}
public void rejected() {
rejected.incrementAndGet();
}
public long developedCount() {
return developed.get();
}
public long failedCount() {
return failed.get();
}
public long rejectedCount() {
return rejected.get();
}
/** Average timings of the developed photos (all zero before the first one). */
public Timings average() {
long n = Math.max(1, developed.get());
return new Timings(queued.get() / n, snapshot.get() / n, trace.get() / n, dither.get() / n, total.get() / n);
}
public long maxTotal() {
return maxTotal.get();
}
public long maxSnapshot() {
return maxSnapshot.get();
}
}
@@ -1,13 +1,11 @@
package eu.mhsl.minecraft.pixelpics.render.entity; package eu.mhsl.minecraft.pixelpics.render.entity;
/** /**
* A 3x3 linear transform plus translation (affine), used to compose entity bone hierarchies and to * A 3x3 linear transform (row-major) plus translation. Ray parameters {@code t} are preserved under affine maps, so
* map a cube's local space to world space. The {@code apply} parameter {@code t} of a ray is * distances stay consistent between world and local space.
* preserved under affine maps, so ray distances stay consistent between world and local space.
*/ */
public final class Affine { public final class Affine {
// row-major 3x3
public final double[] r; public final double[] r;
public final double[] t; public final double[] t;
@@ -16,8 +14,13 @@ public final class Affine {
this.t = t; this.t = t;
} }
/** A linear map (no translation) from its row-major matrix. */
private static Affine linear(double... r) {
return new Affine(r, new double[] {0, 0, 0});
}
public static Affine identity() { public static Affine identity() {
return new Affine(new double[] {1, 0, 0, 0, 1, 0, 0, 0, 1}, new double[] {0, 0, 0}); return linear(1, 0, 0, 0, 1, 0, 0, 0, 1);
} }
public static Affine translation(double x, double y, double z) { public static Affine translation(double x, double y, double z) {
@@ -39,63 +42,62 @@ public final class Affine {
y /= n; y /= n;
z /= n; z /= n;
w /= n; w /= n;
return new Affine( return linear(
new double[] { 1 - 2 * (y * y + z * z),
1 - 2 * (y * y + z * z), 2 * (x * y - z * w),
2 * (x * y - z * w), 2 * (x * z + y * w),
2 * (x * z + y * w), 2 * (x * y + z * w),
2 * (x * y + z * w), 1 - 2 * (x * x + z * z),
1 - 2 * (x * x + z * z), 2 * (y * z - x * w),
2 * (y * z - x * w), 2 * (x * z - y * w),
2 * (x * z - y * w), 2 * (y * z + x * w),
2 * (y * z + x * w), 1 - 2 * (x * x + y * y));
1 - 2 * (x * x + y * y)
},
new double[] {0, 0, 0});
} }
/** Right-handed rotation by {@code a} radians about the unit axis {@code (x, y, z)} (Rodrigues). */ /** Right-handed rotation by {@code a} radians about the unit axis {@code (x, y, z)} (Rodrigues). */
public static Affine rotAxis(double x, double y, double z, double a) { public static Affine rotAxis(double x, double y, double z, double a) {
double c = Math.cos(a), s = Math.sin(a), k = 1 - c; double c = Math.cos(a), s = Math.sin(a), k = 1 - c;
return new Affine( return linear(
new double[] { c + x * x * k,
c + x * x * k, x * y * k - z * s,
x * y * k - z * s, x * z * k + y * s,
x * z * k + y * s, y * x * k + z * s,
y * x * k + z * s, c + y * y * k,
c + y * y * k, y * z * k - x * s,
y * z * k - x * s, z * x * k - y * s,
z * x * k - y * s, z * y * k + x * s,
z * y * k + x * s, c + z * z * k);
c + z * z * k
},
new double[] {0, 0, 0});
} }
public static Affine scale(double s) { 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}); return linear(s, 0, 0, 0, s, 0, 0, 0, s);
} }
public static Affine scale(double sx, double sy, double sz) { public static Affine scale(double sx, double sy, double sz) {
return new Affine(new double[] {sx, 0, 0, 0, sy, 0, 0, 0, sz}, new double[] {0, 0, 0}); return linear(sx, 0, 0, 0, sy, 0, 0, 0, sz);
} }
public static Affine rotX(double a) { public static Affine rotX(double a) {
double c = Math.cos(a), s = Math.sin(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}); return linear(1, 0, 0, 0, c, -s, 0, s, c);
} }
public static Affine rotY(double a) { public static Affine rotY(double a) {
double c = Math.cos(a), s = Math.sin(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}); return linear(c, 0, s, 0, 1, 0, -s, 0, c);
} }
public static Affine rotZ(double a) { public static Affine rotZ(double a) {
double c = Math.cos(a), s = Math.sin(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}); return linear(c, -s, 0, s, c, 0, 0, 0, 1);
} }
/** this ∘ o (apply o first, then this). */ /** JOML {@code rotationAround}: {@code T(c) · rot · T(-c)}. */
public static Affine rotationAround(Affine rot, double cx, double cy, double cz) {
return translation(cx, cy, cz).mul(rot).mul(translation(-cx, -cy, -cz));
}
/** this ∘ o (apply o first, then this). */
public Affine mul(Affine o) { public Affine mul(Affine o) {
double[] a = this.r, b = o.r; double[] a = this.r, b = o.r;
double[] nr = new double[9]; double[] nr = new double[9];
@@ -121,17 +123,16 @@ public final class Affine {
}; };
} }
/** Linear part only (for directions). */ public double determinant() {
public double[] applyLinear(double x, double y, double z) { return r[0] * (r[4] * r[8] - r[5] * r[7])
return new double[] { - r[1] * (r[3] * r[8] - r[5] * r[6])
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 + r[2] * (r[3] * r[7] - r[4] * r[6]);
};
} }
/** General affine inverse (3x3 inverse + translation). */ /** Affine inverse; a singular matrix yields a zero linear part. */
public Affine inverse() { 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 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 det = determinant();
double inv = Math.abs(det) < 1e-12 ? 0 : 1.0 / det; double inv = Math.abs(det) < 1e-12 ? 0 : 1.0 / det;
double[] ir = new double[] { double[] ir = new double[] {
(e * i - f * h) * inv, (c * h - b * i) * inv, (b * f - c * e) * inv, (e * i - f * h) * inv, (c * h - b * i) * inv, (b * f - c * e) * inv,
@@ -1,110 +1,265 @@
package eu.mhsl.minecraft.pixelpics.render.entity; package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation; import eu.mhsl.minecraft.pixelpics.render.util.SpecialDates;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Set;
/** /**
* Maps a {@link BlockEntityState} to its bundled CEM model name and ordered texture-path candidates. * Maps a {@link BlockEntityState} to the dumped models its vanilla BlockEntityRenderer draws (see
* Block-entity geometry ships in the same CEM set as mobs (chest/sign/banner/…); textures live under * {@code tools/VanillaModelDump.java}) and to the renderer's PoseStack. Every pose is transcribed from the decompiled
* {@code assets/minecraft/textures/entity/}. Mirrors the role {@link EntityModels} plays for mobs. * 26.3 client renderers, without calibration offsets. Beds and sign boards are block models since 26.x; only the sign
* text is added here.
*/ */
public final class BlockEntityModels { public final class BlockEntityModels {
private BlockEntityModels() {} private BlockEntityModels() {}
/** The CEM ({@code .jem}) model name for a block-entity. */ /** How a part's texture is produced. */
public static String cemModel(BlockEntityState s) { public enum Paint {
/** First loadable texture of {@link Part#textures}. */
PLAIN,
/** Banner flag: {@code banner_base}, the base-colour {@code base} mask, then each pattern (BannerRenderer). */
BANNER_FLAG,
/** Player head: the owner's skin, else {@link Part#textures} (default Steve). */
SKIN
}
/**
* One {@code submitModel}/{@code submitModelPart} call: a dump model ({@code layer/<model>#<layer>}
* in rest pose, or {@code posed/…} posed by its setupAnim), its texture candidates and the top-level
* parts it leaves out (pot sides each carry their own sherd texture).
*/
public record Part(String model, List<String> textures, Set<String> hidden, Paint paint) {
static Part of(String model, String texture) {
return of(model, List.of(texture));
}
static Part of(String model, List<String> textures) {
return new Part(model, textures, Set.of(), Paint.PLAIN);
}
}
/**
* The renderer's models plus its block pose: {@code pose} maps Java model space (already scaled to
* blocks) into the block cell, origin at the cell's min corner — exactly the PoseStack the renderer
* sets up before submitting. Items and worn heads use the parts WITHOUT this pose (the special item
* renderers submit the bare model; the item model's own transformation places it).
*/
public record VanillaModel(Affine pose, List<Part> parts) {}
/** The vanilla model + pose of a block-entity, or null without an entity model (signs, contents only). */
public static VanillaModel vanillaModel(BlockEntityState s) {
double deg = s.facingDeg();
return switch (s.kind()) { return switch (s.kind()) {
case CHEST, TRAPPED_CHEST, ENDER_CHEST -> switch (s.chestKind() == null // ChestRenderer: rotationAround(Y, -facing.toYRot(), 0.5, 0, 0.5); model y is up (no flip).
? BlockEntityState.ChestKind.SINGLE case CHEST, TRAPPED_CHEST, ENDER_CHEST, COPPER_CHEST -> {
: s.chestKind()) { String layer =
case LEFT -> "chest_left"; switch (chestKind(s)) {
case RIGHT -> "chest_right"; case LEFT -> "layer/double_chest_left#main";
case SINGLE -> "chest"; case RIGHT -> "layer/double_chest_right#main";
}; case SINGLE -> "layer/chest#main";
case SIGN -> "sign"; };
case WALL_SIGN -> "wall_sign"; yield new VanillaModel(
case HANGING_SIGN -> "hanging_sign"; Affine.rotationAround(Affine.rotY(Math.toRadians(-deg)), 0.5, 0, 0.5),
case BANNER, WALL_BANNER -> "banner"; List.of(Part.of(layer, chestTextures(s))));
case BED -> "bed"; }
case SHULKER_BOX -> "shulker_box"; // SkullBlockRenderer ground: T(0.5,0,0.5)·rotY(-segment·22.5)·S(-1,-1,1); wall: T(0.5 - step·0.25,
case CONDUIT -> "conduit"; // 0.25, …)·rotY(-opposite.toYRot())·S(-1,-1,1).
case DECORATED_POT -> "decorated_pot"; case HEAD -> new VanillaModel(
case BELL -> "bell"; Affine.translation(0.5, 0, 0.5)
case HEAD, WALL_HEAD -> headModel(s.headType()); .mul(Affine.rotY(Math.toRadians(-deg)))
.mul(Affine.scale(-1, -1, 1)),
List.of(headPart(s.headType())));
case WALL_HEAD -> {
double sx = -Math.sin(Math.toRadians(deg)), sz = Math.cos(Math.toRadians(deg)); // facing step
yield new VanillaModel(
Affine.translation(0.5 - sx * 0.25, 0.25, 0.5 - sz * 0.25)
.mul(Affine.rotY(Math.toRadians(-(deg + 180))))
.mul(Affine.scale(-1, -1, 1)),
List.of(headPart(s.headType())));
}
// BannerRenderer: T(0.5,0,0.5)·rotY(-angle)·S(2/3,-2/3,-2/3), angle = rotation or wall facing; the
// pole/bar use banner_base, the flag (wave phase 0) gets base colour + patterns.
case BANNER, WALL_BANNER -> {
String model = s.kind() == BlockEntityState.Kind.BANNER ? "standing_banner" : "wall_banner";
double k = 2 / 3.0;
yield new VanillaModel(
Affine.translation(0.5, 0, 0.5)
.mul(Affine.rotY(Math.toRadians(-deg)))
.mul(Affine.scale(k, -k, -k)),
List.of(
Part.of("layer/" + model + "#main", "entity/banner/banner_base"),
new Part(
"posed/" + model + "#flag",
List.of("entity/banner/banner_base"),
Set.of(),
Paint.BANNER_FLAG)));
}
// ShulkerBoxRenderer: T(.5,.5,.5)·S(0.9995)·direction.getRotation()·S(1,-1,-1)·T(0,-1,0); lid closed.
case SHULKER_BOX -> new VanillaModel(
Affine.translation(0.5, 0.5, 0.5)
.mul(Affine.scale(0.9995))
.mul(directionRotation(s.direction()))
.mul(Affine.scale(1, -1, -1))
.mul(Affine.translation(0, -1, 0)),
List.of(Part.of(
"layer/shulker_box#main", variantThenBase("entity/shulker/shulker", s.colorName()))));
// DecoratedPotRenderer: rotateAround(Y, 180 - facing.toYRot(), .5,.5,.5); neck/top/bottom from the
// base sheet, each side its sherd pattern (or the blank side).
case DECORATED_POT -> new VanillaModel(
Affine.rotationAround(Affine.rotY(Math.toRadians(180 - deg)), 0.5, 0.5, 0.5), potParts(s));
// BellRenderer: the model as is (pivot 8,12,8 in block px); the frame is the block model.
case BELL -> new VanillaModel(
Affine.identity(), List.of(Part.of("layer/bell#main", "entity/bell/bell_body")));
// ConduitRenderer: inactive → shell at T(.5,.5,.5); active → cage at T(.5, 0.3 + hh·0.2, .5) with
// hh at animation time 0 (= 0.75), unrotated. Wind and the camera-facing eye are not drawn.
case CONDUIT -> s.active()
? new VanillaModel(
Affine.translation(0.5, 0.3 + 0.75 * 0.2, 0.5),
List.of(Part.of("layer/conduit#cage", "entity/conduit/cage")))
: new VanillaModel(
Affine.translation(0.5, 0.5, 0.5),
List.of(Part.of("layer/conduit#shell", "entity/conduit/base")));
// LecternRenderer (only with a book): T(.5,1.0625,.5)·rotY(-clockwise(facing).toYRot())·rotZ(67.5°)·
// T(0,-0.125,0), book open as BOOK_STATE (dumped posed).
case LECTERN -> s.active()
? new VanillaModel(
Affine.translation(0.5, 1.0625, 0.5)
.mul(Affine.rotY(Math.toRadians(-(deg + 90))))
.mul(Affine.rotZ(Math.toRadians(67.5)))
.mul(Affine.translation(0, -0.125, 0)),
List.of(Part.of("posed/book#main", "entity/enchantment/enchanting_table_book")))
: null;
// EnchantTableRenderer: T(.5,.75,.5)·T(0, 0.1 + sin(time·0.1)·0.01, 0)·rotY(-rot)·rotZ(80°), book state
// forAnimation(time, flip pages, open) at time 0 / flip 0, closed or open (dumped posed).
case ENCHANTING_TABLE -> new VanillaModel(
Affine.translation(0.5, 0.75 + 0.1, 0.5)
.mul(Affine.rotY(Math.toRadians(-deg)))
.mul(Affine.rotZ(Math.toRadians(80))),
List.of(Part.of(
s.active() ? "posed/enchanting_book_open#main" : "posed/enchanting_book_closed#main",
"entity/enchantment/enchanting_table_book")));
// CopperGolemStatueBlockRenderer: T(.5,0,.5)·rotY(-opposite(facing).toYRot()), one model per pose
// (CopperGolemStatueModel.setupAnim: root.y 0, root.zRot π), texture per weathering stage.
case COPPER_GOLEM_STATUE -> new VanillaModel(
Affine.translation(0.5, 0, 0.5).mul(Affine.rotY(Math.toRadians(-(deg + 180)))),
List.of(Part.of(
"posed/copper_golem_statue_" + (s.pose() == null ? "standing" : s.pose()) + "#main",
variantThenBase("entity/copper_golem/copper_golem", s.colorName()))));
// Signs: text only (BlockEntityBaker). Items, spawner mobs, beams and portals are not entity models
// (BlockEntityBaker.addContents).
case SIGN,
WALL_SIGN,
HANGING_SIGN,
CAMPFIRE,
SHELF,
SPAWNER,
VAULT,
BEACON,
END_PORTAL,
END_GATEWAY -> null;
}; };
} }
private static String headModel(String headType) { /**
if (headType == null) return "head"; * Standing/HangingSignRenderer.textTransformation WITHOUT its final {@code scale(s, -s, s)}: the frame whose
return switch (headType) { * origin is the text anchor (centre of the four lines), x right / y up / +z towards the reader; the font-pixel
case "dragon" -> "head_dragon"; * size {@code s} in blocks goes to {@code pxSize[0]}. Back text is the front transform turned by 180°.
case "piglin" -> "head_piglin"; */
case "player" -> "head_player"; public static Affine signTextFrame(BlockEntityState s, boolean front, double[] pxSize) {
case "wither_skeleton" -> "wither_skull"; double deg = s.facingDeg();
default -> "head"; // skeleton, zombie, creeper share the plain humanoid skull box Affine m;
if (s.kind() == BlockEntityState.Kind.HANGING_SIGN) {
m = Affine.translation(0.5, 0.9375, 0.5)
.mul(Affine.rotY(Math.toRadians(-deg)))
.mul(Affine.translation(0, -0.3125, 0));
if (!front) m = m.mul(Affine.rotY(Math.PI));
m = m.mul(Affine.translation(0, -0.32, 0.073));
pxSize[0] = 0.0140625;
} else {
m = Affine.translation(0.5, 0.5, 0.5).mul(Affine.rotY(Math.toRadians(-deg)));
if (s.kind() == BlockEntityState.Kind.WALL_SIGN) m = m.mul(Affine.translation(0, -0.3125, -0.4375));
if (!front) m = m.mul(Affine.rotY(Math.PI));
m = m.mul(Affine.translation(0, 0.33333334, 0.046666667));
pxSize[0] = 0.010416667;
}
return m;
}
private static Part headPart(String headType) {
String t = headType == null ? "skeleton" : headType;
return switch (t) {
case "wither_skeleton" -> Part.of("layer/wither_skeleton_skull#main", "entity/skeleton/wither_skeleton");
case "zombie" -> Part.of("layer/zombie_head#main", "entity/zombie/zombie");
case "creeper" -> Part.of("layer/creeper_head#main", "entity/creeper/creeper");
case "dragon" -> Part.of("posed/dragon_skull#main", "entity/enderdragon/dragon");
case "piglin" -> Part.of("posed/piglin_head#main", "entity/piglin/piglin");
case "player" -> new Part(
"layer/player_head#main", List.of("entity/player/wide/steve"), Set.of(), Paint.SKIN);
default -> Part.of("layer/skeleton_skull#main", "entity/skeleton/skeleton");
}; };
} }
/** Ordered texture-path candidates; the baker uses the first that loads. */ private static final List<String> POT_SIDES = List.of("front", "left", "right", "back"); // = sherd order
public static List<ResourceLocation> textureCandidates(BlockEntityState s) {
List<String> paths = new ArrayList<>(); private static List<Part> potParts(BlockEntityState s) {
switch (s.kind()) { List<Part> parts = new ArrayList<>(5);
case CHEST -> chestTextures(paths, s, "normal"); parts.add(Part.of("layer/decorated_pot_base#main", "entity/decorated_pot/decorated_pot_base"));
case TRAPPED_CHEST -> chestTextures(paths, s, "trapped"); for (int i = 0; i < POT_SIDES.size(); i++) {
case ENDER_CHEST -> paths.add("entity/chest/ender"); List<String> tex = new ArrayList<>(2);
case SIGN, WALL_SIGN -> { String sherd = i < s.sherds().size() ? s.sherds().get(i) : null;
String wood = s.wood() == null ? "oak" : s.wood(); if (sherd != null && sherd.endsWith("_pottery_sherd")) {
paths.add("entity/signs/" + wood); tex.add("entity/decorated_pot/" + sherd.replace("_pottery_sherd", "_pottery_pattern"));
paths.add("entity/sign"); // legacy single-texture fallback
} }
case HANGING_SIGN -> { tex.add("entity/decorated_pot/decorated_pot_side");
String wood = s.wood() == null ? "oak" : s.wood(); List<String> others = new ArrayList<>(POT_SIDES);
paths.add("entity/signs/hanging/" + wood); others.remove(i);
} parts.add(new Part("layer/decorated_pot_sides#main", tex, Set.copyOf(others), Paint.PLAIN));
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()); return parts;
for (String p : paths) out.add(ResourceLocation.parse(p));
return out;
} }
private static void chestTextures(List<String> paths, BlockEntityState s, String base) { private static BlockEntityState.ChestKind chestKind(BlockEntityState s) {
BlockEntityState.ChestKind ck = s.chestKind() == null ? BlockEntityState.ChestKind.SINGLE : s.chestKind(); return 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) { * ChestRenderer.getChestMaterial + Sheets.chooseSprite: copper by stage, ender, CHRISTMAS for regular AND
paths.add("entity/skeleton/skeleton"); * trapped chests between 24 and 26 December ({@link SpecialDates#isExtendedChristmas}), else normal/trapped.
return; */
} private static List<String> chestTextures(BlockEntityState s) {
switch (headType) { if (s.kind() == BlockEntityState.Kind.ENDER_CHEST) return List.of("entity/chest/ender");
case "wither_skeleton" -> paths.add("entity/skeleton/wither_skeleton"); boolean xmas = SpecialDates.isExtendedChristmas();
case "zombie" -> paths.add("entity/zombie/zombie"); String base =
case "creeper" -> paths.add("entity/creeper/creeper"); switch (s.kind()) {
case "piglin" -> paths.add("entity/piglin/piglin"); case TRAPPED_CHEST -> xmas ? "christmas" : "trapped";
case "dragon" -> paths.add("entity/enderdragon/dragon"); case COPPER_CHEST -> s.colorName() == null ? "copper" : "copper_" + s.colorName();
case "player" -> paths.add("entity/player/wide/steve"); // skin handled separately by the baker default -> xmas ? "christmas" : "normal";
default -> paths.add("entity/skeleton/skeleton"); };
} return switch (chestKind(s)) {
case LEFT -> List.of("entity/chest/" + base + "_left", "entity/chest/" + base);
case RIGHT -> List.of("entity/chest/" + base + "_right", "entity/chest/" + base);
case SINGLE -> List.of("entity/chest/" + base);
};
}
/** {@code <base>_<variant>} first, then the base texture; just the base without a variant. */
private static List<String> variantThenBase(String base, String variant) {
return variant == null ? List.of(base) : List.of(base + "_" + variant, base);
}
/** Minecraft {@code Direction.getRotation()} (JOML rotationXYZ = Rx·Ry·Rz); null/unknown = UP. */
private static Affine directionRotation(String direction) {
double h = Math.PI / 2;
if (direction == null) return Affine.identity();
return switch (direction) {
case "down" -> Affine.rotX(Math.PI);
case "north" -> Affine.rotX(h).mul(Affine.rotZ(Math.PI));
case "south" -> Affine.rotX(h);
case "west" -> Affine.rotX(h).mul(Affine.rotZ(h));
case "east" -> Affine.rotX(h).mul(Affine.rotZ(-h));
default -> Affine.identity();
};
} }
} }
@@ -3,13 +3,9 @@ package eu.mhsl.minecraft.pixelpics.render.entity;
import java.util.List; import java.util.List;
/** /**
* Immutable snapshot of one block-entity captured on the main thread, sufficient to bake and place it * Bukkit-free snapshot of one block-entity, captured on the main thread and baked off-thread through the entity path
* off-thread. Block-entities (chests, signs, banners, beds, heads, …) use vanilla {@code builtin/entity} * (see {@link BlockEntityModels}). {@code facingDeg} is the vanilla yaw of the facing/rotation (0 = south, 90 = west;
* block models with no geometry; their real shape lives in the bundled CEM models, so they are rendered * 16-step rotations are {@code segment · 22.5}). Type-specific fields are null/0/false/empty when unused.
* 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( public record BlockEntityState(
Kind kind, Kind kind,
@@ -17,55 +13,108 @@ public record BlockEntityState(
int by, int by,
int bz, int bz,
float facingDeg, float facingDeg,
ChestKind chestKind, // double-chest half (CHEST/TRAPPED_CHEST/ENDER_CHEST) String direction, // 6-way facing ("up", "north", …) of shulker boxes; null otherwise
int baseColorArgb, // banner base tint (white texture); 0 = none boolean active, // lectern: has a book; conduit: active; enchanting table: book open
String colorName, // bed/shulker/banner colour variant ("red", "white", …); null = default ChestKind chestKind, // double-chest half (CHEST/TRAPPED_CHEST/COPPER_CHEST)
String wood, // sign/hanging-sign wood ("oak", "spruce", …); null = default int baseColorArgb, // banner base colour; 0 = none
BedPart bedPart, // bed half String colorName, // shulker colour ("red", …) or copper-chest stage ("exposed", …); null = default
String headType, // "skeleton","wither_skeleton","zombie","creeper","piglin","dragon","player" String headType, // "skeleton","wither_skeleton","zombie","creeper","piglin","dragon","player"
String skinUrl, // player-head owner skin URL; null otherwise String skinUrl, // player-head owner skin URL; null otherwise
List<BannerPattern> patterns, // banner overlay patterns (may be empty) List<BannerPattern> patterns, // banner overlay patterns (may be empty)
List<String> sherds, // decorated-pot sherds: front/back/left/right item keys (may be empty) List<String> sherds, // decorated-pot sherds: front/left/right/back 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 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 SignText backText, // sign back-side text; null when not a sign or blank
List<ItemSpec> items, // campfire (4) / shelf (3) / vault (1) slots, null = empty slot; may be empty
EntityState displayEntity, // spawner / trial spawner mob (captured at its own position); null = none
Beam beam, // beacon beam (sections bottom-up); null = no beam
String pose // copper golem statue pose ("standing", "running", "sitting", "star"); null otherwise
) { ) {
/** Without contents (items, display entity, beam, pose); used by the test harnesses. */
public BlockEntityState(
Kind kind,
int bx,
int by,
int bz,
float facingDeg,
String direction,
boolean active,
ChestKind chestKind,
int baseColorArgb,
String colorName,
String headType,
String skinUrl,
List<BannerPattern> patterns,
List<String> sherds,
SignText frontText,
SignText backText) {
this(
kind,
bx,
by,
bz,
facingDeg,
direction,
active,
chestKind,
baseColorArgb,
colorName,
headType,
skinUrl,
patterns,
sherds,
frontText,
backText,
List.of(),
null,
null,
null);
}
public enum Kind { public enum Kind {
CHEST, CHEST,
TRAPPED_CHEST, TRAPPED_CHEST,
ENDER_CHEST, ENDER_CHEST,
COPPER_CHEST,
SIGN, SIGN,
WALL_SIGN, WALL_SIGN,
HANGING_SIGN, HANGING_SIGN,
BANNER, BANNER,
WALL_BANNER, WALL_BANNER,
BED,
SHULKER_BOX, SHULKER_BOX,
HEAD, HEAD,
WALL_HEAD, WALL_HEAD,
CONDUIT, CONDUIT,
DECORATED_POT, DECORATED_POT,
BELL BELL,
LECTERN,
ENCHANTING_TABLE, // facingDeg = book yaw (vanilla rot, degrees), active = open (player within 3 blocks)
CAMPFIRE,
SHELF,
COPPER_GOLEM_STATUE, // colorName = weathering stage, pose = statue pose
SPAWNER, // spawner and trial spawner: displayEntity
VAULT, // items[0] = displayed key/reward item
BEACON,
END_PORTAL,
END_GATEWAY // direction = "+"-joined faces to draw (neighbour not occluding)
} }
/**
* A beacon beam: coloured sections bottom-up (BeaconBlockEntity: white from the beacon, a stained-glass
* colour or the average with the previous one per change; the last runs to the top) and the radius scale
* (BeaconRenderer: {@code max(1, horizontal camera distance / 96)}).
*/
public record Beam(List<BeamSection> sections, float radiusScale) {}
/** One beam section: ARGB colour and height in blocks. */
public record BeamSection(int argb, int height) {}
public enum ChestKind { public enum ChestKind {
SINGLE, SINGLE,
LEFT, LEFT,
RIGHT 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. */ /** 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) {} public record BannerPattern(String patternKey, int colorArgb) {}
@@ -4,18 +4,16 @@ import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Face; import eu.mhsl.minecraft.pixelpics.assets.model.Face;
/** /**
* Unwraps a {@link ModelCube} into six {@link Face}s using the standard Minecraft/Bedrock box-UV * Unwraps a {@link ModelCube} into six {@link Face}s with the Java box-UV layout (the cross around the {@code uv}
* layout (faces packed in the canonical cross around the {@code uv} offset). Mirror swaps the * offset). Mirror swaps the left/right faces and flips each face horizontally.
* left/right faces and flips each face horizontally.
*/ */
public final class BoxUv { public final class BoxUv {
private BoxUv() {} private BoxUv() {}
/** /**
* Returns Faces indexed by {@link Direction#ordinal()}. UVs are normalized by the model's DECLARED * Faces by {@link Direction#ordinal()}. UVs are normalised by the model's DECLARED texture size, so a
* texel size (so a higher-res pack texture — e.g. a 128x128 sheet for a model authored at 64x64 — * higher-resolution pack texture maps proportionally; the actual size is used when undeclared.
* 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) { public static Face[] build(ModelCube cube, int[][] texture, int declaredW, int declaredH) {
int texW = texture.length > 0 ? texture[0].length : 64; int texW = texture.length > 0 ? texture[0].length : 64;
@@ -23,7 +21,6 @@ public final class BoxUv {
int nW = declaredW > 0 ? declaredW : texW; int nW = declaredW > 0 ? declaredW : texW;
int nH = declaredH > 0 ? declaredH : texH; 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) { if (cube.faceUv != null) {
Face[] faces = new Face[6]; Face[] faces = new Face[6];
for (int i = 0; i < 6; i++) { for (int i = 0; i < 6; i++) {
@@ -35,9 +32,8 @@ public final class BoxUv {
double dx = cube.size[0], dy = cube.size[1], dz = cube.size[2]; double dx = cube.size[0], dy = cube.size[1], dz = cube.size[2];
double u = cube.uv[0], v = cube.uv[1]; 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 // {x, y, w, h} texels, SIGNED (negative = flipped axis): the Blockbench/OptiFine box-UV layout (up/down
// the OptiFine/Blockbench box-UV layout EXACTLY (up/down are flipped), paired with the (s,t) the // flipped). Must stay in sync with the face (s, t) orientation of ElementIntersector.faceS/faceT.
// EntityIntersector feeds in, so every face's texture orientation matches vanilla Java.
double[] east = {u, v + dz, dz, dy}; double[] east = {u, v + dz, dz, dy};
double[] west = {u + dz + dx, v + dz, dz, dy}; double[] west = {u + dz + dx, v + dz, dz, dy};
double[] north = {u + dz, v + dz, dx, dy}; double[] north = {u + dz, v + dz, dx, dy};
@@ -1,25 +1,30 @@
package eu.mhsl.minecraft.pixelpics.render.entity; package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation; import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache; import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import eu.mhsl.minecraft.pixelpics.assets.model.Direction; import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Face; import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.ItemGeometry;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
/** /**
* Bakes flat wall decorations (paintings, item frames) into world-space {@link EntityCube}s. These are * Bakes wall decorations: paintings as a thin box over the captured world bounding box, item frames as their vanilla
* not CEM models but simple textured quads, so the geometry is built directly from the captured world * frame block model plus the framed item or map.
* bounding box and the front-facing direction. Added to the {@link EntityScene} like any other entity.
*/ */
public final class DecorationBaker implements EntityBaker<DecorationState> { public final class DecorationBaker implements EntityBaker<DecorationState> {
private static final double MIN_THICKNESS = 0.0625; // 1px, so the slab test never degenerates private static final double MIN_THICKNESS = 0.0625; // 1px, so the slab test never degenerates
private final TextureCache textures; private final TextureCache textures;
private final BlockModelRegistry registry;
private final ItemGeometry items;
public DecorationBaker(TextureCache textures) { public DecorationBaker(TextureCache textures, BlockModelRegistry registry, ItemGeometry items) {
this.textures = textures; this.textures = textures;
this.registry = registry;
this.items = items;
} }
@Override @Override
@@ -38,121 +43,113 @@ public final class DecorationBaker implements EntityBaker<DecorationState> {
thicken(from, to, s.facing()); thicken(from, to, s.facing());
Face[] faces = new Face[6]; Face[] faces = new Face[6];
Direction front = direction(s.facing()); Direction front = Direction.valueOf(s.facing().name());
faces[front.ordinal()] = frontFace(art, s.facing()); faces[front.ordinal()] = frontFace(art, s.facing());
faces[opposite(front).ordinal()] = new Face(back, 0, 0, 1, 1, 0, -1); faces[front.opposite().ordinal()] = new Face(back, 0, 0, 1, 1, 0, -1);
EntityCube cube = new EntityCube(from, to, faces, Affine.identity()); EntityCube cube = new EntityCube(from, to, faces, Affine.identity());
return RenderedEntity.of(List.of(cube)); return RenderedEntity.of(List.of(cube));
} }
/** /**
* Item frames render as the vanilla geometry: a 12x12 birch border, a recessed 10x10 leather back * Item frames as vanilla's ItemFrameRenderer (decompiled 26.3) draws them. Pose from the entity position
* panel ({@code block/item_frame}), and the held item as a centred 8x8 sprite. Built in a canonical * (the bbox centre; the renderer's own offset cancels the dispatcher's): {@code T(dir·0.46875)} to the
* local frame (front toward +Z, centred on the block face) and oriented to the wall by an affine. * block centre, then {@code rotX(xRot)·rotY(yRot)} (horizontal: 0 / 180−yRot(dir); up/down: ∓90 / 180),
* which turns local +Z toward the wall. Frame = the fake-blockstate block model
* ({@code block/[glow_]item_frame[_map]}) at {@code T(-0.5)}, skipped when invisible. Content at
* {@code T(0,0,0.4375)} ({@code 0.5} when invisible): a map as a 128x128 quad
* ({@code rotZ(rot%4·90)·rotZ(180)·S(1/128)·T(-64,-64,-1)}, z −0.01 map px, MapRenderer), any other item
* through the item model in the {@code fixed} display context at {@code rotZ(rot·45)·S(0.5)}.
*/ */
private RenderedEntity bakeItemFrame(DecorationState s) { private RenderedEntity bakeItemFrame(DecorationState s) {
String frameTex = s.glow() ? "block/glow_item_frame" : "block/item_frame"; DecorationState.Facing f = s.facing();
int[][] leather = textures.get(ResourceLocation.parse(frameTex)).orElse(null); double off = 0.46875;
int[][] wood = double cx = (s.minX() + s.maxX()) / 2 + (f.axis() == 0 ? f.sign() * off : 0);
textures.get(ResourceLocation.parse("block/birch_planks")).orElse(leather); double cy = (s.minY() + s.maxY()) / 2 + (f.axis() == 1 ? f.sign() * off : 0);
if (leather == null) return null; double cz = (s.minZ() + s.maxZ()) / 2 + (f.axis() == 2 ? f.sign() * off : 0);
double xRot, yRot;
Affine toWorld = Affine.translation(faceCenterX(s), faceCenterY(s), faceCenterZ(s)) if (f.axis() != 1) {
.mul(facingRotation(s.facing())); xRot = 0;
int si = Direction.SOUTH.ordinal(); yRot = 180 - yRot(f);
} else {
List<EntityCube> cubes = new ArrayList<>(3); xRot = -90 * f.sign();
// Wood border 12x12 (behind), leather back 10x10 (1px proud), item 8x8 (in front). Front = local +Z. yRot = 180;
// `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); Affine pose = Affine.translation(cx, cy, cz)
.mul(Affine.rotX(Math.toRadians(xRot)))
.mul(Affine.rotY(Math.toRadians(yRot)));
List<EntityCube> cubes = new ArrayList<>();
boolean map = s.mapPixels() != null;
if (!s.invisible()) {
String model = "block/" + (s.glow() ? "glow_item_frame" : "item_frame") + (map ? "_map" : "");
items.bakeModel(
registry.model(ResourceLocation.parse(model)),
null,
pose.mul(Affine.translation(-0.5, -0.5, -0.5)),
cubes);
}
Affine content = pose.mul(Affine.translation(0, 0, s.invisible() ? 0.5 : 0.4375));
if (map) {
Affine m = content.mul(Affine.rotZ(Math.toRadians(s.rotation() % 4 * 90)))
.mul(Affine.rotZ(Math.PI))
.mul(Affine.scale(1 / 128.0))
.mul(Affine.translation(-64, -64, -1));
// Quad (0,0)..(128,128) facing local −Z (away from the wall); u along +x, v along +y.
Face[] faces = new Face[6];
faces[Direction.NORTH.ordinal()] = new Face(s.mapPixels(), 1, 1, 0, 0, 0, -1);
faces[Direction.SOUTH.ordinal()] = new Face(s.mapPixels(), 0, 1, 1, 0, 0, -1);
cubes.add(new EntityCube(new double[] {0, 0, -0.01}, new double[] {128, 128, -0.01}, faces, m));
addMapMarkers(s, m, cubes);
} else if (s.item() != null) {
Affine m =
content.mul(Affine.rotZ(Math.toRadians(s.rotation() * 45))).mul(Affine.scale(0.5));
items.bake(s.item(), "fixed", false, m, cubes);
}
return cubes.isEmpty() ? null : RenderedEntity.of(cubes);
} }
/** 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) { * MapRenderer decorations (only those shown on frames, see {@code MapPixels.markers}): in map pixel space
return new double[] {x / 16.0, y / 16.0, z / 16.0}; * {@code T(x/2 + 64, y/2 + 64, -0.02)·rotZ(rot·22.5°)·S(4, 4, 3)·T(-0.125, 0.125, 0)}, the sprite quad
* (-1,1)..(1,-1) at z = n·-0.001 (n-th drawn decoration), {@code map/decorations/<asset>} with v0 at +y.
*/
private void addMapMarkers(DecorationState s, Affine map, List<EntityCube> cubes) {
List<DecorationState.MapMarker> markers = s.mapMarkers();
for (int n = 0; n < markers.size(); n++) {
DecorationState.MapMarker mk = markers.get(n);
int[][] sprite = textures.get(ResourceLocation.parse("map/decorations/" + mk.asset()))
.orElse(null);
if (sprite == null) continue;
Affine m = map.mul(Affine.translation(mk.x() / 2.0 + 64, mk.y() / 2.0 + 64, -0.02))
.mul(Affine.rotZ(Math.toRadians(mk.rot() * 360 / 16.0)))
.mul(Affine.scale(4, 4, 3))
.mul(Affine.translation(-0.125, 0.125, 0));
double z = n * -0.001;
cubes.add(new EntityCube(new double[] {-1, -1, z}, new double[] {1, 1, z}, ObjectBaker.card(sprite), m));
}
} }
/** Item sprite: generated items live under item/, block items fall back to block/. */ /** Vanilla Direction#toYRot for horizontal facings (south 0, west 90, north 180, east 270). */
private int[][] resolveItem(String id) { private static double yRot(DecorationState.Facing f) {
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) { return switch (f) {
case SOUTH -> Affine.identity(); case WEST -> 90;
case NORTH -> Affine.rotY(Math.PI); case NORTH -> 180;
case EAST -> Affine.rotY(Math.PI / 2); case EAST -> 270;
case WEST -> Affine.rotY(-Math.PI / 2); default -> 0;
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. */ /** Front face with the per-facing horizontal flip so the texture reads upright and unmirrored. */
private Face frontFace(int[][] tex, DecorationState.Facing facing) { private static Face frontFace(int[][] tex, DecorationState.Facing facing) {
boolean flipH = facing == DecorationState.Facing.SOUTH || facing == DecorationState.Facing.WEST; boolean flipH = facing == DecorationState.Facing.SOUTH || facing == DecorationState.Facing.WEST;
double u1 = flipH ? 1 : 0, u2 = flipH ? 0 : 1; double u1 = flipH ? 1 : 0, u2 = flipH ? 0 : 1;
return new Face(tex, u1, 0, u2, 1, 0, -1); return new Face(tex, u1, 0, u2, 1, 0, -1);
} }
private void thicken(double[] from, double[] to, DecorationState.Facing facing) { private static void thicken(double[] from, double[] to, DecorationState.Facing facing) {
int a = facing.axis(); int a = facing.axis();
if (to[a] - from[a] >= MIN_THICKNESS) return; if (to[a] - from[a] >= MIN_THICKNESS) return;
if (facing.sign() > 0) from[a] = to[a] - MIN_THICKNESS; if (facing.sign() > 0) from[a] = to[a] - MIN_THICKNESS;
else to[a] = from[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;
};
}
} }
@@ -1,10 +1,10 @@
package eu.mhsl.minecraft.pixelpics.render.entity; package eu.mhsl.minecraft.pixelpics.render.entity;
import java.util.List;
/** /**
* Immutable snapshot of a flat wall decoration (painting or item frame). These are Bukkit * Snapshot of a wall decoration (painting or item frame). The world bounding box is captured directly: it already
* {@code Hanging} entities, not block-entities, but render as flat textured quads rather than CEM * encodes vanilla's multi-block painting offset, and its centre is the item frame's entity position.
* 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( public record DecorationState(
Kind kind, Kind kind,
@@ -16,10 +16,54 @@ public record DecorationState(
double maxZ, double maxZ,
Facing facing, // direction the front faces (away from the wall) Facing facing, // direction the front faces (away from the wall)
String paintingArt, // painting art asset key (texture painting/<art>); null for frames 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 ItemSpec item, // item-frame contents; null if empty
int itemRotationDeg, // item-frame content rotation (0/45/…/315) int[][] mapPixels, // framed map's 128x128 ARGB colours (row-major [y][x]); null if not a map with data
boolean glow // glow item frame int rotation, // item-frame content rotation in 45° steps (0..7, vanilla ItemFrame#getRotation)
boolean glow, // glow item frame
boolean invisible, // invisible frame (setVisible(false)): no frame model, item sits on the wall
List<MapMarker> mapMarkers // framed map's decorations shown on item frames (banners, frames, …); may be empty
) { ) {
/** Without map markers; used by the test harnesses. */
public DecorationState(
Kind kind,
double minX,
double minY,
double minZ,
double maxX,
double maxY,
double maxZ,
Facing facing,
String paintingArt,
ItemSpec item,
int[][] mapPixels,
int rotation,
boolean glow,
boolean invisible) {
this(
kind,
minX,
minY,
minZ,
maxX,
maxY,
maxZ,
facing,
paintingArt,
item,
mapPixels,
rotation,
glow,
invisible,
List.of());
}
/**
* One map decoration (vanilla MapDecoration): sprite {@code map/decorations/<asset>}, position in half map
* pixels from the centre ({@code x}, {@code y} in -128..127), rotation in 16ths of a full turn.
*/
public record MapMarker(String asset, int x, int y, int rot) {}
public enum Kind { public enum Kind {
PAINTING, PAINTING,
ITEM_FRAME ITEM_FRAME
@@ -0,0 +1,51 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
/**
* Bounded, thread-safe LRU cache of derived (tinted/composited) textures, so identical mobs share one texture.
*
* <p>Keys ({@link #key}) list the inputs that determine the result; source grids compare by identity, which is exact
* because no grid is mutated once built. Cached grids are shared: callers must not mutate them. The computation runs
* outside the lock; concurrent misses may compute twice, the first stored result wins.
*/
public final class DerivedTextureCache {
private static final int[][] NONE = new int[0][0]; // cached "no texture"
private final Map<List<Object>, int[][]> map;
public DerivedTextureCache(int capacity) {
this.map = new LinkedHashMap<>(64, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<List<Object>, int[][]> eldest) {
return size() > capacity;
}
};
}
/** A cache key from the inputs (nulls allowed; arrays compare by identity). */
public static List<Object> key(Object... parts) {
return Arrays.asList(parts);
}
/** The cached texture for {@code key}, computing (and caching) it on a miss; null results are cached too. */
public int[][] get(List<Object> key, Supplier<int[][]> compute) {
int[][] v;
synchronized (map) {
v = map.get(key);
}
if (v == null) {
int[][] computed = compute.get();
synchronized (map) {
v = map.putIfAbsent(key, computed == null ? NONE : computed);
}
if (v == null) return computed;
}
return v == NONE ? null : v;
}
}
@@ -1,10 +1,6 @@
package eu.mhsl.minecraft.pixelpics.render.entity; package eu.mhsl.minecraft.pixelpics.render.entity;
/** /** Bakes a captured state into a {@link RenderedEntity}, or null when it has no geometry. */
* 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> { public interface EntityBaker<S> {
RenderedEntity bake(S state); RenderedEntity bake(S state);
} }
@@ -3,33 +3,78 @@ package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.assets.model.Face; 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 * A baked entity cube: a local box, the affine map into the world, its six faces (by {@code Direction.ordinal()},
* into the world, its six faces, the precomputed inverse transform and a world-space AABB for * null = not drawn), the inverse map and the world-space AABB.
* broad-phase culling.
*/ */
public final class EntityCube { public final class EntityCube {
public final double[] from; // local min (px, inflated) public final double[] from; // local corners; from > to on some axis = inverted (below)
public final double[] to; // local max public final double[] to;
public final Face[] faces; // by Direction.ordinal() public final Face[] faces;
public final Affine toWorld; public final Affine toWorld;
public final Affine toLocal; // inverse public final Affine toLocal;
public final boolean additive; // added to what lies behind (energy swirl) instead of blended
public final boolean unshaded; // no directional face shading (vanilla NO_CARDINAL_LIGHTING: eyes, breeze wind)
// Block-model element with from > to on some axis (spawner/vault inner faces): its faces point INTO the box
// and are back-face culled like vanilla quads (see EntityIntersector); mirrored = toWorld flips handedness.
public final boolean inverted, mirrored;
// Row-major map of the face normals: toWorld's linear part while it is a rotation with a uniform scale (same
// direction), else its inverse-transpose (vanilla PoseStack normal matrix for non-uniform scales, e.g. block
// displays scaled (2, 1, 0.5) and rotated).
public final double[] normalMatrix;
public final double[] aabbMin = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE}; public final double[] aabbMin = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE};
public final double[] aabbMax = {-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) { public EntityCube(double[] from, double[] to, Face[] faces, Affine toWorld) {
this(from, to, faces, toWorld, false);
}
public EntityCube(double[] from, double[] to, Face[] faces, Affine toWorld, boolean additive) {
this(from, to, faces, toWorld, additive, false);
}
/** Whether the columns of {@code r} are orthogonal and of equal length (rotation, reflection, uniform scale). */
private static boolean conformal(double[] r) {
double xx = r[0] * r[0] + r[3] * r[3] + r[6] * r[6];
double yy = r[1] * r[1] + r[4] * r[4] + r[7] * r[7];
double zz = r[2] * r[2] + r[5] * r[5] + r[8] * r[8];
double xy = r[0] * r[1] + r[3] * r[4] + r[6] * r[7];
double xz = r[0] * r[2] + r[3] * r[5] + r[6] * r[8];
double yz = r[1] * r[2] + r[4] * r[5] + r[7] * r[8];
double tol = 1e-9 * Math.max(xx, Math.max(yy, zz));
return Math.abs(xx - yy) <= tol
&& Math.abs(xx - zz) <= tol
&& Math.abs(xy) <= tol
&& Math.abs(xz) <= tol
&& Math.abs(yz) <= tol;
}
/** This cube placed by {@code m} (applied after its own map), keeping its render flags. */
public EntityCube transformed(Affine m) {
return new EntityCube(from, to, faces, m.mul(toWorld), additive, unshaded);
}
public EntityCube(double[] from, double[] to, Face[] faces, Affine toWorld, boolean additive, boolean unshaded) {
this.additive = additive;
this.unshaded = unshaded;
this.from = from; this.from = from;
this.to = to; this.to = to;
this.faces = faces; this.faces = faces;
this.toWorld = toWorld; this.toWorld = toWorld;
this.toLocal = toWorld.inverse(); this.toLocal = toWorld.inverse();
this.inverted = from[0] > to[0] || from[1] > to[1] || from[2] > to[2];
this.mirrored = toWorld.determinant() < 0;
double[] l = toLocal.r;
this.normalMatrix =
conformal(toWorld.r) ? toWorld.r : new double[] {l[0], l[3], l[6], l[1], l[4], l[7], l[2], l[5], l[8]};
double[] r = toWorld.r, t = toWorld.t;
for (int i = 0; i < 8; i++) { for (int i = 0; i < 8; i++) {
double x = (i & 1) == 0 ? from[0] : to[0]; double x = (i & 1) == 0 ? from[0] : to[0];
double y = (i & 2) == 0 ? from[1] : to[1]; double y = (i & 2) == 0 ? from[1] : to[1];
double z = (i & 4) == 0 ? from[2] : to[2]; double z = (i & 4) == 0 ? from[2] : to[2];
double[] w = toWorld.apply(x, y, z);
for (int a = 0; a < 3; a++) { for (int a = 0; a < 3; a++) {
if (w[a] < aabbMin[a]) aabbMin[a] = w[a]; double w = r[a * 3] * x + r[a * 3 + 1] * y + r[a * 3 + 2] * z + t[a];
if (w[a] > aabbMax[a]) aabbMax[a] = w[a]; if (w < aabbMin[a]) aabbMin[a] = w;
if (w > aabbMax[a]) aabbMax[a] = w;
} }
} }
} }
@@ -8,35 +8,66 @@ import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import org.bukkit.util.Vector; import org.bukkit.util.Vector;
/** /**
* Intersects a world-space ray with a single {@link EntityCube} (oriented box). The ray is mapped * Intersects a world-space ray with a single {@link EntityCube}: the ray is mapped into the cube's local frame and
* into the cube's local frame, slab-tested, and the entry face is sampled. Fully transparent texels * slab-tested (t stays the world-space one). Cut-out texels are holes; partially transparent ones keep their alpha
* are treated as holes (alpha cutout). The returned {@code t} is a world-space distance. * for the tracer to blend. The test writes into a caller-owned {@link Hit} and allocates nothing; {@link #toFaceHit}
* builds the result only for the hit the caller keeps.
*/ */
public final class EntityIntersector { public final class EntityIntersector {
private static final double EPS = 1e-7; private static final double EPS = 1e-7;
private static final Direction[] DIRECTIONS = Direction.values();
private EntityIntersector() {} private EntityIntersector() {}
public static FaceHit intersect(EntityCube cube, double ox, double oy, double oz, double dx, double dy, double dz) { /** Mutable per-thread result/scratch of {@link #intersect}; never shared between threads. */
double[] o = cube.toLocal.apply(ox, oy, oz); public static final class Hit {
double[] d = cube.toLocal.applyLinear(dx, dy, dz); public double t;
public int color;
Direction dir;
final double[] o = new double[3], d = new double[3];
}
/**
* Nearest drawn face with {@code t > minT} (entry face first, else the far face). Cubes entered beyond
* {@code maxT} are rejected before any texture lookup; a hit at exactly {@code maxT} still counts.
*/
public static boolean intersect(
EntityCube cube,
double ox,
double oy,
double oz,
double dx,
double dy,
double dz,
double minT,
double maxT,
Hit out) {
double[] r = cube.toLocal.r, tr = cube.toLocal.t;
double[] o = out.o, d = out.d;
o[0] = r[0] * ox + r[1] * oy + r[2] * oz + tr[0];
o[1] = r[3] * ox + r[4] * oy + r[5] * oz + tr[1];
o[2] = r[6] * ox + r[7] * oy + r[8] * oz + tr[2];
d[0] = r[0] * dx + r[1] * dy + r[2] * dz;
d[1] = r[3] * dx + r[4] * dy + r[5] * dz;
d[2] = r[6] * dx + r[7] * dy + r[8] * dz;
double lo = Math.max(EPS, minT);
if (cube.inverted) return intersectInverted(cube, o, d, lo, maxT, out);
double[] from = cube.from, to = cube.to;
double tmin = Double.NEGATIVE_INFINITY, tmax = Double.POSITIVE_INFINITY; double tmin = Double.NEGATIVE_INFINITY, tmax = Double.POSITIVE_INFINITY;
int inAxis = -1, outAxis = -1; int inAxis = -1, outAxis = -1;
boolean inNeg = false, outNeg = false; boolean inNeg = false, outNeg = false;
for (int a = 0; a < 3; a++) { for (int a = 0; a < 3; a++) {
if (Math.abs(d[a]) < EPS) { if (Math.abs(d[a]) < EPS) {
if (o[a] < cube.from[a] - EPS || o[a] > cube.to[a] + EPS) return null; if (o[a] < from[a] - EPS || o[a] > to[a] + EPS) return false;
continue; continue;
} }
double inv = 1.0 / d[a]; double inv = 1.0 / d[a];
// Moving towards +a the ray enters through the min ("negative") face and leaves through the // Entry side from the direction, not from t1 > t2: both are equal for zero-thickness boxes (wings, fins).
// max face. Decided by the direction, not by comparing t1/t2: for a zero-thickness box
// (wing membranes, fins) both are equal and the comparison can't tell the sides apart.
boolean forward = inv > 0; boolean forward = inv > 0;
double tNear = ((forward ? cube.from[a] : cube.to[a]) - o[a]) * inv; double tNear = ((forward ? from[a] : to[a]) - o[a]) * inv;
double tFar = ((forward ? cube.to[a] : cube.from[a]) - o[a]) * inv; double tFar = ((forward ? to[a] : from[a]) - o[a]) * inv;
if (tNear > tmin) { if (tNear > tmin) {
tmin = tNear; tmin = tNear;
inAxis = a; inAxis = a;
@@ -47,63 +78,67 @@ public final class EntityIntersector {
outAxis = a; outAxis = a;
outNeg = !forward; outNeg = !forward;
} }
if (tmin > tmax + EPS) return null; if (tmin > tmax + EPS) return false;
} }
if (inAxis < 0 || tmax < EPS) return null; if (inAxis < 0 || tmax <= lo || tmin > maxT) return false;
// Vanilla draws entity models without back-face culling: a ray looking through a transparent // Vanilla draws entity models without back-face culling: through a transparent texel of the entry face the
// texel of the entry face still sees the inside of the far face (and both sides of a plane). // ray sees the inside of the far face (and both sides of a plane).
if (tmin >= EPS) { if (tmin > lo && sample(cube, o, d, tmin, Direction.of(inAxis, inNeg), out)) return true;
FaceHit hit = sample(cube, o, d, tmin, inAxis, inNeg, ox, oy, oz, dx, dy, dz); return outAxis >= 0 && sample(cube, o, d, tmax, Direction.of(outAxis, outNeg), out);
if (hit != null) return hit;
}
return outAxis < 0 ? null : sample(cube, o, d, tmax, outAxis, outNeg, ox, oy, oz, dx, dy, dz);
} }
private static FaceHit sample( private static boolean sample(EntityCube cube, double[] o, double[] d, double t, Direction dir, Hit out) {
EntityCube cube,
double[] o,
double[] d,
double t,
int axis,
boolean neg,
double ox,
double oy,
double oz,
double dx,
double dy,
double dz) {
double px = o[0] + d[0] * t, py = o[1] + d[1] * t, pz = o[2] + d[2] * t;
Direction dir =
switch (axis) {
case 0 -> neg ? Direction.WEST : Direction.EAST;
case 1 -> neg ? Direction.DOWN : Direction.UP;
default -> neg ? Direction.NORTH : Direction.SOUTH;
};
Face face = cube.faces[dir.ordinal()]; Face face = cube.faces[dir.ordinal()];
if (face == null) return null; if (face == null) return false;
double fx = ElementIntersector.frac(o[0] + d[0] * t, cube.from[0], cube.to[0]);
double fy = ElementIntersector.frac(o[1] + d[1] * t, cube.from[1], cube.to[1]);
double fz = ElementIntersector.frac(o[2] + d[2] * t, cube.from[2], cube.to[2]);
int color = face.sample(ElementIntersector.faceS(dir, fx, fy, fz), ElementIntersector.faceT(dir, fy, fz));
if (!ColorUtil.opaque(color)) return false;
out.t = t;
out.color = color;
out.dir = dir;
return true;
}
double fx = frac(px, cube.from[0], cube.to[0]); /**
double fy = frac(py, cube.from[1], cube.to[1]); * A block-model element with {@code from > to} on some axis (spawner/vault inner faces) baked as an item or
double fz = frac(pz, cube.from[2], cube.to[2]); * display: back-face-culled inward quads like {@link ElementIntersector} (a mirroring {@code toWorld} flips their
// Vanilla face-UV orientation, shared by block-model faces and entity box-UV (see BoxUv). * winding too). The nearest opaque face in {@code (lo, maxT]} wins.
double[] st = ElementIntersector.faceUv(dir, fx, fy, fz); */
double s = st[0], tt = st[1]; private static boolean intersectInverted(EntityCube cube, double[] o, double[] d, double lo, double maxT, Hit out) {
int color = face.sample(s, tt); double[] from = cube.from, to = cube.to;
if (!ColorUtil.opaque(color)) return null; double best = Double.POSITIVE_INFINITY;
for (Direction dir : DIRECTIONS) {
if (Math.abs(d[dir.axis]) < EPS) continue;
double t = ElementIntersector.invertedFaceT(dir, from, to, o, d, cube.mirrored);
if (!(t > lo) || t > maxT || t >= best) continue;
if (sample(cube, o, d, t, dir, out)) best = t;
}
return best < Double.POSITIVE_INFINITY;
}
Vector world = new Vector(ox + dx * t, oy + dy * t, oz + dz * t); /** The result object for a hit of {@code cube} recorded by {@link #intersect} (world point + normal). */
double[] n = cube.toWorld.applyLinear(dir.nx, dir.ny, dir.nz); public static FaceHit toFaceHit(
Vector normal = new Vector(n[0], n[1], n[2]).normalize(); EntityCube cube, Hit h, double ox, double oy, double oz, double dx, double dy, double dz) {
double t = h.t;
Direction dir = h.dir;
double[] r = cube.normalMatrix;
Vector normal = new Vector(
r[0] * dir.nx + r[1] * dir.ny + r[2] * dir.nz,
r[3] * dir.nx + r[4] * dir.ny + r[5] * dir.nz,
r[6] * dir.nx + r[7] * dir.ny + r[8] * dir.nz)
.normalize();
// A far/back face is seen from inside: shade it as facing the viewer. // A far/back face is seen from inside: shade it as facing the viewer.
if (normal.getX() * dx + normal.getY() * dy + normal.getZ() * dz > 0) normal.multiply(-1); if (normal.getX() * dx + normal.getY() * dy + normal.getZ() * dz > 0) normal.multiply(-1);
return new FaceHit(t, world, normal, color, -1); return new FaceHit(
} t,
new Vector(ox + dx * t, oy + dy * t, oz + dz * t),
private static double frac(double v, double lo, double hi) { normal,
double span = hi - lo; h.color,
if (span < 1e-6) return 0; -1,
double f = (v - lo) / span; cube.additive,
return f < 0 ? 0 : Math.min(f, 1); cube.unshaded);
} }
} }
@@ -6,16 +6,14 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* Maps an entity type to its CEM ({@code .jem}) Java model name and candidate texture paths. Most types * Entity type -> CEM template model name (legacy fallback) and candidate texture paths. Most types use the type key
* use the type key directly for both; small override maps handle the exceptions. The models are vanilla * directly; small override maps handle the exceptions.
* Java models (already posed); variant-specific textures (cow/sheep colour, etc.) are handled here.
*/ */
public final class EntityModels { public final class EntityModels {
private EntityModels() {} private EntityModels() {}
// Type key -> CEM (.jem) model name. Default is the type key itself; these are the exceptions // Exceptions to "model name = type key": reused models and renamed/version-suffixed CEM names.
// (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( private static final Map<String, String> CEM_OVERRIDE = Map.ofEntries(
Map.entry("husk", "zombie"), Map.entry("husk", "zombie"),
Map.entry("giant", "zombie"), Map.entry("giant", "zombie"),
@@ -46,7 +44,7 @@ public final class EntityModels {
/** The CEM model name for an entity type (boats/rafts share the boat hull). */ /** The CEM model name for an entity type (boats/rafts share the boat hull). */
public static String cemModel(String typeKey) { public static String cemModel(String typeKey) {
if (typeKey.endsWith("_boat") || typeKey.endsWith("_raft")) return "boat"; if (isBoat(typeKey)) return "boat";
return CEM_OVERRIDE.getOrDefault(typeKey, typeKey); return CEM_OVERRIDE.getOrDefault(typeKey, typeKey);
} }
@@ -54,7 +52,7 @@ public final class EntityModels {
// or not the right default: variant-only mobs whose renderer default is a variant/"missingno", the dump's // or not the right default: variant-only mobs whose renderer default is a variant/"missingno", the dump's
// piglin_brute (= piglin), players/mannequins, and non-mob entities whose renderer texture isn't captured. // piglin_brute (= piglin), players/mannequins, and non-mob entities whose renderer texture isn't captured.
// Everything else comes from the dump (see CemBaker#resolveTexture). // Everything else comes from the dump (see CemBaker#resolveTexture).
private static final Map<String, String> TEX_OVERRIDE = Map.<String, String>ofEntries( private static final Map<String, String> TEX_OVERRIDE = Map.ofEntries(
Map.entry("cow", "entity/cow/cow_temperate"), Map.entry("cow", "entity/cow/cow_temperate"),
Map.entry("player", "entity/player/wide/steve"), Map.entry("player", "entity/player/wide/steve"),
Map.entry("ender_dragon", "entity/enderdragon/dragon"), Map.entry("ender_dragon", "entity/enderdragon/dragon"),
@@ -80,7 +78,7 @@ public final class EntityModels {
/** Ordered texture-path candidates; the baker uses the first that loads. */ /** Ordered texture-path candidates; the baker uses the first that loads. */
public static List<ResourceLocation> textureCandidates(String typeKey, String variant) { public static List<ResourceLocation> textureCandidates(String typeKey, String variant) {
List<ResourceLocation> list = new ArrayList<>(); List<ResourceLocation> list = new ArrayList<>();
if (typeKey.endsWith("_boat") || typeKey.endsWith("_raft")) { if (isBoat(typeKey)) {
String wood = typeKey.substring(0, typeKey.length() - 5); String wood = typeKey.substring(0, typeKey.length() - 5);
boolean chest = wood.endsWith("_chest"); boolean chest = wood.endsWith("_chest");
if (chest) wood = wood.substring(0, wood.length() - "_chest".length()); if (chest) wood = wood.substring(0, wood.length() - "_chest".length());
@@ -100,27 +98,21 @@ public final class EntityModels {
/** How many leading {@link #textureCandidates} entries are type/variant-specific (not generic guesses). */ /** How many leading {@link #textureCandidates} entries are type/variant-specific (not generic guesses). */
public static int specificCandidateCount(String typeKey, String variant) { public static int specificCandidateCount(String typeKey, String variant) {
if (typeKey.endsWith("_boat") || typeKey.endsWith("_raft")) return 1; if (isBoat(typeKey)) return 1;
return (variant != null ? variantPaths(typeKey, variant).size() : 0) return (variant != null ? variantPaths(typeKey, variant).size() : 0)
+ (TEX_OVERRIDE.containsKey(typeKey) ? 1 : 0); + (TEX_OVERRIDE.containsKey(typeKey) ? 1 : 0);
} }
/** /**
* Variant-specific texture paths (modern pack naming is "entity/<folder>/<entity>_<variant>", with a * Texture paths of the ENUM variants vanilla hardcodes in its renderers ("entity/<folder>/<entity>_<variant>",
* handful of mismatches the small maps below normalise). Returned paths are tried before the generic * mismatches normalised by the maps below). Registry variants (cat, wolf, frog, cow, …) come from their
* fallbacks, so an unknown variant still degrades to the base texture. * {@code asset_id} in the dump instead. Tried before the generic fallbacks.
*/ */
private static List<String> variantPaths(String typeKey, String v) { private static List<String> variantPaths(String typeKey, String v) {
return switch (typeKey) { return switch (typeKey) {
case "cat" -> List.of("entity/cat/cat_" + v);
case "axolotl" -> List.of("entity/axolotl/axolotl_" + 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 "horse" -> List.of("entity/horse/horse_" + HORSE_COLOR.getOrDefault(v, v));
case "llama" -> List.of("entity/llama/llama_" + 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 "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 "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 "parrot" -> List.of("entity/parrot/parrot_" + PARROT_COLOR.getOrDefault(v, v));
@@ -129,8 +121,6 @@ public final class EntityModels {
case "shulker" -> List.of("entity/shulker/shulker_" + v); case "shulker" -> List.of("entity/shulker/shulker_" + v);
case "cushion" -> List.of("entity/cushion/" + v + "_cushion"); case "cushion" -> List.of("entity/cushion/" + v + "_cushion");
case "tropical_fish" -> List.of("entity/fish/tropical_" + (tropicalPattern(v) >= 6 ? "b" : "a")); case "tropical_fish" -> List.of("entity/fish/tropical_" + (tropicalPattern(v) >= 6 ? "b" : "a"));
case "zombie_nautilus" -> List.of(
v.equals("warm") ? "entity/nautilus/zombie_nautilus_coral" : "entity/nautilus/zombie_nautilus");
case "copper_golem" -> List.of( case "copper_golem" -> List.of(
v.equals("unaffected") v.equals("unaffected")
? "entity/copper_golem/copper_golem" ? "entity/copper_golem/copper_golem"
@@ -141,6 +131,10 @@ public final class EntityModels {
}; };
} }
private static boolean isBoat(String typeKey) {
return typeKey.endsWith("_boat") || typeKey.endsWith("_raft");
}
/** Vanilla tropical-fish patterns in id order: 0-5 use body shape A (small), 6-11 shape B (large). */ /** Vanilla tropical-fish patterns in id order: 0-5 use body shape A (small), 6-11 shape B (large). */
private static final List<String> TROPICAL_PATTERNS = List.of( private static final List<String> TROPICAL_PATTERNS = List.of(
"kob", "kob",
@@ -4,19 +4,61 @@ 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.CemBaker;
import eu.mhsl.minecraft.pixelpics.render.raytrace.FaceHit; import eu.mhsl.minecraft.pixelpics.render.raytrace.FaceHit;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.logging.Logger;
/** /**
* All baked entities (mobs + block-entities) for one render. Provides the nearest hit along a ray, * All baked entities (mobs, block-entities, decorations, objects) of one render, as one bounding volume hierarchy
* using per-entity and per-cube AABB broad-phase culling. Block-entities are baked into the same list * over the cubes' world AABBs (binned SAH, flat arrays), traversed front to back with the best hit as far bound.
* and tested identically, so the raytracer needs no special case. Immutable after construction → safe * Near-ties (coplanar faces of different cubes) are decided in bake order, the later cube winning (see
* for the parallel tracer. * {@link #nearestHit}). Immutable after construction; the per-ray scratch is per thread.
*/ */
public final class EntityScene { public final class EntityScene {
private static final double EPS = 1e-7; private static final double EPS = 1e-7;
private final List<RenderedEntity> entities; private static final int LEAF_SIZE = 4;
private static volatile boolean warned; private static final int SAH_BINS = 16;
private static final int MAX_SAH_DEPTH = 40;
// Cubes in BVH leaf order, their bake-order rank (tie-break) and world AABBs (minX..maxZ, 6 per cube).
private final EntityCube[] cubes;
private final int[] cubeRank;
private final double[] cubeBox;
// BVH nodes: box (6 per node); inner nodes have left child = node + 1 and right child = nodeRight,
// leaves (nodeCount > 0) cover cubes[nodeStart .. nodeStart + nodeCount).
private double[] nodeBox;
private int[] nodeRight, nodeStart, nodeCount;
private int nodes;
private final int maxDepth;
// Hits within this distance of the nearest one are candidates for the exact tie-break (pass 2 of
// nearestHit); far above double rounding at render distances, far below anything visible.
private static final double TIE_SLACK = 1e-6;
/** Per-thread traversal scratch: stack and the candidate hits (reused, grown on demand). */
private static final class Traversal {
int[] stack = new int[64];
double[] stackT = new double[64];
EntityIntersector.Hit[] cand = new EntityIntersector.Hit[0];
int[] candCube = new int[8];
int[] order = new int[8];
EntityIntersector.Hit candidate(int k) {
if (k == cand.length) {
int len = Math.max(8, k * 2);
EntityIntersector.Hit[] c = Arrays.copyOf(cand, len);
for (int i = k; i < len; i++) c[i] = new EntityIntersector.Hit();
cand = c;
candCube = Arrays.copyOf(candCube, len);
order = Arrays.copyOf(order, len);
}
return cand[k];
}
}
private static final ThreadLocal<Traversal> TRAVERSAL = ThreadLocal.withInitial(Traversal::new);
public EntityScene( public EntityScene(
List<EntityState> states, List<EntityState> states,
@@ -27,76 +69,402 @@ public final class EntityScene {
DecorationBaker decoBaker, DecorationBaker decoBaker,
List<ObjectState> objects, List<ObjectState> objects,
ObjectBaker objectBaker) { ObjectBaker objectBaker) {
this.entities = new ArrayList<>(states.size() + blockEntities.size() + decorations.size() + objects.size()); List<EntityCube> baked = new ArrayList<>();
addAll(states, baker); List<RuntimeException> failures = new ArrayList<>();
addAll(blockEntities, beBaker); addAll(states, baker, baked, failures);
addAll(decorations, decoBaker); addAll(blockEntities, beBaker, baked, failures);
addAll(objects, objectBaker); addAll(decorations, decoBaker, baked, failures);
addAll(objects, objectBaker, baked, failures);
if (!failures.isEmpty()) {
// Once per photo: one bad entity/asset must never fail the whole photo, but should not go unnoticed.
Logger.getLogger("PixelPics")
.warning("PixelPics: skipped " + failures.size() + " entities that failed to bake, first: "
+ failures.getFirst());
}
EntityCube[] all = baked.toArray(new EntityCube[0]);
int n = all.length;
cubes = new EntityCube[n];
cubeRank = new int[n];
cubeBox = new double[n * 6];
maxDepth = build(all);
} }
/** Bakes each state and keeps the ones with geometry. A null baker (path disabled) is skipped. */ /**
private <S> void addAll(List<S> states, EntityBaker<S> baker) { * Bakes each state into {@code out} (in bake order = tie-break rank); states that fail to bake are skipped and
* collected in {@code failures}. A null baker (path disabled) is skipped.
*/
private static <S> void addAll(
List<S> states, EntityBaker<S> baker, List<EntityCube> out, List<RuntimeException> failures) {
if (baker == null) return; if (baker == null) return;
for (S s : states) { for (S s : states) {
RenderedEntity e; RenderedEntity e;
try { try {
e = baker.bake(s); e = baker.bake(s);
} catch (RuntimeException ex) { } catch (RuntimeException ex) {
// One unexpected entity/asset must never fail the whole photo: skip just this one. failures.add(ex);
if (!warned) {
warned = true;
java.util.logging.Logger.getLogger("PixelPics")
.warning("PixelPics: skipped an entity that failed to bake: " + ex);
}
continue; continue;
} }
if (e != null && !e.cubes.isEmpty()) entities.add(e); if (e != null) out.addAll(e.cubes);
} }
} }
public boolean isEmpty() { public boolean isEmpty() {
return entities.isEmpty(); return cubes.length == 0;
} }
/** Nearest entity hit with {@code t < maxT}, or null. */ /** Nearest entity hit with {@code minT < t < maxT}, or null. */
public FaceHit nearestHit(double ox, double oy, double oz, double dx, double dy, double dz, double maxT) { public FaceHit nearestHit(
FaceHit best = null; double ox, double oy, double oz, double dx, double dy, double dz, double minT, double maxT) {
double bestT = maxT; if (nodes == 0) return null;
for (RenderedEntity e : entities) { Traversal tr = TRAVERSAL.get();
if (!rayAabb(e.aabbMin, e.aabbMax, ox, oy, oz, dx, dy, dz, bestT)) continue; if (tr.stack.length < maxDepth + 2) {
for (EntityCube cube : e.cubes) { tr.stack = new int[maxDepth + 2];
if (!rayAabb(cube.aabbMin, cube.aabbMax, ox, oy, oz, dx, dy, dz, bestT)) continue; tr.stackT = new double[maxDepth + 2];
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; int[] stack = tr.stack;
} double[] stackT = tr.stackT;
private static boolean rayAabb( // Per-axis slab setup, the same arithmetic as a per-box test (inv = 1/d; |d| < EPS = parallel).
double[] min, double[] max, double ox, double oy, double oz, double dx, double dy, double dz, double maxT) { boolean px = Math.abs(dx) < EPS, py = Math.abs(dy) < EPS, pz = Math.abs(dz) < EPS;
double tmin = 0, tmax = maxT; double ix = px ? 0 : 1.0 / dx, iy = py ? 0 : 1.0 / dy, iz = pz ? 0 : 1.0 / dz;
double[] o = {ox, oy, oz}, d = {dx, dy, dz}; double t0 = Math.max(0, minT);
for (int a = 0; a < 3; a++) {
if (Math.abs(d[a]) < EPS) { // Pass 1 (BVH, front to back): every cube hit within TIE_SLACK of the nearest one becomes a candidate.
if (o[a] < min[a] || o[a] > max[a]) return false; // The far bound stays 2·TIE_SLACK behind the nearest hit so rounding in the AABB tests can't drop one.
double nearest = Double.POSITIVE_INFINITY, bound = maxT;
int n = 0;
double[] nb = nodeBox, cb = cubeBox;
int sp = 0;
double rootT = entry(nb, 0, ox, oy, oz, ix, iy, iz, px, py, pz, t0, bound);
if (rootT != rootT) return null;
stack[sp] = 0;
stackT[sp++] = rootT;
while (sp > 0) {
int node = stack[--sp];
if (stackT[sp] > bound) continue; // the nearest hit got closer since this node was queued
int count = nodeCount[node];
if (count > 0) {
for (int i = nodeStart[node], end = i + count; i < end; i++) {
double ct = entry(cb, i, ox, oy, oz, ix, iy, iz, px, py, pz, t0, bound);
if (ct != ct) continue; // NaN: AABB missed
EntityIntersector.Hit hit = tr.candidate(n);
if (!EntityIntersector.intersect(cubes[i], ox, oy, oz, dx, dy, dz, minT, bound, hit)) continue;
double t = hit.t;
if (!(t < maxT) || t > nearest + TIE_SLACK) continue;
tr.candCube[n++] = i;
if (t < nearest) {
nearest = t;
bound = Math.min(maxT, t + 2 * TIE_SLACK);
}
}
} else { } else {
double inv = 1.0 / d[a]; int l = node + 1, r = nodeRight[node];
double t1 = (min[a] - o[a]) * inv; double tl = entry(nb, l, ox, oy, oz, ix, iy, iz, px, py, pz, t0, bound);
double t2 = (max[a] - o[a]) * inv; double trr = entry(nb, r, ox, oy, oz, ix, iy, iz, px, py, pz, t0, bound);
if (t1 > t2) { // Near child on top of the stack: visited first, so the far one is usually culled. NaN = missed.
double tmp = t1; int near = l, far = r;
t1 = t2; double tn = tl, tf = trr;
t2 = tmp; if (!(tl <= trr)) {
near = r;
far = l;
tn = trr;
tf = tl;
}
if (tf == tf) {
stack[sp] = far;
stackT[sp++] = tf;
}
if (tn == tn) {
stack[sp] = near;
stackT[sp++] = tn;
} }
if (t1 > tmin) tmin = t1;
if (t2 < tmax) tmax = t2;
if (tmin > tmax) return false;
} }
} }
return true; if (n == 0) return null;
// Pass 2, the tie-break: the near candidates in bake order; a cube counts only if its AABB passes with the
// best t so far as far bound and it is not farther. On equal t the later-baked cube wins, like vanilla's
// LEQUAL depth test for later-drawn quads (block-model overlay elements in displays/items, layers on the
// body mesh). Coplanar faces of different cubes depend on this order, so it must not change.
int[] candCube = tr.candCube;
int[] order = tr.order;
int m = 0;
for (int k = 0; k < n; k++) {
if (tr.cand[k].t > nearest + TIE_SLACK) continue;
// Insertion by bake rank (a handful of candidates at most).
int rank = cubeRank[candCube[k]], j = m++;
while (j > 0 && cubeRank[candCube[order[j - 1]]] > rank) {
order[j] = order[j - 1];
j--;
}
order[j] = k;
}
double bestT = maxT;
int best = -1;
for (int j = 0; j < m; j++) {
int k = order[j];
double t = tr.cand[k].t;
double e = entry(cb, candCube[k], ox, oy, oz, ix, iy, iz, px, py, pz, t0, bestT);
if (e == e && t <= bestT) {
bestT = t;
best = k;
}
}
return EntityIntersector.toFaceHit(cubes[candCube[best]], tr.cand[best], ox, oy, oz, dx, dy, dz);
}
/**
* Slab test of box {@code i} of {@code boxes} (6 doubles per box) against the ray within {@code [t0, tMax]};
* returns the entry t, or NaN on a miss (a box touching the interval at {@code tMax} still counts).
*/
private static double entry(
double[] boxes,
int i,
double ox,
double oy,
double oz,
double ix,
double iy,
double iz,
boolean px,
boolean py,
boolean pz,
double t0,
double tMax) {
int b = i * 6;
double tmin = t0, tmax = tMax;
if (px) {
if (ox < boxes[b] || ox > boxes[b + 3]) return Double.NaN;
} else {
double t1 = (boxes[b] - ox) * ix, t2 = (boxes[b + 3] - ox) * ix;
if (t1 > t2) {
double tmp = t1;
t1 = t2;
t2 = tmp;
}
if (t1 > tmin) tmin = t1;
if (t2 < tmax) tmax = t2;
if (tmin > tmax) return Double.NaN;
}
if (py) {
if (oy < boxes[b + 1] || oy > boxes[b + 4]) return Double.NaN;
} else {
double t1 = (boxes[b + 1] - oy) * iy, t2 = (boxes[b + 4] - oy) * iy;
if (t1 > t2) {
double tmp = t1;
t1 = t2;
t2 = tmp;
}
if (t1 > tmin) tmin = t1;
if (t2 < tmax) tmax = t2;
if (tmin > tmax) return Double.NaN;
}
if (pz) {
if (oz < boxes[b + 2] || oz > boxes[b + 5]) return Double.NaN;
} else {
double t1 = (boxes[b + 2] - oz) * iz, t2 = (boxes[b + 5] - oz) * iz;
if (t1 > t2) {
double tmp = t1;
t1 = t2;
t2 = tmp;
}
if (t1 > tmin) tmin = t1;
if (t2 < tmax) tmax = t2;
if (tmin > tmax) return Double.NaN;
}
return tmin;
}
private double[] centroid; // 3 per original cube
private double[] srcBox; // 6 per original cube
private int[] perm; // original cube indices, partitioned in place
private int filled; // cubes written in leaf order
private int build(EntityCube[] all) {
int n = all.length;
if (n == 0) {
nodeBox = new double[0];
nodeRight = nodeStart = nodeCount = new int[0];
return 0;
}
srcBox = new double[n * 6];
centroid = new double[n * 3];
perm = new int[n];
for (int i = 0; i < n; i++) {
EntityCube c = all[i];
for (int a = 0; a < 3; a++) {
srcBox[i * 6 + a] = c.aabbMin[a];
srcBox[i * 6 + 3 + a] = c.aabbMax[a];
centroid[i * 3 + a] = (c.aabbMin[a] + c.aabbMax[a]) * 0.5;
}
perm[i] = i;
}
int cap = 2 * n;
nodeBox = new double[cap * 6];
nodeRight = new int[cap];
nodeStart = new int[cap];
nodeCount = new int[cap];
int depth = buildNode(all, 0, n, 0);
srcBox = centroid = null;
perm = null;
return depth;
}
/** Builds the subtree over perm[lo, hi); returns its depth. */
private int buildNode(EntityCube[] all, int lo, int hi, int depth) {
int node = nodes++;
int nb = node * 6;
nodeBox[nb] = nodeBox[nb + 1] = nodeBox[nb + 2] = Double.MAX_VALUE;
nodeBox[nb + 3] = nodeBox[nb + 4] = nodeBox[nb + 5] = -Double.MAX_VALUE;
double[] cmin = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE};
double[] cmax = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE};
for (int i = lo; i < hi; i++) {
int c = perm[i];
for (int a = 0; a < 3; a++) {
nodeBox[nb + a] = Math.min(nodeBox[nb + a], srcBox[c * 6 + a]);
nodeBox[nb + 3 + a] = Math.max(nodeBox[nb + 3 + a], srcBox[c * 6 + 3 + a]);
cmin[a] = Math.min(cmin[a], centroid[c * 3 + a]);
cmax[a] = Math.max(cmax[a], centroid[c * 3 + a]);
}
}
int count = hi - lo;
int axis = 0;
for (int a = 1; a < 3; a++) if (cmax[a] - cmin[a] > cmax[axis] - cmin[axis]) axis = a;
double extent = cmax[axis] - cmin[axis];
int mid;
if (count <= LEAF_SIZE || !(extent > 1e-9)) {
mid = -1;
} else if (depth >= MAX_SAH_DEPTH) {
// Pathological centroid distributions can make SAH splits very lopsided: median keeps it bounded.
sortByCentroid(lo, hi, axis);
mid = lo + count / 2;
} else {
mid = sahSplit(nb, lo, hi, axis, cmin[axis], extent);
}
if (mid < 0) {
// Leaf: cubes in bake order inside the leaf (ascending original index).
Arrays.sort(perm, lo, hi);
nodeStart[node] = filled;
nodeCount[node] = count;
for (int i = lo; i < hi; i++) {
int c = perm[i];
cubes[filled] = all[c];
cubeRank[filled] = c;
System.arraycopy(srcBox, c * 6, cubeBox, filled * 6, 6);
filled++;
}
return depth;
}
nodeCount[node] = 0;
int dl = buildNode(all, lo, mid, depth + 1);
nodeRight[node] = nodes;
int dr = buildNode(all, mid, hi, depth + 1);
return Math.max(dl, dr);
}
/**
* Partitions perm[lo, hi) along {@code axis} at the cheapest of {@link #SAH_BINS}-1 bin boundaries
* (surface area heuristic); falls back to the median when every boundary leaves one side empty. Returns
* the split index, or -1 when a leaf is cheaper.
*/
private int sahSplit(int nb, int lo, int hi, int axis, double cmin, double extent) {
int[] binCount = new int[SAH_BINS];
double[] binBox = new double[SAH_BINS * 6];
for (int b = 0; b < SAH_BINS; b++) {
for (int a = 0; a < 3; a++) {
binBox[b * 6 + a] = Double.MAX_VALUE;
binBox[b * 6 + 3 + a] = -Double.MAX_VALUE;
}
}
double scale = SAH_BINS / extent;
for (int i = lo; i < hi; i++) {
int c = perm[i];
int b = bin(centroid[c * 3 + axis], cmin, scale);
binCount[b]++;
for (int a = 0; a < 3; a++) {
binBox[b * 6 + a] = Math.min(binBox[b * 6 + a], srcBox[c * 6 + a]);
binBox[b * 6 + 3 + a] = Math.max(binBox[b * 6 + 3 + a], srcBox[c * 6 + 3 + a]);
}
}
// Sweep: cost(split after bin s) = area(left)·nLeft + area(right)·nRight.
double[] leftArea = new double[SAH_BINS];
int[] leftCount = new int[SAH_BINS];
double[] acc = {
Double.MAX_VALUE,
Double.MAX_VALUE,
Double.MAX_VALUE,
-Double.MAX_VALUE,
-Double.MAX_VALUE,
-Double.MAX_VALUE
};
int cnt = 0;
for (int b = 0; b < SAH_BINS - 1; b++) {
grow(acc, binBox, b);
cnt += binCount[b];
leftArea[b] = area(acc, 0);
leftCount[b] = cnt;
}
acc[0] = acc[1] = acc[2] = Double.MAX_VALUE;
acc[3] = acc[4] = acc[5] = -Double.MAX_VALUE;
cnt = 0;
double bestCost = Double.POSITIVE_INFINITY;
int bestSplit = -1;
for (int b = SAH_BINS - 1; b >= 1; b--) {
grow(acc, binBox, b);
cnt += binCount[b];
int left = leftCount[b - 1];
if (left == 0 || cnt == 0) continue;
double cost = leftArea[b - 1] * left + area(acc, 0) * cnt;
if (cost < bestCost) {
bestCost = cost;
bestSplit = b;
}
}
int count = hi - lo;
if (bestSplit < 0) {
// All centroids in one bin (should not happen with extent > 0): median split.
sortByCentroid(lo, hi, axis);
return lo + count / 2;
}
// A leaf costs area(node)·count (one cube test per cube), a split one node visit more.
double nodeArea = area(nodeBox, nb);
if (count <= 16 && bestCost + nodeArea >= nodeArea * count) return -1;
int i = lo, j = hi - 1;
while (i <= j) {
if (bin(centroid[perm[i] * 3 + axis], cmin, scale) < bestSplit) {
i++;
} else {
int tmp = perm[i];
perm[i] = perm[j];
perm[j--] = tmp;
}
}
return i;
}
private static int bin(double c, double cmin, double scale) {
int b = (int) ((c - cmin) * scale);
return b < 0 ? 0 : Math.min(b, SAH_BINS - 1);
}
private void sortByCentroid(int lo, int hi, int axis) {
Integer[] tmp = new Integer[hi - lo];
for (int i = lo; i < hi; i++) tmp[i - lo] = perm[i];
Arrays.sort(tmp, (a, b) -> Double.compare(centroid[a * 3 + axis], centroid[b * 3 + axis]));
for (int i = lo; i < hi; i++) perm[i] = tmp[i - lo];
}
private static void grow(double[] acc, double[] boxes, int b) {
for (int a = 0; a < 3; a++) {
acc[a] = Math.min(acc[a], boxes[b * 6 + a]);
acc[3 + a] = Math.max(acc[3 + a], boxes[b * 6 + 3 + a]);
}
}
private static double area(double[] box, int o) {
double x = box[o + 3] - box[o], y = box[o + 4] - box[o + 1], z = box[o + 5] - box[o + 2];
if (!(x >= 0) || !(y >= 0) || !(z >= 0)) return 0;
return 2 * (x * y + y * z + z * x);
} }
} }
@@ -1,9 +1,8 @@
package eu.mhsl.minecraft.pixelpics.render.entity; package eu.mhsl.minecraft.pixelpics.render.entity;
/** import java.util.Map;
* Immutable snapshot of one entity captured on the main thread, sufficient to bake and pose it
* off-thread. Angles are in degrees (Minecraft convention). /** Snapshot of one entity captured on the main thread, enough to bake it off-thread. Angles in degrees. */
*/
public record EntityState( public record EntityState(
String typeKey, // e.g. "cow", "zombie", "player" String typeKey, // e.g. "cow", "zombie", "player"
double x, double x,
@@ -16,28 +15,41 @@ public record EntityState(
boolean player, boolean player,
String skinUrl, String skinUrl,
boolean slim, boolean slim,
String variant, // texture-selecting variant key (e.g. "ashen", "warm", "tabby"); for villagers the biome type, String variant, // variant key ("ashen", "tabby"; "<ns>:<key>" outside minecraft; villagers: biome type)
// or null
int tint, // ARGB multiplier for tintable layers (sheep wool); 0 = none int tint, // ARGB multiplier for tintable layers (sheep wool); 0 = none
double sizeScale, // extra model scale (slime/magma-cube size); 1.0 = default double sizeScale, // extra model scale (slime/magma-cube size); 1.0 = default
String profession, // villager profession key (e.g. "farmer", "librarian", "none"), or null String profession, // villager profession key (e.g. "farmer", "librarian", "none"), or null
int villagerLevel, // villager profession level 1-5 (badge tier); 0 = none/unknown 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 String markings, // horse coat markings style (e.g. "white", "whitefield", "whitedots", "blackdots"), or null
boolean saddle, // horse/donkey/mule is saddled boolean saddle, // horse/donkey/mule is saddled
boolean chest, // donkey/mule/llama is carrying a chest boolean chest, // donkey/mule/llama is carrying a chest (pose flag "chest")
String bodyEquip, // BODY-slot equipment asset (horse armor material, llama carpet, harness, wolf armor, boolean arms, // armor stand shows its arms (pose flag "arms")
// nautilus armor) or "trader_llama"; null = none String bodyEquip, // BODY-slot equipment asset (horse armor, llama carpet, harness, …) or "trader_llama"
Equipment equipment, // worn humanoid armor (players, armor stands, zombies, …); null = none/not a wearer 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 boolean invisible, // entity is invisible -> render only its equipment (like vanilla), not the body
float pitch, // entity pitch (deg) — orients projectiles (arrows, tridents, skulls) float pitch, // orients projectiles and pitched bodies
int bodyDye, // ARGB dye of dyeable BODY equipment (leather horse armor, wolf armor); 0 = none int bodyDye, // ARGB dye of dyeable BODY equipment (leather horse armor, wolf armor); 0 = none
int tint2, // second tint (tropical-fish pattern colour); 0 = none int tint2, // second tint (tropical-fish pattern colour); 0 = none
boolean passenger, // riding something (vanilla sitting pose) boolean passenger, // riding something (vanilla sitting pose)
ItemSpec rightHand, // item in the RIGHT hand (main or off hand, resolved by handedness); null = empty ItemSpec rightHand, // item in the RIGHT hand (main or off hand, resolved by handedness); null = empty
ItemSpec leftHand, // item in the LEFT hand; null = empty ItemSpec leftHand, // item in the LEFT hand; null = empty
ItemSpec headItem // non-armor item worn in the head slot (pumpkin, skull, block, …); null = none ItemSpec headItem, // non-armor item worn in the head slot (pumpkin, skull, block, …); null = none
int collar, // ARGB dye of a tamed cat's/wolf's collar; 0 = no collar (untamed)
boolean powered, // energy-swirl layer: charged creeper, wither below half health
ItemSpec mouthItem, // item carried in the mouth (fox/dolphin main hand); null = none
String pose, // "+"-joined STATE_POSES flags of the dump (e.g. "lying+head_up"); null = idle
Map<String, float[]> partPoses, // armor-stand part rotations by render-state field ("headPose", …), x/y/z deg
String shoulderLeft, // parrot variant on a player's left shoulder (e.g. "red"); null = none
String shoulderRight // parrot variant on the right shoulder; null = none
) { ) {
/** Whether the state pose flags contain {@code flag}. */
public boolean hasPose(String flag) {
if (pose == null) return false;
for (String f : pose.split("\\+")) if (f.equals(flag)) return true;
return false;
}
/** Worn armor (4 slots) of a humanoid wearer; any field may be null. */ /** 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 record Equipment(EquipPiece head, EquipPiece chest, EquipPiece legs, EquipPiece feet) {
public boolean isEmpty() { public boolean isEmpty() {
@@ -3,9 +3,7 @@ package eu.mhsl.minecraft.pixelpics.render.entity;
import org.bukkit.block.data.BlockData; import org.bukkit.block.data.BlockData;
/** /**
* An item to render (held, worn on the head, in an item display, dropped): its item key, the block data * An item to render (held, worn, displayed, dropped): item key, the block it places (null if none), that block's
* when the item places a block (block-model geometry), the block-entity state when that block is * block-entity state when it is block-entity rendered, and whether it is enchanted.
* block-entity rendered (chests, heads, banners, …), and whether it is enchanted. Captured on the main
* thread; immutable afterwards.
*/ */
public record ItemSpec(String id, BlockData block, BlockEntityState blockEntity, boolean glint) {} public record ItemSpec(String id, BlockData block, BlockEntityState blockEntity, boolean glint) {}
@@ -1,26 +1,15 @@
package eu.mhsl.minecraft.pixelpics.render.entity; package eu.mhsl.minecraft.pixelpics.render.entity;
/** /** What {@link BoxUv} needs of an entity-model box: size (model px), box-UV offset (texels), mirror, face UVs. */
* 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 class ModelCube {
public final double[] origin; // 3, min corner (px) public final double[] size;
public final double[] size; // 3 (px) public final double[] uv;
public final double inflate; // px, expands the box on all sides (overlay layers)
public final double[] uv; // 2, box-UV offset (texels)
public final boolean mirror; public final 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. */ // Per-face {u, v, w, h} texels by Direction.ordinal() (negative w/h flip); null = box-UV.
public final double[][] faceUv; public final double[][] faceUv;
public ModelCube(double[] origin, double[] size, double inflate, double[] uv, boolean mirror) { public ModelCube(double[] size, double[] uv, boolean mirror, double[][] faceUv) {
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.size = size;
this.inflate = inflate;
this.uv = uv; this.uv = uv;
this.mirror = mirror; this.mirror = mirror;
this.faceUv = faceUv; this.faceUv = faceUv;
@@ -7,20 +7,17 @@ import eu.mhsl.minecraft.pixelpics.render.entity.cem.ItemGeometry;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.SignTextRasterizer; import eu.mhsl.minecraft.pixelpics.render.entity.cem.SignTextRasterizer;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.List; import java.util.List;
/** /**
* Bakes {@link ObjectState}s (display entities, dropped items, falling blocks, primed TNT, minecart * Bakes {@link ObjectState}s (display entities, dropped items, falling blocks, primed TNT, minecart contents,
* contents) into world-space {@link EntityCube}s: block content reuses the resolved BLOCK MODEL elements * leashes): blocks and items through {@link ItemGeometry}, text rasterized with the pack's bitmap font.
* (any block, any version — straight from the resource pack), items use their block model or a flat
* sprite, text is rasterized with the pack's bitmap font (as for signs).
*/ */
public final class ObjectBaker implements EntityBaker<ObjectState> { public final class ObjectBaker implements EntityBaker<ObjectState> {
private static final int MIN_BACKGROUND_ALPHA = // Vanilla LeashRenderer colours, alternating per segment.
0x80; // translucent text backgrounds can't be drawn: skip faint ones private static final Face[] LEASH_LIGHT = solidFaces(0xFF7F6650), LEASH_DARK = solidFaces(0xFF59473A);
// Vanilla leash colours (LeashRenderer): brown, alternating with a darker shade per segment.
private static final int LEASH_LIGHT = 0xFF7F6650, LEASH_DARK = 0xFF59473A;
private static final int LEASH_SEGMENTS = 24; private static final int LEASH_SEGMENTS = 24;
private static final double LEASH_HALF_WIDTH = 0.025; private static final double LEASH_HALF_WIDTH = 0.025;
@@ -37,13 +34,17 @@ public final class ObjectBaker implements EntityBaker<ObjectState> {
List<EntityCube> cubes = new ArrayList<>(); List<EntityCube> cubes = new ArrayList<>();
switch (s.kind()) { switch (s.kind()) {
case BLOCK -> { case BLOCK -> {
items.bakeBlock(s.block(), s.toWorld(), cubes); // Block model plus the block-entity renderer's parts (chest bodies, lectern book, bell, …).
if (cubes.isEmpty()) items.bakeBlockEntity(s.blockEntity(), s.toWorld(), cubes); items.bakeBlock(s.block(), s.modelSeed(), s.toWorld(), cubes);
items.bakeBlockEntity(s.blockEntity(), s.toWorld(), cubes);
} }
case ITEM -> { case ITEM -> {
Affine at = s.toWorld(); Affine at = s.toWorld();
if (s.dropped()) { if (s.dropped()) {
double lift = 0.1 + 0.25 * items.displayScaleY(s.item(), s.context()); // ItemEntityRenderer: T(0, bob + 0.0625 - modelBox.minY), bob = sin(age/10 + offset)·0.1 + 0.1
// frozen at phase 0: the ground model's lowest point floats 0.1625 above the entity position.
double[] box = items.modelBounds(s.item(), s.context());
double lift = 0.1 + 0.0625 - (box == null ? 0 : box[1]);
at = Affine.translation(0, lift, 0).mul(at); at = Affine.translation(0, lift, 0).mul(at);
} }
items.bake(s.item(), s.context(), false, at, cubes); items.bake(s.item(), s.context(), false, at, cubes);
@@ -54,8 +55,6 @@ public final class ObjectBaker implements EntityBaker<ObjectState> {
return cubes.isEmpty() ? null : RenderedEntity.of(cubes); return cubes.isEmpty() ? null : RenderedEntity.of(cubes);
} }
// --- leash ---------------------------------------------------------------------------------------
/** /**
* Vanilla LeashRenderer curve: 24 segments from the leashed entity to the holder, horizontal position * Vanilla LeashRenderer curve: 24 segments from the leashed entity to the holder, horizontal position
* linear, height {@code dy·(t² + t)/2} (sags below the straight line), alternating light/dark brown. * linear, height {@code dy·(t² + t)/2} (sags below the straight line), alternating light/dark brown.
@@ -67,15 +66,14 @@ public final class ObjectBaker implements EntityBaker<ObjectState> {
for (int i = 1; i <= LEASH_SEGMENTS; i++) { for (int i = 1; i <= LEASH_SEGMENTS; i++) {
double t = (double) i / LEASH_SEGMENTS; double t = (double) i / LEASH_SEGMENTS;
double[] cur = {p[0] + dx * t, p[1] + dy * (t * t + t) * 0.5, p[2] + dz * t}; double[] cur = {p[0] + dx * t, p[1] + dy * (t * t + t) * 0.5, p[2] + dz * t};
int color = (i % 2 == 0) ? LEASH_LIGHT : LEASH_DARK; EntityCube seg = segment(prev, cur, i % 2 == 0 ? LEASH_LIGHT : LEASH_DARK);
EntityCube seg = segment(prev, cur, color);
if (seg != null) cubes.add(seg); if (seg != null) cubes.add(seg);
prev = cur; prev = cur;
} }
} }
/** A thin square rope box from a to b. */ /** A thin square rope box from a to b. */
private static EntityCube segment(double[] a, double[] b, int argb) { private static EntityCube segment(double[] a, double[] b, Face[] faces) {
double[] d = {b[0] - a[0], b[1] - a[1], b[2] - a[2]}; double[] d = {b[0] - a[0], b[1] - a[1], b[2] - a[2]};
double len = Math.sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]); double len = Math.sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]);
if (len < 1e-6) return null; if (len < 1e-6) return null;
@@ -86,11 +84,14 @@ public final class ObjectBaker implements EntityBaker<ObjectState> {
double[] v = cross(d, u); double[] v = cross(d, u);
Affine frame = new Affine( Affine frame = new Affine(
new double[] {u[0], v[0], d[0], u[1], v[1], d[1], u[2], v[2], d[2]}, new double[] {a[0], a[1], a[2]}); new double[] {u[0], v[0], d[0], u[1], v[1], d[1], u[2], v[2], d[2]}, new double[] {a[0], a[1], a[2]});
int[][] solid = {{argb}};
Face[] f = new Face[6];
for (int i = 0; i < 6; i++) f[i] = new Face(solid, 0, 0, 1, 1, 0, -1);
double w = LEASH_HALF_WIDTH; double w = LEASH_HALF_WIDTH;
return new EntityCube(new double[] {-w, -w, 0}, new double[] {w, w, len + w}, f, frame); return new EntityCube(new double[] {-w, -w, 0}, new double[] {w, w, len + w}, faces, frame);
}
private static Face[] solidFaces(int argb) {
Face[] f = new Face[6];
Arrays.fill(f, new Face(new int[][] {{argb}}, 0, 0, 1, 1, 0, -1));
return f;
} }
private static double[] cross(double[] a, double[] b) { private static double[] cross(double[] a, double[] b) {
@@ -102,26 +103,27 @@ public final class ObjectBaker implements EntityBaker<ObjectState> {
if (l > 0) for (int i = 0; i < 3; i++) v[i] /= l; if (l > 0) for (int i = 0; i < 3; i++) v[i] /= l;
} }
// --- text ------------------------------------------------------------------------------------
private void addText(ObjectState s, List<EntityCube> cubes) { private void addText(ObjectState s, List<EntityCube> cubes) {
if (font == null || font.isEmpty()) return; if (font == null || font.isEmpty()) return;
List<String> lines = wrap(s.lines(), Math.max(1, s.lineWidth())); List<String> lines = wrap(s.lines(), Math.max(1, s.lineWidth()));
boolean blank = lines.stream().allMatch(String::isBlank); int w = 0, h = 0;
int[][] text = blank ? null : SignTextRasterizer.rasterize(lines, font, s.textArgb(), 0, false); if (!lines.stream().allMatch(String::isBlank)) {
int w = text == null ? 0 : text[0].length, h = text == null ? 0 : text.length; int[][] text = SignTextRasterizer.rasterize(lines, font, s.textArgb(), 0, false);
if (text != null && s.shadow()) text = withShadow(text, s.textArgb()); w = text[0].length;
h = text.length;
// Text block: bottom edge on the origin, horizontally centred, front facing +Z. if (s.shadow()) text = withShadow(text, s.textArgb());
double x0 = -w / 2.0, x1 = x0 + (text == null ? 0 : text[0].length); // Bottom edge on the origin (the shadow hangs below), horizontally centred, front facing +Z.
double y1 = h, y0 = text == null ? 0 : h - text.length; double x0 = -w / 2.0;
if (text != null) { cubes.add(new EntityCube(
cubes.add( new double[] {x0, h - text.length, 0.05},
new EntityCube(new double[] {x0, y0, 0.05}, new double[] {x1, y1, 0.05}, card(text), s.toWorld())); new double[] {x0 + text[0].length, h, 0.05},
card(text),
s.toWorld()));
} }
int bg = s.backgroundArgb(); int bg = s.backgroundArgb();
if ((bg >>> 24) >= MIN_BACKGROUND_ALPHA && (w > 0 || !s.lines().isEmpty())) { // Translucent backgrounds (vanilla default: 25% black) keep their alpha; the tracer blends them.
int[][] solid = {{bg | 0xFF000000}}; if (ColorUtil.opaque(bg) && !s.lines().isEmpty()) {
int[][] solid = {{bg}};
cubes.add(new EntityCube( cubes.add(new EntityCube(
new double[] {-w / 2.0 - 1, -1, 0}, new double[] {w / 2.0 + 1, h, 0}, card(solid), s.toWorld())); new double[] {-w / 2.0 - 1, -1, 0}, new double[] {w / 2.0 + 1, h, 0}, card(solid), s.toWorld()));
} }
@@ -131,7 +133,7 @@ public final class ObjectBaker implements EntityBaker<ObjectState> {
* Faces of a planar (z-thickness 0) card: front (+Z) and back show the SAME texel at each point (the * Faces of a planar (z-thickness 0) card: front (+Z) and back show the SAME texel at each point (the
* back is seen mirrored, as in vanilla), so a ray through a transparent texel misses both sides. * back is seen mirrored, as in vanilla), so a ray through a transparent texel misses both sides.
*/ */
private static Face[] card(int[][] tex) { static Face[] card(int[][] tex) {
Face[] f = new Face[6]; Face[] f = new Face[6];
f[Direction.SOUTH.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, -1); f[Direction.SOUTH.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, -1);
f[Direction.NORTH.ordinal()] = new Face(tex, 1, 0, 0, 1, 0, -1); f[Direction.NORTH.ordinal()] = new Face(tex, 1, 0, 0, 1, 0, -1);
@@ -167,8 +169,10 @@ public final class ObjectBaker implements EntityBaker<ObjectState> {
int h = text.length, w = text[0].length; int h = text.length, w = text[0].length;
int[][] out = new int[h + 1][w + 1]; int[][] out = new int[h + 1][w + 1];
int shadow = ColorUtil.shade(argb, 0.25); int shadow = ColorUtil.shade(argb, 0.25);
for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) if ((text[y][x] >>> 24) > 0) out[y + 1][x + 1] = shadow; for (int y = 0; y < h; y++)
for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) if ((text[y][x] >>> 24) > 0) out[y][x] = text[y][x]; for (int x = 0; x < w; x++) if (ColorUtil.alpha(text[y][x]) > 0) out[y + 1][x + 1] = shadow;
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++) if (ColorUtil.alpha(text[y][x]) > 0) out[y][x] = text[y][x];
return out; return out;
} }
} }
@@ -1,13 +1,13 @@
package eu.mhsl.minecraft.pixelpics.render.entity; package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
import java.util.List; import java.util.List;
import org.bukkit.block.data.BlockData; import org.bukkit.block.data.BlockData;
/** /**
* Snapshot of a non-mob object that renders block/item/text content instead of an entity model: * Snapshot of a non-mob object that renders block/item/text content instead of an entity model: block/item/text
* block/item/text DISPLAY entities, dropped items, falling blocks, primed TNT and the block inside a * display entities, dropped items, falling blocks, primed TNT, minecart blocks and leash ropes. The local→world
* minecart, and leash ropes. Captured on the main thread with its complete local→world transform already resolved * transform is resolved at capture (position, yaw/pitch or billboard, display transformation).
* (entity position, yaw/pitch or billboard facing, display transformation), so baking is pure geometry.
* *
* <p>Local frames: BLOCK = the block cube {@code [0,1]³}; ITEM = an item centred on the origin * <p>Local frames: BLOCK = the block cube {@code [0,1]³}; ITEM = an item centred on the origin
* ({@code [-0.5,0.5]³}); TEXT = font pixels (x right, y up, text front facing +Z), already scaled. * ({@code [-0.5,0.5]³}); TEXT = font pixels (x right, y up, text front facing +Z), already scaled.
@@ -19,21 +19,29 @@ public record ObjectState(
BlockEntityState blockEntity, // BLOCK: set when the block is block-entity rendered (chest, banner, …) BlockEntityState blockEntity, // BLOCK: set when the block is block-entity rendered (chest, banner, …)
ItemSpec item, // ITEM: the item ItemSpec item, // ITEM: the item
String context, // ITEM: display context ("none", "fixed", "ground", "head", …) String context, // ITEM: display context ("none", "fixed", "ground", "head", …)
boolean dropped, // ITEM: a dropped item entity (vanilla lifts it by 0.1 + 0.25·groundScaleY) boolean dropped, // ITEM: a dropped item entity (lifted above the ground by the baker)
double[] leash, // LEASH: rope points {fromX,fromY,fromZ, toX,toY,toZ} (entity -> holder) double[] leash, // LEASH: rope points {fromX,fromY,fromZ, toX,toY,toZ} (entity -> holder)
List<String> lines, // TEXT: explicit lines (wrapped by the baker to lineWidth) List<String> lines, // TEXT: explicit lines (wrapped by the baker to lineWidth)
int lineWidth, // TEXT: wrap width in font px int lineWidth, // TEXT: wrap width in font px
int textArgb, // TEXT: text colour (alpha = text opacity) int textArgb, // TEXT: text colour (alpha = text opacity)
int backgroundArgb, // TEXT: background colour (0 = none) int backgroundArgb, // TEXT: background colour (0 = none)
boolean shadow) { // TEXT: drop shadow boolean shadow, // TEXT: drop shadow
long modelSeed) { // BLOCK: random seed of weighted blockstate variants
/** A block outside the world (display, minecart block): vanilla's fixed model seed. */
public static ObjectState block(Affine toWorld, BlockData block, BlockEntityState blockEntity) { public static ObjectState block(Affine toWorld, BlockData block, BlockEntityState blockEntity) {
return block(toWorld, block, blockEntity, BlockModelRegistry.MODEL_SEED);
}
/** A block with the model seed {@code modelSeed} (falling blocks: {@code getSeed} of their start position). */
public static ObjectState block(Affine toWorld, BlockData block, BlockEntityState blockEntity, long modelSeed) {
return new ObjectState( return new ObjectState(
Kind.BLOCK, toWorld, block, blockEntity, null, null, false, null, List.of(), 0, 0, 0, false); Kind.BLOCK, toWorld, block, blockEntity, null, null, false, null, List.of(), 0, 0, 0, false, modelSeed);
} }
public static ObjectState item(Affine toWorld, ItemSpec item, String context, boolean dropped) { public static ObjectState item(Affine toWorld, ItemSpec item, String context, boolean dropped) {
return new ObjectState(Kind.ITEM, toWorld, null, null, item, context, dropped, null, List.of(), 0, 0, 0, false); return new ObjectState(
Kind.ITEM, toWorld, null, null, item, context, dropped, null, List.of(), 0, 0, 0, false, 0);
} }
public static ObjectState text( public static ObjectState text(
@@ -51,12 +59,13 @@ public record ObjectState(
lineWidth, lineWidth,
textArgb, textArgb,
backgroundArgb, backgroundArgb,
shadow); shadow,
0);
} }
public static ObjectState leash(double[] points) { public static ObjectState leash(double[] points) {
return new ObjectState( return new ObjectState(
Kind.LEASH, Affine.identity(), null, null, null, null, false, points, List.of(), 0, 0, 0, false); Kind.LEASH, Affine.identity(), null, null, null, null, false, points, List.of(), 0, 0, 0, false, 0);
} }
public enum Kind { public enum Kind {
@@ -2,19 +2,18 @@ package eu.mhsl.minecraft.pixelpics.render.entity;
import java.util.List; import java.util.List;
/** A baked entity: its world-space cubes and overall AABB (for broad-phase culling). */ /** A baked entity: its world-space cubes and their union AABB (item model bounds, harness framing). */
public final class RenderedEntity { public final class RenderedEntity {
public final List<EntityCube> cubes; public final List<EntityCube> cubes;
public final double[] aabbMin; public final double[] aabbMin;
public final double[] aabbMax; public final double[] aabbMax;
public RenderedEntity(List<EntityCube> cubes, double[] aabbMin, double[] aabbMax) { private RenderedEntity(List<EntityCube> cubes, double[] aabbMin, double[] aabbMax) {
this.cubes = cubes; this.cubes = cubes;
this.aabbMin = aabbMin; this.aabbMin = aabbMin;
this.aabbMax = aabbMax; 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) { public static RenderedEntity of(List<EntityCube> cubes) {
double[] min = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE}; double[] min = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE};
double[] max = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE}; double[] max = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE};
@@ -1,66 +1,61 @@
package eu.mhsl.minecraft.pixelpics.render.entity; package eu.mhsl.minecraft.pixelpics.render.entity;
/** import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
* 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 /** In-place ARGB pixel-grid ({@code [y][x]}) compositing shared by the entity and block-entity bakers. */
* throughout the renderer.
*/
public final class TextureOps { public final class TextureOps {
private TextureOps() {} private TextureOps() {}
/** A row-by-row copy so callers can tint/overlay without mutating the cached source texture. */ /** A row-by-row copy, so callers can tint/overlay without mutating a cached source texture. */
public static int[][] deepCopy(int[][] src) { public static int[][] deepCopy(int[][] src) {
int[][] out = new int[src.length][]; int[][] out = new int[src.length][];
for (int y = 0; y < src.length; y++) out[y] = src[y].clone(); for (int y = 0; y < src.length; y++) out[y] = src[y].clone();
return out; return out;
} }
/** Multiplies every non-transparent texel by an ARGB tint (RGB channels only), in place. */ /** Multiplies the RGB of every non-transparent texel by {@code argb}, in place. */
public static void tint(int[][] tex, int argb) { 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[] row : tex) {
for (int x = 0; x < row.length; x++) { for (int x = 0; x < row.length; x++) {
int p = row[x]; if (ColorUtil.alpha(row[x]) != 0) row[x] = ColorUtil.multiply(row[x], argb);
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 int[][] tinted(int[][] src, int argb) {
int[][] out = deepCopy(src);
tint(out, argb);
return out;
}
/** Src-over alpha composite of {@code src} onto {@code dst} (clipped to the overlap), in place. */
public static void overlay(int[][] dst, int[][] src) { public static void overlay(int[][] dst, int[][] src) {
int h = Math.min(dst.length, src.length); int h = Math.min(dst.length, src.length);
for (int y = 0; y < h; y++) { for (int y = 0; y < h; y++) {
int w = Math.min(dst[y].length, src[y].length); int w = Math.min(dst[y].length, src[y].length);
for (int x = 0; x < w; x++) { for (int x = 0; x < w; x++) {
int sp = src[y][x]; int sp = src[y][x];
int sa = (sp >>> 24) & 0xFF; int sa = ColorUtil.alpha(sp);
if (sa == 0) continue; if (sa == 0) continue;
if (sa == 255) { if (sa == 255) {
dst[y][x] = sp; dst[y][x] = sp;
continue; continue;
} }
int dp = dst[y][x]; int dp = dst[y][x];
int da = (dp >>> 24) & 0xFF; int da = ColorUtil.alpha(dp);
int outA = sa + da * (255 - sa) / 255; int outA = sa + da * (255 - sa) / 255;
int sr = (sp >> 16) & 0xFF, sg = (sp >> 8) & 0xFF, sb = sp & 0xFF; int r = (ColorUtil.red(sp) * sa + ColorUtil.red(dp) * da * (255 - sa) / 255) / Math.max(1, outA);
int dr = (dp >> 16) & 0xFF, dg = (dp >> 8) & 0xFF, db = dp & 0xFF; int g = (ColorUtil.green(sp) * sa + ColorUtil.green(dp) * da * (255 - sa) / 255) / Math.max(1, outA);
int r = (sr * sa + dr * da * (255 - sa) / 255) / Math.max(1, outA); int b = (ColorUtil.blue(sp) * sa + ColorUtil.blue(dp) * da * (255 - sa) / 255) / Math.max(1, outA);
int g = (sg * sa + dg * da * (255 - sa) / 255) / Math.max(1, outA); dst[y][x] = ColorUtil.argb(outA, r, g, b);
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 * Armor-trim palette swap: each non-transparent texel is replaced by the {@code to} colour whose index is the
* to its nearest entry in the grayscale source palette {@code from} (8-colour key, RGB distance) and * nearest (RGB distance) {@code from} key shade, keeping its alpha. Vanilla trim patterns use exactly those keys.
* 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) { public static void paletteSwap(int[][] tex, int[] from, int[] to) {
int n = Math.min(from.length, to.length); int n = Math.min(from.length, to.length);
@@ -68,13 +63,14 @@ public final class TextureOps {
for (int[] row : tex) { for (int[] row : tex) {
for (int x = 0; x < row.length; x++) { for (int x = 0; x < row.length; x++) {
int p = row[x]; int p = row[x];
int a = (p >>> 24) & 0xFF; int a = ColorUtil.alpha(p);
if (a == 0) continue; if (a == 0) continue;
int pr = (p >> 16) & 0xFF, pg = (p >> 8) & 0xFF, pb = p & 0xFF;
int best = 0, bestD = Integer.MAX_VALUE; int best = 0, bestD = Integer.MAX_VALUE;
for (int i = 0; i < n; i++) { for (int i = 0; i < n; i++) {
int c = from[i]; int c = from[i];
int dr = pr - ((c >> 16) & 0xFF), dg = pg - ((c >> 8) & 0xFF), db = pb - (c & 0xFF); int dr = ColorUtil.red(p) - ColorUtil.red(c);
int dg = ColorUtil.green(p) - ColorUtil.green(c);
int db = ColorUtil.blue(p) - ColorUtil.blue(c);
int d = dr * dr + dg * dg + db * db; int d = dr * dr + dg * dg + db * db;
if (d < bestD) { if (d < bestD) {
bestD = d; bestD = d;
@@ -87,34 +83,27 @@ public final class TextureOps {
} }
/** /**
* Static approximation of the (animated) enchantment glint: a single glint frame is tiled over the * Static approximation of the animated enchantment glint: one glint frame tiled over the texture and added
* texture and additively blended — tinted by {@code glintColor} and scaled by {@code strength} and the * (tinted by {@code glintColor}, scaled by {@code strength}, frame alpha and luminance) to its opaque texels.
* 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) { public static void addGlint(int[][] tex, int[][] glint, int glintColor, double strength) {
if (glint == null || glint.length == 0 || glint[0].length == 0) return; if (glint == null || glint.length == 0 || glint[0].length == 0) return;
int gh = glint.length, gw = glint[0].length; int gh = glint.length, gw = glint[0].length;
int cr = (glintColor >> 16) & 0xFF, cg = (glintColor >> 8) & 0xFF, cb = glintColor & 0xFF; int cr = ColorUtil.red(glintColor), cg = ColorUtil.green(glintColor), cb = ColorUtil.blue(glintColor);
for (int y = 0; y < tex.length; y++) { for (int y = 0; y < tex.length; y++) {
for (int x = 0; x < tex[y].length; x++) { for (int x = 0; x < tex[y].length; x++) {
int p = tex[y][x]; int p = tex[y][x];
int a = (p >>> 24) & 0xFF; int a = ColorUtil.alpha(p);
if (a == 0) continue; // only sheen actual armor pixels if (a == 0) continue;
int gp = glint[y % gh][x % gw]; int gp = glint[y % gh][x % gw];
int ga = (gp >>> 24) & 0xFF; int gl = Math.max(ColorUtil.red(gp), Math.max(ColorUtil.green(gp), ColorUtil.blue(gp)));
int gl = Math.max((gp >> 16) & 0xFF, Math.max((gp >> 8) & 0xFF, gp & 0xFF)); double inten = strength * (ColorUtil.alpha(gp) / 255.0) * (gl / 255.0);
double inten = strength * (ga / 255.0) * (gl / 255.0);
if (inten <= 0) continue; if (inten <= 0) continue;
int r = clamp(((p >> 16) & 0xFF) + (int) (cr * inten)); int r = ColorUtil.clamp(ColorUtil.red(p) + (int) (cr * inten));
int g = clamp(((p >> 8) & 0xFF) + (int) (cg * inten)); int g = ColorUtil.clamp(ColorUtil.green(p) + (int) (cg * inten));
int b = clamp((p & 0xFF) + (int) (cb * inten)); int b = ColorUtil.clamp(ColorUtil.blue(p) + (int) (cb * inten));
tex[y][x] = (a << 24) | (r << 16) | (g << 8) | b; tex[y][x] = ColorUtil.argb(a, r, g, b);
} }
} }
} }
private static int clamp(int v) {
return v < 0 ? 0 : (Math.min(v, 255));
}
} }
@@ -9,46 +9,58 @@ import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.render.entity.Affine; import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityModels; import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityModels;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState; import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.DerivedTextureCache;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityBaker; import eu.mhsl.minecraft.pixelpics.render.entity.EntityBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube; import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec;
import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity; import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity;
import eu.mhsl.minecraft.pixelpics.render.entity.TextureOps; import eu.mhsl.minecraft.pixelpics.render.entity.TextureOps;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet; import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Set; import java.util.Set;
/** /**
* Bakes a {@link BlockEntityState} into world-space cubes using the bundled CEM models, reusing * Bakes a {@link BlockEntityState} into world-space cubes: the dumped models its vanilla BlockEntityRenderer draws
* {@link CemGeometry}. Unlike mobs, block-entities are not dropped to the ground by their model bounds; * ({@link BlockEntityModels#vanillaModel}) at {@code T(cell) · renderer pose}, plus sign text, contents (items,
* each type is placed by an affine that reproduces the vanilla BlockEntityRenderer: the block cell * spawner mob), beacon beam and end portal faces.
* 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> { public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
private static final double SIGN_SCALE = 0.6666667; // vanilla SignRenderer model scale // Deliberate deviation for legibility on 128 px maps: sign text is scaled to fill the writable area (w×h in
// text-frame px), capped so a single line isn't blown up. Vanilla: 1 font px per text px, lines 90 (hanging 60)
// px wide, 10 (9) px apart.
private record TextArea(double w, double h, double cap) {}
// Sign-text placement (model px in the board's local frame; calibrated against the test harness). private static final TextArea SIGN_TEXT = new TextArea(88, 44, 2.0);
// The text block is scaled to fill the board's writable area (W×H) so few-line signs are rendered as private static final TextArea HANGING_TEXT = new TextArea(58, 40, 1.87);
// large as possible (legibility on low-res maps), capped so a single line isn't blown up absurdly. // Slab behind the text plane (text px) so the ray test never degenerates.
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 TEXT_THICK = 0.2;
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 CemModelLoader models;
private final TextureCache textures; private final TextureCache textures;
private final SkinCache skins; private final SkinCache skins;
private final BitmapFont font; private final BitmapFont font;
// Banner/shield pattern composites, shared by identical banners.
private final DerivedTextureCache derived = new DerivedTextureCache(256);
// End portal/gateway faces (one per block face: the star field is not periodic) and beacon beam textures.
private final DerivedTextureCache effects = new DerivedTextureCache(512);
// Contents bakers (items, spawner mob), wired after construction since both depend on this baker; null = off.
private volatile ItemGeometry items;
private volatile CemBaker entities;
/** The sign/text font (shared with text-display rendering). */ public void setItemGeometry(ItemGeometry items) {
this.items = items;
}
public void setEntityBaker(CemBaker entities) {
this.entities = entities;
}
/** The sign font, shared with text displays. */
public BitmapFont font() { public BitmapFont font() {
return font; return font;
} }
@@ -60,93 +72,397 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
this.font = font; this.font = font;
} }
/** Returns the baked block-entity, or null when it has no model/texture (then nothing renders). */ /** The placed block-entity in its cell, or null when nothing renders (unmapped/missing assets). */
@Override @Override
public RenderedEntity bake(BlockEntityState s) { public RenderedEntity bake(BlockEntityState s) {
List<Layer> layers = layers(s);
// Head model depends on the texture aspect (skeleton 64x32 vs zombie/player 64x64), so resolve it
// from the chosen texture rather than statically.
CemModelLoader.CemModel model = layers.isEmpty() ? null : models.get(modelName(s, layers.getFirst().tex));
// Since 26.x signs (and beds) are real block models (textures under block/, no entity/signs/): the
// board then comes from the block-model pipeline and only the sign TEXT is added here.
boolean signKind = s.kind() == BlockEntityState.Kind.SIGN
|| s.kind() == BlockEntityState.Kind.WALL_SIGN
|| s.kind() == BlockEntityState.Kind.HANGING_SIGN;
if (model == null && !signKind) return null;
Place p = place(s);
Affine pre = Affine.scale(p.scale / 16.0);
Affine placement = Affine.translation(s.bx() + 0.5, s.by(), s.bz() + 0.5)
.mul(Affine.rotY(Math.toRadians(p.yaw)))
.mul(Affine.translation(p.lx, p.ly, p.lz));
List<EntityCube> cubes = new ArrayList<>(); List<EntityCube> cubes = new ArrayList<>();
if (model != null) Affine cell = Affine.translation(s.bx(), s.by(), s.bz());
for (Layer layer : layers) { BlockEntityModels.VanillaModel vm = BlockEntityModels.vanillaModel(s);
for (CemGeometry.Baked b : CemGeometry.bakeModel( if (vm != null) bakeParts(s, vm, cell.mul(vm.pose()), cubes);
model, layer.tex, pre, layer.hidden, layer.texW, layer.texH, layer.boxUv)) { addSignText(s, cell, cubes);
cubes.add(new EntityCube(b.from(), b.to(), b.faces(), placement.mul(b.world()))); addContents(s, cell, cubes);
}
}
addSignText(s, placement.mul(pre), cubes);
return cubes.isEmpty() ? null : RenderedEntity.of(cubes); return cubes.isEmpty() ? null : RenderedEntity.of(cubes);
} }
/** The models WITHOUT the block pose, mapped by {@code toWorld} (special item renderers, worn skulls). */
public void bakeModel(BlockEntityState s, Affine toWorld, List<EntityCube> out) {
BlockEntityModels.VanillaModel vm = BlockEntityModels.vanillaModel(s);
if (vm != null) bakeParts(s, vm, toWorld, out);
}
private void bakeParts(BlockEntityState s, BlockEntityModels.VanillaModel vm, Affine pose, List<EntityCube> out) {
Affine pre = CemGeometry.modelSpace(pose);
for (BlockEntityModels.Part part : vm.parts()) {
CemModelLoader.CemModel model = models.vanilla(part.model());
int[][] tex = model == null ? null : texture(s, part);
if (tex != null) CemGeometry.bakeCubes(model, tex, pre, part.hidden(), out);
}
}
private void addContents(BlockEntityState s, Affine cell, List<EntityCube> cubes) {
ItemGeometry ig = items;
switch (s.kind()) {
case CAMPFIRE -> {
if (ig != null) addCampfireItems(ig, s, cell, cubes);
}
case SHELF -> {
if (ig != null) addShelfItems(ig, s, cell, cubes);
}
case VAULT -> {
if (ig != null && slot(s, 0) != null) {
// VaultRenderer: T(.5, .4, .5)·rotY(spin 0), ground context.
ig.bake(slot(s, 0), "ground", false, cell.mul(Affine.translation(0.5, 0.4, 0.5)), cubes);
}
}
case SPAWNER -> addSpawnerMob(s, cell, cubes);
case BEACON -> addBeam(s, cell, cubes);
case END_PORTAL, END_GATEWAY -> addPortal(s, cell, cubes);
default -> {}
}
}
private static ItemSpec slot(BlockEntityState s, int i) {
return i < s.items().size() ? s.items().get(i) : null;
}
/** /**
* Appends flat text quads in front of (and behind) the sign board, rasterized from the captured * CampfireRenderer: slot i lies flat at {@code T(.5, 0.44921875, .5)·rotY(-dir.toYRot())·rotX(90°)·
* {@link BlockEntityState.SignText}. Built in the board's model-px frame and transformed by the same * T(-0.3125, -0.3125, 0)·S(0.375)}, {@code dir = from2DDataValue((i + facing.get2DDataValue()) % 4)}, in
* {@code placement·pre} as the CEM board, so the text rides exactly on the board. * the {@code fixed} display context.
*
* <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) { private static void addCampfireItems(ItemGeometry ig, BlockEntityState s, Affine cell, List<EntityCube> cubes) {
if (font == null || font.isEmpty()) return; int facing2d = Math.floorMod(Math.round(s.facingDeg() / 90), 4); // S0 W1 N2 E3 = yaw / 90
boolean hanging = s.kind() == BlockEntityState.Kind.HANGING_SIGN; for (int i = 0; i < 4; i++) {
boolean sign = hanging || s.kind() == BlockEntityState.Kind.SIGN || s.kind() == BlockEntityState.Kind.WALL_SIGN; ItemSpec item = slot(s, i);
if (!sign) return; if (item == null) continue;
double cy = hanging ? HANGING_TEXT_CY : SIGN_TEXT_CY; double yRot = ((i + facing2d) % 4) * 90;
double tw = hanging ? HANG_TEXT_W : SIGN_TEXT_W; Affine m = cell.mul(Affine.translation(0.5, 0.44921875, 0.5))
double th = hanging ? HANG_TEXT_H : SIGN_TEXT_H; .mul(Affine.rotY(Math.toRadians(-yRot)))
double cap = hanging ? HANG_TEXT_CAP : SIGN_TEXT_CAP; .mul(Affine.rotX(Math.toRadians(90)))
addSide(s.frontText(), Direction.NORTH, tw, th, cap, cy, toWorld, cubes); .mul(Affine.translation(-0.3125, -0.3125, 0))
addSide(s.backText(), Direction.SOUTH, tw, th, cap, cy, toWorld, cubes); .mul(Affine.scale(0.375));
ig.bake(item, "fixed", false, m, cubes);
}
}
/**
* ShelfRenderer (align_items_to_bottom off): slot i at {@code T(.5,.5,.5)·rotY(-facing.toYRot())·
* T((i-1)·0.3125, 0, -0.25)·S(0.25)·T(0, -minY - (maxY-minY)/2, 0)} — the item's {@code on_shelf} model
* bounding box vertically centred.
*/
private static void addShelfItems(ItemGeometry ig, BlockEntityState s, Affine cell, List<EntityCube> cubes) {
for (int i = 0; i < 3; i++) {
ItemSpec item = slot(s, i);
if (item == null) continue;
double[] box = ig.modelBounds(item, "on_shelf");
if (box == null) continue;
double offsetY = -box[1] - (box[4] - box[1]) / 2;
Affine m = cell.mul(Affine.translation(0.5, 0.5, 0.5))
.mul(Affine.rotY(Math.toRadians(-s.facingDeg())))
.mul(Affine.translation((i - 1) * 0.3125, 0, -0.25))
.mul(Affine.scale(0.25))
.mul(Affine.translation(0, offsetY, 0));
ig.bake(item, "on_shelf", false, m, cubes);
}
}
/**
* SpawnerRenderer.submitEntityInSpawner: {@code T(.5,.4,.5)·rotY(spin)·T(0,-.2,0)·rotX(-30°)·S(scale)}
* (spin 0, scale 0.53125 / max(width, height) when that exceeds 1), the mob at the origin (baked at its own
* position, moved back by -pos).
*/
private void addSpawnerMob(BlockEntityState s, Affine cell, List<EntityCube> cubes) {
CemBaker baker = entities;
EntityState e = s.displayEntity();
if (baker == null || e == null) return;
RenderedEntity mob = baker.bake(e);
if (mob == null) return;
double scale = 0.53125;
double max = Math.max(e.width(), e.height());
if (max > 1) scale /= max;
Affine m = cell.mul(Affine.translation(0.5, 0.4, 0.5))
.mul(Affine.translation(0, -0.2, 0))
.mul(Affine.rotX(Math.toRadians(-30)))
.mul(Affine.scale(scale))
.mul(Affine.translation(-e.x(), -e.y(), -e.z()));
for (EntityCube c : mob.cubes) cubes.add(c.transformed(m));
}
// BeaconRenderer: sections up to MAX_RENDER_Y (the last one), solid core radius 0.2 rotated by
// animationTime·2.25 − 45° (time 0: an axis-aligned square of half-width 0.2/√2), texture v 2.5× per block;
// glow radius 0.25 axis-aligned, alpha 32, v 1× per block. Both unlit, no caps.
private static final int BEAM_MAX = 2048;
private static final int BEAM_PIECE = 32; // blocks per cube; textures tiled to cover it
private static final Direction[] SIDES = {Direction.NORTH, Direction.SOUTH, Direction.WEST, Direction.EAST};
private void addBeam(BlockEntityState s, Affine cell, List<EntityCube> cubes) {
BlockEntityState.Beam beam = s.beam();
if (beam == null || beam.sections().isEmpty()) return;
int[][] tex = texture("entity/beacon/beacon_beam");
if (tex == null) return;
double core = 0.2 * beam.radiusScale() / Math.sqrt(2), glow = 0.25 * beam.radiusScale();
int start = 0;
List<BlockEntityState.BeamSection> secs = beam.sections();
for (int i = 0; i < secs.size(); i++) {
int height = i == secs.size() - 1 ? BEAM_MAX : secs.get(i).height();
int argb = secs.get(i).argb();
int[][] coreTex = beamTexture(tex, argb, false);
int[][] glowTex = beamTexture(tex, argb, true);
for (int y = 0; y < height; y += BEAM_PIECE) {
int len = Math.min(BEAM_PIECE, height - y);
double v = (double) len / BEAM_PIECE; // textures span BEAM_PIECE blocks; v grows upwards
Affine at = cell.mul(Affine.translation(0.5, start + y, 0.5));
cubes.add(beamCube(coreTex, core, len, v, at));
cubes.add(beamCube(glowTex, glow, len, v, at));
}
start += height;
}
}
private static EntityCube beamCube(int[][] tex, double r, int len, double v, Affine at) {
Face side = new Face(tex, 0, v, 1, 0, 0, -1);
Face[] faces = new Face[6];
for (Direction d : SIDES) faces[d.ordinal()] = side;
return new EntityCube(new double[] {-r, 0, -r}, new double[] {r, len, r}, faces, at, false, true);
}
/**
* The beam texture tiled over {@link #BEAM_PIECE} blocks (core 2.5 repeats per block, glow 1) and tinted
* with the section colour: the core opaque (BEACON_BEAM_OPAQUE), the glow at alpha 32/255.
*/
private int[][] beamTexture(int[][] tex, int argb, boolean glowLayer) {
return effects.get(DerivedTextureCache.key("beam", tex, argb, glowLayer), () -> {
int reps = glowLayer ? BEAM_PIECE : BEAM_PIECE * 5 / 2;
int h = tex.length, w = tex[0].length;
int[][] out = new int[h * reps][w];
for (int y = 0; y < out.length; y++) {
for (int x = 0; x < w; x++) {
int c = ColorUtil.multiply(tex[y % h][x], argb);
int a = glowLayer ? ColorUtil.alpha(tex[y % h][x]) * 32 / 255 : 255;
out[y][x] = (a << 24) | (c & 0xFFFFFF);
}
}
return out;
});
}
// rendertype_end_portal.fsh: end_sky · COLORS[0] + Σ end_portal sampled through 15 (gateway 16) scaled,
// rotated and shifted layers. Vanilla projects the textures in screen space; here they are mapped onto
// the face plane in world space (one screen ≈ PORTAL_SPAN blocks) at GameTime 0.
private static final double[][] PORTAL_COLORS = {
{0.022087, 0.098399, 0.110818}, {0.011892, 0.095924, 0.089485}, {0.027636, 0.101689, 0.100326},
{0.046564, 0.109883, 0.114838}, {0.064901, 0.117696, 0.097189}, {0.063761, 0.086895, 0.123646},
{0.084817, 0.111994, 0.166380}, {0.097489, 0.154120, 0.091064}, {0.106152, 0.131144, 0.195191},
{0.097721, 0.110188, 0.187229}, {0.133516, 0.138278, 0.148582}, {0.070006, 0.243332, 0.235792},
{0.196766, 0.142899, 0.214696}, {0.047281, 0.315338, 0.321970}, {0.204675, 0.390010, 0.302066},
{0.080955, 0.314821, 0.661491}
};
private static final double PORTAL_SPAN = 16;
private static final int PORTAL_RES = 64; // texels per block face edge
// end_portal_layer constants per layer i (layer = i + 1): scale, rotation and x shift 17/layer.
private static final double[] LAYER_SCALE = new double[16], LAYER_COS = new double[16], LAYER_SIN = new double[16];
private static final double[] LAYER_SHIFT = new double[16];
static {
for (int i = 0; i < 16; i++) {
double layer = i + 1;
LAYER_SCALE[i] = (4.5 - layer / 4.0) * 2.0;
double ang = Math.toRadians((layer * layer * 4321.0 + layer * 9.0) * 2.0);
LAYER_COS[i] = Math.cos(ang);
LAYER_SIN[i] = Math.sin(ang);
LAYER_SHIFT[i] = 17.0 / layer;
}
}
/**
* TheEndPortalRenderer: the top and bottom face of {@code T(0,.375,0)·S(1,.375,1)} (y 0.375–0.75);
* TheEndGatewayRenderer: the full cube's unoccluded faces. Unlit like the end_portal pipeline.
*/
private void addPortal(BlockEntityState s, Affine cell, List<EntityCube> cubes) {
int[][] sky = texture("environment/end_sky");
int[][] stars = texture("entity/end_portal/end_portal");
if (sky == null || stars == null) return;
boolean gateway = s.kind() == BlockEntityState.Kind.END_GATEWAY;
double y0 = gateway ? 0 : 0.375, y1 = gateway ? 1 : 0.75;
int layers = gateway ? 16 : 15;
Set<Direction> shown = gateway ? gatewayFaces(s.direction()) : EnumSet.of(Direction.UP, Direction.DOWN);
Face[] faces = new Face[6];
for (Direction d : shown) {
int[][] tex = effects.get(
DerivedTextureCache.key("portal", sky, stars, layers, d, s.bx(), s.by(), s.bz(), y0, y1),
() -> portalFace(sky, stars, layers, d, s.bx(), s.by(), s.bz(), y0, y1));
faces[d.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, -1);
}
cubes.add(new EntityCube(new double[] {0, y0, 0}, new double[] {1, y1, 1}, faces, cell, false, true));
}
/** The "+"-joined face names of a gateway state; null = all faces. */
private static Set<Direction> gatewayFaces(String joined) {
if (joined == null) return EnumSet.allOf(Direction.class);
Set<Direction> out = EnumSet.noneOf(Direction.class);
for (Direction d : Direction.values()) {
if (("+" + joined + "+").contains("+" + d.name().toLowerCase(Locale.ROOT) + "+")) out.add(d);
}
return out;
}
/**
* One face texture: each texel's world point on the face (inverting ElementIntersector.faceS/faceT), projected
* onto the face plane.
*/
private static int[][] portalFace(
int[][] sky, int[][] stars, int layers, Direction d, int bx, int by, int bz, double y0, double y1) {
int n = PORTAL_RES;
int[][] out = new int[n][n];
for (int j = 0; j < n; j++) {
for (int i = 0; i < n; i++) {
double fs = (i + 0.5) / n, ft = (j + 0.5) / n;
double a, b;
switch (d) {
case UP -> {
a = bx + fs;
b = bz + ft;
}
case DOWN -> {
a = bx + fs;
b = bz + (1 - ft);
}
case SOUTH -> {
a = bx + fs;
b = -(by + y0 + (1 - ft) * (y1 - y0));
}
case NORTH -> {
a = bx + (1 - fs);
b = -(by + y0 + (1 - ft) * (y1 - y0));
}
case WEST -> {
a = bz + fs;
b = -(by + y0 + (1 - ft) * (y1 - y0));
}
default -> {
a = bz + (1 - fs);
b = -(by + y0 + (1 - ft) * (y1 - y0));
}
}
out[j][i] = portalColor(sky, stars, layers, a / PORTAL_SPAN, b / PORTAL_SPAN);
}
}
return out;
}
private static int portalColor(int[][] sky, int[][] stars, int layers, double u, double v) {
double[] c0 = PORTAL_COLORS[0];
int skyTexel = sampleRepeat(sky, u, v);
double r = ColorUtil.red(skyTexel) / 255.0 * c0[0];
double g = ColorUtil.green(skyTexel) / 255.0 * c0[1];
double b = ColorUtil.blue(skyTexel) / 255.0 * c0[2];
for (int i = 0; i < layers; i++) {
// end_portal_layer: (scale·rotate) then translate (17/layer, GameTime term) then ×0.5 + 0.25.
double sc = LAYER_SCALE[i], cos = LAYER_COS[i], sin = LAYER_SIN[i];
double x = sc * (u * cos - v * sin), y = sc * (u * sin + v * cos);
x = (x + LAYER_SHIFT[i]) * 0.5 + 0.25;
y = y * 0.5 + 0.25;
int t = sampleRepeat(stars, x, y);
double[] c = PORTAL_COLORS[i];
r += ColorUtil.red(t) / 255.0 * c[0];
g += ColorUtil.green(t) / 255.0 * c[1];
b += ColorUtil.blue(t) / 255.0 * c[2];
}
return ColorUtil.argb(255, channel(r), channel(g), channel(b));
}
private static int channel(double v) {
return (int) Math.min(255, Math.round(v * 255));
}
private static int sampleRepeat(int[][] tex, double u, double v) {
int h = tex.length, w = tex[0].length;
int x = Math.floorMod((int) Math.floor(u * w), w), y = Math.floorMod((int) Math.floor(v * h), h);
return tex[y][x];
}
private int[][] texture(String path) {
return textures.get(ResourceLocation.parse(path)).orElse(null);
}
private int[][] texture(BlockEntityState s, BlockEntityModels.Part part) {
if (part.paint() == BlockEntityModels.Paint.SKIN && s.skinUrl() != null) {
int[][] skin = skins.get(s.skinUrl()).orElse(null);
if (skin != null) return skin;
}
for (String path : part.textures()) {
int[][] t = texture(path);
if (t == null) continue;
return part.paint() == BlockEntityModels.Paint.BANNER_FLAG
? patterned(t, "entity/banner/", s.baseColorArgb(), s.patterns())
: t;
}
return null;
}
/**
* BannerRenderer.submitPatterns composited into one texture (one geometry, no coplanar layers): {@code base}
* (banner_base / shield_base), the {@code <dir>base} mask dyed with {@code baseColor} (0 = none), then the
* first 16 pattern masks ({@code <dir><key>}) dyed. {@code dir} = {@code entity/banner/} or
* {@code entity/shield/} (ShieldSpecialRenderer). Cached.
*/
public int[][] patterned(int[][] base, String dir, int baseColor, List<BlockEntityState.BannerPattern> patterns) {
List<BlockEntityState.BannerPattern> pats = List.copyOf(patterns.subList(0, Math.min(16, patterns.size())));
return derived.get(
DerivedTextureCache.key("banner", base, dir, baseColor, pats),
() -> buildPatterned(base, dir, baseColor, pats));
}
private int[][] buildPatterned(
int[][] base, String dir, int baseColor, List<BlockEntityState.BannerPattern> patterns) {
int[][] out = TextureOps.deepCopy(base);
if (baseColor != 0) overlayMask(out, dir + "base", baseColor);
for (BlockEntityState.BannerPattern pat : patterns) {
overlayMask(out, dir + pat.patternKey(), pat.colorArgb());
}
return out;
}
private void overlayMask(int[][] out, String path, int argb) {
int[][] mask = texture(path);
if (mask == null || mask.length != out.length || mask[0].length != out[0].length) return;
TextureOps.overlay(out, TextureOps.tinted(mask, argb));
}
/**
* Front and back text as quads centred in the sign renderer's text frame ({@link BlockEntityModels#signTextFrame})
* facing +Z, the reader; the +Z (SOUTH) face reads left to right ({@code s = fx}).
*/
private void addSignText(BlockEntityState s, Affine cell, List<EntityCube> cubes) {
if (font == null) return;
BlockEntityState.Kind k = s.kind();
boolean hanging = k == BlockEntityState.Kind.HANGING_SIGN;
if (!hanging && k != BlockEntityState.Kind.SIGN && k != BlockEntityState.Kind.WALL_SIGN) return;
TextArea area = hanging ? HANGING_TEXT : SIGN_TEXT;
addSide(s, s.frontText(), true, area, cell, cubes);
addSide(s, s.backText(), false, area, cell, cubes);
} }
private void addSide( private void addSide(
BlockEntityState s,
BlockEntityState.SignText t, BlockEntityState.SignText t,
Direction faceDir, boolean front,
double tw, TextArea area,
double th, Affine cell,
double cap,
double cy,
Affine toWorld,
List<EntityCube> cubes) { List<EntityCube> cubes) {
if (t == null) return; if (t == null) return;
int[][] bmp = SignTextRasterizer.rasterize( int[][] bmp = SignTextRasterizer.rasterize(
trimBlankLines(t.lines()), font, t.fillArgb(), t.outlineArgb(), t.glowing()); trimBlankLines(t.lines()), font, t.fillArgb(), t.outlineArgb(), t.glowing());
if (bmp == null) return; if (bmp == null) return;
// Scale so the text block fills the board's writable area, whichever dimension binds, capped. double fpm = Math.min(area.cap, Math.min(area.w / bmp[0].length, area.h / bmp.length));
double fpm = Math.min(cap, Math.min(tw / bmp[0].length, th / bmp.length)); double w = bmp[0].length * fpm, h = bmp.length * fpm;
double blockW = bmp[0].length * fpm; double[] px = new double[1];
double blockH = bmp.length * fpm; Affine frame = BlockEntityModels.signTextFrame(s, front, px);
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]; Face[] faces = new Face[6];
faces[faceDir.ordinal()] = new Face(bmp, 0, 0, 1, 1, 0, -1); faces[Direction.SOUTH.ordinal()] = new Face(bmp, 0, 0, 1, 1, 0, -1);
double[] from = {-blockW / 2, cy - blockH / 2, z0}; double[] from = {-w / 2, -h / 2, -TEXT_THICK};
double[] to = {blockW / 2, cy + blockH / 2, z1}; double[] to = {w / 2, h / 2, 0};
cubes.add(new EntityCube(from, to, faces, toWorld)); cubes.add(new EntityCube(from, to, faces, cell.mul(frame).mul(Affine.scale(px[0]))));
} }
/** Drops leading/trailing blank lines so short text fills the board; keeps interior blanks. */ /** Drops leading/trailing blank lines so short text fills the board; keeps interior blanks. */
@@ -156,158 +472,4 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
while (hi > lo && lines.get(hi - 1).isBlank()) hi--; while (hi > lo && lines.get(hi - 1).isBlank()) hi--;
return lines.subList(lo, 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;
}
} }
File diff suppressed because it is too large. Load diff
@@ -3,6 +3,7 @@ package eu.mhsl.minecraft.pixelpics.render.entity.cem;
import eu.mhsl.minecraft.pixelpics.assets.model.Face; import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.render.entity.Affine; import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
import eu.mhsl.minecraft.pixelpics.render.entity.BoxUv; import eu.mhsl.minecraft.pixelpics.render.entity.BoxUv;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
import eu.mhsl.minecraft.pixelpics.render.entity.ModelCube; import eu.mhsl.minecraft.pixelpics.render.entity.ModelCube;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -10,10 +11,8 @@ import java.util.Map;
import java.util.Set; import java.util.Set;
/** /**
* Shared, stateless CEM geometry baking used by both {@link CemBaker} (mobs) and the block-entity * Turns a {@link CemModelLoader.CemModel} into {@link Baked} boxes (local box + model→pre-space transform) for
* baker. It turns a {@link CemModelLoader.CemModel} into a list of {@link Baked} boxes (local cube + * mobs, block entities and items; placing them in the world is up to the caller.
* 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 { final class CemGeometry {
@@ -24,7 +23,19 @@ final class CemGeometry {
// Java-model block offsets -> CEM px offsets of a part frame (x/y flip, 16 px per block, no translation). // Java-model block offsets -> CEM px offsets of a part frame (x/y flip, 16 px per block, no translation).
static final Affine JAVA_LOCAL = new Affine(new double[] {-16, 0, 0, 0, -16, 0, 0, 0, 16}, new double[] {0, 0, 0}); static final Affine JAVA_LOCAL = new Affine(new double[] {-16, 0, 0, 0, -16, 0, 0, 0, 16}, new double[] {0, 0, 0});
/** A baked box: local min/max corner, its six faces and the transform mapping it into pre-space. */ /** Model px (CEM frame) → the Java-model block space {@code x} maps. */
static Affine modelSpace(Affine x) {
return x.mul(Affine.scale(1 / 16.0)).mul(CEM_TO_JAVA);
}
/** {@link #bakeModel} as cubes. */
static void bakeCubes(
CemModelLoader.CemModel model, int[][] tex, Affine pre, Set<String> hidden, List<EntityCube> out) {
for (Baked b : bakeModel(model, tex, pre, hidden)) {
out.add(new EntityCube(b.from(), b.to(), b.faces(), b.world()));
}
}
record Baked(double[] from, double[] to, Face[] faces, Affine world) { record Baked(double[] from, double[] to, Face[] faces, Affine world) {
double minWorldY() { double minWorldY() {
double m = Double.MAX_VALUE; double m = Double.MAX_VALUE;
@@ -38,44 +49,23 @@ final class CemGeometry {
} }
} }
/** Bake all parts of a model with the given pre-transform; parts in {@code hidden} are skipped. */ /** Rest pose; parts in {@code hidden} are skipped. */
static List<Baked> bakeModel(CemModelLoader.CemModel model, int[][] tex, Affine pre, Set<String> hidden) { static List<Baked> bakeModel(CemModelLoader.CemModel model, int[][] tex, Affine pre, Set<String> hidden) {
return bakeModel(model, tex, pre, hidden, 0, 0, false); return bake(model, tex, pre, hidden, Map.of(), null);
} }
/** /**
* Bake all parts with an explicit texture size for UV normalisation ({@code texW}/{@code texH} of 0 * Vanilla models with pose {@code overrides} by part name (pivot/rotation/visibility/scale; boxes follow an
* fall back to the model's declared size; use a real size when the applied texture differs from the * overridden pivot). {@code frames}, when non-null, receives each part's frame {@code parent·T(P)·R·S} (also
* model's declared {@code textureSize}, e.g. a 16x16 sherd on a 32x32-authored pot face). When * {@link #PIVOT} and {@link #DRAWN} entries), used to attach items to hands and heads.
* {@code ignoreFaceUv} is set, box-UV is forced even if the model declares per-face UV — used for a
* standalone texture (e.g. the conduit cage) whose layout is box-UV, not the combined-sheet layout.
*/
static List<Baked> bakeModel(
CemModelLoader.CemModel model,
int[][] tex,
Affine pre,
Set<String> hidden,
int texW,
int texH,
boolean ignoreFaceUv) {
return bake(model, tex, pre, hidden, Set.of(), texW, texH, ignoreFaceUv, Map.of(), null);
}
/**
* Posed variant for vanilla ("absolute") models: {@code show} names parts vanilla hides by default but
* this entity's state draws (chests, armor-stand arms); {@code overrides} replace parts' pivot/rotation/
* visibility by name (pose variants: sitting, holding items, …; boxes follow their pivot); {@code frames},
* when non-null, receives each part's frame {@code parent·T(P)·R·S} (pre-space, maps a part-local CEM px
* offset) — used to attach held/head items to hands and heads.
*/ */
static List<Baked> bakePosed( static List<Baked> bakePosed(
CemModelLoader.CemModel model, CemModelLoader.CemModel model,
int[][] tex, int[][] tex,
Affine pre, Affine pre,
Set<String> show,
Map<String, CemModelLoader.PartPose> overrides, Map<String, CemModelLoader.PartPose> overrides,
Map<String, Affine> frames) { Map<String, Affine> frames) {
return bake(model, tex, pre, Set.of(), show, 0, 0, false, overrides, frames); return bake(model, tex, pre, Set.of(), overrides, frames);
} }
private static List<Baked> bake( private static List<Baked> bake(
@@ -83,18 +73,12 @@ final class CemGeometry {
int[][] tex, int[][] tex,
Affine pre, Affine pre,
Set<String> hidden, Set<String> hidden,
Set<String> show,
int texW,
int texH,
boolean ignoreFaceUv,
Map<String, CemModelLoader.PartPose> overrides, Map<String, CemModelLoader.PartPose> overrides,
Map<String, Affine> frames) { Map<String, Affine> frames) {
int nw = texW > 0 ? texW : model.texW(); Ctx ctx = new Ctx(hidden, model.texW(), model.texH(), tex, new ArrayList<>(), overrides, frames);
int nh = texH > 0 ? texH : model.texH();
Ctx ctx = new Ctx(hidden, show, nw, nh, tex, ignoreFaceUv, new ArrayList<>(), overrides, frames);
for (CemModelLoader.CemPart p : model.parts()) { for (CemModelLoader.CemPart p : model.parts()) {
if (model.absolute()) { if (model.absolute()) {
bakeAbsolute(p, pre, ctx); bakeAbsolute(p, pre, ctx, true);
} else { } else {
double[] o = {-p.translate()[0], -p.translate()[1], -p.translate()[2]}; double[] o = {-p.translate()[0], -p.translate()[1], -p.translate()[2]};
bakePart(p, pre, o, 0, ctx); bakePart(p, pre, o, 0, ctx);
@@ -103,63 +87,58 @@ final class CemGeometry {
return ctx.out; return ctx.out;
} }
/** Key prefix in the frames map marking parts that were actually drawn (visible). */ // Frame-map key prefixes: parts that were drawn, and a part's pivot frame parent·T(P) (without its rotation).
static final String DRAWN = "drawn:"; static final String DRAWN = "drawn:";
static final String PIVOT = "pivot:";
private record Ctx( private record Ctx(
Set<String> hidden, Set<String> hidden,
Set<String> show,
int texW, int texW,
int texH, int texH,
int[][] tex, int[][] tex,
boolean ignoreFaceUv,
List<Baked> out, List<Baked> out,
Map<String, CemModelLoader.PartPose> overrides, Map<String, CemModelLoader.PartPose> overrides,
Map<String, Affine> frames) {} Map<String, Affine> frames) {}
/** /**
* Vanilla-dumped ("absolute") part: pivot and boxes are absolute model px, so the group transform is * Vanilla ("absolute") part: {@code parent · T(P) · Rz·Ry·Rx · S · T(-P)} like
* simply {@code parent · T(P) · Rz·Ry·Rx · S · T(-P)} — the same order as vanilla's * {@code ModelPart.translateAndRotate}. Invisible parts hide their subtree, {@code skipDraw} only their own
* {@code ModelPart.translateAndRotate}. Invisible parts (vanilla {@code visible=false}) skip their * boxes. With frames requested, a hidden subtree is still walked ({@code draw=false}): vanilla attaches held
* whole subtree unless named in {@code show}; {@code skipDraw} parts only skip their own boxes. * items to an arm even when it isn't drawn (armor stands without arms).
*/
private static void bakeAbsolute(CemModelLoader.CemPart part, Affine parentWorld, Ctx ctx) {
bakeAbsolute(part, parentWorld, ctx, true);
}
/**
* {@code draw=false} walks a hidden subtree only to record part frames: vanilla attaches held items to
* an arm's transform even when the arm itself isn't drawn (armor stands without arms, …).
*/ */
private static void bakeAbsolute(CemModelLoader.CemPart part, Affine parentWorld, Ctx ctx, boolean draw) { private static void bakeAbsolute(CemModelLoader.CemPart part, Affine parentWorld, Ctx ctx, boolean draw) {
if (ctx.hidden.contains(part.name())) return; if (ctx.hidden.contains(part.name())) return;
CemModelLoader.PartPose ov = ctx.overrides.get(part.name()); CemModelLoader.PartPose ov = ctx.overrides.get(part.name());
boolean invisible = ov != null ? !ov.visible() : part.invisible(); boolean invisible = ov != null && ov.visible() != null ? !ov.visible() : part.invisible();
if (invisible && !ctx.show.contains(part.name())) { if (invisible) {
if (ctx.frames == null) return; if (ctx.frames == null) return;
draw = false; draw = false;
} }
double[] o = ov != null ? ov.pivot() : part.pivot(); double[] scale = ov != null && ov.scale() != null ? ov.scale() : part.scale();
double[] rot = ov != null ? ov.rotate() : part.rotate(); double[] add = ov == null ? null : ov.rotateAdd();
Affine world = parentWorld boolean moved = ov != null && ov.pivot() != null;
.mul(Affine.translation(o[0], o[1], o[2])) double[] o = moved ? ov.pivot() : part.pivot();
double[] rot = moved ? ov.rotate() : part.rotate();
if (add != null) rot = new double[] {rot[0] + add[0], rot[1] + add[1], rot[2] + add[2]};
Affine pivotFrame = parentWorld.mul(Affine.translation(o[0], o[1], o[2]));
Affine world = pivotFrame
.mul(Affine.rotZ(Math.toRadians(rot[2]))) .mul(Affine.rotZ(Math.toRadians(rot[2])))
.mul(Affine.rotY(Math.toRadians(rot[1]))) .mul(Affine.rotY(Math.toRadians(rot[1])))
.mul(Affine.rotX(Math.toRadians(rot[0]))); .mul(Affine.rotX(Math.toRadians(rot[0])));
if (part.scale() != null) { if (scale != null) {
// A collapsed (zero) scale is vanilla's way to hide a part; it would also be non-invertible. // A collapsed (zero) scale is vanilla's way to hide a part; it would also be non-invertible.
for (double v : part.scale()) if (Math.abs(v) < 1e-6) return; for (double v : scale) if (Math.abs(v) < 1e-6) return;
world = world.mul(Affine.scale(part.scale()[0], part.scale()[1], part.scale()[2])); world = world.mul(Affine.scale(scale[0], scale[1], scale[2]));
} }
if (ctx.frames != null) { if (ctx.frames != null) {
ctx.frames.putIfAbsent(part.name(), world); ctx.frames.putIfAbsent(part.name(), world);
ctx.frames.putIfAbsent(PIVOT + part.name(), pivotFrame);
if (draw) ctx.frames.putIfAbsent(DRAWN + part.name(), world); if (draw) ctx.frames.putIfAbsent(DRAWN + part.name(), world);
} }
world = world.mul(Affine.translation(-o[0], -o[1], -o[2])); world = world.mul(Affine.translation(-o[0], -o[1], -o[2]));
Affine boxWorld = world; Affine boxWorld = world;
if (ov != null) { if (moved) {
// Boxes are stored relative to the idle pivot: move them along with the overridden pivot // Boxes follow the overridden pivot; children carry their own absolute pivots.
// (children carry their own absolute pivots, so they use the un-shifted transform).
double[] p0 = part.pivot(); double[] p0 = part.pivot();
boxWorld = world.mul(Affine.translation(o[0] - p0[0], o[1] - p0[1], o[2] - p0[2])); boxWorld = world.mul(Affine.translation(o[0] - p0[0], o[1] - p0[1], o[2] - p0[2]));
} }
@@ -173,9 +152,8 @@ final class CemGeometry {
double[] inf = b.inflate(); double[] inf = b.inflate();
double[] from = {org[0] - inf[0], org[1] - inf[1], org[2] - inf[2]}; double[] from = {org[0] - inf[0], org[1] - inf[1], org[2] - inf[2]};
double[] to = {org[0] + b.size()[0] + inf[0], org[1] + b.size()[1] + inf[1], org[2] + b.size()[2] + inf[2]}; double[] to = {org[0] + b.size()[0] + inf[0], org[1] + b.size()[1] + inf[1], org[2] + b.size()[2] + inf[2]};
double[][] faceUv = ctx.ignoreFaceUv ? null : b.faceUv(); ModelCube mc = new ModelCube(b.size(), b.uv(), b.mirror(), b.faceUv());
ModelCube mc = new ModelCube(org, b.size(), inf[0], b.uv(), b.mirror(), faceUv); // Vanilla texScale: UVs normalised by texSize·scale.
// Vanilla texScale stretches the UV normalisation (texture treated as texSize*scale).
int tw = ctx.texW, th = ctx.texH; int tw = ctx.texW, th = ctx.texH;
if (b.texScale() != null) { if (b.texScale() != null) {
tw = (int) Math.round(tw * b.texScale()[0]); tw = (int) Math.round(tw * b.texScale()[0]);
@@ -189,10 +167,9 @@ final class CemGeometry {
} }
/** /**
* Faithful OptiFine/Blockbench CEM transform: each part is a group whose rotation pivots around its * CEM template part (OptiFine/Blockbench import): {@code parent · T(O) · R · T(-O)} with origin {@code O} =
* origin {@code O} (top-level: {@code -translate}; submodel: {@code translate}, accumulated with the * {@code -translate} at the top level, else {@code translate}, accumulated with the parent origin from the
* parent origin from the 2nd nesting level on). Top-level boxes are absolute; nested boxes are offset * 2nd nesting level on. Top-level boxes are absolute, nested ones offset by their group origin.
* by their group origin. The group transform is {@code parent · T(O) · R · T(-O)}.
*/ */
private static void bakePart(CemModelLoader.CemPart part, Affine parentWorld, double[] o, int depth, Ctx ctx) { private static void bakePart(CemModelLoader.CemPart part, Affine parentWorld, double[] o, int depth, Ctx ctx) {
if (ctx.hidden.contains(part.name())) return; if (ctx.hidden.contains(part.name())) return;
@@ -210,7 +187,6 @@ final class CemGeometry {
} }
for (CemModelLoader.CemPart child : part.children()) { for (CemModelLoader.CemPart child : part.children()) {
double[] t = child.translate(); double[] t = child.translate();
// submodel origin = its translate, accumulated with this group's origin from the 2nd level on.
double[] co = 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]}; depth >= 1 ? new double[] {t[0] + o[0], t[1] + o[1], t[2] + o[2]} : new double[] {t[0], t[1], t[2]};
bakePart(child, world, co, depth + 1, ctx); bakePart(child, world, co, depth + 1, ctx);
@@ -4,42 +4,38 @@ import com.google.gson.JsonArray;
import com.google.gson.JsonElement; import com.google.gson.JsonElement;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
import java.io.InputStream; import java.io.InputStream;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Logger; import java.util.logging.Logger;
/** /**
* Loads OptiFine-CEM ({@code .jem}) entity models from the bundled {@code cem_template_models.json} * Loads CEM ({@code .jem} layout) entity models: the vanilla client dump ({@code tools/VanillaModelDump.java},
* (CEM Template Loader data) into baked-ready {@link CemModel}s. These are the vanilla Java entity * "absolute" models plus per-type renderer info, render types and registry variants) and the CEM template set
* models, already in the correct standing pose — no Bedrock geometry / animation needed. * (legacy entity fallback; part {@code translate} = negated pivot, box {@code coordinates} absolute px).
*
* <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 { public final class CemModelLoader {
/** /** A box in px: min corner, size, per-axis inflation, box-UV offset (texels) and mirror. */
* 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( public record CemBox(
double[] origin, double[] origin,
double[] size, double[] size,
double[] inflate, // per-axis grow (px) double[] inflate,
double[] uv, double[] uv,
boolean mirror, boolean mirror,
double[][] faceUv, double[][] faceUv, // per-face {x, y, w, h} texels by Direction ordinal; null = box-UV
double[] texScale, // vanilla CubeDefinition texScale (UV normalised by texSize*scale); null = 1 double[] texScale, // vanilla CubeDefinition texScale (UV normalised by texSize*scale); null = 1
boolean[] hiddenFaces) {} // by Direction ordinal; null = all faces drawn boolean[] hiddenFaces) {} // by Direction ordinal; null = all faces drawn
/** A model part: its (raw) translate, rotation (deg), boxes and nested submodels. The rotation pivot /** A model part: CEM translate (legacy pivot rule, see {@link CemGeometry}), rotation (deg), boxes, children. */
* is {@code -(sum of translates from the root to this part)} — accumulated by the baker. */
public record CemPart( public record CemPart(
String name, String name,
double[] translate, double[] translate,
@@ -52,28 +48,156 @@ public final class CemModelLoader {
boolean skipDraw) {} // own boxes not drawn, children are boolean skipDraw) {} // own boxes not drawn, children are
/** /**
* A whole model: declared texture size and its top-level parts. {@code absolute} models (dumped from * A model: declared texture size, top-level parts; {@code absolute} = vanilla dump (absolute pivots and boxes,
* the vanilla client by {@code tools/VanillaModelDump.java}) carry absolute part pivots + boxes. * {@code renderType} = the vanilla render type it is drawn with, null = unknown).
*/ */
public record CemModel(int texW, int texH, List<CemPart> parts, boolean absolute, String texture) {} public record CemModel(
int texW, int texH, List<CemPart> parts, boolean absolute, String texture, String renderType) {}
/** Per-entity-type renderer info from the vanilla dump: extra render scale and default textures. */ /**
* How a vanilla render type's pipeline draws texels: {@code cutout} = shader ALPHA_CUTOUT threshold (0..1,
* texels below it are discarded; -1 = none), {@code blend} = alpha-blended ("translucent") / added
* ("additive") / null = written opaque (cutout and solid types: alpha ignored), {@code shaded} = directional
* face shading (false with NO_CARDINAL_LIGHTING: eyes, breeze wind).
*/
public record RenderType(double cutout, String blend, boolean shaded) {}
/**
* An emissive render layer (EyesLayer, LivingEntityEmissiveLayer, BreezeEyesLayer): the dumped model it
* draws (the body model's name for layers that draw the parent model), its texture (per render-state enum
* constant in {@code textures}, e.g. copper-golem weathering), render type, the constant vertex alpha of
* its alpha function, the state flag it needs ({@code when}, null = always), whether it is still drawn on an
* invisible entity, and whether its alpha is animated (not drawn: a photo has no age).
*/
public record EmissiveLayer(
String model,
String texture,
Map<String, String> textures,
String renderType,
double alpha,
String when,
boolean invisible,
boolean animated) {}
/** Per-entity-type renderer info from the vanilla dump. */
public record EntityInfo( public record EntityInfo(
String texture, String texture,
String babyTexture, String babyTexture,
double[] transform, // vanilla renderer pose at yaw=pitch=0 (column-major 4x4, Java model -> blocks) double[] transform, // vanilla renderer pose at yaw=pitch=0 (column-major 4x4, Java model -> blocks)
int yawSign, // how the entity yaw enters the renderer: -1 (mobs), +1 (projectiles), 0 (ignored) int yawSign, // how the entity yaw enters the renderer: -1 (mobs), +1 (projectiles), 0 (ignored)
double[] pitchAxis, // world-frame axis the pitch rotates about; null = pitch ignored double[] pitchAxis, // world-frame axis the pitch rotates about; null = pitch ignored
Map<String, Map<String, PartPose>> poses) {} // variant ("passenger+item_r", …) -> part overrides Map<String, Map<String, PartPose>> poses, // pose ("passenger+item_r", "arms", "chest", …) -> overrides
Map<String, Boolean> layers, // render layer class -> drawn for the default (idle, unequipped) state
boolean humanoidArmor, // the renderer has a HumanoidArmorLayer (wears humanoid armor)
Map<String, PoseTransform> poseTransforms, // pose -> renderer transform when it differs (sleeping, …)
Map<String, PoseSet> modelPoses, // body sub-model ("baby", "smallModel", …) -> its own poses
Map<String, RotationBinding> rotationBindings, // state field ("headPose", "xRot", …) -> part rotations
List<EmissiveLayer> emissive, // emissive layers (eyes, glow), in vanilla layer order
Map<String, String> stateTextures) { // state flag ("tipped", "invulnerable") -> body texture
/** A posed part override: absolute CEM pivot, CEM rotation (deg) and visibility. */ /** Whether the vanilla renderer draws this layer for an entity in its default state. */
public record PartPose(double[] pivot, double[] rotate, boolean visible) {} public boolean layerDrawnByDefault(String layerClass) {
return layers.getOrDefault(layerClass, false);
}
/** The poses of a body model: its own set when the dump has one, else the main model's. */
public PoseSet poseSet(String subModel) {
PoseSet own = subModel == null ? null : modelPoses.get(subModel);
return own != null ? own : new PoseSet(poses, poseTransforms);
}
}
/** Pose overrides + renderer transforms of one body model (see {@link EntityInfo#poseSet}). */
public record PoseSet(Map<String, Map<String, PartPose>> poses, Map<String, PoseTransform> transforms) {}
/**
* The renderer transform of a pose (like {@link EntityInfo#transform}/{@code yawSign}/{@code pitchAxis}):
* e.g. sleeping turns the body by the bed direction (yawSign +1), swimming pitches it.
*/
public record PoseTransform(double[] transform, int yawSign, double[] pitchAxis) {}
/**
* A continuous state input bound linearly to part rotations: {@code part rotate += coeff · (value − def)},
* {@code coeff} row-major 3×3 (row = CEM rotate axis, column = input component). Armor-stand part poses,
* body pitch.
*/
public record RotationBinding(double[] def, Map<String, double[]> parts) {}
/**
* A posed part override: absolute CEM pivot and CEM rotation (deg) — both null for a pure visibility
* toggle (the part keeps the pivot of whichever model it is in) — visibility (null = the part's own), scale
* (null = the part's own) and an extra rotation (deg, added to whichever rotation applies; null = none).
*/
public record PartPose(double[] pivot, double[] rotate, Boolean visible, double[] scale, double[] rotateAdd) {
/** This override plus an extra rotation (rotation bindings). */
public PartPose plusRotation(double[] add) {
double[] r = rotateAdd == null
? add
: new double[] {rotateAdd[0] + add[0], rotateAdd[1] + add[1], rotateAdd[2] + add[2]};
return new PartPose(pivot, rotate, visible, scale, r);
}
}
/**
* Client assets of a data-driven mob variant ({@code data/<ns>/<type>_variant/<key>.json}): the texture
* ({@code asset_id}), its baby texture, the per-state textures of wolves ({@code assets.wild/tame/angry},
* {@code baby_assets}) and the model kind ({@code model}: "cold", "warm"; null = normal).
*/
public record VariantAssets(
String asset, String babyAsset, Map<String, String> assets, Map<String, String> babyAssets, String model) {
static VariantAssets parse(JsonObject o) {
return new VariantAssets(
str(o, "asset_id"),
str(o, "baby_asset_id"),
strMap(o, "assets"),
strMap(o, "baby_assets"),
str(o, "model"));
}
/**
* Texture id for the entity's state: wolves by tame / angry / wild (vanilla Wolf.getTexture: a tame wolf
* keeps its tame texture when angry), others by asset id; baby first if asked.
*/
public String texture(boolean baby, boolean tame, boolean angry) {
String state = tame ? "tame" : angry ? "angry" : "wild";
if (baby && babyAssets.containsKey(state)) return babyAssets.get(state);
if (baby && babyAsset != null) return babyAsset;
if (assets.containsKey(state)) return assets.get(state);
return asset;
}
private static Map<String, String> strMap(JsonObject o, String k) {
Map<String, String> out = new HashMap<>();
if (o.has(k) && o.get(k).isJsonObject()) {
for (Map.Entry<String, JsonElement> e : o.getAsJsonObject(k).entrySet()) {
if (e.getValue().isJsonPrimitive())
out.put(e.getKey(), e.getValue().getAsString());
}
}
return out;
}
}
private final Map<String, CemModel> models = new HashMap<>(); private final Map<String, CemModel> models = new HashMap<>();
// Vanilla dump models live in their own namespace so they never shadow CEM-template names the // Own namespace: the dump and the CEM template set use the same names for different models.
// block-entity path relies on (e.g. "wither_skull" head vs. projectile).
private final Map<String, CemModel> vanilla = new HashMap<>(); private final Map<String, CemModel> vanilla = new HashMap<>();
private final Map<String, EntityInfo> entities = new HashMap<>(); private final Map<String, EntityInfo> entities = new HashMap<>();
private final Map<String, RenderType> renderTypes = new HashMap<>();
// entity type -> variant key ("tabby", or "<ns>:<key>" outside minecraft) -> assets
private final Map<String, Map<String, VariantAssets>> variants = new ConcurrentHashMap<>();
/** Assets of a data-driven variant of {@code typeKey} (registry {@code <typeKey>_variant}), or null. */
public VariantAssets variant(String typeKey, String key) {
Map<String, VariantAssets> m = key == null ? null : variants.get(typeKey);
return m == null ? null : m.get(key);
}
/** Registers a (datapack) variant definition — the raw {@code <type>_variant} JSON; replaces the vanilla one. */
public void addVariant(String typeKey, String key, JsonObject definition) {
variants.computeIfAbsent(typeKey, k -> new ConcurrentHashMap<>()).put(key, VariantAssets.parse(definition));
}
/** A model from the vanilla client dump, or null. */ /** A model from the vanilla client dump, or null. */
public CemModel vanilla(String name) { public CemModel vanilla(String name) {
@@ -84,63 +208,56 @@ public final class CemModelLoader {
return vanilla.containsKey(name); return vanilla.containsKey(name);
} }
/** All vanilla dump model names. */ public Set<String> vanillaNames() {
public java.util.Set<String> vanillaNames() {
return vanilla.keySet(); return vanilla.keySet();
} }
/** Pipeline properties of a vanilla render type by name (see {@link CemModel#renderType()}), or null. */
public RenderType renderType(String name) {
return name == null ? null : renderTypes.get(name);
}
/** Vanilla renderer info for an entity type key, or null (not in the dump). */ /** Vanilla renderer info for an entity type key, or null (not in the dump). */
public EntityInfo entityInfo(String typeKey) { public EntityInfo entityInfo(String typeKey) {
return entities.get(typeKey); return entities.get(typeKey);
} }
/** A model of the CEM template set (legacy fallback), or null. */
public CemModel get(String name) { public CemModel get(String name) {
return models.get(name); return models.get(name);
} }
public int size() {
return models.size();
}
/** /**
* Parse a CEM template-models JSON stream (the bundled CEM template set, or the vanilla dump). Later * Parses a CEM template set or a vanilla dump (recognised by its {@code entities}); later loads override
* loads override earlier models of the same name. Returns the total number of models loaded. * models of the same name. Returns the total number of models loaded so far.
*/ */
public int load(InputStream in, Logger logger) { public int load(InputStream in, Logger logger) {
JsonObject root = JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8)) JsonObject root = JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8))
.getAsJsonObject(); .getAsJsonObject();
boolean isVanillaDump = root.has("entities"); boolean isVanillaDump = root.has("entities");
if (isVanillaDump) loadDumpInfo(root);
Map<String, CemModel> target = isVanillaDump ? vanilla : models; Map<String, CemModel> target = isVanillaDump ? vanilla : models;
if (isVanillaDump) { for (Map.Entry<String, JsonElement> e : root.getAsJsonObject("models").entrySet()) {
for (Map.Entry<String, JsonElement> e :
root.getAsJsonObject("entities").entrySet()) {
JsonObject o = e.getValue().getAsJsonObject();
entities.put(
e.getKey(),
new EntityInfo(
o.has("texture") ? o.get("texture").getAsString() : null,
o.has("babyTexture") ? o.get("babyTexture").getAsString() : null,
doubles(o, "transform"),
o.has("yawSign") ? o.get("yawSign").getAsInt() : -1,
doubles(o, "pitchAxis"),
poses(o)));
}
}
JsonObject modelsObj = root.getAsJsonObject("models");
for (Map.Entry<String, JsonElement> e : modelsObj.entrySet()) {
try { try {
JsonObject entry = e.getValue().getAsJsonObject(); JsonObject entry = e.getValue().getAsJsonObject();
if (!entry.has("model")) continue; if (!entry.has("model")) continue;
JsonObject model = // The dump nests the jem as an object; the CEM template set embeds a JSON string.
JsonParser.parseString(entry.get("model").getAsString()).getAsJsonObject(); JsonElement m = entry.get("model");
int tw = model.getAsJsonArray("textureSize").get(0).getAsInt(); JsonObject model = m.isJsonObject()
int th = model.getAsJsonArray("textureSize").get(1).getAsInt(); ? m.getAsJsonObject()
: JsonParser.parseString(m.getAsString()).getAsJsonObject();
JsonArray size = model.getAsJsonArray("textureSize");
List<CemPart> parts = new ArrayList<>(); List<CemPart> parts = new ArrayList<>();
for (JsonElement pe : model.getAsJsonArray("models")) parts.add(parsePart(pe.getAsJsonObject())); for (JsonElement pe : model.getAsJsonArray("models")) parts.add(parsePart(pe.getAsJsonObject()));
boolean absolute = target.put(
model.has("absolute") && model.get("absolute").getAsBoolean(); e.getKey(),
String texture = model.has("texture") ? model.get("texture").getAsString() : null; new CemModel(
target.put(e.getKey(), new CemModel(tw, th, parts, absolute, texture)); size.get(0).getAsInt(),
size.get(1).getAsInt(),
parts,
bool(model, "absolute"),
str(model, "texture"),
str(model, "renderType")));
} catch (Exception ex) { } catch (Exception ex) {
if (logger != null) logger.warning("Failed to parse CEM model " + e.getKey() + ": " + ex.getMessage()); if (logger != null) logger.warning("Failed to parse CEM model " + e.getKey() + ": " + ex.getMessage());
} }
@@ -148,10 +265,55 @@ public final class CemModelLoader {
return models.size() + vanilla.size(); return models.size() + vanilla.size();
} }
private void loadDumpInfo(JsonObject root) {
for (Map.Entry<String, JsonElement> e : root.getAsJsonObject("entities").entrySet()) {
JsonObject o = e.getValue().getAsJsonObject();
entities.put(
e.getKey(),
new EntityInfo(
str(o, "texture"),
str(o, "babyTexture"),
doubles(o, "transform"),
o.has("yawSign") ? o.get("yawSign").getAsInt() : -1,
doubles(o, "pitchAxis"),
poses(o.getAsJsonObject("poses")),
layers(o.getAsJsonObject("layers")),
bool(o, "humanoidArmor"),
poseTransforms(o.getAsJsonObject("poseTransforms")),
modelPoses(o.getAsJsonObject("modelPoses")),
rotationBindings(o.getAsJsonObject("rotationBindings")),
emissive(o.getAsJsonArray("emissive")),
strings(o.getAsJsonObject("stateTextures"))));
}
if (root.has("renderTypes")) {
for (Map.Entry<String, JsonElement> t :
root.getAsJsonObject("renderTypes").entrySet()) {
JsonObject o = t.getValue().getAsJsonObject();
renderTypes.put(
t.getKey(),
new RenderType(
o.has("cutout") ? o.get("cutout").getAsDouble() : -1,
str(o, "blend"),
!o.has("shaded") || o.get("shaded").getAsBoolean()));
}
}
if (root.has("variants")) {
for (Map.Entry<String, JsonElement> t :
root.getAsJsonObject("variants").entrySet()) {
Map<String, VariantAssets> m = new ConcurrentHashMap<>();
for (Map.Entry<String, JsonElement> v :
t.getValue().getAsJsonObject().entrySet()) {
m.put(v.getKey(), VariantAssets.parse(v.getValue().getAsJsonObject()));
}
variants.put(t.getKey(), m);
}
}
}
private CemPart parsePart(JsonObject p) { private CemPart parsePart(JsonObject p) {
double[] translate = arr3(p, "translate"); double[] translate = arr3(p, "translate");
double[] rotate = arr3(p, "rotate"); double[] rotate = arr3(p, "rotate");
boolean partMirror = mirrorsU(p); // mirrorTexture "u" — applies to all of the part's boxes boolean partMirror = mirrorsU(p); // a part's mirrorTexture applies to all its boxes
List<CemBox> boxes = new ArrayList<>(); List<CemBox> boxes = new ArrayList<>();
if (p.has("boxes")) { if (p.has("boxes")) {
for (JsonElement be : p.getAsJsonArray("boxes")) { for (JsonElement be : p.getAsJsonArray("boxes")) {
@@ -175,17 +337,13 @@ public final class CemModelLoader {
if (b.has("hiddenFaces")) { if (b.has("hiddenFaces")) {
hiddenFaces = new boolean[6]; hiddenFaces = new boolean[6];
for (JsonElement hf : b.getAsJsonArray("hiddenFaces")) { for (JsonElement hf : b.getAsJsonArray("hiddenFaces")) {
hiddenFaces[ hiddenFaces[Direction.fromName(hf.getAsString()).ordinal()] = true;
eu.mhsl.minecraft.pixelpics.assets.model.Direction.fromName(hf.getAsString())
.ordinal()] = true;
} }
} }
double[] uv = b.has("textureOffset") JsonArray to = b.has("textureOffset") ? b.getAsJsonArray("textureOffset") : null;
? new double[] { double[] uv = to == null
b.getAsJsonArray("textureOffset").get(0).getAsDouble(), ? new double[] {0, 0}
b.getAsJsonArray("textureOffset").get(1).getAsDouble() : new double[] {to.get(0).getAsDouble(), to.get(1).getAsDouble()};
}
: new double[] {0, 0};
boxes.add(new CemBox( boxes.add(new CemBox(
origin, size, inflate, uv, partMirror || mirrorsU(b), parseFaceUv(b), texScale, hiddenFaces)); origin, size, inflate, uv, partMirror || mirrorsU(b), parseFaceUv(b), texScale, hiddenFaces));
} }
@@ -199,23 +357,16 @@ public final class CemModelLoader {
: (p.has("id") ? p.get("id").getAsString() : ""); : (p.has("id") ? p.get("id").getAsString() : "");
double[] pivot = p.has("pivot") ? arr3(p, "pivot") : null; double[] pivot = p.has("pivot") ? arr3(p, "pivot") : null;
double[] scale = p.has("scale") ? arr3(p, "scale") : null; double[] scale = p.has("scale") ? arr3(p, "scale") : null;
boolean invisible = p.has("invisible") && p.get("invisible").getAsBoolean(); return new CemPart(
boolean skipDraw = p.has("skipDraw") && p.get("skipDraw").getAsBoolean(); name, translate, rotate, boxes, children, pivot, scale, bool(p, "invisible"), bool(p, "skipDraw"));
return new CemPart(name, translate, rotate, boxes, children, pivot, scale, invisible, skipDraw);
} }
// CEM per-face UV keys ordered by Direction ordinal (DOWN, UP, NORTH, SOUTH, WEST, EAST). // 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"}; 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. */ /** Per-face UV ({@code uvNorth} etc., each {@code [u1,v1,u2,v2]}) as {@code {x,y,w,h}}, or null. */
private static double[][] parseFaceUv(JsonObject b) { private static double[][] parseFaceUv(JsonObject b) {
boolean any = false; if (Arrays.stream(FACE_UV_KEYS).noneMatch(b::has)) return null;
for (String k : FACE_UV_KEYS)
if (b.has(k)) {
any = true;
break;
}
if (!any) return null;
double[][] faces = new double[6][]; double[][] faces = new double[6][];
for (int i = 0; i < FACE_UV_KEYS.length; i++) { for (int i = 0; i < FACE_UV_KEYS.length; i++) {
if (!b.has(FACE_UV_KEYS[i])) continue; if (!b.has(FACE_UV_KEYS[i])) continue;
@@ -231,10 +382,10 @@ public final class CemModelLoader {
return o.has("mirrorTexture") && o.get("mirrorTexture").getAsString().contains("u"); return o.has("mirrorTexture") && o.get("mirrorTexture").getAsString().contains("u");
} }
private static Map<String, Map<String, PartPose>> poses(JsonObject o) { private static Map<String, Map<String, PartPose>> poses(JsonObject poses) {
Map<String, Map<String, PartPose>> out = new HashMap<>(); Map<String, Map<String, PartPose>> out = new HashMap<>();
if (!o.has("poses")) return out; if (poses == null) return out;
for (Map.Entry<String, JsonElement> v : o.getAsJsonObject("poses").entrySet()) { for (Map.Entry<String, JsonElement> v : poses.entrySet()) {
Map<String, PartPose> parts = new HashMap<>(); Map<String, PartPose> parts = new HashMap<>();
for (Map.Entry<String, JsonElement> pe : for (Map.Entry<String, JsonElement> pe :
v.getValue().getAsJsonObject().entrySet()) { v.getValue().getAsJsonObject().entrySet()) {
@@ -242,15 +393,104 @@ public final class CemModelLoader {
parts.put( parts.put(
pe.getKey(), pe.getKey(),
new PartPose( new PartPose(
arr3(po, "pivot"), po.has("pivot") ? arr3(po, "pivot") : null,
arr3(po, "rotate"), po.has("rotate") ? arr3(po, "rotate") : null,
!po.has("visible") || po.get("visible").getAsBoolean())); po.has("visible") ? po.get("visible").getAsBoolean() : null,
po.has("scale") ? arr3(po, "scale") : null,
null));
} }
out.put(v.getKey(), parts); out.put(v.getKey(), parts);
} }
return out; return out;
} }
private static Map<String, PoseTransform> poseTransforms(JsonObject o) {
Map<String, PoseTransform> out = new HashMap<>();
if (o == null) return out;
for (Map.Entry<String, JsonElement> e : o.entrySet()) {
JsonObject t = e.getValue().getAsJsonObject();
out.put(
e.getKey(),
new PoseTransform(
doubles(t, "transform"),
t.has("yawSign") ? t.get("yawSign").getAsInt() : -1,
doubles(t, "pitchAxis")));
}
return out;
}
private static Map<String, PoseSet> modelPoses(JsonObject o) {
Map<String, PoseSet> out = new HashMap<>();
if (o == null) return out;
for (Map.Entry<String, JsonElement> e : o.entrySet()) {
JsonObject m = e.getValue().getAsJsonObject();
out.put(
e.getKey(),
new PoseSet(
poses(m.getAsJsonObject("poses")), poseTransforms(m.getAsJsonObject("poseTransforms"))));
}
return out;
}
private static Map<String, RotationBinding> rotationBindings(JsonObject o) {
Map<String, RotationBinding> out = new HashMap<>();
if (o == null) return out;
for (Map.Entry<String, JsonElement> e : o.entrySet()) {
JsonObject b = e.getValue().getAsJsonObject();
Map<String, double[]> parts = new HashMap<>();
for (Map.Entry<String, JsonElement> p : b.getAsJsonObject("parts").entrySet()) {
JsonArray a = p.getValue().getAsJsonArray();
double[] c = new double[9];
for (int i = 0; i < 9 && i < a.size(); i++) c[i] = a.get(i).getAsDouble();
parts.put(p.getKey(), c);
}
out.put(e.getKey(), new RotationBinding(doubles(b, "default"), parts));
}
return out;
}
private static List<EmissiveLayer> emissive(JsonArray a) {
List<EmissiveLayer> out = new ArrayList<>();
if (a == null) return out;
for (JsonElement el : a) {
JsonObject o = el.getAsJsonObject();
out.add(new EmissiveLayer(
o.get("model").getAsString(),
o.get("texture").getAsString(),
strings(o.getAsJsonObject("textures")),
str(o, "renderType"),
o.has("alpha") ? o.get("alpha").getAsDouble() : 1,
str(o, "when"),
bool(o, "invisible"),
bool(o, "animated")));
}
return out;
}
private static Map<String, String> strings(JsonObject o) {
Map<String, String> out = new HashMap<>();
if (o == null) return out;
for (Map.Entry<String, JsonElement> e : o.entrySet())
out.put(e.getKey(), e.getValue().getAsString());
return out;
}
private static Map<String, Boolean> layers(JsonObject o) {
Map<String, Boolean> out = new HashMap<>();
if (o == null) return out;
for (Map.Entry<String, JsonElement> e : o.entrySet())
out.put(e.getKey(), e.getValue().getAsBoolean());
return out;
}
private static String str(JsonObject o, String k) {
return o.has(k) && o.get(k).isJsonPrimitive() ? o.get(k).getAsString() : null;
}
private static boolean bool(JsonObject o, String k) {
return o.has(k) && o.get(k).getAsBoolean();
}
private static double[] doubles(JsonObject o, String key) { private static double[] doubles(JsonObject o, String key) {
if (!o.has(key) || !o.get(key).isJsonArray()) return null; if (!o.has(key) || !o.get(key).isJsonArray()) return null;
JsonArray a = o.getAsJsonArray(key); JsonArray a = o.getAsJsonArray(key);
@@ -0,0 +1,112 @@
package eu.mhsl.minecraft.pixelpics.render.entity.cem;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
/**
* Registers the mob variants of the worlds' datapacks ({@code data/<ns>/<type>_variant/<key>.json}, folder or
* zip packs) with the model loader, overriding the dumped vanilla ones (textures must be in the resource pack).
*/
public final class DatapackVariants {
private static final Pattern VARIANT =
Pattern.compile("data/([a-z0-9_.-]+)/([a-z0-9_]+)_variant/([a-z0-9_/.-]+)\\.json");
private DatapackVariants() {}
/**
* Scans {@code <level>/datapacks/*} for every world folder: the folder itself or the nearest of up to 4 parents
* that has one (26.x keeps dimensions under {@code <level>/dimensions/<ns>/<name>}); returns the number of
* variants registered.
*/
public static int load(CemModelLoader models, Iterable<File> worldFolders, Logger log) {
int n = 0;
Set<File> seen = new HashSet<>();
for (File world : worldFolders) {
File dir = world.getAbsoluteFile();
for (int up = 0; dir != null && up < 4 && !new File(dir, "datapacks").isDirectory(); up++) {
dir = dir.getParentFile();
}
if (dir == null || !seen.add(dir)) continue;
File[] packs = new File(dir, "datapacks").listFiles();
if (packs == null) continue;
for (File pack : packs) {
try {
n += pack.isDirectory() ? scanDir(models, pack.toPath(), log) : scanZip(models, pack, log);
} catch (IOException | RuntimeException e) {
log.warning("Skipping datapack " + pack + ": " + e.getMessage());
}
}
}
return n;
}
private static int scanDir(CemModelLoader models, Path root, Logger log) throws IOException {
int n = 0;
try (Stream<Path> files = Files.walk(root)) {
for (Path f : (Iterable<Path>) files.filter(Files::isRegularFile)::iterator) {
String rel = root.relativize(f).toString().replace(File.separatorChar, '/');
Matcher m = variant(rel);
if (m == null) continue;
try (InputStream in = Files.newInputStream(f)) {
register(models, m, in);
n++;
} catch (IOException | RuntimeException e) {
log.warning("Skipping variant " + f + ": " + e.getMessage());
}
}
}
return n;
}
private static int scanZip(CemModelLoader models, File zip, Logger log) throws IOException {
if (!zip.getName().endsWith(".zip")) return 0;
int n = 0;
try (ZipFile zf = new ZipFile(zip)) {
for (ZipEntry ze : Collections.list(zf.entries())) {
if (ze.isDirectory()) continue;
Matcher m = variant(ze.getName());
if (m == null) continue;
try (InputStream in = zf.getInputStream(ze)) {
register(models, m, in);
n++;
} catch (IOException | RuntimeException e) {
log.warning("Skipping variant " + ze.getName() + " in " + zip + ": " + e.getMessage());
}
}
}
return n;
}
/** The match of a mob variant definition path, or null (other files are not even opened). */
private static Matcher variant(String path) {
Matcher m = VARIANT.matcher(path);
if (!m.matches()) return null;
String type = m.group(2);
if (type.endsWith("_sound") || type.equals("painting")) return null; // no mob texture
return m;
}
private static void register(CemModelLoader models, Matcher m, InputStream in) {
JsonObject def = JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8))
.getAsJsonObject();
String key = m.group(1).equals("minecraft") ? m.group(3) : m.group(1) + ":" + m.group(3);
models.addVariant(m.group(2), key, def);
}
}
@@ -22,38 +22,42 @@ import eu.mhsl.minecraft.pixelpics.render.entity.TextureOps;
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTint; import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTint;
import eu.mhsl.minecraft.pixelpics.render.tint.TintResolver; import eu.mhsl.minecraft.pixelpics.render.tint.TintResolver;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import eu.mhsl.minecraft.pixelpics.render.util.SpecialDates;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.ZonedDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.IdentityHashMap; import java.util.IdentityHashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.TimeZone;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.DyeColor;
import org.bukkit.block.data.BlockData; import org.bukkit.block.data.BlockData;
/** /**
* Item (and block) geometry the way vanilla's ItemRenderer draws it, straight from the resource pack: * Item (and block) geometry like vanilla's ItemRenderer, from the resource pack: the 26.x item definition
* the 26.x item definition ({@code items/<id>.json}: model / special / select / condition) picks the * ({@code items/<id>.json}) picks the model for the display context, its parent chain supplies the {@code display}
* model for the display context, the model's parent chain supplies the {@code display} transform and the * transform and {@code layerN} textures. Generated items are extruded sprites (1 px thick), block items their block
* {@code layerN} textures. Geometry: generated items as an extruded sprite (1px thick, like vanilla), * model, special models (chest, heads, banners, shield, trident, …) entity/block-entity models. The caller's
* block items as their block model, block-entity items (chest, heads, banners, …) through the block-entity * {@code toWorld} maps the ItemRenderer origin; this class applies the display transform and {@code T(-0.5)}.
* baker, shield/trident through their entity models. Shared by held items, head items, item displays and
* dropped items.
*
* <p>All transforms are in vanilla item space: the caller's {@code toWorld} maps the ItemRenderer origin;
* this class applies the display transform and the {@code translate(-0.5)} to the 0..1 model cube.
*/ */
public final class ItemGeometry { public final class ItemGeometry {
// Tinted block faces have no biome context here: plains colours. private static final Info EMPTY = new Info(null, List.of(), List.of(), null, null, null);
private static final BiomeTint DEFAULT_TINT = new BiomeTint(0xFF91BD59, 0xFF77AB2F, 0xFFA9A05B, 0xFF3F76E4); private static final double Z0 = 7.5 / 16, Z1 = 8.5 / 16; // extruded sprite depth
private final AssetReader reader; private final AssetReader reader;
private final BlockModelRegistry registry; private final BlockModelRegistry registry;
private final TextureCache textures; private final TextureCache textures;
private final CemModelLoader models; private final CemModelLoader models;
private final BlockEntityBaker blockEntities; private final BlockEntityBaker blockEntities;
private final Map<String, Info> infos = new ConcurrentHashMap<>(); // Per "id#context"; date-dependent definitions (local_time selects) remember the day they were resolved for.
private final Map<String, DatedInfo> infos = new ConcurrentHashMap<>();
private final Map<int[][], Map<Integer, int[][]>> tinted = new IdentityHashMap<>(); private final Map<int[][], Map<Integer, int[][]>> tinted = new IdentityHashMap<>();
private final Map<int[][], List<Box>> extrusions = new IdentityHashMap<>(); private final Map<int[][], List<Box>> extrusions = new IdentityHashMap<>();
private final Map<Info, Optional<int[][]>> composedSprites = new ConcurrentHashMap<>(); private final Map<Info, Optional<int[][]>> composedSprites = new ConcurrentHashMap<>();
@@ -77,11 +81,10 @@ public final class ItemGeometry {
List<String> layers, // generated-item layer textures (layer0, layer1, …); empty if not generated List<String> layers, // generated-item layer textures (layer0, layer1, …); empty if not generated
List<Integer> tints, // ARGB per layer (0 = none) List<Integer> tints, // ARGB per layer (0 = none)
String special, // special model type ("shield", "trident", "chest", "head", …) or null String special, // special model type ("shield", "trident", "chest", "head", …) or null
JsonObject specialModel, // the special model's own fields (chest texture/chest_type, …) or null
Affine specialTransform) {} // item-model transformation for special models (T·Rl·S·Rr) or null Affine specialTransform) {} // item-model transformation for special models (T·Rl·S·Rr) or null
// --- public API -------------------------------------------------------------------------------- /** An item in a display context ("thirdperson_righthand", "head", "ground", "fixed", "none", …). */
/** Bake an item in a display context ("thirdperson_righthand", "head", "ground", "fixed", "none", …). */
public void bake(ItemSpec item, String context, boolean leftHand, Affine toWorld, List<EntityCube> out) { public void bake(ItemSpec item, String context, boolean leftHand, Affine toWorld, List<EntityCube> out) {
if (item == null) return; if (item == null) return;
Info info = info(item.id(), context); Info info = info(item.id(), context);
@@ -89,72 +92,119 @@ public final class ItemGeometry {
int before = out.size(); int before = out.size();
if (info.special != null) bakeSpecial(item, info, m, out); if (info.special != null) bakeSpecial(item, info, m, out);
if (out.size() == before && !info.layers.isEmpty()) bakeGenerated(info, m, out); if (out.size() == before && !info.layers.isEmpty()) bakeGenerated(info, m, out);
if (out.size() == before && item.block() != null) bakeBlock(item.block(), m, out); if (out.size() == before && item.block() != null) {
if (out.size() == before && item.blockEntity() != null) bakeBlockEntity(item.blockEntity(), m, out); // An item, not a placed block: the first (unrotated) variant like its item model, no random pick.
bakeModel(registry.firstVariants(item.block()), item.block(), m, out);
}
// Placed block-entity fallback only for block items (a shield carries its patterns as a banner state).
if (out.size() == before && item.block() != null && item.blockEntity() != null) {
bakeBlockEntity(item.blockEntity(), m, out);
}
if (out.size() == before) { if (out.size() == before) {
int[][] sprite = sprite(item.id()); int[][] sprite = sprite(item.id());
if (sprite != null) addExtruded(sprite, m, out); if (sprite != null) addExtruded(sprite, m, out);
} }
} }
/** The display transform's Y scale for a context (vanilla lifts dropped items by 0.25·scaleY). */ /**
public double displayScaleY(ItemSpec item, String context) { * ItemStackRenderState.getModelBoundingBox: the model's extents through {@code display·T(-0.5)} as
if (item == null) return 1; * {minX, minY, minZ, maxX, maxY, maxZ}; null when nothing renders. Generated items span their whole sprite
* square, not just the opaque texels.
*/
public double[] modelBounds(ItemSpec item, String context) {
if (item == null) return null;
Info info = info(item.id(), context); Info info = info(item.id(), context);
return info.display == null ? 1 : info.display[7]; List<EntityCube> cubes = new ArrayList<>();
if (info.special == null && !info.layers.isEmpty()) {
Affine m = displayTransform(info.display, false).mul(Affine.translation(-0.5, -0.5, -0.5));
cubes.add(new EntityCube(new double[] {0, 0, Z0}, new double[] {1, 1, Z1}, new Face[6], m));
} else {
bake(item, context, false, Affine.identity(), cubes);
}
if (cubes.isEmpty()) return null;
RenderedEntity r = RenderedEntity.of(cubes);
return new double[] {r.aabbMin[0], r.aabbMin[1], r.aabbMin[2], r.aabbMax[0], r.aabbMax[1], r.aabbMax[2]};
} }
/** A block's model elements in the block cube [0,1]³ (tinted with plains colours). */ /**
public void bakeBlock(BlockData data, Affine toWorld, List<EntityCube> out) { * A block's model elements in the block cube [0,1]³, weighted variants picked by {@code modelSeed}
* ({@link BlockModelRegistry#MODEL_SEED} for displays/minecart blocks).
*/
public void bakeBlock(BlockData data, long modelSeed, Affine toWorld, List<EntityCube> out) {
if (data == null) return; if (data == null) return;
ResolvedModel model = registry.get(data); bakeModel(registry.get(data, modelSeed), data, toWorld, out);
}
/** A block model's elements in the block cube [0,1]³; {@code data} = tint context (plains colours) or null. */
public void bakeModel(ResolvedModel model, BlockData data, Affine toWorld, List<EntityCube> out) {
if (model == null || !model.hasGeometry) return; if (model == null || !model.hasGeometry) return;
for (Element world : model.elements) { for (Element world : model.elements) {
// Rotated without uvlock: bake the model-space element turned by the blockstate rotation, so // Blockstate-rotated: the model-space element turned as a whole, so textures stay bound to the model.
// textures stay bound to the model (vanilla); otherwise the world-space element as is. Element e = world;
Element e = world.unrotated != null ? world.unrotated : world; Affine variant = Affine.identity();
Affine variant = world.unrotated != null ? variantRotation(world.xSteps, world.ySteps) : Affine.identity(); if (world.unrotated != null) {
e = world.unrotated;
variant = variantRotation(world.xSteps, world.ySteps);
}
Face[] faces = new Face[6]; Face[] faces = new Face[6];
for (int i = 0; i < 6; i++) faces[i] = tint(e.faces[i], data); for (int i = 0; i < 6; i++) faces[i] = tint(e.faces[i], data);
out.add(new EntityCube(e.from, e.to, faces, toWorld.mul(variant).mul(elementTransform(e)))); out.add(new EntityCube(e.from, e.to, faces, toWorld.mul(variant).mul(elementTransform(e))));
} }
} }
/** A block-entity block (chest, head, banner, …) in the block cube [0,1]³ via the block-entity baker. */ /** A block entity placed as in the world at cell (0,0,0), in the block cube [0,1]³ (block displays, …). */
public void bakeBlockEntity(BlockEntityState state, Affine toWorld, List<EntityCube> out) { public void bakeBlockEntity(BlockEntityState state, Affine toWorld, List<EntityCube> out) {
if (state == null || blockEntities == null) return; if (state == null || blockEntities == null) return;
RenderedEntity r = blockEntities.bake(state); // placed at cell (bx,by,bz) = (0,0,0) RenderedEntity r = blockEntities.bake(state);
if (r == null) return; if (r == null) return;
for (EntityCube c : r.cubes) out.add(new EntityCube(c.from, c.to, c.faces, toWorld.mul(c.toWorld))); for (EntityCube c : r.cubes) out.add(c.transformed(toWorld));
} }
// --- item definition / model resolution -------------------------------------------------------- /** A block entity's bare models (no block pose) mapped by {@code toWorld}. */
public void bakeBlockEntityModel(BlockEntityState state, Affine toWorld, List<EntityCube> out) {
if (state != null && blockEntities != null) blockEntities.bakeModel(state, toWorld, out);
}
/** A resolved item definition; {@code day} is the date it holds for (local_time selects) or null (any day). */
private record DatedInfo(Info info, LocalDate day) {}
private Info info(String id, String context) { private Info info(String id, String context) {
return infos.computeIfAbsent(id + "#" + context, k -> { ZonedDateTime now = SpecialDates.now();
try { LocalDate today = now.toLocalDate();
return resolve(id, context); String key = id + "#" + context;
} catch (RuntimeException e) { DatedInfo cached = infos.get(key);
// Unexpected item-definition format (newer version?): fall back to the plain sprite. if (cached != null && (cached.day == null || cached.day.equals(today))) return cached.info;
return new Info(null, List.of(), List.of(), null, null); // minecraft:local_time selects (christmas chest items) change with the day: such entries are replaced on a
} // new day instead of adding one per day.
}); boolean[] dated = {false};
Info info;
try {
info = resolve(id, context, now, dated);
} catch (RuntimeException e) {
// Unexpected item-definition format (newer version?): the plain sprite.
info = EMPTY;
}
infos.put(key, new DatedInfo(info, dated[0] ? today : null));
return info;
} }
private Info resolve(String id, String context) { private Info resolve(String id, String context, ZonedDateTime now, boolean[] dated) {
String modelRef = null, special = null; String modelRef = null, special = null;
JsonObject specialModel = null;
Affine specialXf = null; Affine specialXf = null;
List<Integer> tints = new ArrayList<>(); List<Integer> tints = new ArrayList<>();
Optional<JsonObject> def = reader.readJsonObject("assets/minecraft/items/" + id + ".json"); Optional<JsonObject> def = reader.readJsonObject("assets/minecraft/items/" + id + ".json");
if (def.isPresent() && def.get().has("model")) { if (def.isPresent() && def.get().has("model")) {
JsonObject node = pick(def.get().getAsJsonObject("model"), context); Affine[] outer = {null};
JsonObject node = pick(def.get().getAsJsonObject("model"), context, now, outer, dated);
if (node != null) { if (node != null) {
String type = str(node, "type"); String type = str(node, "type");
if (type.endsWith("special")) { if (type.endsWith("special")) {
modelRef = str(node, "base"); modelRef = str(node, "base");
JsonObject sm = node.has("model") ? node.getAsJsonObject("model") : null; JsonObject sm = node.has("model") ? node.getAsJsonObject("model") : null;
special = sm == null ? null : str(sm, "type").replace("minecraft:", ""); special = sm == null ? null : str(sm, "type").replace("minecraft:", "");
if (node.has("transformation")) specialXf = transformation(node.getAsJsonObject("transformation")); specialModel = sm;
specialXf = outer[0];
} else { } else {
modelRef = str(node, "model"); modelRef = str(node, "model");
} }
@@ -165,7 +215,7 @@ public final class ItemGeometry {
} }
if (modelRef == null || modelRef.isEmpty()) modelRef = "minecraft:item/" + id; if (modelRef == null || modelRef.isEmpty()) modelRef = "minecraft:item/" + id;
// Parent chain: nearest "display" entry for the context wins; layer textures; generated flag. // Parent chain: the nearest "display" entry for the context wins.
double[] display = null; double[] display = null;
Map<String, String> tex = new HashMap<>(); Map<String, String> tex = new HashMap<>();
boolean generated = false; boolean generated = false;
@@ -199,13 +249,22 @@ public final class ItemGeometry {
if (generated) { if (generated) {
for (int i = 0; tex.containsKey("layer" + i); i++) layers.add(resolveTex(tex, tex.get("layer" + i))); for (int i = 0; tex.containsKey("layer" + i); i++) layers.add(resolveTex(tex, tex.get("layer" + i)));
} }
return new Info(display, layers, tints, special, specialXf); return new Info(display, layers, tints, special, specialModel, specialXf);
} }
/** Walks an item-model node to the concrete model/special for a display context. */ /**
private static JsonObject pick(JsonObject node, String context) { * Walks an item-model node to the model/special for a display context. {@code outer[0]} accumulates the
* {@code transformation}s of the nodes passed down to a special (ItemModel.Unbaked.bake: shield/trident flips,
* statue placement; null = none). {@code dated[0]} is set when a {@code local_time} select was evaluated.
*/
private static JsonObject pick(
JsonObject node, String context, ZonedDateTime now, Affine[] outer, boolean[] dated) {
for (int depth = 0; node != null && depth < 16; depth++) { for (int depth = 0; node != null && depth < 16; depth++) {
String type = str(node, "type").replace("minecraft:", ""); String type = str(node, "type").replace("minecraft:", "");
if (!type.equals("model") && node.has("transformation")) {
Affine t = transformation(node.getAsJsonObject("transformation"));
outer[0] = outer[0] == null ? t : outer[0].mul(t);
}
switch (type) { switch (type) {
case "model", "special" -> { case "model", "special" -> {
return node; return node;
@@ -213,10 +272,15 @@ public final class ItemGeometry {
case "condition" -> node = obj(node, "on_false"); case "condition" -> node = obj(node, "on_false");
case "select" -> { case "select" -> {
JsonObject chosen = null; JsonObject chosen = null;
if (str(node, "property").endsWith("display_context") && node.has("cases")) { String property = str(node, "property");
if (property.endsWith("local_time")) dated[0] = true;
String value = property.endsWith("display_context")
? context
: property.endsWith("local_time") ? localTime(node, now) : null;
if (value != null && node.has("cases")) {
for (JsonElement c : node.getAsJsonArray("cases")) { for (JsonElement c : node.getAsJsonArray("cases")) {
JsonObject co = c.getAsJsonObject(); JsonObject co = c.getAsJsonObject();
if (matches(co.get("when"), context)) { if (matches(co.get("when"), value)) {
chosen = obj(co, "model"); chosen = obj(co, "model");
break; break;
} }
@@ -247,6 +311,20 @@ public final class ItemGeometry {
return null; return null;
} }
/** The {@code minecraft:local_time} select value (SimpleDateFormat) for {@code now}; null when invalid. */
private static String localTime(JsonObject node, ZonedDateTime now) {
try {
String locale = str(node, "locale");
SimpleDateFormat f = new SimpleDateFormat(
str(node, "pattern"), locale.isEmpty() ? Locale.ROOT : Locale.forLanguageTag(locale));
String tz = str(node, "time_zone");
f.setTimeZone(tz.isEmpty() ? TimeZone.getTimeZone(now.getZone()) : TimeZone.getTimeZone(tz));
return f.format(Date.from(now.toInstant()));
} catch (RuntimeException e) {
return null;
}
}
private static boolean matches(JsonElement when, String context) { private static boolean matches(JsonElement when, String context) {
if (when == null) return false; if (when == null) return false;
if (when.isJsonArray()) { if (when.isJsonArray()) {
@@ -324,36 +402,67 @@ public final class ItemGeometry {
return o.has(k) && o.get(k).isJsonObject() ? o.getAsJsonObject(k) : null; return o.has(k) && o.get(k).isJsonObject() ? o.getAsJsonObject(k) : null;
} }
// --- geometry ------------------------------------------------------------------------------------
private void bakeSpecial(ItemSpec item, Info info, Affine m, List<EntityCube> out) { private void bakeSpecial(ItemSpec item, Info info, Affine m, List<EntityCube> out) {
Affine xf = info.specialTransform != null ? m.mul(info.specialTransform) : m;
switch (info.special) { switch (info.special) {
case "shield", "trident" -> { case "shield" -> {
// Entity model in Java model space; the special renderer flips it with scale(1,-1,-1). CemModelLoader.CemModel model = models.vanilla("layer/shield#main");
boolean shield = info.special.equals("shield"); int[][] tex = shieldTexture(item.blockEntity());
CemModelLoader.CemModel model = models.vanilla(shield ? "layer/shield#main" : "trident"); if (model != null && tex != null) bakeEntityModel(model, tex, xf, out);
if (model == null) model = models.vanilla("layer/" + info.special + "#main");
int[][] tex = textures.get(ResourceLocation.parse(
shield ? "entity/shield/shield_base_nopattern" : "entity/trident/trident"))
.orElse(null);
if (model == null || tex == null) return;
Affine xf = info.specialTransform != null ? m.mul(info.specialTransform) : m;
Affine pre = xf.mul(Affine.scale(1, -1, -1))
.mul(Affine.scale(1 / 16.0))
.mul(CemGeometry.CEM_TO_JAVA);
for (CemGeometry.Baked b : CemGeometry.bakeModel(model, tex, pre, Set.of())) {
out.add(new EntityCube(b.from(), b.to(), b.faces(), b.world()));
}
} }
default -> { case "trident" -> {
// Block-entity specials (chest, head, banner, shulker box, bed, conduit, pot, signs, …). CemModelLoader.CemModel model = models.vanilla("trident");
if (item.blockEntity() != null) bakeBlockEntity(item.blockEntity(), m, out); if (model == null) model = models.vanilla("layer/trident#main");
int[][] tex = texture("entity/trident/trident");
if (model != null && tex != null) bakeEntityModel(model, tex, xf, out);
} }
case "chest" -> {
if (!bakeChest(info.specialModel, xf, out)) bakeBlockEntityModel(item.blockEntity(), xf, out);
}
// Block-entity specials (head, banner, shulker box, conduit, pot, …): the bare model.
default -> bakeBlockEntityModel(item.blockEntity(), xf, out);
} }
} }
private static void bakeEntityModel(CemModelLoader.CemModel model, int[][] tex, Affine xf, List<EntityCube> out) {
CemGeometry.bakeCubes(model, tex, CemGeometry.modelSpace(xf), Set.of(), out);
}
/**
* ChestSpecialRenderer: the chest model of the {@code chest_type} with the definition's texture
* ({@code entity/chest/<texture>}); false when unresolvable.
*/
private boolean bakeChest(JsonObject sm, Affine xf, List<EntityCube> out) {
if (sm == null || str(sm, "texture").isEmpty()) return false;
String layer =
switch (str(sm, "chest_type").replace("minecraft:", "")) {
case "left" -> "layer/double_chest_left#main";
case "right" -> "layer/double_chest_right#main";
default -> "layer/chest#main";
};
CemModelLoader.CemModel model = models.vanilla(layer);
ResourceLocation id = ResourceLocation.parse(str(sm, "texture"));
int[][] tex = texture(id.namespace() + ":entity/chest/" + id.path());
if (model == null || tex == null) return false;
bakeEntityModel(model, tex, xf, out);
return true;
}
/**
* ShieldSpecialRenderer: shield_base_nopattern, or with patterns/base colour (a {@code BANNER} state from the
* item meta) shield_base + the dyed shield masks (base colour default white).
*/
private int[][] shieldTexture(BlockEntityState patterns) {
boolean patterned = patterns != null
&& (patterns.baseColorArgb() != 0 || !patterns.patterns().isEmpty());
int[][] base = texture(patterned ? "entity/shield/shield_base" : "entity/shield/shield_base_nopattern");
if (!patterned || base == null || blockEntities == null) return base;
int baseColor = patterns.baseColorArgb() != 0 ? patterns.baseColorArgb() : ColorUtil.dyeArgb(DyeColor.WHITE, 0);
return blockEntities.patterned(base, "entity/shield/", baseColor, patterns.patterns());
}
private void bakeGenerated(Info info, Affine m, List<EntityCube> out) { private void bakeGenerated(Info info, Affine m, List<EntityCube> out) {
// Cached per Info (one per item+context): a stable texture identity keeps the extrusion cache bounded. // Cached per Info: a stable texture identity keeps the extrusion cache from growing per bake.
int[][] composed = composedSprites int[][] composed = composedSprites
.computeIfAbsent(info, k -> Optional.ofNullable(compose(k))) .computeIfAbsent(info, k -> Optional.ofNullable(compose(k)))
.orElse(null); .orElse(null);
@@ -364,36 +473,30 @@ public final class ItemGeometry {
private int[][] compose(Info info) { private int[][] compose(Info info) {
int[][] composed = null; int[][] composed = null;
for (int i = 0; i < info.layers.size(); i++) { for (int i = 0; i < info.layers.size(); i++) {
int[][] t = textures.get(ResourceLocation.parse(info.layers.get(i))).orElse(null); int[][] t = texture(info.layers.get(i));
if (t == null) continue; if (t == null) t = TextureCache.MISSINGNO; // vanilla: the missing texture, not a hole
int tint = i < info.tints.size() ? info.tints.get(i) : 0; int tint = i < info.tints.size() ? info.tints.get(i) : 0;
if (composed == null) { if (composed == null) {
composed = TextureOps.deepCopy(t); composed = tint != 0 ? TextureOps.tinted(t, tint) : TextureOps.deepCopy(t);
if (tint != 0) TextureOps.tint(composed, tint);
} else if (t.length == composed.length && t[0].length == composed[0].length) { } else if (t.length == composed.length && t[0].length == composed[0].length) {
int[][] layer = t; TextureOps.overlay(composed, tint != 0 ? TextureOps.tinted(t, tint) : t);
if (tint != 0) {
layer = TextureOps.deepCopy(t);
TextureOps.tint(layer, tint);
}
TextureOps.overlay(composed, layer);
} }
} }
return composed; return composed;
} }
private int[][] sprite(String id) { private int[][] sprite(String id) {
int[][] t = textures.get(ResourceLocation.parse("item/" + id)).orElse(null); int[][] t = texture("item/" + id);
if (t != null) return t; return t != null ? t : texture("block/" + id);
return textures.get(ResourceLocation.parse("block/" + id)).orElse(null); }
private int[][] texture(String path) {
return textures.get(ResourceLocation.parse(path)).orElse(null);
} }
private record Box(double[] from, double[] to, Face[] faces) {} private record Box(double[] from, double[] to, Face[] faces) {}
/** /** Extruded sprite: one box per horizontal run of opaque texels; edges show the run's end texels. */
* Vanilla-style extruded sprite in the 0..1 cube, 1px thick at z 7.5..8.5 px: one box per horizontal run
* of opaque texels; front/back show the texture, the edges the run's texel colours.
*/
private void addExtruded(int[][] tex, Affine m, List<EntityCube> out) { private void addExtruded(int[][] tex, Affine m, List<EntityCube> out) {
List<Box> boxes; List<Box> boxes;
synchronized (extrusions) { synchronized (extrusions) {
@@ -405,7 +508,6 @@ public final class ItemGeometry {
private static List<Box> extrude(int[][] tex) { private static List<Box> extrude(int[][] tex) {
int h = tex.length, w = h == 0 ? 0 : tex[0].length; int h = tex.length, w = h == 0 ? 0 : tex[0].length;
List<Box> boxes = new ArrayList<>(); List<Box> boxes = new ArrayList<>();
double z0 = 7.5 / 16, z1 = 8.5 / 16;
for (int py = 0; py < h; py++) { for (int py = 0; py < h; py++) {
for (int px = 0; px < w; ) { for (int px = 0; px < w; ) {
if (!ColorUtil.opaque(tex[py][px])) { if (!ColorUtil.opaque(tex[py][px])) {
@@ -424,7 +526,7 @@ public final class ItemGeometry {
double uw = (start + 0.5) / w, ue = (px - 0.5) / w; double uw = (start + 0.5) / w, ue = (px - 0.5) / w;
f[Direction.WEST.ordinal()] = new Face(tex, uw, vm, uw, vm, 0, -1); f[Direction.WEST.ordinal()] = new Face(tex, uw, vm, uw, vm, 0, -1);
f[Direction.EAST.ordinal()] = new Face(tex, ue, vm, ue, vm, 0, -1); f[Direction.EAST.ordinal()] = new Face(tex, ue, vm, ue, vm, 0, -1);
boxes.add(new Box(new double[] {u1, 1 - v2, z0}, new double[] {u2, 1 - v1, z1}, f)); boxes.add(new Box(new double[] {u1, 1 - v2, Z0}, new double[] {u2, 1 - v1, Z1}, f));
} }
} }
return boxes; return boxes;
@@ -441,33 +543,31 @@ public final class ItemGeometry {
/** Element rotation (origin/axis/angle, optional rescale) as an affine in block units. */ /** Element rotation (origin/axis/angle, optional rescale) as an affine in block units. */
private static Affine elementTransform(Element e) { private static Affine elementTransform(Element e) {
if (e.isAxisAligned()) return Affine.identity(); if (e.isAxisAligned()) return Affine.identity();
double[] o = e.rotOrigin;
Affine r = Affine r =
switch (e.rotAxis) { switch (e.rotAxis) {
case 0 -> Affine.rotX(e.rotAngleRad); case 0 -> Affine.rotX(e.rotAngleRad);
case 1 -> Affine.rotY(e.rotAngleRad); case 1 -> Affine.rotY(e.rotAngleRad);
default -> Affine.rotZ(e.rotAngleRad); case 2 -> Affine.rotZ(e.rotAngleRad);
default -> new Affine(e.linearRotation(), new double[] {0, 0, 0});
}; };
if (e.rescale) { if (e.invScale != null) {
double f = 1 / Math.cos(e.rotAngleRad); // Vanilla CuboidRotation rescale: R·S, S = 1 / max|component of R·axis| per local axis.
r = r.mul(Affine.scale(e.rotAxis == 0 ? 1 : f, e.rotAxis == 1 ? 1 : f, e.rotAxis == 2 ? 1 : f)); double[] f = e.rescaleFactors();
r = r.mul(Affine.scale(f[0], f[1], f[2]));
} }
return Affine.translation(o[0], o[1], o[2]).mul(r).mul(Affine.translation(-o[0], -o[1], -o[2])); return Affine.rotationAround(r, e.rotOrigin[0], e.rotOrigin[1], e.rotOrigin[2]);
} }
/** Bakes a biome tint into a copy of the face texture (the entity path has no per-hit tinting). */ /** The face with its tint baked into a texture copy (the entity path has no per-hit tinting). */
private Face tint(Face f, BlockData data) { private Face tint(Face f, BlockData data) {
if (f == null || f.tintIndex < 0) return f; if (f == null || f.tintIndex < 0 || data == null) return f;
int color = TintResolver.resolve(data, f.tintIndex, DEFAULT_TINT); int color = TintResolver.resolve(data, f.tintIndex, BiomeTint.PLAINS);
if (color == -1) return f; if (color == -1) return f;
int[][] t; int[][] t;
synchronized (tinted) { synchronized (tinted) {
t = tinted.computeIfAbsent(f.texture, k -> new HashMap<>()).computeIfAbsent(color, c -> { t = tinted.computeIfAbsent(f.texture, k -> new HashMap<>())
int[][] copy = TextureOps.deepCopy(f.texture); .computeIfAbsent(color, c -> TextureOps.tinted(f.texture, c));
TextureOps.tint(copy, c);
return copy;
});
} }
return new Face(t, f.u1, f.v1, f.u2, f.v2, f.rotation, -1); return f.withTexture(t, -1);
} }
} }
@@ -5,27 +5,26 @@ import eu.mhsl.minecraft.pixelpics.assets.font.Glyph;
import java.util.List; import java.util.List;
/** /**
* Rasterizes a sign side's text lines into a transparent ARGB grid ({@code int[][]}, top-left origin) at * Rasterizes text lines into a transparent ARGB grid (top-left origin) at the font's native pixel size: lines
* the font's native pixel resolution. Lines are centred horizontally; the block of lines fills the canvas * centred horizontally, blank lines keep their slot, glyphs of all providers on a common baseline, glowing text
* top-to-bottom (blank lines reserve their slot so vertical centring matches vanilla, which always lays * with an 8-directional outline.
* out four lines). The baker maps the result onto a quad whose model size keeps the canvas aspect, so the
* text is never stretched. Glyphs from different providers (ascii/accented) are aligned on a common
* baseline. Glowing text gets an 8-directional outline.
*/ */
public final class SignTextRasterizer { public final class SignTextRasterizer {
private SignTextRasterizer() {} private SignTextRasterizer() {}
/** Returns the rasterized panel, or null when there is nothing to draw (no font/glyphs). */ /** The text panel, or null when nothing is drawn (no font/glyphs). */
public static int[][] rasterize(List<String> lines, BitmapFont font, int fillArgb, int outlineArgb, boolean glow) { public static int[][] rasterize(List<String> lines, BitmapFont font, int fillArgb, int outlineArgb, boolean glow) {
if (font.isEmpty() || lines.isEmpty()) return null; if (font.isEmpty() || lines.isEmpty()) return null;
int ascent = font.maxAscent(); int ascent = font.maxAscent();
int pitch = font.lineHeight(); // ascent + descent int pitch = font.lineHeight();
int margin = glow ? 1 : 0; // room for the outline int margin = glow ? 1 : 0; // room for the outline
int[] widths = new int[lines.size()];
int contentW = 1; int contentW = 1;
for (String line : lines) contentW = Math.max(contentW, lineWidth(line, font)); for (int i = 0; i < widths.length; i++)
contentW = Math.max(contentW, widths[i] = lineWidth(lines.get(i), font));
int contentH = Math.max(1, lines.size() * pitch); int contentH = Math.max(1, lines.size() * pitch);
int w = contentW + 2 * margin; int w = contentW + 2 * margin;
@@ -33,25 +32,21 @@ public final class SignTextRasterizer {
int[][] out = new int[h][w]; int[][] out = new int[h][w];
boolean[][] covered = new boolean[h][w]; boolean[][] covered = new boolean[h][w];
// Pass 1: stamp glyph coverage (fill colour) into `out` + `covered`.
boolean any = false; boolean any = false;
for (int li = 0; li < lines.size(); li++) { for (int li = 0; li < lines.size(); li++) {
String line = lines.get(li); String line = lines.get(li);
int penX = margin + (contentW - lineWidth(line, font)) / 2; int penX = margin + (contentW - widths[li]) / 2;
int baseline = margin + li * pitch + ascent; int baseline = margin + li * pitch + ascent;
for (int ci = 0; ci < line.length(); ) { for (int ci = 0; ci < line.length(); ) {
int cp = line.codePointAt(ci); int cp = line.codePointAt(ci);
ci += Character.charCount(cp); ci += Character.charCount(cp);
Glyph g = font.glyph(cp); Glyph g = font.glyph(cp);
if (g != null) { if (g != null) any |= blit(g, penX, baseline, fillArgb, out, covered);
any |= blit(g, penX, baseline, fillArgb, out, covered);
}
penX += font.advance(cp); penX += font.advance(cp);
} }
} }
if (!any) return null; if (!any) return null;
// Pass 2 (glow only): paint the outline into uncovered neighbours of covered pixels.
if (glow) { if (glow) {
for (int y = 0; y < h; y++) { for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) { for (int x = 0; x < w; x++) {
@@ -70,7 +65,7 @@ public final class SignTextRasterizer {
return out; return out;
} }
/** Blits a glyph's opaque pixels as {@code fillArgb}; aligns the glyph's baseline to {@code baseline}. */ /** Stamps the glyph's opaque pixels in {@code fillArgb}, its baseline on {@code baseline}; false if none landed. */
private static boolean blit(Glyph g, int penX, int baseline, int fillArgb, int[][] out, boolean[][] covered) { private static boolean blit(Glyph g, int penX, int baseline, int fillArgb, int[][] out, boolean[][] covered) {
double scale = g.height() / (double) g.cellH(); double scale = g.height() / (double) g.cellH();
int rw = Math.max(1, (int) (g.glyphPx() * scale + 0.5)); int rw = Math.max(1, (int) (g.glyphPx() * scale + 0.5));
@@ -86,7 +81,7 @@ public final class SignTextRasterizer {
if (ox < 0 || ox >= out[0].length) continue; if (ox < 0 || ox >= out[0].length) continue;
int sx = g.srcX() + (int) (dx / scale); int sx = g.srcX() + (int) (dx / scale);
int argb = g.tex()[sy][sx]; int argb = g.tex()[sy][sx];
if (((argb >>> 24) & 0xFF) == 0) continue; if ((argb >>> 24) == 0) continue;
out[oy][ox] = fillArgb; out[oy][ox] = fillArgb;
covered[oy][ox] = true; covered[oy][ox] = true;
any = true; any = true;
@@ -95,8 +90,7 @@ public final class SignTextRasterizer {
return any; return any;
} }
/** Total advance width of a line in font px. */ /** Width of a line in font px (sum of glyph advances). */
/** Width of a line in font pixels (sum of glyph advances). */
public static int lineWidth(String line, BitmapFont font) { public static int lineWidth(String line, BitmapFont font) {
int w = 0; int w = 0;
for (int ci = 0; ci < line.length(); ) { for (int ci = 0; ci < line.length(); ) {
@@ -0,0 +1,70 @@
package eu.mhsl.minecraft.pixelpics.render.raytrace;
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTint;
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.block.Biome;
/**
* Biome tints of one photo: the vanilla biome blend (radius 2) over a {@link WorldSnapshot}, cached per block column
* and biome cell height ({@code y >> 2}: snapshots store biomes per 4³ cell). Lives as long as its photo, so the
* cache never outlives the snapshot it was computed from (other worlds, later biome changes, chunks loaded since).
*/
public final class BiomeBlend {
private static final int RADIUS = 2;
private static final BiomeTint NONE = new BiomeTint(0, 0, 0, 0); // cached "no biome around"
private final WorldSnapshot snapshot;
private final BiomeTintProvider provider;
private final Map<Long, BiomeTint> cache = new ConcurrentHashMap<>();
BiomeBlend(WorldSnapshot snapshot, BiomeTintProvider provider) {
this.snapshot = snapshot;
this.provider = provider;
}
/** Biome tint averaged over the (2r+1)² columns around the block at its height; null without any biome. */
public BiomeTint at(int bx, int by, int bz) {
// x/z: 26 bits each (world border ±30M), y cell: 12 bits.
long key = ((bx & 0x3FFFFFFL) << 38) | ((bz & 0x3FFFFFFL) << 12) | ((by >> 2) & 0xFFFL);
BiomeTint cached = cache.get(key);
if (cached == null) {
cached = blend(bx, by, bz);
cache.put(key, cached);
}
return cached == NONE ? null : cached;
}
private BiomeTint blend(int bx, int by, int bz) {
long[] g = new long[3], f = new long[3], d = new long[3], w = new long[3];
int n = 0;
for (int dx = -RADIUS; dx <= RADIUS; dx++) {
for (int dz = -RADIUS; dz <= RADIUS; dz++) {
Biome biome = snapshot.getBiome(bx + dx, by, bz + dz);
if (biome == null) continue;
BiomeTint t = provider.forBiome(biome);
accumulate(g, t.grass());
accumulate(f, t.foliage());
accumulate(d, t.dryFoliage());
accumulate(w, t.water());
n++;
}
}
if (n == 0) return NONE;
return new BiomeTint(average(g, n), average(f, n), average(d, n), average(w, n));
}
private static void accumulate(long[] acc, int argb) {
acc[0] += ColorUtil.red(argb);
acc[1] += ColorUtil.green(argb);
acc[2] += ColorUtil.blue(argb);
}
private static int average(long[] acc, int n) {
return ColorUtil.argb(0xFF, (int) (acc[0] / n), (int) (acc[1] / n), (int) (acc[2] / n));
}
}
@@ -0,0 +1,159 @@
package eu.mhsl.minecraft.pixelpics.render.raytrace;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeClimate;
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import org.bukkit.block.Biome;
import org.bukkit.util.Vector;
/**
* What the camera looks through and its distance fog. In water/lava like vanilla 26.3 {@code FogRenderer} with
* {@code WaterFogEnvironment} / {@code LavaFogEnvironment}: linear fog from {@code fogStart} to {@code fogEnd}
* (spherical distance) towards {@code fogColor}; the sky is the fogged sky disc. In air: PixelPics' own
* atmospheric fog towards the sky colour (not vanilla).
*
* <p>Water: start/end/colour are the biome attributes {@code water_fog_start_distance} (-8),
* {@code water_fog_end_distance} (96, swamps x0.85) and {@code water_fog_color} (default #050533), blended over
* the biomes around the camera with vanilla's {@code GaussianSampler} (6x6x6 quart cells, kernel 1-4-6-4-1).
* Water vision ({@code LocalPlayer.getWaterVision}) is taken as fully adapted (1), which keeps the end distance and
* brightens the colour to a maximum channel of 1. Lava: #991A00 from 0.25 to 1 block (no fire resistance).
*
* <p>Air outside the overworld: the Nether fogs like vanilla's environmental fog towards the camera biome's
* {@code fog_color}; the End uses the overworld's atmospheric fog towards its fog colour instead of the sky.
*
* @param fogColor in air: the fixed colour distant surfaces fog towards, or -1 for the sky colour in their direction
* @param fogMax the strongest fog (1 = opaque)
*/
public record CameraMedium(int fluid, int fogColor, double fogStart, double fogEnd, double fogMax) {
/** Overworld air: PixelPics' atmospheric fog towards the sky. */
public static final CameraMedium AIR = new CameraMedium(
FluidRenderer.NONE, -1, CameraMedium.AIR_FOG_START, CameraMedium.AIR_FOG_END, CameraMedium.AIR_FOG_MAX);
private static final double[] KERNEL = {0, 1, 4, 6, 4, 1, 0};
// Atmospheric fog in air.
private static final double AIR_FOG_START = 128;
private static final double AIR_FOG_END = 256;
private static final double AIR_FOG_MAX = 0.75;
// Nether: dimension type attributes minecraft:visual/fog_start_distance / fog_end_distance (vanilla 26.3).
private static final double NETHER_FOG_START = 10;
private static final double NETHER_FOG_END = 96;
// Vanilla sky disc (SkyRenderer.buildSkyDisc): a fan at height 16 around the camera, rim octagon at 512.
private static final double DISC_HEIGHT = 16;
private static final double DISC_RADIUS = 512;
private static final double DISC_APOTHEM = DISC_RADIUS * Math.cos(Math.PI / 8);
private static final double DISC_RIM_DISTANCE = Math.hypot(DISC_RADIUS, DISC_HEIGHT);
public boolean inFluid() {
return fluid != FluidRenderer.NONE;
}
static CameraMedium at(WorldSnapshot snapshot, Vector eye, int fluid, BiomeTintProvider tints, SkyContext sky) {
double x = eye.getX(), y = eye.getY(), z = eye.getZ();
if (fluid == FluidRenderer.LAVA) return new CameraMedium(fluid, 0x991A00, 0.25, 1.0, 1);
if (fluid != FluidRenderer.WATER) {
return switch (sky.skybox()) {
case OVERWORLD -> AIR;
case NONE -> new CameraMedium(fluid, sky.fogColor(), NETHER_FOG_START, NETHER_FOG_END, 1);
case END -> new CameraMedium(fluid, sky.fogColor(), AIR_FOG_START, AIR_FOG_END, AIR_FOG_MAX);
};
}
// GaussianSampler.sample(position / 4) over the noise biomes (quart positions).
double px = x * 0.25 - 0.5, py = y * 0.25 - 0.5, pz = z * 0.25 - 0.5;
int ix = (int) Math.floor(px), iy = (int) Math.floor(py), iz = (int) Math.floor(pz);
double rx = px - ix, ry = py - iy, rz = pz - iz;
double r = 0, g = 0, b = 0, start = 0, end = 0, total = 0;
for (int k = 0; k < 6; k++) {
double wz = lerp(rz, KERNEL[k + 1], KERNEL[k]);
for (int i = 0; i < 6; i++) {
double wx = lerp(rx, KERNEL[i + 1], KERNEL[i]);
for (int j = 0; j < 6; j++) {
double wy = lerp(ry, KERNEL[j + 1], KERNEL[j]);
double w = wx * wy * wz;
if (w == 0) continue;
// Quart y clamped into the captured height range (vanilla clamps to the world height).
int by = Math.clamp((iy - 2 + j) * 4, snapshot.minY(), snapshot.maxY() - 1);
Biome biome = snapshot.getBiome((ix - 2 + i) * 4, by, (iz - 2 + k) * 4);
BiomeClimate.Climate c = biome == null ? BiomeClimate.DEFAULT : tints.climate(biome);
int fog = c.waterFogColor();
r += w * ColorUtil.red(fog) / 255.0;
g += w * ColorUtil.green(fog) / 255.0;
b += w * ColorUtil.blue(fog) / 255.0;
start += w * c.waterFogStart();
end += w * c.waterFogEnd();
total += w;
}
}
}
r /= total;
g /= total;
b /= total;
start /= total;
end /= total;
// FogRenderer.computeFogColor with water vision 1: brightened to a maximum channel of 1.
if (r != 0 && g != 0 && b != 0) {
double scale = 1 / Math.max(r, Math.max(g, b));
r = r + (r * scale - r);
g = g + (g * scale - g);
b = b + (b * scale - b);
}
int color = ColorUtil.argb(255, channel(r), channel(g), channel(b)) & 0xFFFFFF;
return new CameraMedium(fluid, color, start, end, 1);
}
private static double lerp(double a, double from, double to) {
return from + a * (to - from);
}
private static int channel(double v) {
return Math.clamp((int) Math.round(v * 255), 0, 255);
}
/** Fog fraction at distance {@code d} (in fluids fog.glsl {@code linear_fog_value}). */
double fogValue(double d) {
if (d <= fogStart) return 0;
if (d >= fogEnd) return fogMax;
return Math.min((d - fogStart) / (fogEnd - fogStart), fogMax);
}
/** {@code color} fogged at distance {@code d}; overworld air fogs towards {@code skyColor}. */
int fog(int color, double d, int skyColor) {
double f = fogValue(d);
return f > 0 ? ColorUtil.mix(color, fogColor >= 0 ? fogColor : skyColor, 1 - f, f) : color;
}
/** Whether {@link #fog}/{@link #sky} of a surface at {@code t} (infinite = sky) read the sky colour. */
boolean needsSkyColor(double t) {
return Double.isInfinite(t) || (fogColor < 0 && t > fogStart);
}
/**
* The sky seen in direction {@code (dx, dy, dz)} (unit). From inside a fluid vanilla draws the sky disc with fog
* over {@code [0, skyEnd = fogEnd]} using the vertex distances interpolated across the fan triangles (centre 16,
* rim 512, plus the cylindrical distance beyond skyEnd = fully fogged); below the horizon and beyond the disc
* only the clear colour (= fog colour) remains.
*/
int sky(int skyColor, double dx, double dy, double dz) {
if (!inFluid()) return skyColor;
if (fluid == FluidRenderer.LAVA || dy <= 1e-9) return fogColor;
double horizontal = Math.sqrt(dx * dx + dz * dz);
double radius = DISC_HEIGHT * horizontal / dy;
double lambda = 0;
if (radius > 0) {
double phi = Math.atan2(dz, dx);
double sector = Math.PI / 4;
double rel = phi - Math.floor(phi / sector) * sector; // 0..45°
lambda = radius * Math.cos(rel - sector / 2) / DISC_APOTHEM;
}
if (lambda >= 1) return fogColor;
double spherical = DISC_HEIGHT + (DISC_RIM_DISTANCE - DISC_HEIGHT) * lambda;
double cylindrical = DISC_HEIGHT + (DISC_RADIUS - DISC_HEIGHT) * lambda;
double f = cylindrical > fogEnd ? 1 : Math.clamp(spherical / fogEnd, 0, 1);
return ColorUtil.mix(skyColor, fogColor, 1 - f, f);
}
}
@@ -5,26 +5,70 @@ import eu.mhsl.minecraft.pixelpics.assets.model.Element;
import eu.mhsl.minecraft.pixelpics.assets.model.Face; import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel; import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import java.util.ArrayList; import java.util.Arrays;
import java.util.Comparator;
import java.util.List; import java.util.List;
import org.bukkit.util.Vector; import org.bukkit.util.Vector;
/** /**
* Intersects a ray (in block coordinates) with a {@link ResolvedModel}'s element boxes and returns * Intersects a ray (in block coordinates) with a {@link ResolvedModel}'s element boxes and returns the nearest drawn
* the nearest opaque face hit. Axis-aligned boxes use a slab test; rotated elements are handled as * face hit. Rotated elements are slab-tested in their local frame. Cut-out texels are holes the ray passes through;
* oriented boxes by transforming the ray into the element's local frame. Fully transparent texels * partially transparent texels are returned with their alpha for the caller to blend.
* (alpha &le; threshold) are treated as holes so the ray passes through (cutout for plants, glass).
*/ */
public final class ElementIntersector { public final class ElementIntersector {
private static final double EPS = 1e-7; private static final double EPS = 1e-7;
private static final Direction[] DIRECTIONS = Direction.values();
private ElementIntersector() {} private ElementIntersector() {}
/** Per-thread scratch of one {@link #intersect} call: candidate faces as parallel arrays, so nothing is allocated. */
private static final class Scratch {
int n;
Element[] element = new Element[16];
double[] t = new double[16];
Direction[] dir = new Direction[16];
double[] s = new double[16];
double[] t2 = new double[16];
double[] nx = new double[16], ny = new double[16], nz = new double[16];
int[] order = new int[16];
// Candidate indices to sort; pre-boxed (cached Integers) so the sort allocates nothing.
Integer[] idx = new Integer[16];
final double[] o = new double[3], d = new double[3], v = new double[3];
// Result of the last slab test.
double hitT, hitS, hitT2, hitNx, hitNy, hitNz;
Direction hitDir;
// Depth; coplanar within 1e-4: later elements first, like vanilla's draw order (overlays such as the grass
// side tint). Not transitive, so the tie order depends on the sort algorithm: keep Arrays.sort over objects
// (TimSort).
final Comparator<Integer> byDepth = (a, b) -> {
if (Math.abs(t[a] - t[b]) > 1e-4) return Double.compare(t[a], t[b]);
return Integer.compare(order[b], order[a]);
};
void grow() {
int len = element.length * 2;
element = Arrays.copyOf(element, len);
t = Arrays.copyOf(t, len);
dir = Arrays.copyOf(dir, len);
s = Arrays.copyOf(s, len);
t2 = Arrays.copyOf(t2, len);
nx = Arrays.copyOf(nx, len);
ny = Arrays.copyOf(ny, len);
nz = Arrays.copyOf(nz, len);
order = Arrays.copyOf(order, len);
idx = new Integer[len];
}
}
private static final ThreadLocal<Scratch> SCRATCH = ThreadLocal.withInitial(Scratch::new);
/** /**
* @param ox,oy,oz ray origin in block-local coordinates (world origin minus block min corner) * @param 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 dx,dy,dz ray direction (need not be normalized)
* @param bx,by,bz block min corner in world coordinates (for reconstructing the world hit point) * @param bx,by,bz block min corner in world coordinates (for reconstructing the world hit point)
* @param minT only faces strictly beyond this ray parameter count (continuing past a translucent hit)
*/ */
public static FaceHit intersect( public static FaceHit intersect(
ResolvedModel model, ResolvedModel model,
@@ -36,73 +80,117 @@ public final class ElementIntersector {
double dz, double dz,
int bx, int bx,
int by, int by,
int bz) { int bz,
List<Candidate> candidates = new ArrayList<>(model.elements.size()); double minT) {
for (int i = 0; i < model.elements.size(); i++) { List<Element> elements = model.elements;
Element element = model.elements.get(i); int count = elements.size();
Candidate c; if (count == 0) return null;
Scratch sc = SCRATCH.get();
sc.n = 0;
double[] v = sc.v;
for (int i = 0; i < count; i++) {
Element element = elements.get(i);
Element e = element;
double eox = ox, eoy = oy, eoz = oz, edx = dx, edy = dy, edz = dz;
if (element.unrotated != null) { if (element.unrotated != null) {
// Blockstate rotation without uvlock: hit the model-space element with the inverse-rotated ray // Blockstate rotation: hit the model-space element with the inverse-rotated ray (same t: rigid
// (same t: rigid transform), so faces and UVs stay bound to the model like in vanilla. // transform), so faces and UVs stay bound to the model like vanilla's FaceBakery.
Element m = element.unrotated; e = element.unrotated;
double[] mo = Element.unrotate(ox, oy, oz, element.xSteps, element.ySteps, true); Element.unrotate(v, ox, oy, oz, element.xSteps, element.ySteps, true);
double[] md = Element.unrotate(dx, dy, dz, element.xSteps, element.ySteps, false); eox = v[0];
c = m.isAxisAligned() eoy = v[1];
? intersectAabb(m, mo[0], mo[1], mo[2], md[0], md[1], md[2]) eoz = v[2];
: intersectObb(m, mo[0], mo[1], mo[2], md[0], md[1], md[2]); Element.unrotate(v, dx, dy, dz, element.xSteps, element.ySteps, false);
if (c != null) { edx = v[0];
double[] n = Element.rotate( edy = v[1];
c.normal[0], c.normal[1], c.normal[2], element.xSteps, element.ySteps, false); edz = v[2];
c = new Candidate(c.element, c.t, c.dir, c.s, c.t2, n, 0);
}
} else {
c = element.isAxisAligned()
? intersectAabb(element, ox, oy, oz, dx, dy, dz)
: intersectObb(element, ox, oy, oz, dx, dy, dz);
} }
if (c != null) candidates.add(new Candidate(c.element, c.t, c.dir, c.s, c.t2, c.normal, i)); boolean hit = e.isAxisAligned()
? slab(sc, eox, eoy, eoz, edx, edy, edz, e.from, e.to, null)
: intersectObb(sc, e, eox, eoy, eoz, edx, edy, edz);
if (hit && element.unrotated != null) {
Element.rotate(v, sc.hitNx, sc.hitNy, sc.hitNz, element.xSteps, element.ySteps, false);
sc.hitNx = v[0];
sc.hitNy = v[1];
sc.hitNz = v[2];
}
if (!hit || !(sc.hitT > minT)) continue;
int k = sc.n;
if (k == sc.element.length) sc.grow();
sc.element[k] = e;
sc.t[k] = sc.hitT;
sc.dir[k] = sc.hitDir;
sc.s[k] = sc.hitS;
sc.t2[k] = sc.hitT2;
sc.nx[k] = sc.hitNx;
sc.ny[k] = sc.hitNy;
sc.nz[k] = sc.hitNz;
sc.order[k] = i;
sc.n = k + 1;
} }
if (candidates.isEmpty()) return null; int n = sc.n;
// Sort by depth; for coplanar faces (equal t) render later elements first, matching vanilla's if (n == 0) return null;
// draw order so overlays (e.g. the tinted grass side overlay) sit on top of the base face. Integer[] idx = sc.idx;
candidates.sort((a, b) -> { for (int k = 0; k < n; k++) idx[k] = k; // Integer cache for k < 128
if (Math.abs(a.t - b.t) > 1e-4) return Double.compare(a.t, b.t); if (n > 1) Arrays.sort(idx, 0, n, sc.byDepth);
return Integer.compare(b.order, a.order);
});
for (Candidate c : candidates) { for (int j = 0; j < n; j++) {
Face face = c.element.faces[c.dir.ordinal()]; int k = idx[j];
Face face = sc.element[k].faces[sc.dir[k].ordinal()];
if (face == null) continue; if (face == null) continue;
int color = face.sample(c.s, c.t2); int color = face.sample(sc.s[k], sc.t2[k]);
if (!ColorUtil.opaque(color)) continue; if (!ColorUtil.opaque(color)) continue;
Vector world = new Vector(bx + ox + dx * c.t, by + oy + dy * c.t, bz + oz + dz * c.t); double t = sc.t[k];
Vector normal = new Vector(c.normal[0], c.normal[1], c.normal[2]); Vector world = new Vector(bx + ox + dx * t, by + oy + dy * t, bz + oz + dz * t);
return new FaceHit(c.t, world, normal, color, face.tintIndex); Vector normal = new Vector(sc.nx[k], sc.ny[k], sc.nz[k]);
return new FaceHit(t, world, normal, color, face.tintIndex, false, false);
} }
return null; return null;
} }
private record Candidate( /**
Element element, double t, Direction dir, double s, double t2, double[] normal, int order) {} * Rotated element: slab test in the element's local (unrotated, unscaled) frame. Vanilla bakes
* {@code origin + R·S·(p - origin)} (S = rescale); the inverse is affine, so t stays the block-space one.
private static Candidate intersectAabb( */
Element e, double ox, double oy, double oz, double dx, double dy, double dz) { private static boolean intersectObb(
return slab(e, ox, oy, oz, dx, dy, dz, e.from, e.to, null); Scratch sc, Element e, double ox, double oy, double oz, double dx, double dy, double dz) {
double[] v = sc.v;
double[] o = e.rotOrigin;
unrotateElement(v, e, ox - o[0], oy - o[1], oz - o[2]);
double lox = v[0], loy = v[1], loz = v[2];
unrotateElement(v, e, dx, dy, dz);
double ldx = v[0], ldy = v[1], ldz = v[2];
double[] is = e.invScale;
if (is != null) {
lox *= is[0];
loy *= is[1];
loz *= is[2];
ldx *= is[0];
ldy *= is[1];
ldz *= is[2];
}
return slab(sc, lox + o[0], loy + o[1], loz + o[2], ldx, ldy, ldz, e.from, e.to, e);
} }
/** Rotated element: transform the ray into the element's local (unrotated) frame, then slab-test. */ /** Inverse element rotation (block -> local) of a vector into out, without the rescale. */
private static Candidate intersectObb(Element e, double ox, double oy, double oz, double dx, double dy, double dz) { private static void unrotateElement(double[] out, Element e, double x, double y, double z) {
double[] o = rotate(ox - e.rotOrigin[0], oy - e.rotOrigin[1], oz - e.rotOrigin[2], e.rotAxis, -e.rotAngleRad); double[] m = e.invRotMatrix;
o[0] += e.rotOrigin[0]; if (m != null) {
o[1] += e.rotOrigin[1]; out[0] = m[0] * x + m[1] * y + m[2] * z;
o[2] += e.rotOrigin[2]; out[1] = m[3] * x + m[4] * y + m[5] * z;
double[] d = rotate(dx, dy, dz, e.rotAxis, -e.rotAngleRad); out[2] = m[6] * x + m[7] * y + m[8] * z;
return slab(e, o[0], o[1], o[2], d[0], d[1], d[2], e.from, e.to, e); } else {
rotate(out, x, y, z, e.rotAxis, e.invRotCos, e.invRotSin);
}
} }
private static Candidate slab( /**
Element e, * Slab test; on a hit the entry (or, from inside, exit) face is left in the scratch's hit fields. {@code obb}:
* the rotated element whose local frame the ray is in (normal rotated back), else null.
*/
private static boolean slab(
Scratch sc,
double ox, double ox,
double oy, double oy,
double oz, double oz,
@@ -112,27 +200,34 @@ public final class ElementIntersector {
double[] from, double[] from,
double[] to, double[] to,
Element obb) { Element obb) {
double[] o = sc.o, d = sc.d;
o[0] = ox;
o[1] = oy;
o[2] = oz;
d[0] = dx;
d[1] = dy;
d[2] = dz;
if (from[0] > to[0] || from[1] > to[1] || from[2] > to[2]) return invertedBox(sc, from, to, obb);
double tmin = Double.NEGATIVE_INFINITY; double tmin = Double.NEGATIVE_INFINITY;
double tmax = Double.POSITIVE_INFINITY; double tmax = Double.POSITIVE_INFINITY;
int axis = -1; int axis = -1;
boolean negFace = false; // entered through the low-coordinate face 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++) { for (int a = 0; a < 3; a++) {
if (Math.abs(d[a]) < EPS) { if (Math.abs(d[a]) < EPS) {
if (o[a] < from[a] - EPS || o[a] > to[a] + EPS) return null; if (o[a] < from[a] - EPS || o[a] > to[a] + EPS) return false;
continue; continue;
} }
double inv = 1.0 / d[a]; double inv = 1.0 / d[a];
double t1 = (from[a] - o[a]) * inv; double t1 = (from[a] - o[a]) * inv;
double t2 = (to[a] - o[a]) * inv; double t2 = (to[a] - o[a]) * inv;
boolean neg = true; // Entered through the low face when travelling +axis. Decided by the direction, not t1 > t2, so
// zero-thickness elements (vines, lichen: from == to) show the face turned towards the ray.
boolean neg = inv > 0;
if (t1 > t2) { if (t1 > t2) {
double tmp = t1; double tmp = t1;
t1 = t2; t1 = t2;
t2 = tmp; t2 = tmp;
neg = false;
} }
if (t1 > tmin) { if (t1 > tmin) {
tmin = t1; tmin = t1;
@@ -140,77 +235,167 @@ public final class ElementIntersector {
negFace = neg; negFace = neg;
} }
if (t2 < tmax) tmax = t2; if (t2 < tmax) tmax = t2;
if (tmin > tmax) return null; if (tmin > tmax) return false;
} }
if (axis < 0) return null; if (axis < 0) return false;
double tEntry = tmin; double tEntry = tmin;
if (tEntry < EPS) { if (tEntry < EPS) {
// origin inside the box (e.g. camera within a block): use exit point instead // Origin inside the box (camera within a block): the exit face.
tEntry = tmax; tEntry = tmax;
if (tEntry < EPS) return null; if (tEntry < EPS) return false;
} }
double px = o[0] + d[0] * tEntry; double px = o[0] + d[0] * tEntry;
double py = o[1] + d[1] * tEntry; double py = o[1] + d[1] * tEntry;
double pz = o[2] + d[2] * tEntry; double pz = o[2] + d[2] * tEntry;
Direction dir = faceFor(axis, negFace); Direction dir = Direction.of(axis, negFace);
double[] normal = {dir.nx, dir.ny, dir.nz}; setNormal(sc, obb, 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 fracX = frac(px, from[0], to[0]);
double fracY = frac(py, from[1], to[1]); double fracY = frac(py, from[1], to[1]);
double fracZ = frac(pz, from[2], to[2]); double fracZ = frac(pz, from[2], to[2]);
sc.hitT = tEntry;
double[] st = faceUv(dir, fracX, fracY, fracZ); sc.hitDir = dir;
double s = st[0], t = st[1]; sc.hitS = faceS(dir, fracX, fracY, fracZ);
return new Candidate(e, tEntry, dir, s, t, normal, 0); sc.hitT2 = faceT(dir, fracY, fracZ);
return true;
} }
/** /**
* Face-local (s, t) in 0..1 for a hit at block fractions (fx, fy, fz), matching vanilla's * Element with {@code from > to} on some axis (spawner/vault/trial-spawner inner faces, snow layer 2's inner
* {@code BlockElement.uvsByFace}: a face's UV rect {@code [u1,v1,u2,v2]} is lerped with these, so north/east * bottom): vanilla places the WEST/DOWN/NORTH face at {@code from} and EAST/UP/SOUTH at {@code to} of its axis,
* faces run their horizontal axis towards -x/-z (left-to-right seen from outside) and down runs v towards * and every inverted OTHER axis mirrors the quad, flipping its winding. So these faces point into the box and
* north. Shared with the entity intersector (entity box-UV uses the same orientation). * are seen from inside (through the cage: the far wall). Each face is tested as its own back-face-culled quad;
* the nearest visible one wins. Texture coordinates follow the mirrored corners (signed span). The ray is in
* the scratch's {@code o}/{@code d}.
*/ */
public static double[] faceUv(Direction dir, double fx, double fy, double fz) { private static boolean invertedBox(Scratch sc, double[] from, double[] to, Element obb) {
return switch (dir) { double[] o = sc.o, d = sc.d;
case UP -> new double[] {fx, fz}; double best = Double.POSITIVE_INFINITY;
case DOWN -> new double[] {fx, 1 - fz}; Direction bestDir = null;
case NORTH -> new double[] {1 - fx, 1 - fy}; for (Direction dir : DIRECTIONS) {
case SOUTH -> new double[] {fx, 1 - fy}; double t = invertedFaceT(dir, from, to, o, d, false);
case WEST -> new double[] {fz, 1 - fy}; if (!(t >= EPS) || t >= best) continue;
case EAST -> new double[] {1 - fz, 1 - fy}; best = t;
}; bestDir = dir;
}
if (bestDir == null) return false;
int a = bestDir.axis;
double sign = invertedFaceSign(bestDir, from, to, false);
setNormal(sc, obb, a == 0 ? sign : 0, a == 1 ? sign : 0, a == 2 ? sign : 0);
double fx = frac(o[0] + d[0] * best, from[0], to[0]);
double fy = frac(o[1] + d[1] * best, from[1], to[1]);
double fz = frac(o[2] + d[2] * best, from[2], to[2]);
sc.hitT = best;
sc.hitDir = bestDir;
sc.hitS = faceS(bestDir, fx, fy, fz);
sc.hitT2 = faceT(bestDir, fy, fz);
return true;
} }
private static Direction faceFor(int axis, boolean negFace) { /**
return switch (axis) { * Outward normal sign of face {@code dir} of an inverted box along its axis: each inverted OTHER axis flips the
case 0 -> negFace ? Direction.WEST : Direction.EAST; * winding, as does a mirroring transform ({@code mirrored}).
case 1 -> negFace ? Direction.DOWN : Direction.UP; */
default -> negFace ? Direction.NORTH : Direction.SOUTH; private static double invertedFaceSign(Direction dir, double[] from, double[] to, boolean mirrored) {
}; int b = (dir.axis + 1) % 3, c = (dir.axis + 2) % 3;
int flips = (from[b] > to[b] ? 1 : 0) + (from[c] > to[c] ? 1 : 0);
boolean neg = dir.nx + dir.ny + dir.nz < 0;
return (neg ? -1 : 1) * (flips % 2 == 0 ? 1 : -1) * (mirrored ? -1 : 1);
} }
private static double frac(double v, double lo, double hi) { /**
double span = hi - lo; * Ray parameter where the ray {@code o + t·d} meets face {@code dir} of an inverted box (see
if (span < 1e-6) return 0; * {@link #invertedBox}) from its visible side inside the face rectangle; NaN for a back face, a parallel ray or
double f = (v - lo) / span; * a miss. Shared with the entity intersector (inverted elements baked as items/displays).
*/
public static double invertedFaceT(
Direction dir, double[] from, double[] to, double[] o, double[] d, boolean mirrored) {
int a = dir.axis, b = (a + 1) % 3, c = (a + 2) % 3;
if (invertedFaceSign(dir, from, to, mirrored) * d[a] >= 0) return Double.NaN;
boolean neg = dir.nx + dir.ny + dir.nz < 0;
double t = ((neg ? from[a] : to[a]) - o[a]) / d[a];
double pb = o[b] + d[b] * t, pc = o[c] + d[c] * t;
if (pb < Math.min(from[b], to[b]) - EPS || pb > Math.max(from[b], to[b]) + EPS) return Double.NaN;
if (pc < Math.min(from[c], to[c]) - EPS || pc > Math.max(from[c], to[c]) + EPS) return Double.NaN;
return t;
}
/**
* The hit normal (local axis normal of the element's frame) in block space: rotated back for {@code obb}. The
* rescale is diagonal in the local frame, so an axis normal keeps its direction (n = R·n_local).
*/
private static void setNormal(Scratch sc, Element obb, double nx, double ny, double nz) {
if (obb == null) {
sc.hitNx = nx;
sc.hitNy = ny;
sc.hitNz = nz;
return;
}
double[] m = obb.rotMatrix;
if (m != null) {
sc.hitNx = m[0] * nx + m[1] * ny + m[2] * nz;
sc.hitNy = m[3] * nx + m[4] * ny + m[5] * nz;
sc.hitNz = m[6] * nx + m[7] * ny + m[8] * nz;
} else {
double[] v = sc.v;
rotate(v, nx, ny, nz, obb.rotAxis, obb.rotCos, obb.rotSin);
sc.hitNx = v[0];
sc.hitNy = v[1];
sc.hitNz = v[2];
}
}
/** Position between {@code from} (0) and {@code to} (1), also when {@code from > to}; clamped to 0..1. */
public static double frac(double v, double from, double to) {
double span = to - from;
if (Math.abs(span) < 1e-6) return 0;
double f = (v - from) / span;
return f < 0 ? 0 : Math.min(f, 1); 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) { * Face-local horizontal coordinate s in 0..1 for a hit at box fractions (fx, fy, fz), like vanilla's
double cos = Math.cos(angle); * {@code BlockElement.uvsByFace}: north/east faces run towards -x/-z (left to right seen from outside), down runs
double sin = Math.sin(angle); * v towards north. Shared with the entity intersector (box UVs have the same orientation).
return switch (axis) { */
case 0 -> new double[] {x, y * cos - z * sin, y * sin + z * cos}; public static double faceS(Direction dir, double fx, double fy, double fz) {
case 1 -> new double[] {x * cos + z * sin, y, -x * sin + z * cos}; return switch (dir) {
default -> new double[] {x * cos - y * sin, x * sin + y * cos, z}; case UP, DOWN, SOUTH -> fx;
case NORTH -> 1 - fx;
case WEST -> fz;
case EAST -> 1 - fz;
}; };
} }
/** Face-local vertical coordinate t in 0..1 (see {@link #faceS}); t does not depend on fx. */
public static double faceT(Direction dir, double fy, double fz) {
return switch (dir) {
case UP -> fz;
case DOWN -> 1 - fz;
default -> 1 - fy;
};
}
/** Rotates (x, y, z) about axis 0/1/2 (x/y/z) by the angle with the given cos/sin into out. */
private static void rotate(double[] out, double x, double y, double z, int axis, double cos, double sin) {
switch (axis) {
case 0 -> {
out[0] = x;
out[1] = y * cos - z * sin;
out[2] = y * sin + z * cos;
}
case 1 -> {
out[0] = x * cos + z * sin;
out[1] = y;
out[2] = -x * sin + z * cos;
}
default -> {
out[0] = x * cos - y * sin;
out[1] = x * sin + y * cos;
out[2] = z;
}
}
}
} }
@@ -3,7 +3,9 @@ package eu.mhsl.minecraft.pixelpics.render.raytrace;
import org.bukkit.util.Vector; import org.bukkit.util.Vector;
/** /**
* The result of intersecting a ray with a block's geometry: the world-space hit point and normal, * A ray hit on block or entity geometry: world-space point and normal, sampled ARGB texel (before shading/tint),
* the sampled ARGB color (before shading/tinting) and the face's tint index ({@code -1} = none). * tint index (-1 = none), additive (emissive energy swirl, added to what lies behind) and unshaded (no cardinal
* lighting: eyes, breeze wind).
*/ */
public record FaceHit(double t, Vector point, Vector normal, int color, int tintIndex) {} public record FaceHit(
double t, Vector point, Vector normal, int color, int tintIndex, boolean additive, boolean unshaded) {}
@@ -0,0 +1,571 @@
package eu.mhsl.minecraft.pixelpics.render.raytrace;
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Element;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel;
import java.util.EnumSet;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.Material;
import org.bukkit.block.BlockFace;
import org.bukkit.block.BlockSupport;
import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.Levelled;
import org.bukkit.block.data.Waterlogged;
/**
* Water and lava like vanilla 26.3 {@code FluidRenderer} (decompiled client): not block models but polygons built
* per cell from the fluid state and its neighbours. The top surface has four corner heights (amount / 9, full
* under the same fluid, averaged with the neighbours like {@code calculateAverageHeight}) split into the triangles
* NW-SW-SE / NW-SE-NE; side faces against every neighbour that is not the same fluid and does not cover the face;
* a bottom face. Top and sides are double-sided (the top only if {@code shouldRenderBackwardUpFace}), except side
* water faces against {@code HalfTransparentBlock}/{@code LeavesBlock}, which use the overlay texture one-sided; the
* bottom is seen from below only. Waterlogged blocks and seagrass/kelp/bubble columns hold a water source.
*/
public final class FluidRenderer {
public static final int NONE = 0;
static final int WATER = 1;
static final int LAVA = 2;
// Vanilla insets: faces 0.001 inside the cell, the top 0.001 below the computed height.
private static final float OFFS = 0.001F;
private static final double EPS = 1e-9;
/** Block access by world position: a {@code WorldSnapshot} or the live world (main thread). */
@FunctionalInterface
public interface BlockLookup {
BlockData at(int x, int y, int z);
}
// LiquidBlockContainers without a waterlogged property: always a water source.
private static final EnumSet<Material> ALWAYS_WATER =
materials("SEAGRASS", "TALL_SEAGRASS", "KELP", "KELP_PLANT", "BUBBLE_COLUMN");
// Vanilla HalfTransparentBlock / LeavesBlock (FluidRenderer isOverlay). Blue ice too, but it occludes anyway.
private static final EnumSet<Material> OVERLAY_NEIGHBOURS = EnumSet.noneOf(Material.class);
// Vanilla tag blocks_fluid_flow = #blocks_motion (Material.isSolid) + #all_signs.
private static final EnumSet<Material> SIGNS = EnumSet.noneOf(Material.class);
static {
for (Material m : Material.values()) {
if (m.isLegacy()) continue;
String n = m.name();
if (n.equals("GLASS")
|| n.equals("TINTED_GLASS")
|| n.endsWith("_STAINED_GLASS")
|| n.equals("ICE")
|| n.equals("FROSTED_ICE")
|| n.equals("BLUE_ICE")
|| n.equals("SLIME_BLOCK")
|| n.equals("HONEY_BLOCK")
|| n.endsWith("_LEAVES")) OVERLAY_NEIGHBOURS.add(m);
if (n.endsWith("_SIGN")) SIGNS.add(m);
}
}
private static EnumSet<Material> materials(String... names) {
EnumSet<Material> set = EnumSet.noneOf(Material.class);
for (String n : names) {
try {
set.add(Material.valueOf(n));
} catch (IllegalArgumentException ignored) {
// Older/newer server
}
}
return set;
}
// Packed fluid state: type (bits 0-1), amount 1..8 (bits 2-5), falling (bit 6), source (bit 7).
private static final int WATER_SOURCE = WATER | 8 << 2 | 1 << 7;
/** The fluid state of a block (vanilla {@code BlockState.getFluidState}), packed; 0 = no fluid. */
static int state(BlockData data) {
Material m = data.getMaterial();
if (m == Material.AIR) return 0;
if (m == Material.WATER || m == Material.LAVA) {
int type = m == Material.WATER ? WATER : LAVA;
// LiquidBlock: level 0 = source, 1..7 = flowing amount 8 - level, 8..15 = falling amount 8.
int level = data instanceof Levelled l ? l.getLevel() : 0;
if (level == 0) return type | 8 << 2 | 1 << 7;
if (level >= 8) return type | 8 << 2 | 1 << 6;
return type | (8 - level) << 2;
}
if (ALWAYS_WATER.contains(m)) return WATER_SOURCE;
if (data instanceof Waterlogged w && w.isWaterlogged()) return WATER_SOURCE;
return 0;
}
static int type(int state) {
return state & 3;
}
static int amount(int state) {
return (state >> 2) & 15;
}
static boolean source(int state) {
return (state & 1 << 7) != 0;
}
/** FlowingFluid.getOwnHeight: amount / 9 (0 without fluid). */
static float ownHeight(int state) {
return amount(state) / 9.0F;
}
/**
* Which fluid the camera at {@code (x, y, z)} is in (vanilla {@code Camera.getFluidInCamera}): the eye block's
* fluid when the eye is below its {@code FluidState.getHeightForCamera} (1 under the same fluid or for a source
* under a sturdy bottom face, else amount / 9).
*/
public static int cameraFluid(BlockLookup level, double x, double y, double z) {
int bx = (int) Math.floor(x), by = (int) Math.floor(y), bz = (int) Math.floor(z);
int s = state(level.at(bx, by, bz));
int type = type(s);
if (type == NONE) return NONE;
BlockData above = level.at(bx, by + 1, bz);
float h = type(state(above)) == type || (source(s) && sturdyBottom(above)) ? 1 : ownHeight(s);
double rel = y - by;
// Water: strictly below the surface; lava (near-plane check): at or below it.
return type == WATER ? (rel < h ? WATER : NONE) : (rel <= h ? LAVA : NONE);
}
private static boolean sturdyBottom(BlockData data) {
if (data.getMaterial().isAir()) return false;
try {
return data.isFaceSturdy(BlockFace.DOWN, BlockSupport.FULL);
} catch (RuntimeException e) {
return data.getMaterial().isOccluding();
}
}
/**
* Vanilla sky light level of the cell (x, y, z) under water: 15 minus one per block from this cell up to the
* topmost water block of the column (water and non-occluding blocks below the surface cost one level each).
* 15 without water in the next 15 blocks.
*/
static int skyLevel(BlockLookup level, int x, int y, int z) {
int water = 0;
for (int i = 0; i < 15; i++) {
BlockData d = level.at(x, y + i, z);
if (type(state(d)) == WATER) {
water = i + 1;
continue;
}
Material m = d.getMaterial();
if (m.isAir() || m.isOccluding()) break;
}
return 15 - water;
}
private final BlockModelRegistry registry;
private final int[][] waterStill, waterFlow, waterOverlay, lavaStill, lavaFlow;
// Bit per Direction: the model covers that whole cell face with opaque texels (approximates vanilla's face
// occlusion shape). Identity keys: ResolvedModel is cached per BlockData.
private final Map<ResolvedModel, Integer> fullFaces = new ConcurrentHashMap<>();
FluidRenderer(BlockModelRegistry registry) {
this.registry = registry;
TextureCache textures = registry.textures();
this.waterStill = texture(textures, "block/water_still", 0xB4B1B1B1);
this.waterFlow = texture(textures, "block/water_flow", 0xB4AAAAAA);
this.waterOverlay = texture(textures, "block/water_overlay", 0xB4A5A5A5);
this.lavaStill = texture(textures, "block/lava_still", 0xFFD9691A);
this.lavaFlow = texture(textures, "block/lava_flow", 0xFFCF5C15);
}
private static int[][] texture(TextureCache textures, String path, int fallback) {
int[][] tex = textures.get(ResourceLocation.parse(path)).orElse(null);
return tex != null && tex.length > 0 && tex[0].length > 0 ? tex : new int[][] {{fallback}};
}
/** Samples a sprite at (u, v) in 0..1 of the sprite (vanilla getU/getV), nearest texel. */
private static int sample(int[][] tex, double u, double v) {
int h = tex.length, w = tex[0].length;
int x = Math.clamp((int) Math.floor(u * w), 0, w - 1);
int y = Math.clamp((int) Math.floor(v * h), 0, h - 1);
return tex[y][x];
}
private int fullFaces(BlockData data) {
if (data.getMaterial().isAir()) return 0;
// Any pick of weighted variants: vanilla occludes by the state's occlusion shape, not by the drawn variant.
return fullFaces(registry.get(data));
}
private int fullFaces(ResolvedModel model) {
return fullFaces.computeIfAbsent(model, FluidRenderer::computeFullFaces);
}
/** Faces fully covered by opaque, unrotated element faces on the cell boundary (8x8 sample grid). */
private static int computeFullFaces(ResolvedModel model) {
int mask = 0;
if (model.elements.isEmpty()) return 0;
for (Direction d : Direction.values()) {
int axis = d.axis;
int a1 = axis == 0 ? 1 : 0, a2 = axis == 2 ? 1 : 2;
boolean positive = d.nx + d.ny + d.nz > 0;
boolean covered = true;
for (int i = 0; i < 8 && covered; i++) {
for (int j = 0; j < 8 && covered; j++) {
double p1 = (i + 0.5) / 8, p2 = (j + 0.5) / 8;
boolean any = false;
for (Element e : model.elements) {
if (e.rotAxis != -1) continue;
Face f = e.faces[d.ordinal()];
if (f == null || !opaqueTexture(f)) continue;
double bound = positive ? e.to[axis] : e.from[axis];
if (Math.abs(bound - (positive ? 1 : 0)) > 1e-6) continue;
if (p1 < Math.min(e.from[a1], e.to[a1]) || p1 > Math.max(e.from[a1], e.to[a1])) continue;
if (p2 < Math.min(e.from[a2], e.to[a2]) || p2 > Math.max(e.from[a2], e.to[a2])) continue;
any = true;
break;
}
covered = any;
}
}
if (covered) mask |= 1 << d.ordinal();
}
return mask;
}
private static boolean opaqueTexture(Face f) {
if (f.texture == null) return false;
for (int[] row : f.texture) for (int px : row) if ((px >>> 24) != 0xFF) return false;
return true;
}
/** FluidRenderer.isFaceOccludedByState for a neighbour block whose face points at the fluid. */
private boolean occludes(BlockData block, Direction face) {
return covers(fullFaces(block), face);
}
private static boolean covers(int fullFaces, Direction face) {
return (fullFaces & 1 << face.ordinal()) != 0;
}
/** FluidRenderer.getHeight: 1 under the same fluid, own height in it, 0 for non-solid blocks, -1 for solids. */
private static float height(BlockLookup level, int type, int x, int y, int z, BlockData data, int state) {
if (type(state) == type) {
return type(state(level.at(x, y + 1, z))) == type ? 1.0F : ownHeight(state);
}
return !data.getMaterial().isSolid() ? 0.0F : -1.0F;
}
private static float height(BlockLookup level, int type, int x, int y, int z) {
BlockData d = level.at(x, y, z);
return height(level, type, x, y, z, d, state(d));
}
/** FluidRenderer.calculateAverageHeight (weights: 10 for heights >= 0.8, 1 below, solids (-1) none). */
private static float averageHeight(
BlockLookup level, int type, float self, float h2, float h1, int cx, int cy, int cz) {
if (h1 >= 1.0F || h2 >= 1.0F) return 1.0F;
float corner = -1.0F;
if (h1 > 0.0F || h2 > 0.0F) {
corner = height(level, type, cx, cy, cz);
if (corner >= 1.0F) return 1.0F;
}
float wc = weight(corner), ws = weight(self), w1 = weight(h1), w2 = weight(h2);
// Same summation order as vanilla's running sums.
return (0.0F + corner * wc + self * ws + h1 * w1 + h2 * w2) / (0.0F + wc + ws + w1 + w2);
}
private static float weight(float h) {
return h >= 0.8F ? 10.0F : h >= 0.0F ? 1.0F : 0.0F;
}
private static final int[][] HORIZONTAL = {{0, -1}, {1, 0}, {0, 1}, {-1, 0}};
/**
* Texture angle of the flowing top sprite from FlowingFluid.getFlow (horizontal part; the falling tweak only adds
* y), or NaN for the still texture.
*/
private static float flowAngle(BlockLookup level, int type, int x, int y, int z, int state) {
double fx = 0, fz = 0;
float own = ownHeight(state);
for (int[] d : HORIZONTAL) {
BlockData nd = level.at(x + d[0], y, z + d[1]);
int ns = state(nd);
if (!affectsFlow(type, ns)) continue;
float nh = ownHeight(ns);
float distance = 0.0F;
if (nh == 0.0F) {
if (!blocksFluidFlow(nd)) {
int below = state(level.at(x + d[0], y - 1, z + d[1]));
if (affectsFlow(type, below)) {
nh = ownHeight(below);
if (nh > 0.0F) distance = own - (nh - 0.8888889F);
}
}
} else if (nh > 0.0F) {
distance = own - nh;
}
if (distance != 0.0F) {
fx += d[0] * distance;
fz += d[1] * distance;
}
}
// Vec3.normalize: below 1e-5 the vector is zero (still texture).
if (Math.sqrt(fx * fx + fz * fz) < 1.0E-5) return Float.NaN;
return (float) Math.atan2(fz, fx) - (float) (Math.PI / 2);
}
private static boolean affectsFlow(int type, int neighbourState) {
int t = type(neighbourState);
return t == NONE || t == type;
}
private static boolean blocksFluidFlow(BlockData data) {
Material m = data.getMaterial();
return m.isSolid() || SIGNS.contains(m);
}
/** FluidState.shouldRenderBackwardUpFace: some block of the 3x3 above is neither the fluid nor solid-render. */
private static boolean backwardUpFace(BlockLookup level, int type, int x, int y, int z) {
for (int ox = -1; ox <= 1; ox++) {
for (int oz = -1; oz <= 1; oz++) {
BlockData d = level.at(x + ox, y, z + oz);
if (type(state(d)) != type && !d.getMaterial().isOccluding()) return true;
}
}
return false;
}
/** A drawn fluid polygon crossed by a ray. */
static final class Crossing {
double t;
int texel;
boolean entering; // the ray comes from outside the fluid (front side)
double nx, ny, nz; // outward unit normal
boolean bottom; // the bottom face, lit by the cell below
}
/**
* The nearest drawn fluid polygon of cell (bx, by, bz) (block model {@code model}, fluid {@code state}) crossed
* by the ray {@code o + t·d} with {@code t > minT}, or null.
*/
Crossing nearest(
BlockLookup level,
int bx,
int by,
int bz,
ResolvedModel model,
int state,
double ox,
double oy,
double oz,
double dx,
double dy,
double dz,
double minT) {
int type = type(state);
BlockData down = level.at(bx, by - 1, bz), up = level.at(bx, by + 1, bz);
BlockData north = level.at(bx, by, bz - 1), south = level.at(bx, by, bz + 1);
BlockData west = level.at(bx - 1, by, bz), east = level.at(bx + 1, by, bz);
int sNorth = state(north), sSouth = state(south), sWest = state(west), sEast = state(east);
boolean sameUp = type(state(up)) == type, sameDown = type(state(down)) == type;
boolean sameN = type(sNorth) == type, sameS = type(sSouth) == type;
boolean sameW = type(sWest) == type, sameE = type(sEast) == type;
// Own faces covering the cell faces; only looked up when a face other than the top may be drawn.
int selfFaces = sameDown && sameN && sameS && sameW && sameE ? 0 : fullFaces(model);
boolean renderUp = !sameUp;
boolean renderDown = !sameDown && !covers(selfFaces, Direction.DOWN) && !occludes(down, Direction.UP);
boolean renderN = !sameN && !covers(selfFaces, Direction.NORTH);
boolean renderS = !sameS && !covers(selfFaces, Direction.SOUTH);
boolean renderW = !sameW && !covers(selfFaces, Direction.WEST);
boolean renderE = !sameE && !covers(selfFaces, Direction.EAST);
if (!(renderUp || renderDown || renderN || renderS || renderW || renderE)) return null;
float self = sameUp ? 1.0F : ownHeight(state);
float hNE, hNW, hSE, hSW;
if (self >= 1.0F) {
hNE = hNW = hSE = hSW = 1.0F;
} else {
float hN = height(level, type, bx, by, bz - 1, north, sNorth);
float hS = height(level, type, bx, by, bz + 1, south, sSouth);
float hE = height(level, type, bx + 1, by, bz, east, sEast);
float hW = height(level, type, bx - 1, by, bz, west, sWest);
hNE = averageHeight(level, type, self, hN, hE, bx + 1, by, bz - 1);
hNW = averageHeight(level, type, self, hN, hW, bx - 1, by, bz - 1);
hSE = averageHeight(level, type, self, hS, hE, bx + 1, by, bz + 1);
hSW = averageHeight(level, type, self, hS, hW, bx - 1, by, bz + 1);
}
float bottom = renderDown ? OFFS : 0.0F;
// The top is not drawn under a block covering it when every corner is full.
boolean drawUp =
renderUp && !(Math.min(Math.min(hNW, hSW), Math.min(hSE, hNE)) >= 1.0F && occludes(up, Direction.DOWN));
if (drawUp) {
hNW -= OFFS;
hSW -= OFFS;
hSE -= OFFS;
hNE -= OFFS;
}
double lx = ox - bx, ly = oy - by, lz = oz - bz;
double bestT = Double.POSITIVE_INFINITY;
int bestFace = -1; // 0 top, 1 bottom, 2 N, 3 S, 4 W, 5 E
double bestNx = 0, bestNy = 0, bestNz = 0;
boolean bestEntering = false;
double hitX = 0, hitY = 0, hitZ = 0;
boolean sideOverlay = false;
double sideS = 0;
if (drawUp) {
Boolean backUp = null;
// Triangle 1: NW(0,0) SW(0,1) SE(1,1) (z >= x); triangle 2: NW, SE, NE(1,0) (z < x).
for (int tri = 0; tri < 2; tri++) {
double a = hNW, b, c;
if (tri == 0) {
b = hSE - hSW;
c = hSW - hNW;
} else {
b = hNE - hNW;
c = hSE - hNE;
}
// Plane y = a + b·x + c·z, outward normal (-b, 1, -c).
double denom = dy - b * dx - c * dz;
if (Math.abs(denom) < 1e-12) continue;
double t = (a + b * lx + c * lz - ly) / denom;
if (!(t > minT) || t >= bestT) continue;
double x = lx + dx * t, z = lz + dz * t;
if (x < -EPS || x > 1 + EPS || z < -EPS || z > 1 + EPS) continue;
if (tri == 0 ? z < x - EPS : z >= x + EPS) continue;
boolean entering = denom < 0;
if (!entering) {
if (backUp == null) backUp = backwardUpFace(level, type, bx, by + 1, bz);
if (!backUp) continue;
}
double len = Math.sqrt(b * b + 1 + c * c);
bestT = t;
bestFace = 0;
bestNx = -b / len;
bestNy = 1 / len;
bestNz = -c / len;
bestEntering = entering;
hitX = x;
hitY = ly + dy * t;
hitZ = z;
}
}
if (renderDown && dy > 0) {
double t = (bottom - ly) / dy;
if (t > minT && t < bestT) {
double x = lx + dx * t, z = lz + dz * t;
if (x >= -EPS && x <= 1 + EPS && z >= -EPS && z <= 1 + EPS) {
bestT = t;
bestFace = 1;
bestNx = 0;
bestNy = -1;
bestNz = 0;
bestEntering = true;
hitX = x;
hitY = bottom;
hitZ = z;
}
}
}
// Side faces: the neighbour's face towards the fluid, plane, outward sign and the edge heights from vertex 0
// to vertex 1 in vanilla's order (N: west->east, S: east->west, W: south->north, E: north->south).
for (int face = 2; face <= 5; face++) {
boolean render;
BlockData neighbour;
Direction neighbourFace;
float h0, h1;
switch (face) {
case 2 -> {
render = renderN;
neighbour = north;
neighbourFace = Direction.SOUTH;
h0 = hNW;
h1 = hNE;
}
case 3 -> {
render = renderS;
neighbour = south;
neighbourFace = Direction.NORTH;
h0 = hSE;
h1 = hSW;
}
case 4 -> {
render = renderW;
neighbour = west;
neighbourFace = Direction.EAST;
h0 = hSW;
h1 = hNW;
}
default -> {
render = renderE;
neighbour = east;
neighbourFace = Direction.WEST;
h0 = hNE;
h1 = hSE;
}
}
if (!render) continue;
boolean alongX = face == 2 || face == 3;
boolean negative = face == 2 || face == 4;
double plane = negative ? OFFS : 1 - OFFS;
double dAxis = alongX ? dz : dx;
if (Math.abs(dAxis) < 1e-12) continue;
double t = (plane - (alongX ? lz : lx)) / dAxis;
if (!(t > minT) || t >= bestT) continue;
double outward = negative ? -1 : 1;
boolean entering = dAxis * outward < 0;
if (occludes(neighbour, neighbourFace)) continue;
boolean overlay = type == WATER && OVERLAY_NEIGHBOURS.contains(neighbour.getMaterial());
if (!entering && overlay) continue;
double x = lx + dx * t, y = ly + dy * t, z = lz + dz * t;
double along = alongX ? x : z;
if (along < -EPS || along > 1 + EPS) continue;
double s = alongX ? (negative ? x : 1 - x) : (negative ? 1 - z : z);
double top = h0 + (h1 - h0) * s;
if (y < bottom - EPS || y > top + EPS) continue;
bestT = t;
bestFace = face;
bestNx = alongX ? 0 : outward;
bestNy = 0;
bestNz = alongX ? outward : 0;
bestEntering = entering;
hitX = x;
hitY = y;
hitZ = z;
sideOverlay = overlay;
sideS = s;
}
if (bestFace < 0) return null;
Crossing best = new Crossing();
best.t = bestT;
best.entering = bestEntering;
best.nx = bestNx;
best.ny = bestNy;
best.nz = bestNz;
best.bottom = bestFace == 1;
boolean water = type == WATER;
int[][] still = water ? waterStill : lavaStill, flow = water ? waterFlow : lavaFlow;
if (bestFace == 0) {
float angle = flowAngle(level, type, bx, by, bz, state);
if (Float.isNaN(angle)) {
best.texel = sample(still, hitX, hitZ);
} else {
// Flowing sprite turned to the flow (vanilla UVs at the corners; affine over the cell).
double sn = Math.sin(angle) * 0.25F, cs = Math.cos(angle) * 0.25F;
double u = 0.5 - cs - sn + 2 * cs * hitX + 2 * sn * hitZ;
double v = 0.5 - cs + sn - 2 * sn * hitX + 2 * cs * hitZ;
best.texel = sample(flow, u, v);
}
} else if (bestFace == 1) {
best.texel = sample(still, hitX, hitZ);
} else {
best.texel = sample(sideOverlay ? waterOverlay : flow, sideS * 0.5, (1 - hitY) * 0.5);
}
return best;
}
}
@@ -11,37 +11,56 @@ import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
import eu.mhsl.minecraft.pixelpics.render.tint.TintResolver; import eu.mhsl.minecraft.pixelpics.render.tint.TintResolver;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; 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 java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.block.Biome;
import org.bukkit.block.data.BlockData; import org.bukkit.block.data.BlockData;
import org.bukkit.util.Vector; import org.bukkit.util.Vector;
/** /**
* Traces a single ray against a {@link WorldSnapshot}, sampling block models via the * Traces a single ray against a {@link WorldSnapshot}: block models ({@link ElementIntersector}), fluid polygons
* {@link ElementIntersector} and applying biome tint, directional face shading, transparency and * ({@link FluidRenderer}) and entities, with biome tint, face shading, ambient occlusion, alpha blending of
* reflection. Contains no Bukkit world access, so it is safe to invoke from worker threads. * translucent surfaces, fog and water reflection. No Bukkit world access, so it runs on worker threads.
*/ */
public final class SnapshotRaytracer { public final class SnapshotRaytracer {
private static final int BIOME_BLEND_RADIUS = 2; // Vanilla-style ambient occlusion brightness per occlusion level (0 = most occluded .. 3 = open), kept subtle.
// 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 static final double[] AO_BRIGHTNESS = {0.55, 0.70, 0.85, 1.0};
// Texels with alpha >= OPAQUE_ALPHA end the ray; translucent ones are blended over what lies behind them.
// A surface after which less than MIN_TRANSMITTANCE (one 8-bit step) of the background would show through ends
// the ray too (error <= 1 colour level): water faces (alpha 180/255) stay see-through up to the 5th one, e.g.
// through two water blocks with air between them (4 faces) or a waterfall in front of a pool. Bounded so a stack
// of glass costs at most a few extra intersection rounds per ray; only rays through that many faces pay.
private static final int OPAQUE_ALPHA = 250;
private static final int MAX_TRANSLUCENT_LAYERS = 8;
private static final double MIN_TRANSMITTANCE = 1 / 255.0;
private static final double LAYER_EPS = 1e-5;
// layerAlpha marker of an additive (emissive) layer: its colour is added to what lies behind.
private static final double ADDITIVE = -1;
// Water reflection (not vanilla): weight of the reflected ray on the first water surface entered from the air.
private static final double WATER_REFLECTION = 0.10;
// Brightness by sky light level: vanilla lightmap.fsh (level / (4 - 3·level), mixed halfway with notGamma at the
// default brightness 0.5, overworld ambient 0). PixelPics has no other light model, so it only matters under
// water, where sky light drops by vertical water depth (FluidRenderer.skyLevel). SKY_LIGHT[15] == 1.
private static final double[] SKY_LIGHT = new double[16];
static {
for (int level = 0; level < 16; level++) {
double l = level / 15.0;
double b = l / (4 - 3 * l);
double inv = 1 - b;
SKY_LIGHT[level] = (b + (1 - inv * inv * inv * inv)) / 2;
}
}
private final BlockModelRegistry registry; private final BlockModelRegistry registry;
private final FluidRenderer fluids;
private final BiomeTintProvider tintProvider; private final BiomeTintProvider tintProvider;
private final SkyRenderer skyRenderer; private final SkyRenderer skyRenderer;
private final double maxDistance; private final double maxDistance;
private final int reflectionDepth; private final int reflectionDepth;
private final int maxSteps; private final int maxSteps;
private final Map<Long, BiomeTint> tintCache = new ConcurrentHashMap<>();
public SnapshotRaytracer( public SnapshotRaytracer(
BlockModelRegistry registry, BlockModelRegistry registry,
@@ -50,6 +69,7 @@ public final class SnapshotRaytracer {
double maxDistance, double maxDistance,
int reflectionDepth) { int reflectionDepth) {
this.registry = registry; this.registry = registry;
this.fluids = new FluidRenderer(registry);
this.tintProvider = tintProvider; this.tintProvider = tintProvider;
this.skyRenderer = skyRenderer; this.skyRenderer = skyRenderer;
this.maxDistance = maxDistance; this.maxDistance = maxDistance;
@@ -57,126 +77,325 @@ public final class SnapshotRaytracer {
this.maxSteps = (int) (maxDistance * 3) + 3; this.maxSteps = (int) (maxDistance * 3) + 3;
} }
public int trace(WorldSnapshot snapshot, Vector origin, Vector direction, SkyContext sky, EntityScene scene) { /** The per-photo biome tint blend over {@code snapshot}; once per photo. */
return trace(snapshot, origin, direction, sky, scene, reflectionDepth); public BiomeBlend biomeBlend(WorldSnapshot snapshot) {
return new BiomeBlend(snapshot, tintProvider);
}
/**
* The medium (air or {@code fluid} with its fog) of the camera at {@code origin}; once per photo. {@code fluid} =
* {@link FluidRenderer#cameraFluid} (determined once per photo, also for the FOV).
*/
public CameraMedium cameraMedium(WorldSnapshot snapshot, Vector origin, int fluid, SkyContext sky) {
return CameraMedium.at(snapshot, origin, fluid, tintProvider, sky);
}
public int trace(
WorldSnapshot snapshot,
Vector origin,
Vector direction,
SkyContext sky,
EntityScene scene,
CameraMedium medium,
BiomeBlend tints) {
return trace(snapshot, origin, direction, sky, scene, reflectionDepth, medium, tints);
} }
private int trace( private int trace(
WorldSnapshot snapshot, Vector origin, Vector direction, SkyContext sky, EntityScene scene, int depth) { WorldSnapshot snapshot,
Vector origin,
Vector direction,
SkyContext sky,
EntityScene scene,
int depth,
CameraMedium medium,
BiomeBlend tints) {
double ox = origin.getX(), oy = origin.getY(), oz = origin.getZ(); double ox = origin.getX(), oy = origin.getY(), oz = origin.getZ();
double dx = direction.getX(), dy = direction.getY(), dz = direction.getZ(); double dx = direction.getX(), dy = direction.getY(), dz = direction.getZ();
VoxelDDA dda = new VoxelDDA(ox, oy, oz, dx, dy, dz); BlockCursor blocks = new BlockCursor(snapshot, origin, direction, sky, scene, depth, medium, tints);
boolean entities = scene != null && !scene.isEmpty();
int skyColor = skyRenderer.colorFor(direction, origin, sky); // Translucent surfaces in front of the final one, front to back (shaded colour, alpha 0..1 or ADDITIVE,
int baseColor = skyColor; // ray t, already lit).
Vector finalPoint = null; int[] layerColor = null;
double[] layerAlpha = null, layerT = null;
boolean[] layerLit = null;
int layers = 0;
double transmittance = 1;
int reflectionColor = 0; int finalColor = 0;
double reflectionFactor = 0; double finalT = Double.POSITIVE_INFINITY; // sky
boolean reflected = false; boolean finalLit = false;
double minT = 0;
BlockHit pending = null; // nearest block surface not consumed yet
Vector transparencyStart = null; // Surfaces in depth order (blocks, fluid faces and entities interleaved): translucent ones become layers,
int transparencyColor = 0; // the first opaque one ends the ray.
double transparencyFactor = 0; while (true) {
if (pending == null) pending = blocks.next(minT);
BlockData occlusion = null; double limit = pending != null ? pending.t : maxDistance;
FaceHit eh = entities ? scene.nearestHit(ox, oy, oz, dx, dy, dz, minT, limit) : null;
for (int i = 0; i < maxSteps && dda.tCurrent <= maxDistance; i++) { int color;
int bx = dda.x, by = dda.y, bz = dda.z; double t;
BlockData data = snapshot.getBlockData(bx, by, bz); boolean additive = false;
if (data.getMaterial() == Material.AIR) { boolean lit = false;
occlusion = null; if (eh != null) {
dda.advance(); // Vanilla NO_CARDINAL_LIGHTING: additive energy swirl, eyes, breeze wind.
continue; additive = eh.additive();
boolean flat = additive || eh.unshaded();
color = flat ? eh.color() : ColorUtil.shade(eh.color(), shadeFactor(eh.normal()));
t = eh.t();
} else if (pending != null) {
color = pending.color;
t = pending.t;
lit = pending.lit;
pending = null;
} else {
break;
} }
int alpha = ColorUtil.alpha(color);
ResolvedModel model = registry.get(data); double a = alpha / 255.0;
FaceHit hit = ElementIntersector.intersect(model, ox - bx, oy - by, oz - bz, dx, dy, dz, bx, by, bz); if (additive) {
if (hit == null) { // Additive light neither ends nor dims the ray; beyond the layer budget it is dropped.
occlusion = null; if (layers == MAX_TRANSLUCENT_LAYERS) {
dda.advance(); minT = t + LAYER_EPS;
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; continue;
} }
occlusion = data; } else if (alpha >= OPAQUE_ALPHA
|| layers == MAX_TRANSLUCENT_LAYERS
|| transmittance * (1 - a) < MIN_TRANSMITTANCE) {
finalColor = color;
finalT = t;
finalLit = lit;
break;
}
if (layerColor == null) {
layerColor = new int[MAX_TRANSLUCENT_LAYERS];
layerAlpha = new double[MAX_TRANSLUCENT_LAYERS];
layerT = new double[MAX_TRANSLUCENT_LAYERS];
layerLit = new boolean[MAX_TRANSLUCENT_LAYERS];
}
layerColor[layers] = color;
layerAlpha[layers] = additive ? ADDITIVE : a;
layerT[layers] = t;
layerLit[layers] = lit;
layers++;
if (!additive) transmittance *= 1 - a;
minT = t + LAYER_EPS;
}
// Compose back to front, each surface fogged; surfaces under water dimmed by their sky light. The sky
// colour is only computed when something reads it (most rays end on a near surface).
int skyColor = medium.needsSkyColor(finalT) ? skyRenderer.colorFor(direction, origin, sky) : 0;
int color;
if (Double.isInfinite(finalT)) {
color = medium.sky(skyColor, dx, dy, dz);
} else {
int lit = finalLit ? finalColor : blocks.underwaterLight(finalColor, finalT);
color = medium.fog(lit, finalT, skyColor);
}
for (int i = layers - 1; i >= 0; i--) {
if (layerAlpha[i] == ADDITIVE) {
double f = medium.fogValue(layerT[i]);
color = ColorUtil.add(color, ColorUtil.shade(layerColor[i], 1 - f));
} else { } else {
occlusion = null; int layer = layerLit[i] ? layerColor[i] : blocks.underwaterLight(layerColor[i], layerT[i]);
} color = ColorUtil.mix(medium.fog(layer, layerT[i], skyColor), color, layerAlpha[i], 1 - layerAlpha[i]);
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;
}
} }
} }
return color & 0xFFFFFF;
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) { /** A surface found by the {@link BlockCursor}; {@code lit}: carries its own sky light (fluid faces). */
private record BlockHit(int color, double t, boolean lit) {}
/**
* Walks the voxel grid along one ray and hands out block surfaces in depth order. It stays on the voxel of the
* last returned hit, so a later call (with {@code minT} behind that hit) also finds surfaces further inside the
* same block (slime/honey cores, the far side of a pane, the water polygons of a waterlogged block).
*/
private final class BlockCursor {
private final WorldSnapshot snapshot;
private final Vector direction;
private final SkyContext sky;
private final EntityScene scene;
private final int depth;
private final CameraMedium medium;
private final BiomeBlend tints;
private final double ox, oy, oz, dx, dy, dz;
private final FluidRenderer.BlockLookup blocks;
private final VoxelDDA dda;
private int steps;
private boolean done;
// Ray parameter from which on the ray is in water (camera in water: -inf; never: +inf): surfaces behind it
// may need the underwater light, surfaces in front of it (also entities and layers composed after the whole
// walk) never do. Set by the first water cell the walk enters: the water face it enters through, else the
// cell entry.
private double waterT;
private boolean reflected;
BlockCursor(
WorldSnapshot snapshot,
Vector origin,
Vector direction,
SkyContext sky,
EntityScene scene,
int depth,
CameraMedium medium,
BiomeBlend tints) {
this.snapshot = snapshot;
this.tints = tints;
this.direction = direction;
this.sky = sky;
this.scene = scene;
this.depth = depth;
this.medium = medium;
this.ox = origin.getX();
this.oy = origin.getY();
this.oz = origin.getZ();
this.dx = direction.getX();
this.dy = direction.getY();
this.dz = direction.getZ();
this.blocks = snapshot::getBlockData;
this.dda = new VoxelDDA(ox, oy, oz, dx, dy, dz);
this.waterT = medium.fluid() == FluidRenderer.WATER ? Double.NEGATIVE_INFINITY : Double.POSITIVE_INFINITY;
}
/** The next block surface with {@code t > minT}, or null when the ray leaves the render distance. */
BlockHit next(double minT) {
while (!done) {
if (steps >= maxSteps || dda.tCurrent > maxDistance) {
done = true;
break;
}
int bx = dda.x, by = dda.y, bz = dda.z;
BlockData data = snapshot.getBlockData(bx, by, bz);
Material material = data.getMaterial();
if (material == Material.AIR) {
advance();
continue;
}
ResolvedModel model = registry.get(data, bx, by, bz);
FaceHit hit =
ElementIntersector.intersect(model, ox - bx, oy - by, oz - bz, dx, dy, dz, bx, by, bz, minT);
int fluidState = FluidRenderer.state(data);
if (fluidState != 0) {
int type = FluidRenderer.type(fluidState);
FluidRenderer.Crossing c =
fluids.nearest(blocks, bx, by, bz, model, fluidState, ox, oy, oz, dx, dy, dz, minT);
if (type == FluidRenderer.WATER && waterT == Double.POSITIVE_INFINITY) {
// An exit face first: the ray was in the water from the cell entry on.
waterT = c != null && c.entering ? c.t : dda.tCurrent;
}
if (c != null && (hit == null || c.t < hit.t())) return fluidHit(c, type, bx, by, bz);
}
if (hit == null) {
advance();
continue;
}
// Shading and tint keep the texel alpha, so the culling test can run first.
if (ColorUtil.alpha(hit.color()) < OPAQUE_ALPHA && culledByNeighbour(hit, data, bx, by, bz)) {
minT = hit.t();
continue;
}
return new BlockHit(shadeAndTint(hit, data, snapshot, tints, bx, by, bz), hit.t(), false);
}
return null;
}
private void advance() {
dda.advance();
steps++;
}
/**
* A crossed fluid polygon as a surface: texel × biome water colour × face shade × sky light of the face's
* cell (the one below for the bottom face; max with the cell above, like vanilla's light coords). Lava is
* neither tinted nor dimmed. The first water surface entered from the air also reflects.
*/
private BlockHit fluidHit(FluidRenderer.Crossing c, int type, int bx, int by, int bz) {
int color = c.texel;
boolean water = type == FluidRenderer.WATER;
double light = 1;
if (water) {
BiomeTint tint = tints.at(bx, by, bz);
if (tint != null) color = ColorUtil.multiply(color, tint.water());
int y = c.bottom ? by - 1 : by;
int level = Math.max(
FluidRenderer.skyLevel(blocks, bx, y, bz), FluidRenderer.skyLevel(blocks, bx, y + 1, bz));
if (level < 15) light = SKY_LIGHT[level];
}
color = ColorUtil.shade(color, shadeFactor(c.nx, c.ny, c.nz) * light);
if (water && c.entering && !reflected && depth > 0 && !medium.inFluid()) {
reflected = true;
double px = ox + dx * c.t, py = oy + dy * c.t, pz = oz + dz * c.t;
Vector normal = new Vector(c.nx, c.ny, c.nz);
Vector reflectDir = MathUtil.reflectVector(direction, normal);
Vector reflectStart = new Vector(px, py, pz).add(normal.clone().multiply(1e-3));
int reflection =
trace(snapshot, reflectStart, reflectDir, sky, scene, depth - 1, CameraMedium.AIR, tints);
// mix(layer over behind, reflection) as one layer with alpha a' = 1 - (1-a)(1-r).
double a = ColorUtil.alpha(color) / 255.0, r = WATER_REFLECTION;
double a2 = 1 - (1 - a) * (1 - r);
double wl = a * (1 - r) / a2, wr = r / a2;
int mixed = ColorUtil.mix(color, reflection, wl, wr);
color = ColorUtil.argb(
(int) Math.round(a2 * 255),
ColorUtil.red(mixed),
ColorUtil.green(mixed),
ColorUtil.blue(mixed));
}
return new BlockHit(color, c.t, true);
}
/**
* {@code color} of a surface at ray parameter {@code t} dimmed by the sky light of the block in front of it
* (back along the ray), when the ray is in water there.
*/
int underwaterLight(int color, double t) {
if (t < waterT) return color;
double back = 1e-4 / Math.sqrt(dx * dx + dy * dy + dz * dz);
int bx = (int) Math.floor(ox + dx * (t - back));
int by = (int) Math.floor(oy + dy * (t - back));
int bz = (int) Math.floor(oz + dz * (t - back));
int level = FluidRenderer.skyLevel(blocks, bx, by, bz);
return level >= 15 ? color : ColorUtil.shade(color, SKY_LIGHT[level]);
}
/**
* Vanilla skips the faces between two translucent blocks of the same kind (HalfTransparentBlock: stained
* glass walls, ice, connected panes): the hit lies on the block boundary and the neighbour is the same type.
*/
private boolean culledByNeighbour(FaceHit hit, BlockData data, int bx, int by, int bz) {
Vector n = hit.normal();
double nx = n.getX(), ny = n.getY(), nz = n.getZ();
int sx = 0, sy = 0, sz = 0;
double local;
if (Math.abs(nx) > 0.99) {
sx = nx > 0 ? 1 : -1;
local = hit.point().getX() - bx;
} else if (Math.abs(ny) > 0.99) {
sy = ny > 0 ? 1 : -1;
local = hit.point().getY() - by;
} else if (Math.abs(nz) > 0.99) {
sz = nz > 0 ? 1 : -1;
local = hit.point().getZ() - bz;
} else {
return false;
}
double boundary = (sx + sy + sz) > 0 ? 1 : 0;
if (Math.abs(local - boundary) > 1e-4) return false;
return snapshot.getBlockData(bx + sx, by + sy, bz + sz).getMaterial() == data.getMaterial();
}
}
private int shadeAndTint(
FaceHit hit, BlockData data, WorldSnapshot snapshot, BiomeBlend tints, int bx, int by, int bz) {
int color = hit.color(); int color = hit.color();
if (hit.tintIndex() >= 0) { if (hit.tintIndex() >= 0) {
BiomeTint tint = blendedTint(snapshot, bx, by, bz); BiomeTint tint = tints.at(bx, by, bz);
if (tint != null) { if (tint != null) {
int tintColor = TintResolver.resolve(data, hit.tintIndex(), tint); int tintColor = TintResolver.resolve(data, hit.tintIndex(), tint);
if (tintColor != -1) color = ColorUtil.multiply(color, tintColor); if (tintColor != -1) color = ColorUtil.multiply(color, tintColor);
@@ -186,93 +405,64 @@ public final class SnapshotRaytracer {
return ColorUtil.shade(color, light); 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 * Smooth ambient occlusion like vanilla: each face corner is darkened by how many of the three blocks touching
* touching that corner (in the layer just outside the face) are solid, bilinearly interpolated * it (in the layer just outside the face) are solid, bilinearly interpolated across the face. Axis-aligned
* across the face. Only applied to axis-aligned faces. * faces only.
*/ */
private double ambientOcclusion(FaceHit hit, WorldSnapshot snapshot, int bx, int by, int bz) { private double ambientOcclusion(FaceHit hit, WorldSnapshot snapshot, int bx, int by, int bz) {
double nx = hit.normal().getX(), double nx = hit.normal().getX(),
ny = hit.normal().getY(), ny = hit.normal().getY(),
nz = hit.normal().getZ(); nz = hit.normal().getZ();
double ax = Math.abs(nx), ay = Math.abs(ny), az = Math.abs(nz); 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 if (Math.max(ax, Math.max(ay, az)) < 0.99) return 1.0;
double lx = hit.point().getX() - bx; double lx = hit.point().getX() - bx;
double ly = hit.point().getY() - by; double ly = hit.point().getY() - by;
double lz = hit.point().getZ() - bz; double lz = hit.point().getZ() - bz;
// Offset to the layer just outside the face, plus the two in-plane unit axes and face coords. // In-plane axes u = (ux, 0, uz), v = (0, vy, vz) and the face coordinates along them.
int ofx = (int) Math.round(nx), ofy = (int) Math.round(ny), ofz = (int) Math.round(nz); int ux, uz, vy, vz;
int ux, uy, uz, vx, vy, vz;
double su, sv; double su, sv;
if (ay > 0.5) { // up/down if (ay > 0.5) { // up/down
ux = 1; ux = 1;
uy = 0;
uz = 0; uz = 0;
vx = 0;
vy = 0; vy = 0;
vz = 1; vz = 1;
su = lx; su = lx;
sv = lz; sv = lz;
} else if (ax > 0.5) { // east/west } else if (ax > 0.5) { // east/west
ux = 0; ux = 0;
uy = 0;
uz = 1; uz = 1;
vx = 0;
vy = 1; vy = 1;
vz = 0; vz = 0;
su = lz; su = lz;
sv = ly; sv = ly;
} else { // north/south } else { // north/south
ux = 1; ux = 1;
uy = 0;
uz = 0; uz = 0;
vx = 0;
vy = 1; vy = 1;
vz = 0; vz = 0;
su = lx; su = lx;
sv = ly; sv = ly;
} }
// The cell just outside the face.
double b00 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, -1, -1); int x = bx + (int) Math.round(nx), y = by + (int) Math.round(ny), z = bz + (int) Math.round(nz);
double b10 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, +1, -1); boolean uMinus = solid(snapshot, x - ux, y, z - uz), uPlus = solid(snapshot, x + ux, y, z + uz);
double b01 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, -1, +1); boolean vMinus = solid(snapshot, x, y - vy, z - vz), vPlus = solid(snapshot, x, y + vy, z + vz);
double b11 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, +1, +1); double b00 = aoBrightness(uMinus, vMinus, solid(snapshot, x - ux, y - vy, z - uz - vz));
double b10 = aoBrightness(uPlus, vMinus, solid(snapshot, x + ux, y - vy, z + uz - vz));
double b01 = aoBrightness(uMinus, vPlus, solid(snapshot, x - ux, y + vy, z - uz + vz));
double b11 = aoBrightness(uPlus, vPlus, solid(snapshot, x + ux, y + vy, z + uz + vz));
double top = b00 + (b10 - b00) * su; double top = b00 + (b10 - b00) * su;
double bottom = b01 + (b11 - b01) * su; double bottom = b01 + (b11 - b01) * su;
return top + (bottom - top) * sv; return top + (bottom - top) * sv;
} }
private double aoCorner( private static double aoBrightness(boolean side1, boolean side2, boolean corner) {
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); int level = (side1 && side2) ? 0 : 3 - (side1 ? 1 : 0) - (side2 ? 1 : 0) - (corner ? 1 : 0);
return AO_BRIGHTNESS[Math.clamp(level, 0, 3)]; return AO_BRIGHTNESS[level];
} }
private boolean solid(WorldSnapshot snapshot, int x, int y, int z) { private boolean solid(WorldSnapshot snapshot, int x, int y, int z) {
@@ -280,52 +470,14 @@ public final class SnapshotRaytracer {
return m != Material.AIR && m.isOccluding(); return m != Material.AIR && m.isOccluding();
} }
/** private static double shadeFactor(Vector normal) {
* Biome-blended tint: averages the per-biome tint over a {@code (2r+1)x(2r+1)} neighbourhood in return shadeFactor(normal.getX(), normal.getY(), normal.getZ());
* 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) { /** Vanilla directional shading by the dominant normal axis: up 1.0, down 0.5, north/south 0.8, east/west 0.6. */
acc[0] += (argb >> 16) & 0xFF; private static double shadeFactor(double nx, double ny, double nz) {
acc[1] += (argb >> 8) & 0xFF; double ax = Math.abs(nx), ay = Math.abs(ny), az = Math.abs(nz);
acc[2] += argb & 0xFF; if (ay >= ax && ay >= az) return ny >= 0 ? 1.0 : 0.5;
}
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; if (az >= ax) return 0.8;
return 0.6; return 0.6;
} }
@@ -1,23 +1,19 @@
package eu.mhsl.minecraft.pixelpics.render.raytrace; package eu.mhsl.minecraft.pixelpics.render.raytrace;
/** /** Amanatides-Woo voxel traversal: the integer block cells a ray passes through, in order. */
* Amanatides-Woo voxel traversal: walks the integer block grid a ray passes through, in order, final class VoxelDDA {
* without any Bukkit world access (safe to run off the main thread).
*/
public final class VoxelDDA {
public int x, y, z; int x, y, z;
public double tCurrent; // ray parameter at which the current voxel was entered double tCurrent; // ray parameter at which the current voxel was entered
private final int stepX, stepY, stepZ; private final int stepX, stepY, stepZ;
private final double tDeltaX, tDeltaY, tDeltaZ; private final double tDeltaX, tDeltaY, tDeltaZ;
private double tMaxX, tMaxY, tMaxZ; private double tMaxX, tMaxY, tMaxZ;
public VoxelDDA(double ox, double oy, double oz, double dx, double dy, double dz) { VoxelDDA(double ox, double oy, double oz, double dx, double dy, double dz) {
this.x = (int) Math.floor(ox); this.x = (int) Math.floor(ox);
this.y = (int) Math.floor(oy); this.y = (int) Math.floor(oy);
this.z = (int) Math.floor(oz); this.z = (int) Math.floor(oz);
this.tCurrent = 0;
this.stepX = dx > 0 ? 1 : (dx < 0 ? -1 : 0); this.stepX = dx > 0 ? 1 : (dx < 0 ? -1 : 0);
this.stepY = dy > 0 ? 1 : (dy < 0 ? -1 : 0); this.stepY = dy > 0 ? 1 : (dy < 0 ? -1 : 0);
@@ -38,8 +34,7 @@ public final class VoxelDDA {
return (next - origin) / dir; return (next - origin) / dir;
} }
/** Advance to the next voxel along the ray, updating {@link #tCurrent}. */ void advance() {
public void advance() {
if (tMaxX < tMaxY) { if (tMaxX < tMaxY) {
if (tMaxX < tMaxZ) { if (tMaxX < tMaxZ) {
x += stepX; x += stepX;
@@ -12,6 +12,9 @@ import eu.mhsl.minecraft.pixelpics.render.entity.ObjectState;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker; import eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker; import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.ItemGeometry; import eu.mhsl.minecraft.pixelpics.render.entity.cem.ItemGeometry;
import eu.mhsl.minecraft.pixelpics.render.raytrace.BiomeBlend;
import eu.mhsl.minecraft.pixelpics.render.raytrace.CameraMedium;
import eu.mhsl.minecraft.pixelpics.render.raytrace.FluidRenderer;
import eu.mhsl.minecraft.pixelpics.render.raytrace.SnapshotRaytracer; import eu.mhsl.minecraft.pixelpics.render.raytrace.SnapshotRaytracer;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext; import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyRenderer; import eu.mhsl.minecraft.pixelpics.render.sky.SkyRenderer;
@@ -22,12 +25,12 @@ import eu.mhsl.minecraft.pixelpics.render.snapshot.FrustumBounds;
import eu.mhsl.minecraft.pixelpics.render.snapshot.ObjectSnapshotBuilder; import eu.mhsl.minecraft.pixelpics.render.snapshot.ObjectSnapshotBuilder;
import eu.mhsl.minecraft.pixelpics.render.snapshot.SnapshotBuilder; import eu.mhsl.minecraft.pixelpics.render.snapshot.SnapshotBuilder;
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot; import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeClimate;
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider; import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil; import eu.mhsl.minecraft.pixelpics.render.util.SpecialDates;
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.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -36,7 +39,9 @@ import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Logger; import java.util.logging.Logger;
import java.util.stream.IntStream; import java.util.stream.IntStream;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.block.data.BlockData;
import org.bukkit.entity.Entity; import org.bukkit.entity.Entity;
import org.bukkit.util.Vector; import org.bukkit.util.Vector;
@@ -44,17 +49,20 @@ import org.bukkit.util.Vector;
* Renders the scene by capturing a world snapshot on the main thread ({@link #prepare}) and then * 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}). * tracing one ray per pixel in parallel against that snapshot ({@link #execute}).
*/ */
public class DefaultScreenRenderer implements Renderer { public class DefaultScreenRenderer {
/** Horizontal half field-of-view; the vertical half is derived from the output aspect ratio. */ /** Vertical field of view in degrees (vanilla's default FOV option is vertical too). */
private static final double H_FOV_HALF_RAD = Math.toRadians(35); public static final double V_FOV_DEG = 70;
// Camera.getFov in water/lava: fov · lerp(fovEffectScale = 1, 1, 6/7).
private static final Vector BASE_VEC = new Vector(1, 0, 0); private static final double FLUID_FOV_SCALE = 6.0 / 7.0;
private static final double MAX_DISTANCE = 256; private static final double MAX_DISTANCE = 256;
// Fog colours of the vanilla 26.3 dimension types / nether_wastes (the default of a nether biome without one).
private static final int NETHER_FOG_COLOR = 0x330808;
private static final int END_FOG_COLOR = 0x181318;
private static final int REFLECTION_DEPTH = 4; private static final int REFLECTION_DEPTH = 4;
/** Supersampling factor: SSAA x SSAA rays per output pixel, downsampled gamma-correctly. */ /** SSAA x SSAA rays per output pixel, downsampled gamma-correctly. */
private static final int SSAA = 3; private static final int SSAA = 3;
private final SnapshotRaytracer raytracer; private final SnapshotRaytracer raytracer;
@@ -88,7 +96,6 @@ public class DefaultScreenRenderer implements Renderer {
this.raytracer = new SnapshotRaytracer(registry, tintProvider, skyRenderer, MAX_DISTANCE, REFLECTION_DEPTH); this.raytracer = new SnapshotRaytracer(registry, tintProvider, skyRenderer, MAX_DISTANCE, REFLECTION_DEPTH);
this.entityBaker = entityBaker; this.entityBaker = entityBaker;
this.blockEntityBaker = blockEntityBaker; this.blockEntityBaker = blockEntityBaker;
this.decorationBaker = new DecorationBaker(textures);
ItemGeometry items = new ItemGeometry( ItemGeometry items = new ItemGeometry(
registry.reader(), registry.reader(),
registry, registry,
@@ -96,46 +103,86 @@ public class DefaultScreenRenderer implements Renderer {
entityBaker == null ? null : entityBaker.models(), entityBaker == null ? null : entityBaker.models(),
blockEntityBaker); blockEntityBaker);
if (entityBaker != null) entityBaker.setItemGeometry(items); if (entityBaker != null) entityBaker.setItemGeometry(items);
if (blockEntityBaker != null) {
blockEntityBaker.setItemGeometry(items); // campfire/shelf/vault contents
blockEntityBaker.setEntityBaker(entityBaker); // spawner mobs
}
this.decorationBaker = new DecorationBaker(textures, registry, items);
this.objectBaker = new ObjectBaker(items, blockEntityBaker == null ? null : blockEntityBaker.font()); this.objectBaker = new ObjectBaker(items, blockEntityBaker == null ? null : blockEntityBaker.font());
this.logger = logger; this.logger = logger;
this.tracePool = tracePool; this.tracePool = tracePool;
} }
/** Convenience: prepare and execute in one call (must run on the main thread). */
@Override
public BufferedImage render(Location eyeLocation, Resolution resolution) {
return execute(prepare(eyeLocation, resolution, null));
}
/** Builds the (supersampled) ray map and captures world + entities. MUST run on the main thread. */ /** Builds the (supersampled) ray map and captures world + entities. MUST run on the main thread. */
public RenderJob prepare(Location eyeLocation, Resolution resolution, UUID shooter) { public RenderJob prepare(Location eyeLocation, Resolution resolution, UUID shooter) {
int superW = resolution.getWidth() * SSAA; int superW = resolution.width() * SSAA;
int superH = resolution.getHeight() * SSAA; int superH = resolution.height() * SSAA;
List<Vector> rayMap = buildRayMap(eyeLocation, superW, superH); World world = eyeLocation.getWorld();
WorldSnapshot snapshot = SnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE, logger); // Determined once, here: the FOV needs it before the snapshot exists, and execute's fog uses the same value.
Collection<Entity> nearby = FrustumBounds.nearbyEntities(eyeLocation, rayMap, MAX_DISTANCE); int cameraFluid = cameraFluid(eyeLocation);
RayMap rayMap = rayMap(eyeLocation, superW, superH, cameraFluid);
FrustumBounds bounds = FrustumBounds.of(eyeLocation, rayMap, MAX_DISTANCE);
WorldSnapshot snapshot = SnapshotBuilder.build(world, eyeLocation, bounds, logger);
Collection<Entity> nearby = bounds.nearbyEntities(world);
List<EntityState> entities = EntitySnapshotBuilder.build(nearby, shooter); List<EntityState> entities = EntitySnapshotBuilder.build(nearby, shooter);
List<BlockEntityState> blockEntities = BlockEntitySnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE); List<BlockEntityState> blockEntities = BlockEntitySnapshotBuilder.build(eyeLocation, bounds);
List<DecorationState> decorations = DecorationSnapshotBuilder.build(nearby); List<DecorationState> decorations = DecorationSnapshotBuilder.build(nearby);
List<ObjectState> objects = ObjectSnapshotBuilder.build(nearby, eyeLocation); List<ObjectState> objects = ObjectSnapshotBuilder.build(nearby, eyeLocation);
World world = eyeLocation.getWorld();
long dayTime = world.getTime(); long dayTime = world.getTime();
long fullTime = world.getFullTime(); long fullTime = world.getFullTime();
int moonPhase = (int) (fullTime / 24000L % 8L); int moonPhase = (int) (fullTime / 24000L % 8L);
SkyContext sky = new SkyContext(dayTime, moonPhase, fullTime); SkyContext sky =
switch (world.getEnvironment()) {
case NETHER -> new SkyContext(
dayTime,
moonPhase,
fullTime,
SkyContext.Skybox.NONE,
BiomeClimate.fogColor(
world.getBiome(eyeLocation).getKey().getKey(), NETHER_FOG_COLOR));
case THE_END -> new SkyContext(dayTime, moonPhase, fullTime, SkyContext.Skybox.END, END_FOG_COLOR);
default -> new SkyContext(dayTime, moonPhase, fullTime);
};
return new RenderJob( return new RenderJob(
snapshot, snapshot,
rayMap, rayMap,
eyeLocation.toVector(), eyeLocation.toVector(),
resolution.getWidth(), cameraFluid,
resolution.getHeight(), resolution.width(),
resolution.height(),
sky, sky,
entities, entities,
blockEntities, blockEntities,
decorations, decorations,
objects); objects,
SpecialDates.now());
}
/**
* The chunks {@link #prepare} will capture for this view (with a block of slack: computed from one ray per output
* pixel instead of the supersampled ones). Main thread.
*/
public SnapshotBuilder.ChunkArea chunkArea(Location eyeLocation, Resolution resolution) {
RayMap rayMap = rayMap(eyeLocation, resolution.width(), resolution.height(), cameraFluid(eyeLocation));
return SnapshotBuilder.chunkArea(eyeLocation, FrustumBounds.of(eyeLocation, rayMap, MAX_DISTANCE), 2);
}
/** The fluid at the eye; an unloaded chunk counts as air (never loads one, see {@link SnapshotBuilder}). */
private static int cameraFluid(Location eye) {
World world = eye.getWorld();
BlockData air = Material.AIR.createBlockData();
return FluidRenderer.cameraFluid(
(x, y, z) -> world.isChunkLoaded(x >> 4, z >> 4) ? world.getBlockData(x, y, z) : air,
eye.getX(),
eye.getY(),
eye.getZ());
}
private static RayMap rayMap(Location eye, int width, int height, int cameraFluid) {
return RayMap.of(
eye, width, height, cameraFluid != FluidRenderer.NONE ? V_FOV_DEG * FLUID_FOV_SCALE : V_FOV_DEG);
} }
/** Traces every (super)ray in parallel, then downsamples gamma-correctly. Safe off the main thread. */ /** Traces every (super)ray in parallel, then downsamples gamma-correctly. Safe off the main thread. */
@@ -144,37 +191,42 @@ public class DefaultScreenRenderer implements Renderer {
} }
/** /**
* As {@link #execute(RenderJob)}, but cooperatively abortable: once {@code cancelled} is set, the * As {@link #execute(RenderJob)}, but abortable: once {@code cancelled} is set (or the tracing thread is
* parallel loops short-circuit (remaining work becomes a no-op) so a stuck/overlong render drains * interrupted) the remaining work is skipped and the returned image is partial (to be discarded).
* out quickly. The returned image is then partial and should be discarded by the caller.
*/ */
public BufferedImage execute(RenderJob job, AtomicBoolean cancelled) { public BufferedImage execute(RenderJob job, AtomicBoolean cancelled) {
int finalW = job.width(); int finalW = job.width();
int finalH = job.height(); int finalH = job.height();
int superW = finalW * SSAA; int superW = finalW * SSAA;
List<Vector> rayMap = job.rayMap(); RayMap rayMap = job.rayMap();
WorldSnapshot snapshot = job.snapshot(); WorldSnapshot snapshot = job.snapshot();
Vector origin = job.origin(); Vector origin = job.origin();
SkyContext sky = job.sky(); SkyContext sky = job.sky();
EntityScene scene = new EntityScene( // Date-dependent assets (christmas chests, local_time item models) use the photo's time throughout.
job.entities(), EntityScene scene = SpecialDates.at(
entityBaker, job.time(),
job.blockEntities(), () -> new EntityScene(
blockEntityBaker, job.entities(),
job.decorations(), entityBaker,
decorationBaker, job.blockEntities(),
job.objects(), blockEntityBaker,
objectBaker); job.decorations(),
decorationBaker,
job.objects(),
objectBaker));
CameraMedium medium = raytracer.cameraMedium(snapshot, origin, job.cameraFluid(), sky);
BiomeBlend tints = raytracer.biomeBlend(snapshot);
int[] superBuf = new int[rayMap.size()]; int[] superBuf = new int[rayMap.size()];
runParallel(() -> IntStream.range(0, rayMap.size()).parallel().forEach(i -> { runParallel(() -> IntStream.range(0, rayMap.size()).parallel().forEach(i -> {
if (!cancelled.get()) superBuf[i] = raytracer.trace(snapshot, origin, rayMap.get(i), sky, scene); if (!stopped(cancelled))
superBuf[i] = raytracer.trace(snapshot, origin, rayMap.get(i), sky, scene, medium, tints);
})); }));
BufferedImage image = new BufferedImage(finalW, finalH, BufferedImage.TYPE_INT_RGB); BufferedImage image = new BufferedImage(finalW, finalH, BufferedImage.TYPE_INT_RGB);
int[] imageData = ((DataBufferInt) image.getRaster().getDataBuffer()).getData(); int[] imageData = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
runParallel(() -> IntStream.range(0, finalH).parallel().forEach(fy -> { runParallel(() -> IntStream.range(0, finalH).parallel().forEach(fy -> {
if (cancelled.get()) return; if (stopped(cancelled)) return;
int[] block = new int[SSAA * SSAA]; int[] block = new int[SSAA * SSAA];
for (int fx = 0; fx < finalW; fx++) { for (int fx = 0; fx < finalW; fx++) {
int n = 0; int n = 0;
@@ -190,10 +242,17 @@ public class DefaultScreenRenderer implements Renderer {
return image; return image;
} }
/** Cancelled, or the worker was interrupted (pool shut down), which then cancels the whole job. */
private static boolean stopped(AtomicBoolean cancelled) {
if (cancelled.get()) return true;
if (!Thread.currentThread().isInterrupted()) return false;
cancelled.set(true);
return true;
}
/** /**
* Runs a parallel-stream task. With a {@link #tracePool} the work is confined to that bounded, * Runs a parallel-stream task in the {@link #tracePool} (parallel streams adopt the pool of the running fork-join
* low-priority pool (parallel streams adopt the pool of the running fork-join worker); without one * worker), or on the common pool without one (offline render tools).
* it runs inline on the common pool (used by the offline render tools).
*/ */
private void runParallel(Runnable task) { private void runParallel(Runnable task) {
if (tracePool == null) { if (tracePool == null) {
@@ -203,45 +262,8 @@ public class DefaultScreenRenderer implements Renderer {
} }
} }
private List<Vector> buildRayMap(Location eyeLocation, int width, int height) { /** {@link RayMap#of} with the default vertical FOV. */
Vector lineDirection = eyeLocation.getDirection(); public static RayMap buildRayMap(Location eye, int width, int height) {
return RayMap.of(eye, width, height, V_FOV_DEG);
double x = lineDirection.getX();
double y = lineDirection.getY();
double z = lineDirection.getZ();
double angleYaw = Math.atan2(z, x);
double anglePitch = Math.atan2(y, Math.sqrt(x * x + z * z));
// Derive the vertical half-FOV from the horizontal one so square output is not distorted.
double yawHalf = H_FOV_HALF_RAD;
double pitchHalf = Math.atan(Math.tan(yawHalf) * ((double) height / width));
Vector lowerLeftCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, -yawHalf, -pitchHalf, angleYaw, anglePitch);
Vector upperLeftCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, -yawHalf, pitchHalf, angleYaw, anglePitch);
Vector lowerRightCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, yawHalf, -pitchHalf, angleYaw, anglePitch);
Vector upperRightCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, yawHalf, pitchHalf, angleYaw, anglePitch);
List<Vector> rayMap = new ArrayList<>(width * height);
Vector leftFraction = upperLeftCorner.clone().subtract(lowerLeftCorner).multiply(1.0 / (height - 1));
Vector rightFraction =
upperRightCorner.clone().subtract(lowerRightCorner).multiply(1.0 / (height - 1));
for (int pitch = 0; pitch < height; pitch++) {
Vector leftPitch =
upperLeftCorner.clone().subtract(leftFraction.clone().multiply(pitch));
Vector rightPitch =
upperRightCorner.clone().subtract(rightFraction.clone().multiply(pitch));
Vector yawFraction = rightPitch.clone().subtract(leftPitch).multiply(1.0 / (width - 1));
for (int yaw = 0; yaw < width; yaw++) {
Vector ray =
leftPitch.clone().add(yawFraction.clone().multiply(yaw)).normalize();
rayMap.add(ray);
}
}
return rayMap;
} }
} }
@@ -0,0 +1,59 @@
package eu.mhsl.minecraft.pixelpics.render.render;
import org.bukkit.Location;
import org.bukkit.util.Vector;
/**
* The camera rays of a photo, computed on demand instead of stored (a stored ray map of a 384×384 photo held 147k
* {@link Vector}s, ~6 MB per queued photo). Pinhole camera like vanilla's projection: {@code ray = forward +
* u·tan(fovX/2)·right + v·tan(fovY/2)·up} with u, v ∈ [-1, 1] at the pixel centres, row 0 = top; {@code tan(fovX/2)
* = tan(fovY/2)·w/h}. Right/up come from the yaw of the eye (no roll), so looking straight up or down keeps the yaw
* orientation. Immutable and thread-safe.
*/
public final class RayMap {
private final int width, height;
private final double tanX, tanY;
private final double fx, fy, fz, rx, ry, rz, ux, uy, uz;
private RayMap(int width, int height, double tanX, double tanY, Vector forward, Vector right, Vector up) {
this.width = width;
this.height = height;
this.tanX = tanX;
this.tanY = tanY;
this.fx = forward.getX();
this.fy = forward.getY();
this.fz = forward.getZ();
this.rx = right.getX();
this.ry = right.getY();
this.rz = right.getZ();
this.ux = up.getX();
this.uy = up.getY();
this.uz = up.getZ();
}
/** {@code width × height} rays from {@code eye}'s view with the vertical FOV {@code vFovDeg}. */
public static RayMap of(Location eye, int width, int height, double vFovDeg) {
double yaw = Math.toRadians(eye.getYaw());
Vector forward = eye.getDirection();
// Player's right in Minecraft's coordinates (yaw 0 = south/+Z → right = west/−X).
Vector right = new Vector(-Math.cos(yaw), 0, -Math.sin(yaw));
Vector up = right.getCrossProduct(forward).normalize();
double tanY = Math.tan(Math.toRadians(vFovDeg) / 2);
double tanX = tanY * width / height;
return new RayMap(width, height, tanX, tanY, forward, right, up);
}
public int size() {
return width * height;
}
/** The normalized direction of ray {@code i} (row-major, row 0 = top); a new vector per call. */
public Vector get(int i) {
int row = i / width, col = i % width;
double v = (1 - 2 * (row + 0.5) / height) * tanY;
double u = (2 * (col + 0.5) / width - 1) * tanX;
return new Vector(fx + u * rx + v * ux, fy + u * ry + v * uy, fz + u * rz + v * uz).normalize();
}
}
@@ -6,62 +6,28 @@ import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.ObjectState; import eu.mhsl.minecraft.pixelpics.render.entity.ObjectState;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext; import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot; import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
import java.time.ZonedDateTime;
import java.util.List; import java.util.List;
import org.bukkit.util.Vector; import org.bukkit.util.Vector;
/** /**
* A prepared render: the world snapshot (captured on the main thread) plus the ray map, camera origin, * A prepared render: everything captured on the main thread (world snapshot, ray map, camera origin and the fluid
* the sky context (time of day / moon phase) and the captured entity, block-entity and decoration * it is in, sky state, entity/block-entity/decoration/object states, the time for date-dependent assets).
* (painting / item frame) states. {@link DefaultScreenRenderer#execute} can run this off the main thread. * {@link DefaultScreenRenderer#execute} runs it off the main thread.
*
* @param cameraFluid {@code FluidRenderer.cameraFluid} at the origin (NONE/WATER/LAVA), determined once in
* {@code prepare}: it sets the FOV of the ray map and the fog
*/ */
public record RenderJob( public record RenderJob(
WorldSnapshot snapshot, WorldSnapshot snapshot,
List<Vector> rayMap, RayMap rayMap,
Vector origin, Vector origin,
int cameraFluid,
int width, int width,
int height, int height,
SkyContext sky, SkyContext sky,
List<EntityState> entities, List<EntityState> entities,
List<BlockEntityState> blockEntities, List<BlockEntityState> blockEntities,
List<DecorationState> decorations, List<DecorationState> decorations,
List<ObjectState> objects) { List<ObjectState> objects,
ZonedDateTime time) {}
/** Without display/item/falling-block objects. */
public RenderJob(
WorldSnapshot snapshot,
List<Vector> rayMap,
Vector origin,
int width,
int height,
SkyContext sky,
List<EntityState> entities,
List<BlockEntityState> blockEntities,
List<DecorationState> decorations) {
this(snapshot, rayMap, origin, width, height, sky, entities, blockEntities, decorations, List.of());
}
/** Backwards-compatible constructor (no block-entities/decorations), used by the standalone harness. */
public RenderJob(
WorldSnapshot snapshot,
List<Vector> rayMap,
Vector origin,
int width,
int height,
SkyContext sky,
List<EntityState> entities) {
this(snapshot, rayMap, origin, width, height, sky, entities, List.of(), List.of());
}
/** Convenience for callers that supply entities + block-entities but no decorations. */
public RenderJob(
WorldSnapshot snapshot,
List<Vector> rayMap,
Vector origin,
int width,
int height,
SkyContext sky,
List<EntityState> entities,
List<BlockEntityState> blockEntities) {
this(snapshot, rayMap, origin, width, height, sky, entities, blockEntities, List.of());
}
}
@@ -1,8 +0,0 @@
package eu.mhsl.minecraft.pixelpics.render.render;
import java.awt.image.BufferedImage;
import org.bukkit.Location;
public interface Renderer {
BufferedImage render(Location eyeLocation, Resolution resolution);
}
@@ -2,54 +2,10 @@ package eu.mhsl.minecraft.pixelpics.render.render;
import com.google.common.base.Preconditions; import com.google.common.base.Preconditions;
public final class Resolution { public record Resolution(int width, int height) {
private final int width; public Resolution {
private final int height;
public Resolution(Pixels pixels, AspectRatio aspectRatio) {
this(
(int) Math.round(
Preconditions.checkNotNull(pixels).height * Preconditions.checkNotNull(aspectRatio).ratio),
pixels.height);
}
public Resolution(int width, int height) {
Preconditions.checkArgument(width > 0); Preconditions.checkArgument(width > 0);
Preconditions.checkArgument(height > 0); Preconditions.checkArgument(height > 0);
this.width = width;
this.height = height;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
public enum Pixels {
_128P(128),
_256P(256);
private final int height;
Pixels(int height) {
this.height = height;
}
}
public enum AspectRatio {
_1_1(1),
_2_1(2),
_3_2(3 / 2.0);
private final double ratio;
AspectRatio(double ratio) {
this.ratio = ratio;
}
} }
} }
@@ -2,7 +2,20 @@ 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 * 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 * (0..7), the absolute world time (for continuous cloud drift), the dimension's sky box and its fog colour (Nether:
* worker threads. * the camera biome's {@code fog_color}, End: the dimension's). Immutable so it can be read from worker threads.
*/ */
public record SkyContext(long dayTime, int moonPhase, long fullTime) {} public record SkyContext(long dayTime, int moonPhase, long fullTime, Skybox skybox, int fogColor) {
/** Like vanilla's {@code DimensionType.skybox}; {@code NONE} (the Nether) shows only the fog colour. */
public enum Skybox {
OVERWORLD,
NONE,
END
}
/** An overworld sky. */
public SkyContext(long dayTime, int moonPhase, long fullTime) {
this(dayTime, moonPhase, fullTime, Skybox.OVERWORLD, 0);
}
}
@@ -6,9 +6,9 @@ import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import org.bukkit.util.Vector; import org.bukkit.util.Vector;
/** /**
* Computes a time-of-day dependent sky color for rays that escape the world: a day/night gradient * Sky colour of rays that escape the world. Overworld (not vanilla's sky renderer): day/night gradient with twilight
* with twilight glow, the sun and moon (with phase), stars at night and a procedural cloud layer. * glow, sun and moon (with phase), stars and vanilla's cloud layer. The Nether has no sky (fog colour) and the End
* All inputs are immutable ({@link SkyContext} + captured textures), so it is thread safe. * vanilla's textured sky box ({@code SkyRenderer.buildEndSky}). Immutable, so thread safe.
*/ */
public final class SkyRenderer { public final class SkyRenderer {
@@ -17,8 +17,9 @@ public final class SkyRenderer {
private static final double CLOUD_CELL = 12.0; // world blocks per cloud texel 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 CLOUD_SPEED = 0.03; // blocks per tick, drift along +X
private static final double SUN_HALF = 0.085; // angular half-size (radians) // sin of the angular half-size.
private static final double MOON_HALF = 0.075; private static final double SUN_SIN_HALF = Math.sin(0.085);
private static final double MOON_SIN_HALF = Math.sin(0.075);
// Gradient endpoints (RGB). // Gradient endpoints (RGB).
private static final int DAY_ZENITH = rgb(86, 138, 252); private static final int DAY_ZENITH = rgb(86, 138, 252);
@@ -29,17 +30,81 @@ public final class SkyRenderer {
private static final int SUNSET_RED = rgb(205, 70, 60); private static final int SUNSET_RED = rgb(205, 70, 60);
private static final int TWI_PURPLE = rgb(80, 42, 92); private static final int TWI_PURPLE = rgb(80, 42, 92);
private final int[][] sunTexture; // End sky box (vanilla SkyRenderer.buildEndSky): 6 quads at distance 100, the texture repeated 16 times per face,
// vertex colour #282828.
private static final double END_SKY_REPEAT = 16;
private static final int END_SKY_TINT = 0x28;
// Face poses of the end sky quads: identity, rotX(90°), rotX(-90°), rotX(180°), rotZ(90°), rotZ(-90°), as
// {axis (0 none, 1 x, 2 z), cos, sin}.
private static final double[][] END_FACES = {
endFace(0, 0),
endFace(1, Math.PI / 2),
endFace(1, -Math.PI / 2),
endFace(1, Math.PI),
endFace(2, Math.PI / 2),
endFace(2, -Math.PI / 2)
};
private static double[] endFace(int axis, double angle) {
return new double[] {axis, Math.cos(angle), Math.sin(angle)};
}
private final int[][] moonTexture; private final int[][] moonTexture;
private final int[][] cloudTexture; private final int[][] cloudTexture;
private final int[][] endSkyTexture;
public SkyRenderer(TextureCache textures) { public SkyRenderer(TextureCache textures) {
this.sunTexture =
textures.get(ResourceLocation.parse("environment/sun")).orElse(null);
this.moonTexture = this.moonTexture =
textures.get(ResourceLocation.parse("environment/moon_phases")).orElse(null); textures.get(ResourceLocation.parse("environment/moon_phases")).orElse(null);
this.cloudTexture = this.cloudTexture =
textures.get(ResourceLocation.parse("environment/clouds")).orElse(null); textures.get(ResourceLocation.parse("environment/clouds")).orElse(null);
this.endSkyTexture =
textures.get(ResourceLocation.parse("environment/end_sky")).orElse(null);
}
/**
* The end sky in a direction: the quad of vanilla's sky box hit by the ray, its texel (nearest, repeating) times
* the vertex colour.
*/
private int endSky(double dx, double dy, double dz) {
int[][] tex = endSkyTexture;
if (tex == null || tex.length == 0) return rgb(END_SKY_TINT, END_SKY_TINT, END_SKY_TINT);
// The quad whose pose maps its plane y = -100 onto the ray: q = pose^T d with the most negative q.y.
double bestY = 0, qx = 0, qz = 0;
for (double[] face : END_FACES) {
double c = face[1], sn = face[2];
double x, y, z;
if (face[0] == 1) { // rotX(a)^T
x = dx;
y = dy * c + dz * sn;
z = -dy * sn + dz * c;
} else if (face[0] == 2) { // rotZ(a)^T
x = dx * c + dy * sn;
y = -dx * sn + dy * c;
z = dz;
} else {
x = dx;
y = dy;
z = dz;
}
if (y < bestY) {
bestY = y;
qx = x;
qz = z;
}
}
if (bestY >= 0) return rgb(END_SKY_TINT, END_SKY_TINT, END_SKY_TINT);
double t = -100 / bestY;
double u = (qx * t + 100) / 200 * END_SKY_REPEAT;
double v = (qz * t + 100) / 200 * END_SKY_REPEAT;
int h = tex.length, w = tex[0].length;
int px = Math.floorMod((int) Math.floor(u * w), w);
int py = Math.floorMod((int) Math.floor(v * h), h);
int texel = tex[py][px];
return rgb(
ColorUtil.red(texel) * END_SKY_TINT / 255,
ColorUtil.green(texel) * END_SKY_TINT / 255,
ColorUtil.blue(texel) * END_SKY_TINT / 255);
} }
public int colorFor(Vector direction, Vector origin, SkyContext ctx) { public int colorFor(Vector direction, Vector origin, SkyContext ctx) {
@@ -49,10 +114,18 @@ public final class SkyRenderer {
dx /= len; dx /= len;
dy /= len; dy /= len;
dz /= len; dz /= len;
switch (ctx.skybox()) {
case NONE -> {
return ctx.fogColor() & 0xFFFFFF;
}
case END -> {
return endSky(dx, dy, dz);
}
case OVERWORLD -> {}
}
// Sun/moon position, derived exactly from Minecraft's sky transforms: // Vanilla sun direction: rotated by celestialAngle·360° about X (after -90° about Y)
// celestialAngle ca = getTimeOfDay(dayTime); the sun is rotated by ca*360deg about the X axis // = (-sin(2π·ca), cos(2π·ca), 0).
// (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 ca = celestialAngle(ctx.dayTime());
double ang = ca * 2 * Math.PI; double ang = ca * 2 * Math.PI;
double sunX = -Math.sin(ang), sunY = Math.cos(ang); double sunX = -Math.sin(ang), sunY = Math.cos(ang);
@@ -64,8 +137,8 @@ public final class SkyRenderer {
int nightColor = lerp(NIGHT_HORIZON, NIGHT_ZENITH, up); int nightColor = lerp(NIGHT_HORIZON, NIGHT_ZENITH, up);
int color = lerp(nightColor, dayColor, dayFactor); int color = lerp(nightColor, dayColor, dayFactor);
// Sunrise/sunset: a full-sky warm wash (orange at the horizon -> red -> purple at the zenith), // Sunrise/sunset: a full-sky warm wash (orange at the horizon -> red -> purple at the zenith), strongest while
// strongest while the sun is near the horizon and warmer toward its azimuth. Matches vanilla. // the sun is near the horizon and warmer toward its azimuth.
double twilight = Math.clamp(1 - Math.abs(sunY) / 0.45, 0, 1); double twilight = Math.clamp(1 - Math.abs(sunY) / 0.45, 0, 1);
if (twilight > 0) { if (twilight > 0) {
double az = Math.clamp(dx * Math.signum(sunX) * 0.5 + 0.5, 0, 1); // 1 toward sun .. 0 away double az = Math.clamp(dx * Math.signum(sunX) * 0.5 + 0.5, 0, 1); // 1 toward sun .. 0 away
@@ -83,7 +156,7 @@ public final class SkyRenderer {
double star = starField(dx, dy, dz); double star = starField(dx, dy, dz);
if (star > 0) { if (star > 0) {
int s = (int) (star * 255 * visibility); int s = (int) (star * 255 * visibility);
color = add(color, s, s, s); color = ColorUtil.add(color, rgb(s, s, s));
} }
} }
} }
@@ -97,19 +170,14 @@ public final class SkyRenderer {
} }
} }
// Sun disc (soft glowing disc, texture used only as a shape mask).
if (sunY > -0.15) { if (sunY > -0.15) {
color = overlayDisc(color, dx, dy, dz, sunX, sunY, 0, SUN_HALF, sunTexture, rgb(255, 244, 214), -1); color = overlayDisc(color, dx, dy, dz, sunX, sunY, 0, SUN_SIN_HALF, rgb(255, 244, 214), -1);
} }
// Moon disc (phase shape from the texture's alpha). if (sunY < 0.15) {
if (-sunY > -0.15) { color = overlayDisc(color, dx, dy, dz, -sunX, -sunY, 0, MOON_SIN_HALF, rgb(228, 228, 238), ctx.moonPhase());
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 // Cloud layer at y = CLOUD_HEIGHT, drifting along +X with the world time.
// 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) { if (dy > 0.02 && origin.getY() < CLOUD_HEIGHT) {
double t = (CLOUD_HEIGHT - origin.getY()) / dy; double t = (CLOUD_HEIGHT - origin.getY()) / dy;
double cx = origin.getX() + dx * t + ctx.fullTime() * CLOUD_SPEED; double cx = origin.getX() + dx * t + ctx.fullTime() * CLOUD_SPEED;
@@ -126,7 +194,11 @@ public final class SkyRenderer {
return color & 0xFFFFFF; return color & 0xFFFFFF;
} }
/** Draws a sun/moon disc, sampling a texture when available (moonPhase &ge; 0 picks the phase tile). */ /**
* Draws the sun or moon (a square billboard like vanilla) in colour {@code solid} around the body direction
* {@code c}. The sun ({@code moonPhase < 0}) is a solid square, the moon takes its phase shape from the alpha of
* the moon texture's tile (only a mask, so its dark transparent texels never bleed in as a rim).
*/
private int overlayDisc( private int overlayDisc(
int base, int base,
double dx, double dx,
@@ -135,15 +207,12 @@ public final class SkyRenderer {
double cx, double cx,
double cy, double cy,
double cz, double cz,
double half, double sinHalf,
int[][] texture,
int solid, int solid,
int moonPhase) { int moonPhase) {
double cos = dx * cx + dy * cy + dz * cz; double cos = dx * cx + dy * cy + dz * cz;
if (cos <= 0) return base; if (cos <= 0) return base;
double sinHalf = Math.sin(half); // Disc coordinates: right = normalize(body x worldUp), discUp = right x body.
// 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 crx = cz, cry = 0, crz = -cx;
double crl = Math.sqrt(crx * crx + crz * crz); double crl = Math.sqrt(crx * crx + crz * crz);
if (crl < 1e-6) { if (crl < 1e-6) {
@@ -155,25 +224,20 @@ public final class SkyRenderer {
crx /= crl; crx /= crl;
cry /= crl; cry /= crl;
crz /= crl; crz /= crl;
// discUp = right cross body
double ux = cry * cz - crz * cy; double ux = cry * cz - crz * cy;
double uy = crz * cx - crx * cz; double uy = crz * cx - crx * cz;
double uz = crx * cy - cry * cx; double uz = crx * cy - cry * cx;
double u = dx * crx + dy * cry + dz * crz; double u = dx * crx + dy * cry + dz * crz;
double v = dx * ux + dy * uy + dz * uz; 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 double m = Math.max(Math.abs(u), Math.abs(v)) / sinHalf; // 0 center .. 1 square edge
if (m > 1) return base; if (m > 1) return base;
double su = u / sinHalf * 0.5 + 0.5; double su = u / sinHalf * 0.5 + 0.5;
double sv = v / 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; double alpha;
if (moonPhase >= 0 && texture != null && texture.length > 0) { if (moonPhase >= 0 && moonTexture != null && moonTexture.length > 0) {
alpha = bodyAlpha(texture, su, sv, moonPhase) > 80 ? 1.0 : 0.0; // phase shape alpha = bodyAlpha(moonTexture, su, sv, moonPhase) > 80 ? 1.0 : 0.0;
} else { } else {
alpha = 1 - smoothstep(0.92, 1.0, m); // solid square, faint edge softening alpha = 1 - smoothstep(0.92, 1.0, m); // solid square, faint edge softening
} }
@@ -181,17 +245,17 @@ public final class SkyRenderer {
return lerp(base, solid, alpha); return lerp(base, solid, alpha);
} }
/** Alpha of the body texture at the disc coordinate; moonPhase&ge;0 selects a tile in the 4x2 grid. */ /** Alpha of the texture at the disc coordinate in the moon phase's tile of the 4x2 grid. */
private int bodyAlpha(int[][] texture, double su, double sv, int moonPhase) { private int bodyAlpha(int[][] texture, double su, double sv, int moonPhase) {
int h = texture.length; int h = texture.length;
int w = texture[0].length; int w = texture[0].length;
double u = su, v = 1 - sv; // texture v is top-down double u = su, v = 1 - sv; // texture v is top-down
int col = moonPhase % 4; int col = moonPhase % 4;
int row = (moonPhase / 4) % 2; int row = moonPhase / 4;
u = (col + u) / 4.0; u = (col + u) / 4.0;
v = (row + v) / 2.0; v = (row + v) / 2.0;
int px = clamp((int) (u * w), 0, w - 1); int px = Math.clamp((int) (u * w), 0, w - 1);
int py = clamp((int) (v * h), 0, h - 1); int py = Math.clamp((int) (v * h), 0, h - 1);
return ColorUtil.alpha(texture[py][px]); return ColorUtil.alpha(texture[py][px]);
} }
@@ -206,13 +270,8 @@ public final class SkyRenderer {
} }
/** /**
* Exact vanilla cloud coverage. Minecraft tiles {@code clouds.png} (256x256) over the world with * Vanilla cloud coverage: {@code clouds.png} tiled over the world, one texel per {@link #CLOUD_CELL} block square
* each texel covering a {@link #CLOUD_CELL} (=12) block square, so the pattern repeats every * (U along X, V along Z); a texel with alpha is a cloud. Value noise only when the pack lacks the texture.
* 256*12 = 3072 blocks. A world position maps to texel
* {@code (col = floorMod(floor(x/12), 256), row = floorMod(floor(z/12), 256))} with the texture's
* U axis along world X and V axis along world Z; a texel is a cloud where its alpha &gt; 0. This
* reproduces the blocky cloud shapes and their world alignment exactly. Falls back to value noise
* only when the texture is missing from the pack.
*/ */
private double clouds(double x, double z) { private double clouds(double x, double z) {
if (cloudTexture != null && cloudTexture.length > 0) { if (cloudTexture != null && cloudTexture.length > 0) {
@@ -250,8 +309,6 @@ public final class SkyRenderer {
return h ^ (h >>> 16); return h ^ (h >>> 16);
} }
// --- small color/math helpers ---
private static int rgb(int r, int g, int b) { private static int rgb(int r, int g, int b) {
return (r << 16) | (g << 8) | b; return (r << 16) | (g << 8) | b;
} }
@@ -261,13 +318,6 @@ public final class SkyRenderer {
return ColorUtil.mix(a, b, 1 - t, t); 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). */ /** Minecraft's {@code Level.getTimeOfDay}: the celestial angle as a fraction [0,1). */
private static double celestialAngle(long dayTime) { private static double celestialAngle(long dayTime) {
double d = frac(dayTime / TICKS_PER_DAY - 0.25); double d = frac(dayTime / TICKS_PER_DAY - 0.25);
@@ -283,8 +333,4 @@ public final class SkyRenderer {
double t = Math.clamp((x - edge0) / (edge1 - edge0), 0, 1); double t = Math.clamp((x - edge0) / (edge1 - edge0), 0, 1);
return t * t * (3 - 2 * t); return t * t * (3 - 2 * t);
} }
private static int clamp(int v, int lo, int hi) {
return v < lo ? lo : Math.min(v, hi);
}
} }
@@ -1,52 +1,68 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot; package eu.mhsl.minecraft.pixelpics.render.snapshot;
import eu.mhsl.minecraft.pixelpics.assets.BlockEntityBlocks;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState; import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.BannerPattern; 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.ChestKind;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.Kind; import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.Kind;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.EnumMap; import java.util.EnumMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.regex.Pattern;
import org.bukkit.Bukkit;
import org.bukkit.DyeColor; import org.bukkit.DyeColor;
import org.bukkit.HeightMap;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.block.Banner; import org.bukkit.block.Banner;
import org.bukkit.block.Beacon;
import org.bukkit.block.BlockFace; import org.bukkit.block.BlockFace;
import org.bukkit.block.BlockState; import org.bukkit.block.BlockState;
import org.bukkit.block.Campfire;
import org.bukkit.block.Conduit;
import org.bukkit.block.CreatureSpawner;
import org.bukkit.block.DecoratedPot; import org.bukkit.block.DecoratedPot;
import org.bukkit.block.Shelf;
import org.bukkit.block.Sign; import org.bukkit.block.Sign;
import org.bukkit.block.Skull; import org.bukkit.block.Skull;
import org.bukkit.block.banner.Pattern; import org.bukkit.block.TrialSpawner;
import org.bukkit.block.Vault;
import org.bukkit.block.data.BlockData; import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.Directional; import org.bukkit.block.data.Directional;
import org.bukkit.block.data.Rotatable; 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.data.type.Chest;
import org.bukkit.block.data.type.CopperGolemStatue;
import org.bukkit.block.data.type.Lectern;
import org.bukkit.block.sign.Side; import org.bukkit.block.sign.Side;
import org.bukkit.block.sign.SignSide; import org.bukkit.block.sign.SignSide;
import org.bukkit.entity.Entity;
import org.bukkit.entity.EntitySnapshot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.BannerMeta;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.inventory.meta.ShieldMeta;
import org.bukkit.inventory.meta.SkullMeta;
import org.bukkit.profile.PlayerProfile; import org.bukkit.profile.PlayerProfile;
import org.bukkit.util.Vector; import org.bukkit.spawner.BaseSpawner;
/** /**
* Captures block-entities (chests, signs, banners, beds, heads, …) near the view frustum into immutable * Captures block-entities (chests, signs, banners, heads, pots, lecterns, …) inside the frustum bounds into
* {@link BlockEntityState}s. MUST run on the main thread (live {@link BlockState} access). Reads each * {@link BlockEntityState}s. MUST run on the main thread (live {@link BlockState} access). Reads each loaded
* loaded chunk's tile-entities rather than scanning every block, then keeps only those inside the * chunk's tile entities instead of scanning every block.
* camera's bounding box.
*/ */
public final class BlockEntitySnapshotBuilder { public final class BlockEntitySnapshotBuilder {
private BlockEntitySnapshotBuilder() {} private BlockEntitySnapshotBuilder() {}
public static List<BlockEntityState> build(Location eye, List<Vector> rayMap, double maxDistance) { /** {@code eye}: camera position for the camera-dependent parts (enchanting-table book, beacon beam width). */
public static List<BlockEntityState> build(Location eye, FrustumBounds bounds) {
World world = eye.getWorld(); 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. // 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 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 bMinY = (int) Math.floor(bounds.minY) - 1, bMaxY = (int) Math.ceil(bounds.maxY) + 1;
@@ -60,12 +76,17 @@ public final class BlockEntitySnapshotBuilder {
if (!world.isChunkLoaded(cx, cz)) continue; if (!world.isChunkLoaded(cx, cz)) continue;
for (BlockState ts : world.getChunkAt(cx, cz).getTileEntities(false)) { for (BlockState ts : world.getChunkAt(cx, cz).getTileEntities(false)) {
int bx = ts.getX(), by = ts.getY(), bz = ts.getZ(); int bx = ts.getX(), by = ts.getY(), bz = ts.getZ();
if (bx < bMinX || bx > bMaxX || by < bMinY || by > bMaxY || bz < bMinZ || bz > bMaxZ) continue; // A beacon's beam rises from below: keep beacons under the view box too.
if (bx < bMinX
|| bx > bMaxX
|| (by < bMinY && !(ts instanceof Beacon))
|| by > bMaxY
|| bz < bMinZ
|| bz > bMaxZ) continue;
try { try {
BlockEntityState s = classify(ts); BlockEntityState s = classify(ts.getBlockData(), bx, by, bz, ts, eye);
if (s != null) out.add(s); if (s != null) out.add(s);
} catch (Throwable ignored) { } catch (Throwable ignored) {
// Unsupported on this server version / odd state — skip this one.
} }
} }
} }
@@ -73,102 +94,126 @@ public final class BlockEntitySnapshotBuilder {
return out; return out;
} }
private static BlockEntityState classify(BlockState ts) {
return classify(ts.getBlockData(), ts.getX(), ts.getY(), ts.getZ(), ts);
}
/** /**
* A block-entity state from bare block data (no world tile entity) at cell (0,0,0): for block-entity * A state from bare block data (no tile) at cell 0 for block displays, minecarts and special items; without
* blocks shown by BLOCK DISPLAYS, minecarts and special ITEMS (chest/head/banner/shulker/… items). * tile-only details (patterns, sign text, skins). Null for blocks that aren't block-entity rendered.
* Tile-only details (banner patterns, sign text, head skins) are absent. MUST run on the main thread.
* Returns null for blocks that aren't block-entity rendered.
*/ */
public static BlockEntityState fromBlockData(BlockData data) { static BlockEntityState fromBlockData(BlockData data) {
try { try {
return classify(data, 0, 0, 0, null); return classify(data, 0, 0, 0, null, null);
} catch (Throwable t) { } catch (Throwable t) {
return null; return null;
} }
} }
private static BlockEntityState classify(BlockData data, int bx, int by, int bz, BlockState ts) { /**
* Adds the stack details a placed tile would carry to a {@link #fromBlockData} state: a player head's skin
* ({@code SkullMeta}) and banner patterns ({@code BannerMeta}).
*/
static BlockEntityState withItemMeta(BlockEntityState s, ItemMeta meta) {
if (s == null || meta == null) return s;
try {
if (meta instanceof SkullMeta skull && "player".equals(s.headType())) {
String skin = skinUrl(skull.getOwnerProfile());
return skin == null ? s : copy(s, skin, s.patterns());
}
if (meta instanceof BannerMeta banner && !banner.getPatterns().isEmpty()) {
return copy(s, s.skinUrl(), patterns(banner.getPatterns()));
}
} catch (Throwable ignored) {
}
return s;
}
/** A shield's base colour + patterns as a BANNER state for the shield special model; null for a plain shield. */
static BlockEntityState shieldPatterns(ItemMeta meta) {
try {
if (!(meta instanceof ShieldMeta shield)) return null;
DyeColor baseColor = shield.getBaseColor();
List<BannerPattern> pats = patterns(shield.getPatterns());
if (baseColor == null && pats.isEmpty()) return null;
return base(Kind.BANNER, 0, 0, 0, 0)
.baseColorArgb(ColorUtil.dyeArgb(baseColor, 0))
.patterns(pats)
.build();
} catch (Throwable ignored) {
return null;
}
}
private static BlockEntityState copy(BlockEntityState s, String skinUrl, List<BannerPattern> patterns) {
return new BlockEntityState(
s.kind(),
s.bx(),
s.by(),
s.bz(),
s.facingDeg(),
s.direction(),
s.active(),
s.chestKind(),
s.baseColorArgb(),
s.colorName(),
s.headType(),
skinUrl,
patterns,
s.sherds(),
s.frontText(),
s.backText(),
s.items(),
s.displayEntity(),
s.beam(),
s.pose());
}
/** {@code ts} = the live tile (null for bare block data), {@code eye} = camera position or null. */
private static BlockEntityState classify(BlockData data, int bx, int by, int bz, BlockState ts, Location eye) {
Material mat = data.getMaterial(); Material mat = data.getMaterial();
String n = mat.name(); String n = mat.name();
// --- chests --- Kind chest = BlockEntityBlocks.isCopperChest(mat)
if (mat == Material.CHEST || mat == Material.TRAPPED_CHEST || mat == Material.ENDER_CHEST) { ? Kind.COPPER_CHEST
Kind kind = mat == Material.TRAPPED_CHEST : switch (mat) {
? Kind.TRAPPED_CHEST case CHEST -> Kind.CHEST;
: mat == Material.ENDER_CHEST ? Kind.ENDER_CHEST : Kind.CHEST; case TRAPPED_CHEST -> Kind.TRAPPED_CHEST;
ChestKind ck = ChestKind.SINGLE; case ENDER_CHEST -> Kind.ENDER_CHEST;
if (data instanceof Chest cd) { default -> null;
ck = switch (cd.getType()) { };
case LEFT -> ChestKind.LEFT; if (chest != null) {
case RIGHT -> ChestKind.RIGHT; ChestKind ck =
case SINGLE -> ChestKind.SINGLE;}; data instanceof Chest cd ? ChestKind.valueOf(cd.getType().name()) : ChestKind.SINGLE;
} Builder b = base(chest, bx, by, bz, facingYaw(data)).chestKind(ck);
return base(kind, bx, by, bz, facingYaw(data)).chestKind(ck).build(); if (chest == Kind.COPPER_CHEST) b.colorName(copperStage(n, "COPPER_CHEST"));
return b.build();
} }
// --- beds --- if (BlockEntityBlocks.isShulkerBox(mat)) {
if (data instanceof Bed bed) { String color =
BedPart part = bed.getPart() == Bed.Part.HEAD ? BedPart.HEAD : BedPart.FOOT; n.equals("SHULKER_BOX") ? null : prefix(n, "_SHULKER_BOX").toLowerCase(Locale.ROOT);
return base(Kind.BED, bx, by, bz, faceToYaw(bed.getFacing())) String dir = data instanceof Directional d ? d.getFacing().name().toLowerCase(Locale.ROOT) : "up";
.bedPart(part) return base(Kind.SHULKER_BOX, bx, by, bz, 0)
.colorName(stripColor(n, "_BED")) .direction(dir)
.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) .colorName(color)
.build(); .build();
} }
// --- banners --- if (BlockEntityBlocks.isBanner(mat)) {
if (n.endsWith("_BANNER")) { boolean wall = BlockEntityBlocks.isWallBanner(mat);
boolean wall = n.endsWith("_WALL_BANNER"); Builder b = base(wall ? Kind.WALL_BANNER : Kind.BANNER, bx, by, bz, facingYaw(data));
float yaw = wall ? facingYaw(data) : rotationYaw(data);
Builder b = base(wall ? Kind.WALL_BANNER : Kind.BANNER, bx, by, bz, yaw);
if (!(ts instanceof Banner)) {
try { // no tile: the base colour is in the block name
String colour =
stripColor(n, wall ? "_WALL_BANNER" : "_BANNER").toUpperCase(Locale.ROOT);
b.baseColorArgb(ColorUtil.dyeArgb(DyeColor.valueOf(colour), 0xFFFFFFFF));
} catch (IllegalArgumentException ignored) {
// unknown colour name
}
}
if (ts instanceof Banner banner) { if (ts instanceof Banner banner) {
DyeColor base = banner.getBaseColor(); b.baseColorArgb(ColorUtil.dyeArgb(banner.getBaseColor(), 0xFFFFFFFF));
b.baseColorArgb(ColorUtil.dyeArgb(base, 0xFFFFFFFF)); b.patterns(patterns(banner.getPatterns()));
List<BannerPattern> pats = new ArrayList<>(); } else { // no tile: the base colour is in the block name
for (Pattern p : banner.getPatterns()) { DyeColor dye = dye(prefix(n, wall ? "_WALL_BANNER" : "_BANNER"));
pats.add(new BannerPattern(patternKey(p), ColorUtil.dyeArgb(p.getColor(), 0xFFFFFFFF))); if (dye != null) b.baseColorArgb(ColorUtil.dyeArgb(dye, 0xFFFFFFFF));
}
b.patterns(pats);
} }
return b.build(); return b.build();
} }
// --- signs --- if (BlockEntityBlocks.isSign(mat)) {
if (n.endsWith("_SIGN")) { Kind kind = BlockEntityBlocks.isWallSign(mat)
String wood = signWood(n); ? Kind.WALL_SIGN
Kind kind; : BlockEntityBlocks.isHangingSign(mat) ? Kind.HANGING_SIGN : Kind.SIGN;
float yaw; Builder b = base(kind, bx, by, bz, facingYaw(data));
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) { if (ts instanceof Sign sign) {
b.frontText(signText(sign.getSide(Side.FRONT))); b.frontText(signText(sign.getSide(Side.FRONT)));
b.backText(signText(sign.getSide(Side.BACK))); b.backText(signText(sign.getSide(Side.BACK)));
@@ -176,66 +221,252 @@ public final class BlockEntitySnapshotBuilder {
return b.build(); return b.build();
} }
// --- heads / skulls --- if (BlockEntityBlocks.isHead(mat)) {
if (n.endsWith("_SKULL") || n.endsWith("_HEAD")) { boolean wall = BlockEntityBlocks.isWallHead(mat);
boolean wall = n.contains("_WALL_");
String headType = headType(n); String headType = headType(n);
float yaw = wall ? facingYaw(data) : rotationYaw(data); Builder b = base(wall ? Kind.WALL_HEAD : Kind.HEAD, bx, by, bz, facingYaw(data))
Builder b = base(wall ? Kind.WALL_HEAD : Kind.HEAD, bx, by, bz, yaw).headType(headType); .headType(headType);
if ("player".equals(headType) && ts instanceof Skull skull) { if ("player".equals(headType) && ts instanceof Skull skull) {
b.skinUrl(skinUrl(skull)); try {
b.skinUrl(skinUrl(skull.getOwnerProfile()));
} catch (Throwable ignored) {
}
} }
return b.build(); return b.build();
} }
// --- conduit ---
if (mat == Material.CONDUIT) { if (mat == Material.CONDUIT) {
return base(Kind.CONDUIT, bx, by, bz, 0).build(); return base(Kind.CONDUIT, bx, by, bz, 0)
.active(ts instanceof Conduit c && c.isActive())
.build();
}
// Lectern and bell frames are block models; the renderers add only the book / the bell body.
if (data instanceof Lectern lectern) {
return base(Kind.LECTERN, bx, by, bz, facingYaw(data))
.active(lectern.hasBook())
.build();
} }
// --- decorated pot ---
if (mat == Material.DECORATED_POT) { if (mat == Material.DECORATED_POT) {
Builder b = base(Kind.DECORATED_POT, bx, by, bz, facingYaw(data)); Builder b = base(Kind.DECORATED_POT, bx, by, bz, facingYaw(data));
if (ts instanceof DecoratedPot pot) b.sherds(sherds(pot)); if (ts instanceof DecoratedPot pot) b.sherds(sherds(pot));
return b.build(); return b.build();
} }
// --- bell ---
if (mat == Material.BELL) { if (mat == Material.BELL) {
BellAttach attach = BellAttach.FLOOR; return base(Kind.BELL, bx, by, bz, 0).build();
if (data instanceof Bell bd) { }
attach = switch (bd.getAttachment()) {
case FLOOR -> BellAttach.FLOOR; if (mat == Material.ENCHANTING_TABLE) {
case CEILING -> BellAttach.CEILING; return enchantingTable(bx, by, bz, eye);
case SINGLE_WALL -> BellAttach.SINGLE_WALL; }
case DOUBLE_WALL -> BellAttach.DOUBLE_WALL;};
if (data instanceof org.bukkit.block.data.type.Campfire) {
Builder b = base(Kind.CAMPFIRE, bx, by, bz, facingYaw(data));
if (ts instanceof Campfire c) {
List<ItemSpec> items = new ArrayList<>(c.getSize());
for (int i = 0; i < c.getSize(); i++) items.add(ItemSpecs.of(c.getItem(i)));
b.items(items);
} }
return base(Kind.BELL, bx, by, bz, facingYaw(data)) return b.build();
.bellAttach(attach) }
// ShelfRenderer's align_items_to_bottom has no API (false).
if (data instanceof org.bukkit.block.data.type.Shelf) {
Builder b = base(Kind.SHELF, bx, by, bz, facingYaw(data));
if (ts instanceof Shelf shelf) {
ItemStack[] content = shelf.getSnapshotInventory().getContents();
List<ItemSpec> items = new ArrayList<>(content.length);
for (ItemStack it : content) items.add(ItemSpecs.of(it));
b.items(items);
}
return b.build();
}
if (data instanceof CopperGolemStatue statue) {
return base(Kind.COPPER_GOLEM_STATUE, bx, by, bz, facingYaw(data))
.colorName(copperStage(n, "COPPER_GOLEM_STATUE"))
.pose(statue.getCopperGolemPose().name().toLowerCase(Locale.ROOT))
.build();
}
if (mat == Material.SPAWNER || n.equals("TRIAL_SPAWNER")) {
return base(Kind.SPAWNER, bx, by, bz, 0)
.displayEntity(spawnerEntity(ts, data))
.build();
}
// VaultRenderer shows the displayed item only while a player is connected.
if (n.equals("VAULT")) {
Builder b = base(Kind.VAULT, bx, by, bz, facingYaw(data));
if (ts instanceof Vault vault && !vault.getConnectedPlayers().isEmpty()) {
ItemSpec item = ItemSpecs.of(vault.getDisplayedItem());
if (item != null) b.items(List.of(item));
}
return b.build();
}
// No beam without a pyramid (getBeamSections is empty at level 0).
if (mat == Material.BEACON) {
Builder b = base(Kind.BEACON, bx, by, bz, 0);
if (ts instanceof Beacon beacon && beacon.getTier() > 0) b.beam(beam(ts.getWorld(), bx, by, bz, eye));
return b.build();
}
if (mat == Material.END_PORTAL) {
return base(Kind.END_PORTAL, bx, by, bz, 0).build();
}
if (mat == Material.END_GATEWAY) {
return base(Kind.END_GATEWAY, bx, by, bz, 0)
.direction(ts == null ? null : gatewayFaces(ts))
.build(); .build();
} }
return null; return null;
} }
// --- facing / rotation helpers --- /**
* EnchantingTableBlockEntity.bookAnimationTick: with a player within 3 blocks of the table centre the book
* opens and turns to {@code atan2(dz, dx)}; otherwise it is closed (its idle yaw drifts — static 0 here).
* The photographer's feet ({@code eye − 1.62}) stand in for the nearest player.
*/
private static BlockEntityState enchantingTable(int bx, int by, int bz, Location eye) {
boolean open = false;
float yaw = 0;
if (eye != null) {
double dx = eye.getX() - (bx + 0.5), dy = eye.getY() - 1.62 - (by + 0.5), dz = eye.getZ() - (bz + 0.5);
if (dx * dx + dy * dy + dz * dz <= 9) {
open = true;
yaw = (float) Math.toDegrees(Math.atan2(dz, dx));
}
}
return base(Kind.ENCHANTING_TABLE, bx, by, bz, yaw).active(open).build();
}
/** Yaw preferring the block's {@link Directional} facing (wall-mounted block-entities). */ /**
* The spawner's display mob (vanilla BaseSpawner/TrialSpawnerStateData.getOrCreateDisplayEntity loads the
* same NBT): the spawn data's entity snapshot, else a bare entity of the spawned type. Trial spawners show
* it only while waiting/active, from the ominous configuration when ominous. Null when empty or no tile.
*/
private static EntityState spawnerEntity(BlockState ts, BlockData data) {
try {
BaseSpawner spawner;
if (ts instanceof CreatureSpawner cs) {
spawner = cs;
} else if (ts instanceof TrialSpawner trial && data instanceof org.bukkit.block.data.type.TrialSpawner td) {
String st = td.getTrialSpawnerState().name();
if (!st.equals("WAITING_FOR_PLAYERS") && !st.equals("ACTIVE")) return null;
spawner = trial.isOminous() ? trial.getOminousConfiguration() : trial.getNormalConfiguration();
} else {
return null;
}
EntitySnapshot snap = spawner.getSpawnedEntity();
if (snap == null && !spawner.getPotentialSpawns().isEmpty()) {
snap = spawner.getPotentialSpawns().getFirst().getSnapshot();
}
if (snap == null && spawner.getSpawnedType() != null) {
snap = Bukkit.getEntityFactory()
.createEntitySnapshot(
"{id:\"" + spawner.getSpawnedType().getKey() + "\"}");
}
if (snap == null) return null;
// createEntity(World), NOT createEntity(Location): the Location variant ADDS the entity to the world
// (createEntity(World) also skips finalizeSpawn randomisation). The baker moves the state to the cage.
Entity e = snap.createEntity(ts.getWorld());
return e == null ? null : EntitySnapshotBuilder.toState(e);
} catch (Throwable t) {
return null;
}
}
/**
* BeaconBlockEntity.tick's beam scan from the beacon up to the WORLD_SURFACE height: the beacon starts a
* white section, stained glass (panes) starts a section of its dye colour (the first one over the beacon
* is not blended with white, later ones average with the previous colour, equal colours just extend it), other
* blocks extend the current section, and a light-blocking block (except bedrock) stops the beam.
*/
private static BlockEntityState.Beam beam(World world, int bx, int by, int bz, Location eye) {
int top = world.getHighestBlockYAt(bx, bz, HeightMap.WORLD_SURFACE);
List<int[]> sections = new ArrayList<>(); // {argb, height}
for (int y = by; y <= top; y++) {
Material m = y == by ? Material.BEACON : world.getType(bx, y, bz);
int color = beamColor(m);
if (color != 0) {
if (sections.size() <= 1) {
sections.add(new int[] {color, 1});
} else {
int[] last = sections.getLast();
if (color == last[0]) last[1]++;
else sections.add(new int[] {average(last[0], color), 1});
}
} else {
if (sections.isEmpty() || (blocksBeam(m) && m != Material.BEDROCK)) return null;
sections.getLast()[1]++;
}
}
List<BlockEntityState.BeamSection> out = new ArrayList<>(sections.size());
for (int[] sec : sections) out.add(new BlockEntityState.BeamSection(sec[0], sec[1]));
float scale = 1;
if (eye != null) {
double dx = eye.getX() - (bx + 0.5), dz = eye.getZ() - (bz + 0.5);
scale = (float) Math.max(1, Math.sqrt(dx * dx + dz * dz) / 96);
}
return new BlockEntityState.Beam(out, scale);
}
/** BeaconBeamBlock colour (DyeColor texture-diffuse colour) of a beacon/stained glass (pane); 0 = none. */
private static int beamColor(Material m) {
if (m == Material.BEACON) return ColorUtil.dyeArgb(DyeColor.WHITE, 0);
String n = m.name();
int i = n.indexOf("_STAINED_GLASS");
return i < 0 ? 0 : ColorUtil.dyeArgb(dye(n.substring(0, i)), 0);
}
/** Light dampening 15 (full opaque cubes, tinted glass) — the beam stops there. */
private static boolean blocksBeam(Material m) {
return m.isOccluding() || m == Material.TINTED_GLASS;
}
/** ARGB.average: per-channel integer mean. */
private static int average(int a, int b) {
return ColorUtil.argb(
(ColorUtil.alpha(a) + ColorUtil.alpha(b)) / 2,
(ColorUtil.red(a) + ColorUtil.red(b)) / 2,
(ColorUtil.green(a) + ColorUtil.green(b)) / 2,
(ColorUtil.blue(a) + ColorUtil.blue(b)) / 2);
}
/**
* TheEndGatewayBlockEntity.shouldRenderFace ≈ the neighbour is not a full opaque cube; "+"-joined. A neighbour in
* an unloaded chunk counts as open (like the client's empty chunk) and is not loaded.
*/
private static String gatewayFaces(BlockState ts) {
World world = ts.getWorld();
int x = ts.getX(), y = ts.getY(), z = ts.getZ();
StringBuilder sb = new StringBuilder();
for (BlockFace f : GATEWAY_FACES) {
int nx = x + f.getModX(), nz = z + f.getModZ();
if (world.isChunkLoaded(nx >> 4, nz >> 4)
&& world.getType(nx, y + f.getModY(), nz).isOccluding()) continue;
if (!sb.isEmpty()) sb.append('+');
sb.append(f.name().toLowerCase(Locale.ROOT));
}
return sb.toString();
}
private static final BlockFace[] GATEWAY_FACES = {
BlockFace.DOWN, BlockFace.UP, BlockFace.NORTH, BlockFace.SOUTH, BlockFace.WEST, BlockFace.EAST
};
/**
* Yaw in degrees (0 = south, clockwise, vanilla rotation convention) of a wall block's {@link Directional}
* facing or a standing block's {@link Rotatable} rotation (no block data implements both).
*/
private static float facingYaw(BlockData data) { private static float facingYaw(BlockData data) {
if (data instanceof Directional d) return faceToYaw(d.getFacing()); BlockFace face =
if (data instanceof Rotatable r) return faceToYaw(r.getRotation()); data instanceof Directional d ? d.getFacing() : data instanceof Rotatable r ? r.getRotation() : null;
return 0; Float y = face == null ? null : FACE_YAW.get(face);
}
/** 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; return y == null ? 0 : y;
} }
@@ -262,19 +493,42 @@ public final class BlockEntitySnapshotBuilder {
return m; return m;
} }
// --- data extraction helpers --- private static String prefix(String name, String suffix) {
return name.substring(0, name.length() - suffix.length());
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. */ /** The dye of an upper-case colour name, or null. */
private static DyeColor dye(String name) {
try {
return DyeColor.valueOf(name);
} catch (IllegalArgumentException e) {
return null;
}
}
/** Weathering stage from {@code [WAXED_][EXPOSED_|WEATHERED_|OXIDIZED_]<suffix>}; null when unweathered. */
private static String copperStage(String name, String suffix) {
String stage = name.replace("WAXED_", "").replace(suffix, "").replace("_", "");
return stage.isEmpty() ? null : stage.toLowerCase(Locale.ROOT);
}
private static List<BannerPattern> patterns(List<org.bukkit.block.banner.Pattern> patterns) {
List<BannerPattern> out = new ArrayList<>(patterns.size());
for (org.bukkit.block.banner.Pattern p : patterns) {
out.add(new BannerPattern(patternKey(p), ColorUtil.dyeArgb(p.getColor(), 0xFFFFFFFF)));
}
return out;
}
private static final Pattern LEGACY_FORMAT = Pattern.compile("§.");
/** Null when all four lines are blank. */
private static BlockEntityState.SignText signText(SignSide side) { private static BlockEntityState.SignText signText(SignSide side) {
String[] raw = side.getLines(); String[] raw = side.getLines();
List<String> lines = new ArrayList<>(raw.length); List<String> lines = new ArrayList<>(raw.length);
boolean any = false; boolean any = false;
for (String l : raw) { for (String l : raw) {
String s = l == null ? "" : l.replaceAll("§.", ""); // strip legacy §-codes String s = l == null ? "" : LEGACY_FORMAT.matcher(l).replaceAll("");
if (!s.isEmpty()) any = true; if (!s.isEmpty()) any = true;
lines.add(s); lines.add(s);
} }
@@ -285,17 +539,6 @@ public final class BlockEntitySnapshotBuilder {
lines, ColorUtil.signFillArgb(dye, glow), ColorUtil.signOutlineArgb(dye), glow); 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) { private static String headType(String name) {
String s = name.replace("_WALL_", "_"); String s = name.replace("_WALL_", "_");
return switch (s) { return switch (s) {
@@ -309,38 +552,30 @@ public final class BlockEntitySnapshotBuilder {
}; };
} }
/** Order of the DecoratedPotRenderer side parts (BlockEntityModels.potParts). */
private static final DecoratedPot.Side[] SHERD_SIDES = {
DecoratedPot.Side.FRONT, DecoratedPot.Side.LEFT, DecoratedPot.Side.RIGHT, DecoratedPot.Side.BACK
};
private static List<String> sherds(DecoratedPot pot) { 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); List<String> out = new ArrayList<>(4);
for (DecoratedPot.Side side : new DecoratedPot.Side[] { for (DecoratedPot.Side side : SHERD_SIDES)
DecoratedPot.Side.FRONT, DecoratedPot.Side.LEFT, DecoratedPot.Side.RIGHT, DecoratedPot.Side.BACK out.add(pot.getSherd(side).name().toLowerCase(Locale.ROOT));
}) {
Material m = pot.getSherd(side);
out.add(m.name().toLowerCase(Locale.ROOT));
}
return out; return out;
} }
private static String skinUrl(Skull skull) { private static String skinUrl(PlayerProfile profile) {
try { return profile == null || profile.getTextures().getSkin() == null
PlayerProfile profile = skull.getOwnerProfile(); ? null
if (profile != null && profile.getTextures().getSkin() != null) { : profile.getTextures().getSkin().toString();
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 /** Every PatternType key accessor is marked for removal with no stable replacement. */
* for removal in this API with no stable replacement, so the warning is suppressed here. */
@SuppressWarnings("removal") @SuppressWarnings("removal")
private static String patternKey(Pattern p) { private static String patternKey(org.bukkit.block.banner.Pattern p) {
return p.getPattern().getKey().getKey(); 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) { private static Builder base(Kind kind, int bx, int by, int bz, float yaw) {
return new Builder(kind, bx, by, bz, yaw); return new Builder(kind, bx, by, bz, yaw);
} }
@@ -349,18 +584,21 @@ public final class BlockEntitySnapshotBuilder {
private final Kind kind; private final Kind kind;
private final int bx, by, bz; private final int bx, by, bz;
private final float yaw; private final float yaw;
private String direction;
private boolean active;
private ChestKind chestKind; private ChestKind chestKind;
private int baseColorArgb; private int baseColorArgb;
private String colorName; private String colorName;
private String wood;
private BedPart bedPart;
private String headType; private String headType;
private String skinUrl; private String skinUrl;
private List<BannerPattern> patterns = List.of(); private List<BannerPattern> patterns = List.of();
private List<String> sherds = List.of(); private List<String> sherds = List.of();
private BellAttach bellAttach;
private BlockEntityState.SignText frontText; private BlockEntityState.SignText frontText;
private BlockEntityState.SignText backText; private BlockEntityState.SignText backText;
private List<ItemSpec> items = List.of();
private EntityState displayEntity;
private BlockEntityState.Beam beam;
private String pose;
Builder(Kind kind, int bx, int by, int bz, float yaw) { Builder(Kind kind, int bx, int by, int bz, float yaw) {
this.kind = kind; this.kind = kind;
@@ -385,13 +623,13 @@ public final class BlockEntitySnapshotBuilder {
return this; return this;
} }
Builder wood(String v) { Builder direction(String v) {
this.wood = v; this.direction = v;
return this; return this;
} }
Builder bedPart(BedPart v) { Builder active(boolean v) {
this.bedPart = v; this.active = v;
return this; return this;
} }
@@ -415,11 +653,6 @@ public final class BlockEntitySnapshotBuilder {
return this; return this;
} }
Builder bellAttach(BellAttach v) {
this.bellAttach = v;
return this;
}
Builder frontText(BlockEntityState.SignText v) { Builder frontText(BlockEntityState.SignText v) {
this.frontText = v; this.frontText = v;
return this; return this;
@@ -430,6 +663,26 @@ public final class BlockEntitySnapshotBuilder {
return this; return this;
} }
Builder items(List<ItemSpec> v) {
this.items = v;
return this;
}
Builder displayEntity(EntityState v) {
this.displayEntity = v;
return this;
}
Builder beam(BlockEntityState.Beam v) {
this.beam = v;
return this;
}
Builder pose(String v) {
this.pose = v;
return this;
}
BlockEntityState build() { BlockEntityState build() {
return new BlockEntityState( return new BlockEntityState(
kind, kind,
@@ -437,18 +690,21 @@ public final class BlockEntitySnapshotBuilder {
by, by,
bz, bz,
yaw, yaw,
direction,
active,
chestKind, chestKind,
baseColorArgb, baseColorArgb,
colorName, colorName,
wood,
bedPart,
headType, headType,
skinUrl, skinUrl,
patterns, patterns,
sherds, sherds,
bellAttach,
frontText, frontText,
backText); backText,
items,
displayEntity,
beam,
pose);
} }
} }
} }
@@ -1,27 +1,29 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot; package eu.mhsl.minecraft.pixelpics.render.snapshot;
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState; import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState;
import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec;
import eu.mhsl.minecraft.pixelpics.utils.MapManager;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import org.bukkit.Material;
import org.bukkit.block.BlockFace; import org.bukkit.block.BlockFace;
import org.bukkit.entity.Entity; import org.bukkit.entity.Entity;
import org.bukkit.entity.GlowItemFrame; import org.bukkit.entity.GlowItemFrame;
import org.bukkit.entity.ItemFrame; import org.bukkit.entity.ItemFrame;
import org.bukkit.entity.Painting; import org.bukkit.entity.Painting;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.bukkit.map.MapView;
import org.bukkit.util.BoundingBox; import org.bukkit.util.BoundingBox;
/** /**
* Captures flat wall decorations (paintings + item frames) near the view frustum into immutable * Captures paintings and item frames into {@link DecorationState}s. MUST run on the main thread. The entity's
* {@link DecorationState}s. MUST run on the main thread. Uses each entity's world bounding box directly, * world bounding box already encodes vanilla's painting placement offsets.
* which already encodes vanilla's painting placement offsets.
*/ */
public final class DecorationSnapshotBuilder { public final class DecorationSnapshotBuilder {
private DecorationSnapshotBuilder() {} private DecorationSnapshotBuilder() {}
/** {@code nearby}: {@link FrustumBounds#nearbyEntities} of this photo. */
public static List<DecorationState> build(Collection<Entity> nearby) { public static List<DecorationState> build(Collection<Entity> nearby) {
List<DecorationState> out = new ArrayList<>(); List<DecorationState> out = new ArrayList<>();
for (Entity e : nearby) { for (Entity e : nearby) {
@@ -35,50 +37,54 @@ public final class DecorationSnapshotBuilder {
} }
private static DecorationState toState(Entity e) { private static DecorationState toState(Entity e) {
DecorationState.Kind kind;
String art = null;
ItemSpec item = null;
int[][] map = null;
List<DecorationState.MapMarker> markers = List.of();
int rotation = 0;
boolean glow = false, invisible = false;
if (e instanceof Painting painting) { if (e instanceof Painting painting) {
BoundingBox bb = e.getBoundingBox(); kind = DecorationState.Kind.PAINTING;
String art = painting.getArt().assetId().value(); art = painting.getArt().assetId().value();
return new DecorationState( } else if (e instanceof ItemFrame frame) {
DecorationState.Kind.PAINTING, kind = DecorationState.Kind.ITEM_FRAME;
bb.getMinX(), ItemStack stack = frame.getItem();
bb.getMinY(), item = ItemSpecs.of(stack);
bb.getMinZ(), if (stack.getType() == Material.FILLED_MAP) {
bb.getMaxX(), int id = MapPixels.mapId(stack);
bb.getMaxY(), // the attached renderer first: spares the file checks for loaded photos
bb.getMaxZ(), if (id >= 0 && (MapManager.renderer(id) != null || MapManager.isManaged(id))) {
facing(e.getFacing()), map = MapPixels.photo(id); // photos have no markers
art, } else {
null, MapView view = MapPixels.view(stack);
0, map = MapPixels.of(view);
false); if (map != null) markers = MapPixels.markers(view);
}
}
rotation = frame.getRotation().ordinal();
glow = e instanceof GlowItemFrame;
invisible = !frame.isVisible();
} else {
return null;
} }
if (e instanceof ItemFrame frame) { BoundingBox bb = e.getBoundingBox();
BoundingBox bb = e.getBoundingBox(); return new DecorationState(
boolean glow = kind,
e instanceof GlowItemFrame || e.getType().getKey().getKey().equals("glow_item_frame"); bb.getMinX(),
String itemId = itemId(frame.getItem()); bb.getMinY(),
int rot = frame.getRotation().ordinal() * 45; bb.getMinZ(),
return new DecorationState( bb.getMaxX(),
DecorationState.Kind.ITEM_FRAME, bb.getMaxY(),
bb.getMinX(), bb.getMaxZ(),
bb.getMinY(), facing(e.getFacing()),
bb.getMinZ(), art,
bb.getMaxX(), item,
bb.getMaxY(), map,
bb.getMaxZ(), rotation,
facing(e.getFacing()), glow,
null, invisible,
itemId, markers);
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) { private static DecorationState.Facing facing(BlockFace face) {
@@ -7,48 +7,73 @@ import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec; import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import io.papermc.paper.datacomponent.DataComponentTypes; import io.papermc.paper.datacomponent.DataComponentTypes;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Base64; import java.util.Base64;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.TreeSet;
import java.util.UUID; import java.util.UUID;
import java.util.function.DoubleSupplier; import java.util.function.DoubleSupplier;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.Color;
import org.bukkit.DyeColor;
import org.bukkit.Keyed; import org.bukkit.Keyed;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.entity.AbstractHorse; import org.bukkit.Tag;
import org.bukkit.World;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.block.BlockFace;
import org.bukkit.block.data.type.Bed;
import org.bukkit.entity.AbstractArrow;
import org.bukkit.entity.Ageable; import org.bukkit.entity.Ageable;
import org.bukkit.entity.ArmorStand; import org.bukkit.entity.ArmorStand;
import org.bukkit.entity.Arrow;
import org.bukkit.entity.Axolotl; import org.bukkit.entity.Axolotl;
import org.bukkit.entity.Bat;
import org.bukkit.entity.Cat; import org.bukkit.entity.Cat;
import org.bukkit.entity.ChestedHorse; import org.bukkit.entity.ChestedHorse;
import org.bukkit.entity.Chicken; import org.bukkit.entity.Creaking;
import org.bukkit.entity.Cow; import org.bukkit.entity.Creeper;
import org.bukkit.entity.Entity; import org.bukkit.entity.Entity;
import org.bukkit.entity.Fox; import org.bukkit.entity.Fox;
import org.bukkit.entity.Frog;
import org.bukkit.entity.Horse; import org.bukkit.entity.Horse;
import org.bukkit.entity.HumanEntity; import org.bukkit.entity.HumanEntity;
import org.bukkit.entity.Illusioner;
import org.bukkit.entity.LivingEntity; import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Llama; import org.bukkit.entity.Llama;
import org.bukkit.entity.Mob; import org.bukkit.entity.Mob;
import org.bukkit.entity.MushroomCow; import org.bukkit.entity.MushroomCow;
import org.bukkit.entity.Panda; import org.bukkit.entity.Panda;
import org.bukkit.entity.Parrot; import org.bukkit.entity.Parrot;
import org.bukkit.entity.Pig; import org.bukkit.entity.Piglin;
import org.bukkit.entity.PiglinAbstract;
import org.bukkit.entity.Pillager;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.entity.PufferFish; import org.bukkit.entity.PufferFish;
import org.bukkit.entity.Rabbit; import org.bukkit.entity.Rabbit;
import org.bukkit.entity.Sheep; import org.bukkit.entity.Sheep;
import org.bukkit.entity.Shulker; import org.bukkit.entity.Shulker;
import org.bukkit.entity.Sittable;
import org.bukkit.entity.Slime; import org.bukkit.entity.Slime;
import org.bukkit.entity.Sniffer;
import org.bukkit.entity.Snowman; import org.bukkit.entity.Snowman;
import org.bukkit.entity.Spellcaster;
import org.bukkit.entity.TraderLlama; import org.bukkit.entity.TraderLlama;
import org.bukkit.entity.Trident;
import org.bukkit.entity.TropicalFish; import org.bukkit.entity.TropicalFish;
import org.bukkit.entity.Villager; import org.bukkit.entity.Villager;
import org.bukkit.entity.Vindicator;
import org.bukkit.entity.Wither;
import org.bukkit.entity.Wolf; import org.bukkit.entity.Wolf;
import org.bukkit.entity.ZombieVillager; import org.bukkit.entity.ZombieVillager;
import org.bukkit.inventory.EntityEquipment; import org.bukkit.inventory.EntityEquipment;
@@ -56,20 +81,20 @@ import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.MainHand; import org.bukkit.inventory.MainHand;
import org.bukkit.inventory.meta.ArmorMeta; import org.bukkit.inventory.meta.ArmorMeta;
import org.bukkit.inventory.meta.CrossbowMeta;
import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.inventory.meta.LeatherArmorMeta; import org.bukkit.inventory.meta.LeatherArmorMeta;
import org.bukkit.potion.PotionEffectType; import org.bukkit.potion.PotionEffectType;
import org.bukkit.util.BoundingBox;
import org.bukkit.util.EulerAngle;
/** /** Captures entities into {@link EntityState}s. MUST run on the main thread (live entity access). */
* 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 { public final class EntitySnapshotBuilder {
private EntitySnapshotBuilder() {} private EntitySnapshotBuilder() {}
// Technical / non-mob entity types that have no meaningful geometry; rendering them would only // Types without a mob model (technical entities, or drawn by the decoration/object builders); they would
// produce stray fallback boxes. Markers, displays, item frames, paintings, projectiles, drops, etc. // otherwise render as fallback boxes.
private static final Set<String> NON_RENDERABLE = Set.of( private static final Set<String> NON_RENDERABLE = Set.of(
"area_effect_cloud", "area_effect_cloud",
"marker", "marker",
@@ -93,26 +118,10 @@ public final class EntitySnapshotBuilder {
"falling_block", "falling_block",
"item"); "item");
// Entities whose vanilla renderer draws the humanoid armor layers (HumanoidArmorLayer) and held // Mobs whose main-hand item is drawn in the mouth (FoxHeldItemLayer, DolphinCarryingItemLayer).
// items. Their CEM bodies share standard humanoid proportions, so the armor_layer_1/2 models align. private static final Set<String> MOUTH_CARRIERS = Set.of("fox", "dolphin");
private static final Set<String> HUMANOID_ARMOR_WEARERS = Set.of(
"player",
"mannequin",
"armor_stand",
"giant",
"zombie",
"husk",
"drowned",
"zombie_villager",
"zombified_piglin",
"skeleton",
"stray",
"wither_skeleton",
"bogged",
"piglin",
"piglin_brute");
/** {@code nearby}: {@link FrustumBounds#nearbyEntities} of this photo. */ /** {@code shooter}: the photographer, skipped. */
public static List<EntityState> build(Collection<Entity> nearby, UUID shooter) { public static List<EntityState> build(Collection<Entity> nearby, UUID shooter) {
List<EntityState> states = new ArrayList<>(); List<EntityState> states = new ArrayList<>();
for (Entity e : nearby) { for (Entity e : nearby) {
@@ -123,19 +132,13 @@ public final class EntitySnapshotBuilder {
return states; return states;
} }
private static EntityState toState(Entity e) { /** The entity's state, or null for non-renderable types. Also used for unspawned spawner display mobs. */
Location loc = e.getLocation(); static EntityState toState(Entity e) {
// Skip non-renderable technical entities.
String type = e.getType().getKey().getKey(); String type = e.getType().getKey().getKey();
// Boats now have a bundled geometry.boat; rafts use a different hull we don't ship yet — skip those.
if (NON_RENDERABLE.contains(type)) return null; if (NON_RENDERABLE.contains(type)) return null;
Location loc = e.getLocation();
float bodyYaw = e instanceof LivingEntity le ? le.getBodyYaw() : loc.getYaw();
float bodyYaw = loc.getYaw();
if (e instanceof LivingEntity le) {
bodyYaw = le.getBodyYaw();
}
// (Zombie implements Ageable — no extra case; an earlier "|| zombie.isAdult()" made ADULT zombies babies.)
boolean baby = (e instanceof Ageable a && !a.isAdult()) || (e instanceof ArmorStand st && st.isSmall()); boolean baby = (e instanceof Ageable a && !a.isAdult()) || (e instanceof ArmorStand st && st.isSmall());
// Invisible entities render only their equipment (like vanilla): the generic invisible flag, an // Invisible entities render only their equipment (like vanilla): the generic invisible flag, an
@@ -150,8 +153,8 @@ public final class EntitySnapshotBuilder {
boolean player = e instanceof Player; boolean player = e instanceof Player;
String skinUrl = null; String skinUrl = null;
boolean slim = false; boolean slim = false;
if (player) { if (e instanceof Player p) {
String[] skin = resolveSkin((Player) e); String[] skin = resolveSkin(p);
skinUrl = skin[0]; skinUrl = skin[0];
slim = "slim".equals(skin[1]); slim = "slim".equals(skin[1]);
} }
@@ -164,15 +167,17 @@ public final class EntitySnapshotBuilder {
String markings = null; String markings = null;
boolean saddle = false; boolean saddle = false;
boolean chest = false; boolean chest = false;
boolean arms = false;
String bodyEquip = null; String bodyEquip = null;
int bodyDye = 0; int bodyDye = 0;
int tint2 = 0; int tint2 = 0;
int collar = 0;
boolean powered = false;
try { try {
// Slime & magma cube (MagmaCube extends Slime) scale their model by size (1/2/4). if (e instanceof Slime sl) sizeScale = sl.getSize(); // incl. magma cubes
if (e instanceof Slime sl) sizeScale = sl.getSize(); // Order matters for subtypes (Llama extends ChestedHorse extends AbstractHorse).
// MushroomCow extends Cow, ZombieVillager does not extend Villager — order matters.
if (e instanceof Sheep sh) { if (e instanceof Sheep sh) {
tint = ColorUtil.dyeArgb(sh.getColor(), 0); tint = isJebSheep(sh) ? jebWoolColor(sh.getTicksLived()) : sheepWoolColor(sh.getColor());
if (sh.isSheared()) variant = "sheared"; if (sh.isSheared()) variant = "sheared";
} else if (e instanceof TropicalFish tf) { } else if (e instanceof TropicalFish tf) {
variant = keyOf(tf.getPattern()); variant = keyOf(tf.getPattern());
@@ -185,8 +190,19 @@ public final class EntitySnapshotBuilder {
default -> "big";}; default -> "big";};
} else if (e instanceof Cat c) { } else if (e instanceof Cat c) {
variant = keyOf(c.getCatType()); variant = keyOf(c.getCatType());
if (c.isTamed()) collar = ColorUtil.dyeArgb(c.getCollarColor(), 0);
} else if (e instanceof Wolf w) { } else if (e instanceof Wolf w) {
variant = keyOf(w.getVariant()); variant = keyOf(w.getVariant());
if (w.isTamed()) collar = ColorUtil.dyeArgb(w.getCollarColor(), 0);
} else if (e instanceof Creeper cr) {
powered = cr.isPowered();
} else if (e instanceof Wither wi) {
// WitherBoss.isPowered(): health <= max health / 2 (wither armor layer).
AttributeInstance max = wi.getAttribute(Attribute.MAX_HEALTH);
powered = max != null && wi.getHealth() <= max.getValue() / 2;
// WitherBossRenderer.scale: 2 - invulnerableTicks / 220 · 0.5 (the dumped transform holds the 2).
int inv = wi.getInvulnerableTicks();
if (inv > 0) sizeScale = (2 - inv / 220f * 0.5f) / 2;
} else if (e instanceof Axolotl a) { } else if (e instanceof Axolotl a) {
variant = keyOf(a.getVariant()); variant = keyOf(a.getVariant());
} else if (e instanceof Parrot p) { } else if (e instanceof Parrot p) {
@@ -196,54 +212,36 @@ public final class EntitySnapshotBuilder {
} else if (e instanceof Horse h) { } else if (e instanceof Horse h) {
variant = keyOf(h.getColor()); variant = keyOf(h.getColor());
markings = markingsKey(h.getStyle()); markings = markingsKey(h.getStyle());
saddle = isSaddled(h);
bodyEquip = horseArmorKey(h);
} else if (e instanceof Llama l) { } else if (e instanceof Llama l) {
variant = keyOf(l.getColor()); variant = keyOf(l.getColor());
chest = l.isCarryingChest(); 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) { } else if (e instanceof ChestedHorse ch) {
// Donkey & mule (llama already handled above).
chest = ch.isCarryingChest(); chest = ch.isCarryingChest();
saddle = isSaddled(ch);
} else if (e instanceof AbstractHorse ah) {
// Skeleton/zombie horse: only saddle (no colour/markings/armor variants).
saddle = isSaddled(ah);
} else if (e instanceof Fox f) { } else if (e instanceof Fox f) {
variant = keyOf(f.getFoxType()); variant = keyOf(f.getFoxType());
} else if (e instanceof MushroomCow mc) { } else if (e instanceof MushroomCow mc) {
variant = keyOf(mc.getVariant()); variant = keyOf(mc.getVariant());
} else if (e instanceof Panda pa) { } else if (e instanceof Panda pa) {
variant = keyOf(pa.getMainGene()); variant = keyOf(pa.getMainGene());
} else if (e instanceof Frog fr) {
variant = keyOf(fr.getVariant());
} else if (e instanceof Shulker s) { } else if (e instanceof Shulker s) {
variant = s.getColor() == null ? null : keyOf(s.getColor()); variant = keyOf(s.getColor());
} else if (e instanceof ZombieVillager zv) { } else if (e instanceof ZombieVillager zv) {
variant = keyOf(zv.getVillagerType()); variant = keyOf(zv.getVillagerType());
// ZombieVillager exposes no level via Bukkit -> no profession-level badge (matches vanilla).
} else if (e instanceof Villager vi) { } else if (e instanceof Villager vi) {
variant = keyOf(vi.getVillagerType()); variant = keyOf(vi.getVillagerType());
profession = keyOf(vi.getProfession()); profession = keyOf(vi.getProfession());
villagerLevel = vi.getVillagerLevel(); villagerLevel = vi.getVillagerLevel();
} else if (e instanceof Cow co) {
variant = keyOf(co.getVariant());
} else if (e instanceof Pig pg) {
variant = keyOf(pg.getVariant());
} else if (e instanceof Chicken ch) {
variant = keyOf(ch.getVariant());
} }
if (e instanceof ArmorStand st && st.hasArms()) variant = "arms"; if (e instanceof ArmorStand st) arms = st.hasArms();
// Any other mob with a variant/weathering getter (new mobs, copper golem, zombie nautilus, …). // Any other mob with a variant/weathering getter (frog, cow, pig, copper golem, new mobs, …).
// Reflective on purpose: the compile-time declaring interface of getVariant() (AbstractCow) doesn't
// exist at runtime on every server build (NoSuchMethodError).
if (variant == null) variant = genericVariant(e); if (variant == null) variant = genericVariant(e);
} catch (Throwable ignored) { } catch (Throwable ignored) {
// Unsupported on this server version — fall back to the base texture.
} }
// Equipment slots: saddle + BODY equipment (horse armor, llama carpet, harness, wolf/nautilus armor, …) // Saddle + BODY slot for every mount (horse armor, carpet, harness, wolf armor, …), held items by hand
// for every mount — including ones added in newer versions — plus held items (by hand side, like // side (a left-handed mob holds its main-hand item left) and a non-armor head item.
// vanilla: a left-handed mob holds its main-hand item left) and a non-armor head item. ItemSpec rightHand = null, leftHand = null, headItem = null, mouthItem = null;
ItemSpec rightHand = null, leftHand = null, headItem = null;
EntityEquipment eq = e instanceof LivingEntity le ? le.getEquipment() : null; EntityEquipment eq = e instanceof LivingEntity le ? le.getEquipment() : null;
if (eq != null) { if (eq != null) {
try { try {
@@ -251,20 +249,28 @@ public final class EntitySnapshotBuilder {
if (sd != null && !sd.getType().isAir()) saddle = true; if (sd != null && !sd.getType().isAir()) saddle = true;
ItemStack body = eq.getItem(EquipmentSlot.BODY); ItemStack body = eq.getItem(EquipmentSlot.BODY);
String asset = equipAsset(body); String asset = equipAsset(body);
if (asset != null && bodyEquip == null) bodyEquip = asset; if (asset != null) {
if (asset != null) bodyDye = dyeOf(body); bodyEquip = asset;
bodyDye = dyeOf(body);
}
} catch (Throwable ignored) { } catch (Throwable ignored) {
// slot not supported by this entity / server version // slot not supported by this entity
} }
// LlamaDecorLayer: the trader llama's own decor unless it wears a carpet.
if (bodyEquip == null && e instanceof TraderLlama) bodyEquip = "trader_llama";
try { try {
boolean leftHanded = (e instanceof Mob mob && mob.isLeftHanded()) boolean leftHanded = (e instanceof Mob mob && mob.isLeftHanded())
|| (e instanceof HumanEntity he && he.getMainHand() == MainHand.LEFT); || (e instanceof HumanEntity he && he.getMainHand() == MainHand.LEFT);
ItemSpec main = ItemSpecs.of(eq.getItemInMainHand()); ItemSpec main = ItemSpecs.of(eq.getItemInMainHand());
ItemSpec off = ItemSpecs.of(eq.getItemInOffHand()); ItemSpec off = ItemSpecs.of(eq.getItemInOffHand());
rightHand = leftHanded ? off : main; if (MOUTH_CARRIERS.contains(type)) {
leftHand = leftHanded ? main : off; mouthItem = main;
} else {
rightHand = leftHanded ? off : main;
leftHand = leftHanded ? main : off;
}
ItemStack helmet = eq.getHelmet(); ItemStack helmet = eq.getHelmet();
if (helmet != null && !helmet.getType().isAir() && armorAsset(helmet.getType()) == null) { if (helmet != null && !helmet.getType().isAir() && armorAsset(helmet, EquipmentSlot.HEAD) == null) {
headItem = ItemSpecs.of(helmet); headItem = ItemSpecs.of(helmet);
} }
// Snow golems wear a carved pumpkin unless sheared (vanilla SnowGolemHeadLayer). // Snow golems wear a carved pumpkin unless sheared (vanilla SnowGolemHeadLayer).
@@ -272,20 +278,41 @@ public final class EntitySnapshotBuilder {
headItem = ItemSpecs.of(new ItemStack(Material.CARVED_PUMPKIN)); headItem = ItemSpecs.of(new ItemStack(Material.CARVED_PUMPKIN));
} }
} catch (Throwable ignored) { } catch (Throwable ignored) {
// no hands / equipment on this entity
} }
} }
EntityState.Equipment equipment = null; // Worn armor of every living entity; the baker draws it only for renderers with a HumanoidArmorLayer.
if (HUMANOID_ARMOR_WEARERS.contains(type) && e instanceof LivingEntity wearer) { EntityState.Equipment equipment = eq == null ? null : captureEquipment(eq);
equipment = captureEquipment(wearer);
String pose = poseFlags(e);
double x = loc.getX(), z = loc.getZ();
if (pose != null && pose.contains("sleeping") && e instanceof LivingEntity sleeper && !(e instanceof Fox)) {
// LivingEntityRenderer: a sleeper in a bed lies along the bed (yaw from the bed direction instead of
// the body yaw) and is shifted by its standing eye height - 0.1 towards the foot end.
BlockFace bed = bedFacing(e);
if (bed != null) {
bodyYaw = switch (bed) {
case SOUTH -> 90;
case NORTH -> 270;
case EAST -> 180;
default -> 0;};
double off = standingEyeHeight(sleeper) - 0.1;
x -= bed.getModX() * off;
z -= bed.getModZ() * off;
}
}
Map<String, float[]> partPoses = e instanceof ArmorStand as ? armorStandPoses(as) : null;
String shoulderLeft = null, shoulderRight = null;
if (e instanceof HumanEntity he) {
shoulderLeft = shoulderParrot(he, true);
shoulderRight = shoulderParrot(he, false);
} }
return new EntityState( return new EntityState(
type, type,
loc.getX(), x,
loc.getY(), loc.getY(),
loc.getZ(), z,
bodyYaw, bodyYaw,
baby, baby,
width, width,
@@ -301,36 +328,288 @@ public final class EntitySnapshotBuilder {
markings, markings,
saddle, saddle,
chest, chest,
arms,
bodyEquip, bodyEquip,
equipment, equipment,
invisible, invisible,
loc.getPitch(), loc.getPitch() + arrowShake(e),
bodyDye, bodyDye,
tint2, tint2,
e.isInsideVehicle(), e.isInsideVehicle(),
rightHand, rightHand,
leftHand, leftHand,
headItem); headItem,
collar,
powered,
mouthItem,
pose,
partPoses,
shoulderLeft,
shoulderRight);
} }
/** Worn armor (4 slots) from a humanoid wearer; null when nothing is equipped. */ /**
private static EntityState.Equipment captureEquipment(LivingEntity le) { * State-pose flags (names of the vanilla dump's STATE_POSES, sorted, "+"-joined; null = idle): the generic
* pose plus the per-mob flags vanilla's renderers read from the entity. The baker ignores unknown flags.
*/
private static String poseFlags(Entity e) {
TreeSet<String> f = new TreeSet<>();
try {
switch (e.getPose()) {
case SLEEPING -> f.add("sleeping");
case SNEAKING -> f.add("crouching");
case SWIMMING -> f.add(e.isInWater() ? "swimming" : "crawling");
case FALL_FLYING -> f.add("fall_flying");
case SITTING -> f.add("sitting");
case DIGGING -> f.add("digging");
default -> {}
}
if (e instanceof LivingEntity le && le.isGliding()) f.add("fall_flying");
if (e.isInWater()) f.add("in_water");
if (e instanceof Sittable st && st.isSitting()) f.add("sitting");
if (e instanceof Fox fox) {
if (fox.isSleeping()) f.add("sleeping");
if (fox.isCrouching()) f.add("crouching");
if (fox.isLeaping()) f.add("pouncing");
if (fox.isFaceplanted()) f.add("faceplanted");
} else if (e instanceof Cat cat) {
if (cat.isLyingDown()) {
f.add("lying");
if (sleepingPlayerNear(cat)) f.add("on_player");
}
if (cat.isHeadUp()) f.add("head_up");
} else if (e instanceof Panda pa) {
if (pa.isOnBack()) f.add("on_back");
} else if (e instanceof Bat bat) {
if (!bat.isAwake()) f.add("resting");
} else if (e instanceof Parrot pa) {
// ParrotModel.getPose: party > sitting > flying (= not on the ground) > standing.
if (pa.isDancing()) f.add("party");
else if (!pa.isSitting() && pa.isOnGround()) f.add("standing");
} else if (e instanceof Sniffer sn && sn.getState() == Sniffer.State.DIGGING) {
f.add("digging");
}
renderFlags(e, f);
} catch (Throwable ignored) {
// keep the flags found so far
}
return f.isEmpty() ? null : String.join("+", f);
}
/** Render-state flags beyond poses: arm poses ({@code arms_<pose>}), textures and layers. */
private static void renderFlags(Entity e, Set<String> f) {
String arms = armPose(e);
if (arms != null) f.add("arms_" + arms);
if (e instanceof Wolf w && w.isAngry()) f.add("angry");
// TippableArrowRenderer: isTipped = Arrow.getColor() > 0 — the raw ARGB int, so only colours without the
// alpha bit (custom RGB colours) count; potion colours are opaque ARGB (negative) and keep the plain arrow.
if (e instanceof Arrow ar && ar.getColor() != null && ar.getColor().asARGB() > 0) f.add("tipped");
if (e instanceof Wither wi) {
// WitherBossRenderer.getTextureLocation: invulnerable texture above 80 ticks, then blinking every 5.
int t = wi.getInvulnerableTicks();
if (t > 0 && (t > 80 || t / 5 % 2 != 1)) f.add("invulnerable");
}
if (e instanceof Creaking cr && cr.isActive()) f.add("eyes_glowing");
// SheepWoolUndercoatLayer: jeb_ or non-white wool (adults only, decided by the baker), sheared or not.
if (e instanceof Sheep sh && (isJebSheep(sh) || sh.getColor() != DyeColor.WHITE)) f.add("undercoat");
}
/**
* Arm pose (lower-case IllagerArmPose / PiglinArmPose constant; null = idle) like Piglin/PiglinBrute.getArmPose
* and the illagers' getArmPose.
*/
private static String armPose(Entity e) {
boolean aggressive = e instanceof Mob m && m.isAggressive();
if (e instanceof PiglinAbstract pa) {
EntityEquipment eq = pa.getEquipment();
ItemStack main = eq == null ? null : eq.getItemInMainHand();
ItemStack off = eq == null ? null : eq.getItemInOffHand();
boolean melee = main != null && !main.getType().isAir() && main.hasData(DataComponentTypes.TOOL);
if (e instanceof Piglin p) {
if (p.isDancing()) return "dancing";
if (off != null && Tag.ITEMS_PIGLIN_LOVED.isTagged(off.getType())) return "admiring_item";
if (aggressive && melee) return "attacking_with_melee_weapon";
if (p.isChargingCrossbow()) return "crossbow_charge";
boolean holding = (main != null && main.getType() == Material.CROSSBOW)
|| (off != null && off.getType() == Material.CROSSBOW);
return holding
&& main != null
&& main.getItemMeta() instanceof CrossbowMeta cm
&& cm.hasChargedProjectiles()
? "crossbow_hold"
: null;
}
return aggressive && melee ? "attacking_with_melee_weapon" : null;
}
if (e instanceof Pillager pi) {
if (pi.isChargingAttack()) return "crossbow_charge";
EntityEquipment eq = pi.getEquipment();
boolean crossbow = eq != null
&& (eq.getItemInMainHand().getType() == Material.CROSSBOW
|| eq.getItemInOffHand().getType() == Material.CROSSBOW);
return crossbow ? "crossbow_hold" : aggressive ? "attacking" : "neutral";
}
if (e instanceof Vindicator v) {
return aggressive ? "attacking" : v.isCelebrating() ? "celebrating" : null;
}
if (e instanceof Spellcaster sc) {
if (sc.getSpell() != Spellcaster.Spell.NONE) return "spellcasting";
if (e instanceof Illusioner) return aggressive ? "bow_and_arrow" : null;
return sc.isCelebrating() ? "celebrating" : null;
}
return null;
}
/** SheepRenderer: a sheep named "jeb_" (checkMagicName) cycles through all wool colours. */
private static boolean isJebSheep(Sheep sh) {
Component name = sh.customName();
return name != null
&& "jeb_".equals(PlainTextComponentSerializer.plainText().serialize(name));
}
/**
* Vanilla wool colour (ColorLerper.Type.SHEEP): the dye's texture-diffuse colour × 0.75, white 0xE6E6E6.
*/
private static int sheepWoolColor(DyeColor dye) {
if (dye == null || dye == DyeColor.WHITE) return 0xFFE6E6E6;
Color c = dye.getColor();
return ColorUtil.argb(
0xFF, (int) (c.getRed() * 0.75f), (int) (c.getGreen() * 0.75f), (int) (c.getBlue() * 0.75f));
}
/**
* jeb_ wool (ColorLerper.getLerpedColor(SHEEP, ageInTicks)) at the entity's age: 25 ticks per dye colour, linearly
* blended to the next one.
*/
private static int jebWoolColor(int ticks) {
DyeColor[] dyes = DyeColor.values();
int value = ticks / 25;
int c1 = sheepWoolColor(dyes[value % dyes.length]);
int c2 = sheepWoolColor(dyes[(value + 1) % dyes.length]);
float step = (ticks % 25) / 25f;
int out = 0xFF000000;
for (int sh = 0; sh <= 16; sh += 8) {
int a = (c1 >> sh) & 0xFF, b = (c2 >> sh) & 0xFF;
out |= (a + (int) Math.floor(step * (b - a))) << sh;
}
return out;
}
/** Cat.isLyingOnTopOfSleepingPlayer: a sleeping player within the cat's block box inflated by 2. */
private static boolean sleepingPlayerNear(Entity cat) {
Location l = cat.getLocation();
BoundingBox box = new BoundingBox(
l.getBlockX() - 2,
l.getBlockY() - 2,
l.getBlockZ() - 2,
l.getBlockX() + 3,
l.getBlockY() + 3,
l.getBlockZ() + 3);
for (Entity p : cat.getWorld().getNearbyEntities(box, en -> en instanceof Player)) {
if (((Player) p).isSleeping()) return true;
}
return false;
}
/**
* Eye height in the STANDING pose (vanilla state.eyeHeight of a sleeper): {@code getEyeHeight(true)} returns the
* sleeping pose's 0.2 on Paper 26.x, so ask the server entity ({@code getEyeHeight(Pose.STANDING)}).
*/
private static double standingEyeHeight(LivingEntity le) {
try {
Object handle = handle(le);
StandingEyeHeight m = STANDING_EYE_HEIGHT.get(handle.getClass());
if (m != null) return ((Number) m.method.invoke(handle, m.standing)).doubleValue();
} catch (ReflectiveOperationException | RuntimeException ignored) {
}
return le.getEyeHeight(true);
}
private record StandingEyeHeight(Method method, Object standing) {}
/** Per NMS entity class: its {@code getEyeHeight(Pose)} with the STANDING constant, or null. */
private static final ClassValue<StandingEyeHeight> STANDING_EYE_HEIGHT = new ClassValue<>() {
@Override
protected StandingEyeHeight computeValue(Class<?> c) {
for (Method m : c.getMethods()) {
if (!m.getName().equals("getEyeHeight") || m.getParameterCount() != 1) continue;
Class<?> pose = m.getParameterTypes()[0];
if (!pose.isEnum()) continue;
for (Object p : pose.getEnumConstants()) {
if (((Enum<?>) p).name().equals("STANDING")) return new StandingEyeHeight(m, p);
}
}
return null;
}
};
private static final ClassValue<Method> GET_HANDLE = Reflection.method("getHandle"); // Craft entity
private static Object handle(Entity e) throws ReflectiveOperationException {
Method m = GET_HANDLE.get(e.getClass());
if (m == null) throw new NoSuchMethodException("getHandle");
return m.invoke(e);
}
/**
* Facing of the bed the entity sleeps in (LivingEntity.getBedOrientation = the bed block's FACING), or null. Never
* loads a chunk: spawner display mobs are not in a world (their location is the origin), and an unloaded chunk
* has no bed to read.
*/
private static BlockFace bedFacing(Entity e) {
if (!e.isInWorld()) return null;
Location l = e.getLocation();
World w = l.getWorld();
int x = l.getBlockX(), y = l.getBlockY(), z = l.getBlockZ();
if (w == null || !w.isChunkLoaded(x >> 4, z >> 4)) return null;
return w.getBlockData(x, y, z) instanceof Bed bed ? bed.getFacing() : null;
}
/** Armor-stand part poses in degrees, keyed by the vanilla render-state field (dump rotation bindings). */
private static Map<String, float[]> armorStandPoses(ArmorStand as) {
Map<String, float[]> out = new HashMap<>();
out.put("headPose", deg(as.getHeadPose()));
out.put("bodyPose", deg(as.getBodyPose()));
out.put("leftArmPose", deg(as.getLeftArmPose()));
out.put("rightArmPose", deg(as.getRightArmPose()));
out.put("leftLegPose", deg(as.getLeftLegPose()));
out.put("rightLegPose", deg(as.getRightLegPose()));
return out;
}
private static float[] deg(EulerAngle a) {
return new float[] {
(float) Math.toDegrees(a.getX()), (float) Math.toDegrees(a.getY()), (float) Math.toDegrees(a.getZ())
};
}
/** Variant key of a parrot sitting on the player's left/right shoulder, or null. */
@SuppressWarnings("deprecation")
private static String shoulderParrot(HumanEntity he, boolean left) {
try {
Entity p = left ? he.getShoulderEntityLeft() : he.getShoulderEntityRight();
return p instanceof Parrot parrot ? keyOf(parrot.getVariant()) : null;
} catch (Throwable t) {
return null;
}
}
/** Worn armor; null when nothing is equipped. */
private static EntityState.Equipment captureEquipment(EntityEquipment eq) {
try { try {
EntityEquipment eq = le.getEquipment();
if (eq == null) return null;
EntityState.Equipment equip = new EntityState.Equipment( EntityState.Equipment equip = new EntityState.Equipment(
armorPiece(eq.getHelmet()), armorPiece(eq.getChestplate()), armorPiece(eq.getHelmet(), EquipmentSlot.HEAD), armorPiece(eq.getChestplate(), EquipmentSlot.CHEST),
armorPiece(eq.getLeggings()), armorPiece(eq.getBoots())); armorPiece(eq.getLeggings(), EquipmentSlot.LEGS), armorPiece(eq.getBoots(), EquipmentSlot.FEET));
return equip.isEmpty() ? null : equip; return equip.isEmpty() ? null : equip;
} catch (Throwable t) { } catch (Throwable t) {
return null; return null;
} }
} }
/** One armor slot -> EquipPiece (asset, leather dye, trim, glint); null for empty / non-armor items. */ /** Null for empty / non-armor items. */
private static EntityState.EquipPiece armorPiece(ItemStack it) { private static EntityState.EquipPiece armorPiece(ItemStack it, EquipmentSlot slot) {
if (it == null || it.getType().isAir()) return null; if (it == null || it.getType().isAir()) return null;
String asset = armorAsset(it.getType()); String asset = armorAsset(it, slot);
if (asset == null) return null; // not a humanoid-armor item (e.g. a mob head / pumpkin) if (asset == null) return null; // not a humanoid-armor item (e.g. a mob head / pumpkin)
int dye = 0; int dye = 0;
String trimMat = null, trimPat = null; String trimMat = null, trimPat = null;
@@ -347,20 +626,39 @@ public final class EntitySnapshotBuilder {
return new EntityState.EquipPiece(asset, dye, trimMat, trimPat, glint); 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 final String[] ARMOR_SUFFIXES = {"_helmet", "_chestplate", "_leggings", "_boots"};
private static String armorAsset(Material m) {
/**
* Equipment asset id (= texture name) a humanoid armor layer draws for the item worn in {@code slot}: the
* EQUIPPABLE asset when its slot matches (vanilla HumanoidArmorLayer.shouldRender, custom armor included);
* from the item name without the component. Null if none.
*/
private static String armorAsset(ItemStack it, EquipmentSlot slot) {
try {
var comp = it.getData(DataComponentTypes.EQUIPPABLE);
if (comp != null)
return comp.slot() == slot && comp.assetId() != null
? comp.assetId().value()
: null;
} catch (Throwable ignored) {
}
return armorAssetByName(it.getType());
}
/** Item Material -> equipment asset id: strips the slot suffix; null if not armor. */
private static String armorAssetByName(Material m) {
String key = m.getKey().getKey(); String key = m.getKey().getKey();
if (key.equals("elytra")) return "elytra"; if (key.equals("elytra")) return "elytra";
if (key.equals("turtle_helmet")) return "turtle_scute"; if (key.equals("turtle_helmet")) return "turtle_scute";
String base = null; for (String suf : ARMOR_SUFFIXES) {
for (String suf : new String[] {"_helmet", "_chestplate", "_leggings", "_boots"}) { if (key.endsWith(suf)) return assetMaterial(key.substring(0, key.length() - suf.length()));
if (key.endsWith(suf)) {
base = key.substring(0, key.length() - suf.length());
break;
}
} }
if (base == null) return null; return null;
return base.equals("golden") ? "gold" : base; }
/** Equipment assets name the gold material "gold", items "golden". */
private static String assetMaterial(String itemMaterial) {
return itemMaterial.equals("golden") ? "gold" : itemMaterial;
} }
/** Horse coat markings overlay key (vanilla texture suffix); null for the plain NONE style. */ /** Horse coat markings overlay key (vanilla texture suffix); null for the plain NONE style. */
@@ -369,22 +667,6 @@ public final class EntitySnapshotBuilder {
return style.name().toLowerCase(Locale.ROOT).replace("_", ""); // WHITE_DOTS -> whitedots 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. */ /** Equipment asset id (= equipment/<asset> texture name) from an item's EQUIPPABLE component; null if none. */
private static String equipAsset(ItemStack it) { private static String equipAsset(ItemStack it) {
if (it == null || it.getType().isAir()) return null; if (it == null || it.getType().isAir()) return null;
@@ -406,24 +688,30 @@ public final class EntitySnapshotBuilder {
} }
} }
/** Per entity class: its {@code getVariant()} / {@code getWeatheringState()} getter, or null. */ private static final ClassValue<Field> SHAKE_FIELD = Reflection.field("shakeTime"); // NMS AbstractArrow
private static final ClassValue<java.lang.reflect.Method> VARIANT_GETTER = new ClassValue<>() {
@Override /**
protected java.lang.reflect.Method computeValue(Class<?> c) { * Vanilla impact wobble of a freshly stuck arrow (ArrowModel#setupAnim, partialTick 0): the model root turns
for (String m : new String[] {"getVariant", "getWeatheringState"}) { * by {@code -sin(3·shake)·shake} degrees about its z axis — the same axis as the pitch, so it is added to it.
try { * {@code shakeTime} (7 → 0 ticks after hitting a block) has no API; read from the server entity. Tridents
return c.getMethod(m); * ignore it (ThrownTridentRenderer has no shake).
} catch (NoSuchMethodException ignored) { */
// try the next name private static float arrowShake(Entity e) {
} if (!(e instanceof AbstractArrow) || e instanceof Trident) return 0;
} try {
return null; Object handle = handle(e);
} Field f = SHAKE_FIELD.get(handle.getClass());
}; int shake = f == null ? 0 : f.getInt(handle);
return shake <= 0 ? 0 : (float) (-Math.sin(shake * 3.0) * shake);
} catch (ReflectiveOperationException | RuntimeException ex) {
return 0;
}
}
private static final ClassValue<Method> VARIANT_GETTER = Reflection.method("getVariant", "getWeatheringState");
/** Variant key via a {@code getVariant()} / {@code getWeatheringState()} getter, if the entity has one. */
private static String genericVariant(Entity e) { private static String genericVariant(Entity e) {
java.lang.reflect.Method m = VARIANT_GETTER.get(e.getClass()); Method m = VARIANT_GETTER.get(e.getClass());
if (m == null) return null; if (m == null) return null;
try { try {
return keyOf(m.invoke(e)); return keyOf(m.invoke(e));
@@ -432,17 +720,16 @@ public final class EntitySnapshotBuilder {
} }
} }
/** Whether a horse-like mount carries a saddle in its dedicated saddle slot. */ /**
private static boolean isSaddled(AbstractHorse h) { * Registry/Keyed values yield their key path ({@code "<ns>:<path>"} outside the minecraft namespace, e.g.
ItemStack st = h.getInventory().getSaddle(); * datapack variants); plain enums yield their lower-case name.
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) { private static String keyOf(Object o) {
return switch (o) { return switch (o) {
case null -> null; case null -> null;
case Keyed k -> k.getKey().getKey(); case Keyed k -> k.getKey().getNamespace().equals("minecraft")
? k.getKey().getKey()
: k.getKey().toString();
case Enum<?> en -> en.name().toLowerCase(Locale.ROOT); case Enum<?> en -> en.name().toLowerCase(Locale.ROOT);
default -> o.toString().toLowerCase(Locale.ROOT); default -> o.toString().toLowerCase(Locale.ROOT);
}; };
@@ -1,16 +1,15 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot; package eu.mhsl.minecraft.pixelpics.render.snapshot;
import eu.mhsl.minecraft.pixelpics.render.render.RayMap;
import java.util.Collection; import java.util.Collection;
import java.util.List;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.entity.Entity; import org.bukkit.entity.Entity;
import org.bukkit.util.Vector; import org.bukkit.util.Vector;
/** /**
* Axis-aligned world-space bounds of the camera frustum: the min/max corner of the camera origin * Axis-aligned world-space bounds of the camera frustum (origin plus every ray end at {@code maxDistance}),
* together with every ray endpoint at {@code maxDistance}. Shared by all snapshot builders to size * computed once per photo and shared by all snapshot builders.
* their chunk/entity queries identically.
*/ */
public final class FrustumBounds { public final class FrustumBounds {
@@ -26,13 +25,15 @@ public final class FrustumBounds {
this.maxZ = maxZ; this.maxZ = maxZ;
} }
static FrustumBounds of(Vector origin, List<Vector> rayMap, double maxDistance) { public static FrustumBounds of(Location eye, RayMap rayMap, double maxDistance) {
double minX = origin.getX(), minY = origin.getY(), minZ = origin.getZ(); double ox = eye.getX(), oy = eye.getY(), oz = eye.getZ();
double maxX = origin.getX(), maxY = origin.getY(), maxZ = origin.getZ(); double minX = ox, minY = oy, minZ = oz;
for (Vector ray : rayMap) { double maxX = ox, maxY = oy, maxZ = oz;
double fx = origin.getX() + ray.getX() * maxDistance; for (int i = 0, n = rayMap.size(); i < n; i++) {
double fy = origin.getY() + ray.getY() * maxDistance; Vector ray = rayMap.get(i);
double fz = origin.getZ() + ray.getZ() * maxDistance; double fx = ox + ray.getX() * maxDistance;
double fy = oy + ray.getY() * maxDistance;
double fz = oz + ray.getZ() * maxDistance;
minX = Math.min(minX, fx); minX = Math.min(minX, fx);
maxX = Math.max(maxX, fx); maxX = Math.max(maxX, fx);
minY = Math.min(minY, fy); minY = Math.min(minY, fy);
@@ -43,20 +44,15 @@ public final class FrustumBounds {
return new FrustumBounds(minX, minY, minZ, maxX, maxY, maxZ); return new FrustumBounds(minX, minY, minZ, maxX, maxY, maxZ);
} }
/** All entities whose centre falls within these bounds expanded by {@code margin} on every side. */
/** /**
* All entities near the view frustum, queried ONCE per photo and shared by the entity, decoration and * Entities near the frustum (2-block margin), queried once per photo and shared by the entity, decoration
* object snapshot builders. MUST run on the main thread. * and object builders. MUST run on the main thread.
*/ */
public static Collection<Entity> nearbyEntities(Location eye, List<Vector> rayMap, double maxDistance) { public Collection<Entity> nearbyEntities(World world) {
return of(eye.toVector(), rayMap, maxDistance).nearbyEntities(eye.getWorld(), 2);
}
Collection<Entity> nearbyEntities(World world, double margin) {
Location center = new Location(world, (minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2); Location center = new Location(world, (minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2);
double hx = (maxX - minX) / 2 + margin; double hx = (maxX - minX) / 2 + 2;
double hy = (maxY - minY) / 2 + margin; double hy = (maxY - minY) / 2 + 2;
double hz = (maxZ - minZ) / 2 + margin; double hz = (maxZ - minZ) / 2 + 2;
return world.getNearbyEntities(center, hx, hy, hz); return world.getNearbyEntities(center, hx, hy, hz);
} }
} }
@@ -9,17 +9,17 @@ import org.bukkit.block.data.BlockData;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
/** Builds {@link ItemSpec}s from Bukkit item stacks. MUST run on the main thread. */ /** Builds {@link ItemSpec}s from Bukkit item stacks. MUST run on the main thread. */
public final class ItemSpecs { final class ItemSpecs {
private ItemSpecs() {} private ItemSpecs() {}
/** Block data + block-entity state depend only on the material: computed once per material. */ /** Block data + block-entity state depend only on the material. */
private record BlockInfo(BlockData data, BlockEntityState blockEntity) {} private record BlockInfo(BlockData data, BlockEntityState blockEntity) {}
private static final Map<Material, BlockInfo> BLOCKS = new ConcurrentHashMap<>(); private static final Map<Material, BlockInfo> BLOCKS = new ConcurrentHashMap<>();
/** The spec for a stack, or null for empty/air. */ /** The spec for a stack, or null for empty/air. */
public static ItemSpec of(ItemStack stack) { static ItemSpec of(ItemStack stack) {
if (stack == null || stack.getType().isAir()) return null; if (stack == null || stack.getType().isAir()) return null;
Material m = stack.getType(); Material m = stack.getType();
BlockInfo b = BLOCKS.computeIfAbsent(m, ItemSpecs::blockInfo); BlockInfo b = BLOCKS.computeIfAbsent(m, ItemSpecs::blockInfo);
@@ -29,7 +29,14 @@ public final class ItemSpecs {
} catch (Throwable t) { } catch (Throwable t) {
glint = false; glint = false;
} }
return new ItemSpec(m.getKey().getKey(), b.data, b.blockEntity, glint); BlockEntityState be = b.blockEntity;
// Head skins / banner patterns live on the stack: only those kinds pay for the meta copy.
if (be != null && ("player".equals(be.headType()) || be.kind() == BlockEntityState.Kind.BANNER)) {
be = BlockEntitySnapshotBuilder.withItemMeta(be, stack.getItemMeta());
} else if (m == Material.SHIELD) {
be = BlockEntitySnapshotBuilder.shieldPatterns(stack.getItemMeta());
}
return new ItemSpec(m.getKey().getKey(), b.data, be, glint);
} }
private static BlockInfo blockInfo(Material m) { private static BlockInfo blockInfo(Material m) {
@@ -0,0 +1,143 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState;
import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer;
import eu.mhsl.minecraft.pixelpics.utils.MapColorPalette;
import eu.mhsl.minecraft.pixelpics.utils.MapManager;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.MapMeta;
import org.bukkit.map.MapRenderer;
import org.bukkit.map.MapView;
/**
* Captures a filled map's 128x128 colours (and vanilla frame markers) for rendering it in an item frame.
* PixelPics photo maps come from their {@link ImageMapRenderer} (or the on-disk index cache), other maps from
* the server's {@code MapItemSavedData} via reflection (Bukkit has no API to read it). MUST run on the main
* thread.
*/
final class MapPixels {
private static final int SIZE = ImageMapRenderer.MAP_SIZE;
private MapPixels() {}
/** The stack's map view, or null when it has none. */
static MapView view(ItemStack stack) {
try {
return stack.getItemMeta() instanceof MapMeta meta && meta.hasMapView() ? meta.getMapView() : null;
} catch (Throwable t) {
return null;
}
}
/** The stack's map id without loading its view, or -1. */
static int mapId(ItemStack stack) {
try {
return stack.getItemMeta() instanceof MapMeta meta && meta.hasMapId() ? meta.getMapId() : -1;
} catch (Throwable t) {
return -1;
}
}
/**
* A PixelPics photo without loading the server's map data (expensive on the main thread for a wall of photos): its
* renderer's indices, else the stored index cache, else blank film like its map shows meanwhile.
*/
static int[][] photo(int mapId) {
ImageMapRenderer renderer = MapManager.renderer(mapId);
byte[] colors = renderer != null ? renderer.getIndices() : MapManager.readIndices(mapId);
return argb(colors != null ? colors : film());
}
/** ARGB {@code [y][x]}, or null without map data (vanilla then draws the item). */
static int[][] of(MapView view) {
if (view == null) return null;
try {
return argb(colors(view));
} catch (Throwable t) {
return null;
}
}
private static int[][] argb(byte[] colors) {
if (colors == null || colors.length != SIZE * SIZE) return null;
int[][] out = new int[SIZE][SIZE];
for (int y = 0; y < SIZE; y++) {
for (int x = 0; x < SIZE; x++) out[y][x] = MapColorPalette.argb(colors[y * SIZE + x]);
}
return out;
}
private static byte[] colors(MapView view) throws ReflectiveOperationException {
for (MapRenderer r : view.getRenderers()) {
if (r instanceof ImageMapRenderer image) {
byte[] indices = image.getIndices();
return indices != null ? indices : film();
}
}
if (MapManager.isManaged(view.getId())) {
byte[] cached = MapManager.readIndices(view.getId());
if (cached != null) return cached;
}
Object data = worldMap(view);
Field colors = data == null ? null : COLORS.get(data.getClass());
return colors == null ? null : (byte[]) colors.get(data);
}
/** A photo still being rendered shows blank film, as its map does. */
private static byte[] film() {
byte[] b = new byte[SIZE * SIZE];
Arrays.fill(b, ImageMapRenderer.filmColor());
return b;
}
/**
* The decorations an item frame shows (MapDecorationType.showOnItemFrame: banners, frame/structure/target
* markers; never players), in the map data's order. Only for maps drawn by the vanilla CraftMapRenderer,
* which sends {@code MapItemSavedData.decorations} as cursors; plugin-rendered maps (photos) get none.
*/
static List<DecorationState.MapMarker> markers(MapView view) {
try {
if (view == null || MapManager.isManaged(view.getId())) return List.of();
boolean vanilla = false;
for (MapRenderer r : view.getRenderers()) {
if (r instanceof ImageMapRenderer) return List.of();
if (r.getClass().getSimpleName().equals("CraftMapRenderer")) vanilla = true;
}
if (!vanilla) return List.of();
Object data = worldMap(view);
Field f = data == null ? null : DECORATIONS.get(data.getClass());
if (f == null || !(f.get(data) instanceof Map<?, ?> decorations) || decorations.isEmpty()) return List.of();
List<DecorationState.MapMarker> out = new ArrayList<>();
for (Object d : decorations.values()) {
Method[] m = DECORATION_METHODS.get(d.getClass());
if (m == null) return List.of();
if (!(Boolean) m[0].invoke(d)) continue;
String sprite = String.valueOf(m[1].invoke(d));
out.add(new DecorationState.MapMarker(
sprite.substring(sprite.indexOf(':') + 1), (Byte) m[2].invoke(d), (Byte) m[3].invoke(d), (Byte)
m[4].invoke(d)));
}
return out;
} catch (Throwable t) {
return List.of();
}
}
private static Object worldMap(MapView view) throws ReflectiveOperationException {
Field f = WORLD_MAP.get(view.getClass());
return f == null ? null : f.get(view);
}
private static final ClassValue<Field> WORLD_MAP = Reflection.field("worldMap"); // CraftMapView
private static final ClassValue<Field> DECORATIONS = Reflection.field("decorations"); // MapItemSavedData
private static final ClassValue<Field> COLORS = Reflection.field("colors"); // MapItemSavedData
private static final ClassValue<Method[]> DECORATION_METHODS =
Reflection.methods("renderOnFrame", "getSpriteLocation", "x", "y", "rot"); // MapDecoration
}
@@ -1,13 +1,15 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot; package eu.mhsl.minecraft.pixelpics.render.snapshot;
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
import eu.mhsl.minecraft.pixelpics.render.entity.Affine; import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec; import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec;
import eu.mhsl.minecraft.pixelpics.render.entity.ObjectState; import eu.mhsl.minecraft.pixelpics.render.entity.ObjectState;
import java.lang.reflect.Method;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Locale;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.TextColor; import net.kyori.adventure.text.format.TextColor;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
@@ -32,15 +34,14 @@ import org.bukkit.util.Transformation;
import org.joml.Quaternionf; import org.joml.Quaternionf;
/** /**
* Captures block/item/text content objects into {@link ObjectState}s on the main thread: display * Captures display entities (transformation, billboard resolved against the camera), dropped items, falling
* entities (with their transformation and billboard mode, resolved against the camera), dropped items, * blocks, primed TNT, minecart blocks and leash ropes into {@link ObjectState}s. MUST run on the main thread.
* falling blocks, primed TNT and the block shown inside minecarts.
*/ */
public final class ObjectSnapshotBuilder { public final class ObjectSnapshotBuilder {
private ObjectSnapshotBuilder() {} private ObjectSnapshotBuilder() {}
/** {@code nearby}: {@link FrustumBounds#nearbyEntities} of this photo; {@code eye} orients billboards. */ /** {@code eye} orients billboards. */
public static List<ObjectState> build(Collection<Entity> nearby, Location eye) { public static List<ObjectState> build(Collection<Entity> nearby, Location eye) {
List<ObjectState> out = new ArrayList<>(); List<ObjectState> out = new ArrayList<>();
for (Entity e : nearby) { for (Entity e : nearby) {
@@ -48,13 +49,11 @@ public final class ObjectSnapshotBuilder {
ObjectState s = toState(e, eye); ObjectState s = toState(e, eye);
if (s != null) out.add(s); if (s != null) out.add(s);
} catch (Throwable ignored) { } catch (Throwable ignored) {
// API differences on other server versions: skip this object
} }
try { try {
ObjectState leash = leash(e); ObjectState leash = leash(e);
if (leash != null) out.add(leash); if (leash != null) out.add(leash);
} catch (Throwable ignored) { } catch (Throwable ignored) {
// not leashable on this version
} }
} }
return out; return out;
@@ -64,13 +63,14 @@ public final class ObjectSnapshotBuilder {
Location l = e.getLocation(); Location l = e.getLocation();
Affine at = Affine.translation(l.getX(), l.getY(), l.getZ()); Affine at = Affine.translation(l.getX(), l.getY(), l.getZ());
if (e instanceof Display d) { if (e instanceof Display d) {
Affine base = at.mul(billboard(d, eye)).mul(transformation(d.getTransformation())); Affine base = at.mul(billboard(d, l, eye)).mul(transformation(d.getTransformation()));
if (d instanceof BlockDisplay bd) { if (d instanceof BlockDisplay bd) {
return block(base, bd.getBlock()); return block(base, bd.getBlock());
} }
if (d instanceof ItemDisplay id) { if (d instanceof ItemDisplay id) {
String ctx = id.getItemDisplayTransform().name().toLowerCase(java.util.Locale.ROOT); String ctx = id.getItemDisplayTransform().name().toLowerCase(Locale.ROOT);
return item(base, id.getItemStack(), ctx, false); // ItemDisplayRenderer.submitInner turns the item by 180° about Y before submitting it.
return item(base.mul(Affine.rotY(Math.PI)), id.getItemStack(), ctx, false);
} }
if (d instanceof TextDisplay td) { if (d instanceof TextDisplay td) {
return text(base, td); return text(base, td);
@@ -78,19 +78,28 @@ public final class ObjectSnapshotBuilder {
return null; return null;
} }
if (e instanceof FallingBlock fb) { if (e instanceof FallingBlock fb) {
return block(at.mul(Affine.translation(-0.5, 0, -0.5)), fb.getBlockData()); // FallingBlockRenderer: weighted variants seeded by getSeed(startPos) (the block it fell from; Paper's
// origin = the spawn position at the block centre).
BlockData data = fb.getBlockData();
if (data == null || data.getMaterial().isAir()) return null;
Location start = fb.getOrigin() != null ? fb.getOrigin() : fb.getLocation();
long seed = BlockModelRegistry.seed(data, start.getBlockX(), start.getBlockY(), start.getBlockZ());
return ObjectState.block(
at.mul(Affine.translation(-0.5, 0, -0.5)),
data,
BlockEntitySnapshotBuilder.fromBlockData(data),
seed);
} }
if (e instanceof TNTPrimed) { if (e instanceof TNTPrimed) {
return block(at.mul(Affine.translation(-0.5, 0, -0.5)), Material.TNT.createBlockData()); return block(at.mul(Affine.translation(-0.5, 0, -0.5)), Material.TNT.createBlockData());
} }
if (e instanceof Item it) { if (e instanceof Item it) {
// Dropped item: vanilla GROUND display transform, lifted by the baker; a fixed per-entity spin. // GROUND transform, lifted by the baker. The spin (vanilla: age/20 + random bobOffset) is frozen from
double spin = (e.getUniqueId().getLeastSignificantBits() & 0xFFFF) / 65536.0 * 2 * Math.PI; // the yaw, which ItemEntity also randomises at creation (tests fix it with setRotation).
double spin = Math.toRadians(l.getYaw());
return item(at.mul(Affine.rotY(spin)), it.getItemStack(), "ground", true); return item(at.mul(Affine.rotY(spin)), it.getItemStack(), "ground", true);
} }
if (e instanceof Minecart mc) { if (e instanceof Minecart mc) {
BlockData data = mc.getDisplayBlockData();
if (data == null || data.getMaterial().isAir()) return null;
// Vanilla AbstractMinecartRenderer: cart pivot 0.375 up, yaw/pitch, then the block at 3/4 size. // Vanilla AbstractMinecartRenderer: cart pivot 0.375 up, yaw/pitch, then the block at 3/4 size.
Affine base = at.mul(Affine.translation(0, 0.375, 0)) Affine base = at.mul(Affine.translation(0, 0.375, 0))
.mul(Affine.rotY(Math.PI - Math.toRadians(l.getYaw()))) .mul(Affine.rotY(Math.PI - Math.toRadians(l.getYaw())))
@@ -98,15 +107,14 @@ public final class ObjectSnapshotBuilder {
.mul(Affine.scale(0.75)) .mul(Affine.scale(0.75))
.mul(Affine.translation(-0.5, (mc.getDisplayBlockOffset() - 8) / 16.0, 0.5)) .mul(Affine.translation(-0.5, (mc.getDisplayBlockOffset() - 8) / 16.0, 0.5))
.mul(Affine.rotY(Math.PI / 2)); .mul(Affine.rotY(Math.PI / 2));
return block(base, data); return block(base, mc.getDisplayBlockData());
} }
return null; return null;
} }
private static ObjectState block(Affine toWorld, BlockData data) { private static ObjectState block(Affine toWorld, BlockData data) {
if (data == null || data.getMaterial().isAir()) return null; if (data == null || data.getMaterial().isAir()) return null;
BlockEntityState be = BlockEntitySnapshotBuilder.fromBlockData(data); // chest/banner/head/… content return ObjectState.block(toWorld, data, BlockEntitySnapshotBuilder.fromBlockData(data));
return ObjectState.block(toWorld, data, be);
} }
private static ObjectState item(Affine toWorld, ItemStack stack, String context, boolean dropped) { private static ObjectState item(Affine toWorld, ItemStack stack, String context, boolean dropped) {
@@ -116,22 +124,12 @@ public final class ObjectSnapshotBuilder {
} }
/** Per class: {isLeashed, getLeashHolder} for non-living leashables (boats, …), or null. */ /** Per class: {isLeashed, getLeashHolder} for non-living leashables (boats, …), or null. */
private static final ClassValue<java.lang.reflect.Method[]> LEASH_METHODS = new ClassValue<>() { private static final ClassValue<Method[]> LEASH_METHODS = Reflection.methods("isLeashed", "getLeashHolder");
@Override
protected java.lang.reflect.Method[] computeValue(Class<?> c) {
try {
return new java.lang.reflect.Method[] {c.getMethod("isLeashed"), c.getMethod("getLeashHolder")};
} catch (NoSuchMethodException e) {
return null;
}
}
};
/** /**
* A leash rope from a leashed entity to its holder (vanilla LeashRenderer endpoints, approximated): the * A leash rope from a leashed entity to its holder (vanilla LeashRenderer endpoints, approximated): the
* entity end at eye height, 0.4·width in front; the holder end at a knot's centre, a humanoid holder's * entity end at eye height, 0.4·width in front; the holder end at a knot's centre, a humanoid holder's
* right hand, or 0.7·eye height for other holders. Works for any entity exposing * right hand, or 0.7·eye height for other holders.
* {@code isLeashed()/getLeashHolder()} (mobs, and boats on versions where they are leashable).
*/ */
private static ObjectState leash(Entity e) throws ReflectiveOperationException { private static ObjectState leash(Entity e) throws ReflectiveOperationException {
Entity holder; Entity holder;
@@ -139,34 +137,42 @@ public final class ObjectSnapshotBuilder {
if (!le.isLeashed()) return null; if (!le.isLeashed()) return null;
holder = le.getLeashHolder(); holder = le.getLeashHolder();
} else { } else {
java.lang.reflect.Method[] m = LEASH_METHODS.get(e.getClass()); Method[] m = LEASH_METHODS.get(e.getClass());
if (m == null || !(Boolean) m[0].invoke(e)) return null; if (m == null || !(Boolean) m[0].invoke(e)) return null;
holder = (Entity) m[1].invoke(e); holder = (Entity) m[1].invoke(e);
} }
if (holder == null) return null; if (holder == null) return null;
Location l = e.getLocation(); Location l = e.getLocation();
double yaw = Math.toRadians(e instanceof LivingEntity liv ? liv.getBodyYaw() : l.getYaw()); double yaw = Math.toRadians(bodyYaw(e, l));
double eye = e instanceof LivingEntity liv ? liv.getEyeHeight() : e.getHeight() * 0.5;
double fwd = e.getWidth() * 0.4; double fwd = e.getWidth() * 0.4;
double[] from = {l.getX() - Math.sin(yaw) * fwd, l.getY() + eye, l.getZ() + Math.cos(yaw) * fwd}; double[] p = new double[6];
p[0] = l.getX() - Math.sin(yaw) * fwd;
p[1] = l.getY() + eyeHeight(e);
p[2] = l.getZ() + Math.cos(yaw) * fwd;
Location h = holder.getLocation(); Location h = holder.getLocation();
double[] to; p[3] = h.getX();
p[5] = h.getZ();
if (holder instanceof LeashHitch) { if (holder instanceof LeashHitch) {
to = new double[] {h.getX(), h.getY() + 0.2, h.getZ()}; p[4] = h.getY() + 0.2;
} else if (holder instanceof HumanEntity } else if (holder instanceof HumanEntity
|| holder.getType().getKey().getKey().equals("mannequin")) { || holder.getType().getKey().getKey().equals("mannequin")) {
double hy = Math.toRadians(holder instanceof LivingEntity hl ? hl.getBodyYaw() : h.getYaw()); double hy = Math.toRadians(bodyYaw(holder, h));
double side = 0.35, f = 0.1; double side = 0.35, f = 0.1;
to = new double[] { p[3] = h.getX() - Math.cos(hy) * side - Math.sin(hy) * f;
h.getX() - Math.cos(hy) * side - Math.sin(hy) * f, p[4] = h.getY() + holder.getHeight() * 0.42;
h.getY() + holder.getHeight() * 0.42, p[5] = h.getZ() - Math.sin(hy) * side + Math.cos(hy) * f;
h.getZ() - Math.sin(hy) * side + Math.cos(hy) * f
};
} else { } else {
double he = holder instanceof LivingEntity hl ? hl.getEyeHeight() : holder.getHeight() * 0.5; p[4] = h.getY() + eyeHeight(holder) * 0.7;
to = new double[] {h.getX(), h.getY() + he * 0.7, h.getZ()};
} }
return ObjectState.leash(new double[] {from[0], from[1], from[2], to[0], to[1], to[2]}); return ObjectState.leash(p);
}
private static double bodyYaw(Entity e, Location l) {
return e instanceof LivingEntity le ? le.getBodyYaw() : l.getYaw();
}
private static double eyeHeight(Entity e) {
return e instanceof LivingEntity le ? le.getEyeHeight() : e.getHeight() * 0.5;
} }
private static ObjectState text(Affine base, TextDisplay td) { private static ObjectState text(Affine base, TextDisplay td) {
@@ -175,24 +181,26 @@ public final class ObjectSnapshotBuilder {
if (plain.isBlank()) return null; if (plain.isBlank()) return null;
TextColor color = c.color(); TextColor color = c.color();
int opacity = td.getTextOpacity() & 0xFF; int opacity = td.getTextOpacity() & 0xFF;
if (opacity == 0) opacity = 255; // -1 (default) = fully opaque if (opacity < 26) opacity = 255; // vanilla: -1 (default) and very low values render opaque
if (opacity < 26) opacity = 255; // vanilla treats very low values as opaque too
int rgb = color == null ? 0xFFFFFF : color.value(); int rgb = color == null ? 0xFFFFFF : color.value();
int textArgb = (opacity << 24) | rgb; int textArgb = (opacity << 24) | rgb;
Color bg = td.getBackgroundColor(); Color bg = td.getBackgroundColor();
int bgArgb = td.isDefaultBackground() || bg == null ? 0x40000000 : bg.asARGB(); int bgArgb = td.isDefaultBackground() || bg == null ? 0x40000000 : bg.asARGB();
// Vanilla text displays render font pixels at 1/40 block. // Vanilla text displays render font pixels at 1/40 block.
Affine toWorld = base.mul(Affine.scale(0.025));
return ObjectState.text( return ObjectState.text(
toWorld, Arrays.asList(plain.split("\n", -1)), td.getLineWidth(), textArgb, bgArgb, td.isShadowed()); base.mul(Affine.scale(0.025)),
Arrays.asList(plain.split("\n", -1)),
td.getLineWidth(),
textArgb,
bgArgb,
td.isShadowed());
} }
/** /**
* Display orientation (vanilla DisplayRenderer): FIXED uses the entity yaw/pitch; CENTER faces the * Display orientation (vanilla DisplayRenderer): FIXED uses the entity yaw/pitch; CENTER faces the
* camera; VERTICAL turns only around Y towards it; HORIZONTAL keeps the entity yaw, tilts with the camera. * camera; VERTICAL turns only around Y towards it; HORIZONTAL keeps the entity yaw, tilts with the camera.
*/ */
private static Affine billboard(Display d, Location eye) { private static Affine billboard(Display d, Location l, Location eye) {
Location l = d.getLocation();
double yaw = Math.toRadians(l.getYaw()), pitch = Math.toRadians(l.getPitch()); double yaw = Math.toRadians(l.getYaw()), pitch = Math.toRadians(l.getPitch());
double camYaw = Math.toRadians(eye.getYaw()), camPitch = Math.toRadians(eye.getPitch()); double camYaw = Math.toRadians(eye.getYaw()), camPitch = Math.toRadians(eye.getPitch());
return switch (d.getBillboard()) { return switch (d.getBillboard()) {
@@ -0,0 +1,65 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
/**
* Per-class cached reflection handles for server internals Bukkit has no API for (null when absent, so callers
* fall back). Looked up once per runtime class, not per entity/photo.
*/
final class Reflection {
private Reflection() {}
/** The first of the public no-arg methods {@code names} the class has, or null. */
static ClassValue<Method> method(String... names) {
return new ClassValue<>() {
@Override
protected Method computeValue(Class<?> c) {
for (String n : names) {
try {
return c.getMethod(n);
} catch (NoSuchMethodException ignored) {
}
}
return null;
}
};
}
/** All the public no-arg methods {@code names}, or null unless the class has every one. */
static ClassValue<Method[]> methods(String... names) {
return new ClassValue<>() {
@Override
protected Method[] computeValue(Class<?> c) {
Method[] m = new Method[names.length];
try {
for (int i = 0; i < names.length; i++) m[i] = c.getMethod(names[i]);
} catch (NoSuchMethodException e) {
return null;
}
return m;
}
};
}
/** A public (possibly inherited) field, else a field declared by the class itself (made accessible), or null. */
static ClassValue<Field> field(String name) {
return new ClassValue<>() {
@Override
protected Field computeValue(Class<?> c) {
try {
return c.getField(name);
} catch (NoSuchFieldException ignored) {
}
try {
Field f = c.getDeclaredField(name);
f.setAccessible(true);
return f;
} catch (ReflectiveOperationException | RuntimeException e) {
return null;
}
}
};
}
}
@@ -1,66 +1,70 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot; package eu.mhsl.minecraft.pixelpics.render.snapshot;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.logging.Logger; import java.util.logging.Logger;
import org.bukkit.ChunkSnapshot; import org.bukkit.ChunkSnapshot;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.util.Vector;
/** /**
* Captures the world region covered by the camera frustum into a {@link WorldSnapshot}. * Captures the world region covered by the camera frustum into a {@link WorldSnapshot}. MUST run on the main
* thread. Only already-loaded chunks are captured (no forced loading), rays into unloaded areas hit sky.
* *
* <p>MUST be called on the main server thread: it reads live chunks. Only already-loaded chunks are * <p>Chunk snapshots always hold the full world height, so the snapshot keeps all of it: the sky light under water
* captured (no forced generation), so the call is cheap and rays into unloaded areas hit sky. * counts the water column up to its surface ({@code FluidRenderer.skyLevel}), which may lie above the frustum. The
* chunks around the camera ({@link #CAMERA_MARGIN}) are captured too, also those behind it: the underwater fog
* blends the biomes around the eye.
*/ */
public final class SnapshotBuilder { public final class SnapshotBuilder {
/** Safety cap on captured chunks to avoid pathological memory/latency. */ /** Safety cap on captured chunks (memory/latency). */
private static final int MAX_CHUNKS = 4096; private static final int MAX_CHUNKS = 4096;
/**
* Blocks around the eye always captured: the water fog's Gaussian biome blend reads quart cells up to 12 blocks
* (+3 within the cell) from the eye ({@code CameraMedium}).
*/
private static final int CAMERA_MARGIN = 16;
private SnapshotBuilder() {} private SnapshotBuilder() {}
public static WorldSnapshot build(Location eye, List<Vector> rayMap, double maxDistance, Logger logger) { public static WorldSnapshot build(World world, Location eye, FrustumBounds bounds, Logger logger) {
World world = eye.getWorld(); int minY = world.getMinHeight();
FrustumBounds bounds = FrustumBounds.of(eye.toVector(), rayMap, maxDistance); int maxY = world.getMaxHeight();
int worldMinY = world.getMinHeight(); ChunkArea area = chunkArea(eye, bounds, 0);
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<>(); Map<Long, ChunkSnapshot> chunks = new HashMap<>();
int captured = 0;
int skipped = 0; int skipped = 0;
for (int cx = minCX; cx <= maxCX; cx++) { for (int cx = area.minCX(); cx <= area.maxCX(); cx++) {
for (int cz = minCZ; cz <= maxCZ; cz++) { for (int cz = area.minCZ(); cz <= area.maxCZ(); cz++) {
if (captured >= MAX_CHUNKS) { if (chunks.size() >= MAX_CHUNKS || !world.isChunkLoaded(cx, cz)) {
skipped++; skipped++;
continue; continue;
} }
if (!world.isChunkLoaded(cx, cz)) { chunks.put(
skipped++; WorldSnapshot.chunkKey(cx, cz), world.getChunkAt(cx, cz).getChunkSnapshot(false, true, false));
continue;
}
ChunkSnapshot cs = world.getChunkAt(cx, cz).getChunkSnapshot(false, true, false);
chunks.put(WorldSnapshot.chunkKey(cx, cz), cs);
captured++;
} }
} }
if (skipped > 0) { if (skipped > 0) {
logger.fine( logger.fine(String.format(
String.format("Snapshot captured %d chunks, skipped %d (unloaded or over cap)", captured, skipped)); "Snapshot captured %d chunks, skipped %d (unloaded or over cap)", chunks.size(), skipped));
} }
return new WorldSnapshot(chunks, minY, maxY, Material.AIR.createBlockData());
}
return new WorldSnapshot(chunks, clampedMinY, clampedMaxY, Material.AIR.createBlockData()); /** Chunk coordinates (inclusive) of a capture area. */
public record ChunkArea(int minCX, int maxCX, int minCZ, int maxCZ) {}
/** The chunks {@link #build} captures for these bounds, widened by {@code margin} blocks on every side. */
public static ChunkArea chunkArea(Location eye, FrustumBounds bounds, double margin) {
double r = CAMERA_MARGIN + margin;
return new ChunkArea(
(int) Math.floor(Math.min(bounds.minX - margin, eye.getX() - r)) >> 4,
(int) Math.floor(Math.max(bounds.maxX + margin, eye.getX() + r)) >> 4,
(int) Math.floor(Math.min(bounds.minZ - margin, eye.getZ() - r)) >> 4,
(int) Math.floor(Math.max(bounds.maxZ + margin, eye.getZ() + r)) >> 4);
} }
} }
@@ -6,37 +6,73 @@ import org.bukkit.block.Biome;
import org.bukkit.block.data.BlockData; import org.bukkit.block.data.BlockData;
/** /**
* An immutable, thread-safe view of a bounded region of the world, backed by {@link ChunkSnapshot}s. * Immutable, thread-safe view of the captured chunks; lookups outside them return air/null.
* Block/biome lookups outside the captured region return air/null so rays simply terminate there. *
* <p>Performance: the chunks live in a primitive open-addressing table (linear probing, Fibonacci hash of the
* full {@link #chunkKey}), not a {@code HashMap<Long, …>}: {@code Long.hashCode} is {@code cx ^ cz}, so every
* diagonal would collide into treeified bins and each block lookup would pay for boxing.
*/ */
public final class WorldSnapshot { public final class WorldSnapshot {
private final Map<Long, ChunkSnapshot> chunks; private static final long GOLDEN = 0x9E3779B97F4A7C15L;
private final long[] keys;
private final ChunkSnapshot[] values; // null = empty slot
private final int mask;
private final int shift;
private final int minY; private final int minY;
private final int maxY; // exclusive private final int maxY; // exclusive
private final BlockData air; private final BlockData air;
public WorldSnapshot(Map<Long, ChunkSnapshot> chunks, int minY, int maxY, BlockData air) { public WorldSnapshot(Map<Long, ChunkSnapshot> chunks, int minY, int maxY, BlockData air) {
this.chunks = chunks; // Power-of-two capacity >= 2·size (load factor <= 0.5), at least 2 so the shift stays below 64.
int cap = Math.max(2, Integer.highestOneBit(Math.max(1, chunks.size()) * 2 - 1) << 1);
this.keys = new long[cap];
this.values = new ChunkSnapshot[cap];
this.mask = cap - 1;
this.shift = 64 - Integer.numberOfTrailingZeros(cap);
for (Map.Entry<Long, ChunkSnapshot> e : chunks.entrySet()) {
long key = e.getKey();
int i = slot(key);
while (values[i] != null) i = (i + 1) & mask;
keys[i] = key;
values[i] = e.getValue();
}
this.minY = minY; this.minY = minY;
this.maxY = maxY; this.maxY = maxY;
this.air = air; this.air = air;
} }
/** Unique key of a chunk position (x in the high, z in the low 32 bits). */
public static long chunkKey(int chunkX, int chunkZ) { public static long chunkKey(int chunkX, int chunkZ) {
return ((long) chunkX << 32) ^ (chunkZ & 0xFFFFFFFFL); return ((long) chunkX << 32) ^ (chunkZ & 0xFFFFFFFFL);
} }
private int slot(long key) {
return (int) ((key * GOLDEN) >>> shift);
}
private ChunkSnapshot chunk(int chunkX, int chunkZ) {
long key = chunkKey(chunkX, chunkZ);
int i = slot(key);
ChunkSnapshot cs;
while ((cs = values[i]) != null) {
if (keys[i] == key) return cs;
i = (i + 1) & mask;
}
return null;
}
public BlockData getBlockData(int x, int y, int z) { public BlockData getBlockData(int x, int y, int z) {
if (y < minY || y >= maxY) return air; if (y < minY || y >= maxY) return air;
ChunkSnapshot cs = chunks.get(chunkKey(x >> 4, z >> 4)); ChunkSnapshot cs = chunk(x >> 4, z >> 4);
if (cs == null) return air; if (cs == null) return air;
return cs.getBlockData(x & 15, y, z & 15); return cs.getBlockData(x & 15, y, z & 15);
} }
public Biome getBiome(int x, int y, int z) { public Biome getBiome(int x, int y, int z) {
if (y < minY || y >= maxY) return null; if (y < minY || y >= maxY) return null;
ChunkSnapshot cs = chunks.get(chunkKey(x >> 4, z >> 4)); ChunkSnapshot cs = chunk(x >> 4, z >> 4);
if (cs == null) return null; if (cs == null) return null;
return cs.getBiome(x & 15, y, z & 15); return cs.getBiome(x & 15, y, z & 15);
} }
@@ -3,6 +3,7 @@ package eu.mhsl.minecraft.pixelpics.render.tint;
import com.google.gson.JsonElement; import com.google.gson.JsonElement;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
import com.google.gson.JsonPrimitive;
import java.io.InputStream; import java.io.InputStream;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -26,11 +27,14 @@ public final class BiomeClimate {
public static final int NONE = -1; public static final int NONE = -1;
/** /**
* @param water ARGB-less RGB water color * @param water RGB water colour (no alpha)
* @param grass explicit grass color override, or {@link #NONE} * @param grass explicit grass color override, or {@link #NONE}
* @param foliage explicit foliage color override, or {@link #NONE} * @param foliage explicit foliage color override, or {@link #NONE}
* @param dryFoliage explicit dry-foliage color override, or {@link #NONE} * @param dryFoliage explicit dry-foliage color override, or {@link #NONE}
* @param grassModifier vanilla {@code grass_color_modifier} ("swamp", "dark_forest") or null * @param grassModifier vanilla {@code grass_color_modifier} ("swamp", "dark_forest") or null
* @param waterFogColor RGB underwater fog colour (attribute {@code minecraft:visual/water_fog_color})
* @param waterFogStart underwater fog start distance ({@code minecraft:visual/water_fog_start_distance})
* @param waterFogEnd underwater fog end distance ({@code minecraft:visual/water_fog_end_distance})
*/ */
public record Climate( public record Climate(
double temperature, double temperature,
@@ -39,9 +43,52 @@ public final class BiomeClimate {
int grass, int grass,
int foliage, int foliage,
int dryFoliage, int dryFoliage,
String grassModifier) {} String grassModifier,
int waterFogColor,
double waterFogStart,
double waterFogEnd) {
public static final int DEFAULT_WATER = 0x3F76E4; Climate(
double temperature,
double downfall,
int water,
int grass,
int foliage,
int dryFoliage,
String grassModifier) {
this(
temperature,
downfall,
water,
grass,
foliage,
dryFoliage,
grassModifier,
DEFAULT_WATER_FOG_COLOR,
DEFAULT_WATER_FOG_START,
DEFAULT_WATER_FOG_END);
}
Climate withWaterFog(int color, double endFactor) {
return new Climate(
temperature,
downfall,
water,
grass,
foliage,
dryFoliage,
grassModifier,
color,
waterFogStart,
waterFogEnd * endFactor);
}
}
private static final int DEFAULT_WATER = 0x3F76E4;
// Vanilla 26.3 EnvironmentAttributes defaults (no dimension type sets them).
private static final int DEFAULT_WATER_FOG_COLOR = 0x050533;
private static final double DEFAULT_WATER_FOG_START = -8;
private static final double DEFAULT_WATER_FOG_END = 96;
public static final Climate DEFAULT = new Climate(0.8, 0.4, DEFAULT_WATER, NONE, NONE, NONE, null); public static final Climate DEFAULT = new Climate(0.8, 0.4, DEFAULT_WATER, NONE, NONE, NONE, null);
private static final String DATA_PATH = "data/minecraft/worldgen/biome/"; private static final String DATA_PATH = "data/minecraft/worldgen/biome/";
@@ -123,13 +170,60 @@ public final class BiomeClimate {
put("windswept_hills", 0.2, 0.3, 0x3F76E4, NONE, NONE, NONE, null); put("windswept_hills", 0.2, 0.3, 0x3F76E4, NONE, NONE, NONE, null);
put("windswept_savanna", 2.0, 0.0, 0x3F76E4, NONE, NONE, NONE, null); put("windswept_savanna", 2.0, 0.0, 0x3F76E4, NONE, NONE, NONE, null);
put("wooded_badlands", 2.0, 0.0, 0x3F76E4, 0x90814D, 0x9E814D, NONE, null); put("wooded_badlands", 2.0, 0.0, 0x3F76E4, 0x90814D, 0x9E814D, NONE, null);
// Biome attributes minecraft:visual/water_fog_color (+ water_fog_end_distance multiply modifier).
fog("cherry_grove", 0x5DB7EF, 1);
fog("dappled_forest", 0x375154, 1);
fog("deep_lukewarm_ocean", 0x041633, 1);
fog("lukewarm_ocean", 0x041633, 1);
fog("mangrove_swamp", 0x4D7A60, 0.85);
fog("pale_garden", 0x556980, 1);
fog("sulfur_caves", 0x17543C, 1);
fog("swamp", 0x232317, 0.85);
fog("warm_ocean", 0x041F33, 1);
}
private static void fog(String key, int color, double endFactor) {
FALLBACK.put(key, FALLBACK.get(key).withWaterFog(color, endFactor));
} }
private BiomeClimate() {} private BiomeClimate() {}
// Biome attribute minecraft:visual/fog_color of the vanilla 26.3 nether biomes (fallback without server data).
private static final Map<String, Integer> FOG_FALLBACK = Map.of(
"nether_wastes", 0x330808,
"basalt_deltas", 0x685F70,
"crimson_forest", 0x330303,
"warped_forest", 0x1A051A,
"soul_sand_valley", 0x1B4745);
private static final Map<String, Integer> FOG_LOADED = new ConcurrentHashMap<>();
/** The biome's {@code minecraft:visual/fog_color} attribute applied to {@code base}. */
public static int fogColor(String biomePath, int base) {
int loaded = FOG_LOADED.computeIfAbsent(biomePath, BiomeClimate::loadFogColor);
if (loaded != NONE) return loaded == Integer.MIN_VALUE ? base : loaded;
return FOG_FALLBACK.getOrDefault(biomePath, base);
}
/** The fog colour of the biome file (MIN_VALUE: file without one), NONE without server data. */
private static int loadFogColor(String biomePath) {
if (!biomePath.matches("[a-z0-9_./-]+")) return NONE;
ClassLoader loader = BiomeClimate.class.getClassLoader();
if (loader == null) return NONE;
try (InputStream in = loader.getResourceAsStream(DATA_PATH + biomePath + ".json")) {
if (in == null) return NONE;
JsonObject root = JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8))
.getAsJsonObject();
JsonObject attributes = root.has("attributes") ? root.getAsJsonObject("attributes") : new JsonObject();
if (!attributes.has("minecraft:visual/fog_color")) return Integer.MIN_VALUE;
return rgbAttribute(attributes, "minecraft:visual/fog_color", 0);
} catch (Exception e) {
return NONE;
}
}
public static Climate forKey(String biomePath) { public static Climate forKey(String biomePath) {
Optional<Climate> loaded = LOADED.computeIfAbsent(biomePath, BiomeClimate::loadFromServerData); Optional<Climate> loaded = LOADED.computeIfAbsent(biomePath, BiomeClimate::loadFromServerData);
return loaded.orElseGet(() -> FALLBACK.getOrDefault(biomePath, DEFAULT)); return loaded.isPresent() ? loaded.get() : FALLBACK.getOrDefault(biomePath, DEFAULT);
} }
private static Optional<Climate> loadFromServerData(String biomePath) { private static Optional<Climate> loadFromServerData(String biomePath) {
@@ -142,6 +236,7 @@ public final class BiomeClimate {
.getAsJsonObject(); .getAsJsonObject();
JsonObject effects = root.has("effects") ? root.getAsJsonObject("effects") : new JsonObject(); JsonObject effects = root.has("effects") ? root.getAsJsonObject("effects") : new JsonObject();
JsonElement modifier = effects.get("grass_color_modifier"); JsonElement modifier = effects.get("grass_color_modifier");
JsonObject attributes = root.has("attributes") ? root.getAsJsonObject("attributes") : new JsonObject();
return Optional.of(new Climate( return Optional.of(new Climate(
root.has("temperature") ? root.get("temperature").getAsDouble() : DEFAULT.temperature(), root.has("temperature") ? root.get("temperature").getAsDouble() : DEFAULT.temperature(),
root.has("downfall") ? root.get("downfall").getAsDouble() : DEFAULT.downfall(), root.has("downfall") ? root.get("downfall").getAsDouble() : DEFAULT.downfall(),
@@ -149,15 +244,153 @@ public final class BiomeClimate {
color(effects, "grass_color", NONE), color(effects, "grass_color", NONE),
color(effects, "foliage_color", NONE), color(effects, "foliage_color", NONE),
color(effects, "dry_foliage_color", NONE), color(effects, "dry_foliage_color", NONE),
modifier != null && modifier.isJsonPrimitive() ? modifier.getAsString() : null)); modifier != null && modifier.isJsonPrimitive() ? modifier.getAsString() : null,
rgbAttribute(attributes, "minecraft:visual/water_fog_color", DEFAULT_WATER_FOG_COLOR),
floatAttribute(attributes, "minecraft:visual/water_fog_start_distance", DEFAULT_WATER_FOG_START),
floatAttribute(attributes, "minecraft:visual/water_fog_end_distance", DEFAULT_WATER_FOG_END)));
} catch (Exception e) { } catch (Exception e) {
return Optional.empty(); return Optional.empty();
} }
} }
/**
* A float environment attribute of a biome applied to the default: a plain value overrides it, an object
* {@code {"argument": a, "modifier": op}} applies vanilla's float modifier (override/alpha_blend/add/subtract/
* multiply/minimum/maximum).
*/
private static double floatAttribute(JsonObject attributes, String key, double base) {
JsonElement el = attributes.get(key);
if (el == null) return base;
try {
if (el.isJsonPrimitive()) return el.getAsDouble();
if (!el.isJsonObject()) return base;
JsonObject o = el.getAsJsonObject();
String op = o.has("modifier") ? o.get("modifier").getAsString() : "override";
JsonElement arg = o.get("argument");
if (op.equals("alpha_blend")) {
// FloatWithAlpha: a plain value (alpha 1) or {"value", "alpha"}; lerp(alpha, base, value).
if (!arg.isJsonObject()) return arg.getAsDouble();
JsonObject v = arg.getAsJsonObject();
double alpha = v.has("alpha") ? v.get("alpha").getAsDouble() : 1;
return base + alpha * (v.get("value").getAsDouble() - base);
}
double a = arg.getAsDouble();
return switch (op) {
case "add" -> base + a;
case "subtract" -> base - a;
case "multiply" -> base * a;
case "minimum" -> Math.min(base, a);
case "maximum" -> Math.max(base, a);
default -> a;
};
} catch (RuntimeException e) {
return base;
}
}
/**
* An RGB colour environment attribute of a biome applied to the default {@code base}: a plain colour overrides
* it, an object {@code {"argument": a, "modifier": op}} applies vanilla's {@code ColorModifier} (26.3
* {@code AttributeModifier.RGB_COLOR_LIBRARY}): override, alpha_blend (argument ARGB), add, subtract, multiply
* (argument RGB), blend_to_gray ({@code {brightness, factor}}). Channels as floats 0..1 like vanilla.
*/
static int rgbAttribute(JsonObject attributes, String key, int base) {
JsonElement el = attributes.get(key);
if (el == null) return base;
try {
if (!el.isJsonObject()) return toRgb(rgb(el));
JsonObject o = el.getAsJsonObject();
JsonElement arg = o.get("argument");
String op = o.has("modifier") ? o.get("modifier").getAsString() : "override";
float[] c = rgb(new JsonPrimitive(base));
float[] r =
switch (op) {
case "override" -> rgb(arg);
case "alpha_blend" -> {
float[] a = argb(arg); // ARGB.alphaBlend: lerp towards the argument by its alpha
yield a[3] == 0 ? c : lerp(a[3], c, new float[] {a[0], a[1], a[2]});
}
case "add" -> {
float[] a = rgb(arg);
yield new float[] {
Math.min(c[0] + a[0], 1), Math.min(c[1] + a[1], 1), Math.min(c[2] + a[2], 1)
};
}
case "subtract" -> {
float[] a = rgb(arg);
yield new float[] {
Math.max(c[0] - a[0], 0), Math.max(c[1] - a[1], 0), Math.max(c[2] - a[2], 0)
};
}
case "multiply" -> {
float[] a = rgb(arg);
yield new float[] {c[0] * a[0], c[1] * a[1], c[2] * a[2]};
}
case "blend_to_gray" -> {
JsonObject a = arg.getAsJsonObject();
float brightness = a.get("brightness").getAsFloat();
float factor = a.get("factor").getAsFloat();
float g = Math.clamp((c[0] * 0.3F + c[1] * 0.59F + c[2] * 0.11F) * brightness, 0, 1);
yield lerp(factor, c, new float[] {g, g, g});
}
// Unknown operation: vanilla rejects the biome; keep the default.
default -> c;
};
return toRgb(r);
} catch (RuntimeException e) {
return base;
}
}
private static float[] lerp(float t, float[] from, float[] to) {
return new float[] {
from[0] + t * (to[0] - from[0]), from[1] + t * (to[1] - from[1]), from[2] + t * (to[2] - from[2])
};
}
/** ExtraCodecs.STRING_RGB_VEC3_COLOR: "#rrggbb", a packed int or [r, g, b] floats. */
private static float[] rgb(JsonElement el) {
if (el.isJsonArray()) {
var a = el.getAsJsonArray();
return new float[] {
a.get(0).getAsFloat(), a.get(1).getAsFloat(), a.get(2).getAsFloat()
};
}
int v = el.getAsJsonPrimitive().isNumber() ? el.getAsInt() : hex(el.getAsString(), 6);
return new float[] {(v >> 16 & 0xFF) / 255F, (v >> 8 & 0xFF) / 255F, (v & 0xFF) / 255F};
}
/** ExtraCodecs.STRING_ARGB_VEC4_COLOR as {r, g, b, a}: "#aarrggbb", a packed int or [r, g, b, a] floats. */
private static float[] argb(JsonElement el) {
if (el.isJsonArray()) {
var a = el.getAsJsonArray();
return new float[] {
a.get(0).getAsFloat(),
a.get(1).getAsFloat(),
a.get(2).getAsFloat(),
a.get(3).getAsFloat()
};
}
int v = el.getAsJsonPrimitive().isNumber() ? el.getAsInt() : hex(el.getAsString(), 8);
return new float[] {(v >> 16 & 0xFF) / 255F, (v >> 8 & 0xFF) / 255F, (v & 0xFF) / 255F, (v >>> 24) / 255F};
}
private static int hex(String s, int digits) {
if (!s.startsWith("#") || s.length() != digits + 1) throw new IllegalArgumentException(s);
return (int) Long.parseLong(s.substring(1), 16);
}
private static int toRgb(float[] c) {
return channel(c[0]) << 16 | channel(c[1]) << 8 | channel(c[2]);
}
private static int channel(float v) {
return Math.clamp(Math.round(v * 255), 0, 255);
}
/** Colors are either "#rrggbb" strings or packed integers, depending on the data version. */ /** Colors are either "#rrggbb" strings or packed integers, depending on the data version. */
private static int color(JsonObject effects, String key, int fallback) { private static int color(JsonObject obj, String key, int fallback) {
JsonElement el = effects.get(key); JsonElement el = obj.get(key);
if (el == null || !el.isJsonPrimitive()) return fallback; if (el == null || !el.isJsonPrimitive()) return fallback;
if (el.getAsJsonPrimitive().isNumber()) return el.getAsInt() & 0xFFFFFF; if (el.getAsJsonPrimitive().isNumber()) return el.getAsInt() & 0xFFFFFF;
String s = el.getAsString().trim(); String s = el.getAsString().trim();
@@ -1,6 +1,7 @@
package eu.mhsl.minecraft.pixelpics.render.tint; package eu.mhsl.minecraft.pixelpics.render.tint;
/** /** Biome tint colours (opaque ARGB) of the colormap-driven channels. */
* The biome-dependent tint colors (RGB) for the colormap-driven channels. public record BiomeTint(int grass, int foliage, int dryFoliage, int water) {
*/ /** Plains colours: the fallback without a colormap, and the tint of blocks without biome context. */
public record BiomeTint(int grass, int foliage, int dryFoliage, int water) {} public static final BiomeTint PLAINS = new BiomeTint(0xFF91BD59, 0xFF77AB2F, 0xFFA9A05B, 0xFF3F76E4);
}
@@ -6,10 +6,7 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.block.Biome; import org.bukkit.block.Biome;
/** /** Per-biome grass/foliage/water tints: explicit biome colours or the pack's colormaps, cached per 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 { public final class BiomeTintProvider {
private final int[][] grassMap; private final int[][] grassMap;
@@ -29,6 +26,11 @@ public final class BiomeTintProvider {
return cache.computeIfAbsent(keyOf(biome), this::compute); return cache.computeIfAbsent(keyOf(biome), this::compute);
} }
/** The biome's climate/effects/attribute data (water fog etc.). */
public BiomeClimate.Climate climate(Biome biome) {
return BiomeClimate.forKey(keyOf(biome));
}
private String keyOf(Biome biome) { private String keyOf(Biome biome) {
try { try {
return biome.getKey().getKey(); return biome.getKey().getKey();
@@ -39,18 +41,11 @@ public final class BiomeTintProvider {
private BiomeTint compute(String key) { private BiomeTint compute(String key) {
BiomeClimate.Climate climate = BiomeClimate.forKey(key); BiomeClimate.Climate climate = BiomeClimate.forKey(key);
// Explicit biome colors (biome "effects") win over the colormap, like in vanilla. int grass = channel(climate.grass(), grassMap, climate, BiomeTint.PLAINS.grass());
int grass = climate.grass() != BiomeClimate.NONE int foliage = channel(climate.foliage(), foliageMap, climate, BiomeTint.PLAINS.foliage());
? 0xFF000000 | climate.grass() int dry = channel(climate.dryFoliage(), dryFoliageMap, climate, BiomeTint.PLAINS.dryFoliage());
: sample(grassMap, climate.temperature(), climate.downfall(), 0xFF91BD59);
int foliage = climate.foliage() != BiomeClimate.NONE
? 0xFF000000 | climate.foliage()
: sample(foliageMap, climate.temperature(), climate.downfall(), 0xFF77AB2F);
int dry = climate.dryFoliage() != BiomeClimate.NONE
? 0xFF000000 | climate.dryFoliage()
: sample(dryFoliageMap, climate.temperature(), climate.downfall(), 0xFFA9A05B);
// Vanilla grass_color_modifier (applies to grass only). // Vanilla grass_color_modifier (grass only).
if ("swamp".equals(climate.grassModifier())) { if ("swamp".equals(climate.grassModifier())) {
// Vanilla picks 0x4C763C/0x6A7039 via position noise; use the dominant color. // Vanilla picks 0x4C763C/0x6A7039 via position noise; use the dominant color.
grass = 0xFF6A7039; grass = 0xFF6A7039;
@@ -60,11 +55,16 @@ public final class BiomeTintProvider {
return new BiomeTint(grass, foliage, dry, 0xFF000000 | climate.water()); return new BiomeTint(grass, foliage, dry, 0xFF000000 | climate.water());
} }
/** An explicit biome colour wins over the colormap, like in vanilla. */
private static int channel(int explicit, int[][] colormap, BiomeClimate.Climate climate, int fallback) {
return explicit != BiomeClimate.NONE ? 0xFF000000 | explicit : sample(colormap, climate, fallback);
}
/** Vanilla colormap lookup: x = (1-temp)*255, y = (1-downfall*temp)*255. */ /** Vanilla colormap lookup: x = (1-temp)*255, y = (1-downfall*temp)*255. */
private int sample(int[][] colormap, double temperature, double downfall, int fallback) { private static int sample(int[][] colormap, BiomeClimate.Climate climate, int fallback) {
if (colormap == null || colormap.length == 0) return fallback; if (colormap == null || colormap.length == 0) return fallback;
double temp = Math.clamp(temperature, 0, 1); double temp = Math.clamp(climate.temperature(), 0, 1);
double down = Math.clamp(downfall, 0, 1) * temp; double down = Math.clamp(climate.downfall(), 0, 1) * temp;
int x = (int) ((1.0 - temp) * 255.0); int x = (int) ((1.0 - temp) * 255.0);
int y = (int) ((1.0 - down) * 255.0); int y = (int) ((1.0 - down) * 255.0);
int h = colormap.length; int h = colormap.length;
@@ -1,63 +1,76 @@
package eu.mhsl.minecraft.pixelpics.render.tint; package eu.mhsl.minecraft.pixelpics.render.tint;
import org.bukkit.Material;
import org.bukkit.block.data.Ageable;
import org.bukkit.block.data.BlockData; import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.type.RedstoneWire;
/** /**
* Maps a tinted face (material + tintindex) to the concrete tint color, choosing between the * Maps a tinted face (block + tintindex) to its in-world tint colour like vanilla BlockColors.createDefault (26.3):
* biome-driven channels and a handful of vanilla constants. * a biome channel or a constant. Blocks without a registered tint source (bamboo, cherry and pale oak leaves, the
* lava/powder-snow cauldron, the stonecutter, …) and tint layers beyond the registered ones are not tinted.
*/ */
public final class TintResolver { public final class TintResolver {
private static final int BIRCH = 0xFF80A755;
private static final int SPRUCE = 0xFF619961; private static final int SPRUCE = 0xFF619961;
private static final int LILY_PAD = 0xFF208030; private static final int BIRCH = 0xFF80A755;
private static final int STEM = 0xFF60A017; private static final int LILY_PAD_IN_WORLD = 0xFF208030;
private static final int ATTACHED_STEM = 0xFFE0C71C;
/** RedstoneWireBlock.COLORS: the wire colour per power level, with vanilla's float arithmetic. */
private static final int[] REDSTONE = new int[16];
static {
for (int i = 0; i <= 15; i++) {
float power = i / 15.0F;
float red = power * 0.6F + (power > 0.0F ? 0.4F : 0.3F);
float green = clamp(power * power * 0.7F - 0.5F);
float blue = clamp(power * power * 0.6F - 0.7F);
REDSTONE[i] = 0xFF000000 | channel8(red) << 16 | channel8(green) << 8 | channel8(blue);
}
}
private TintResolver() {} private TintResolver() {}
/** Returns the ARGB tint to multiply with, or {@code -1} when the face should not be tinted. */ /** 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) { public static int resolve(BlockData data, int tintIndex, BiomeTint biomeTint) {
if (tintIndex < 0) return -1; Material m = data.getMaterial();
String name = data.getMaterial().name().toLowerCase(); // Pink petals and wildflowers: layer 0 (the flowers) is blank, layer 1 (the stems) grass.
if (m == Material.PINK_PETALS || m == Material.WILDFLOWERS) return tintIndex == 1 ? biomeTint.grass() : -1;
if (name.equals("birch_leaves")) return BIRCH; if (tintIndex != 0) return -1; // every other source has one layer
if (name.equals("spruce_leaves")) return SPRUCE; return switch (m) {
if (name.endsWith("leaves") || name.equals("vine")) return biomeTint.foliage(); case GRASS_BLOCK, SHORT_GRASS, FERN, POTTED_FERN -> biomeTint.grass();
if (name.equals("lily_pad")) return LILY_PAD; case BUSH, TALL_GRASS, LARGE_FERN, SUGAR_CANE -> biomeTint.grass();
case OAK_LEAVES, JUNGLE_LEAVES, ACACIA_LEAVES -> biomeTint.foliage();
if (name.equals("water") || name.equals("water_cauldron") || name.equals("bubble_column")) { case DARK_OAK_LEAVES, MANGROVE_LEAVES, VINE -> biomeTint.foliage();
return biomeTint.water(); case SPRUCE_LEAVES -> SPRUCE;
} case BIRCH_LEAVES -> BIRCH;
if (name.equals("redstone_wire")) return redstone(data); case LEAF_LITTER -> biomeTint.dryFoliage();
if (name.endsWith("stem")) return STEM; case WATER_CAULDRON -> biomeTint.water();
case REDSTONE_WIRE -> redstone(data);
// grass_block (top/overlay), short_grass, tall_grass, fern, large_fern, sugar_cane, ... case MELON_STEM, PUMPKIN_STEM -> stem(data);
if (name.contains("grass") case ATTACHED_MELON_STEM, ATTACHED_PUMPKIN_STEM -> ATTACHED_STEM;
|| name.equals("fern") case LILY_PAD -> LILY_PAD_IN_WORLD;
|| name.equals("large_fern") default -> -1;
|| 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) { private static int redstone(BlockData data) {
int power = 0; return REDSTONE[data instanceof RedstoneWire wire ? Math.clamp(wire.getPower(), 0, 15) : 0];
String s = data.getAsString(false); }
int idx = s.indexOf("power=");
if (idx >= 0) { /** BlockTintSources.stem: {@code ARGB.color(age·32, 255 - age·8, age·4)}. */
int end = idx + 6; private static int stem(BlockData data) {
int e = end; int age = data instanceof Ageable a ? a.getAge() : 0;
while (e < s.length() && Character.isDigit(s.charAt(e))) e++; return 0xFF000000 | age * 32 << 16 | (255 - age * 8) << 8 | age * 4;
try { }
power = Integer.parseInt(s.substring(end, e));
} catch (NumberFormatException ignored) { private static float clamp(float v) {
} return v < 0.0F ? 0.0F : Math.min(v, 1.0F);
} }
int r = Math.min(255, 75 + power * 12);
return 0xFF000000 | (r << 16); /** ARGB.as8BitChannel: {@code floor(v·255)}. */
private static int channel8(float v) {
return (int) Math.floor(v * 255.0F);
} }
} }
@@ -3,9 +3,7 @@ package eu.mhsl.minecraft.pixelpics.render.util;
import org.bukkit.Color; import org.bukkit.Color;
import org.bukkit.DyeColor; import org.bukkit.DyeColor;
/** /** Helpers for packed ARGB colours. */
* Helpers for packed ARGB integer colors.
*/
public final class ColorUtil { public final class ColorUtil {
private ColorUtil() {} private ColorUtil() {}
@@ -37,11 +35,8 @@ public final class ColorUtil {
return argb(0xFF, c.getRed(), c.getGreen(), c.getBlue()); return argb(0xFF, c.getRed(), c.getGreen(), c.getBlue());
} }
/** /** Vanilla {@code DyeColor.getTextColor()} (not the cloth colour) as opaque ARGB; null = black (default ink). */
* The vanilla sign-text colour for a dye (opaque ARGB), from Mojang's {@code DyeColor.getTextColor()} private static int signTextColor(DyeColor dye) {
* table (the firework/text colours, NOT the cloth colours). Null = black (the default sign ink).
*/
public static int signTextColor(DyeColor dye) {
int rgb = int rgb =
switch (dye == null ? DyeColor.BLACK : dye) { switch (dye == null ? DyeColor.BLACK : dye) {
case WHITE -> 0xF9FFFE; case WHITE -> 0xF9FFFE;
@@ -64,23 +59,16 @@ public final class ColorUtil {
return 0xFF000000 | rgb; return 0xFF000000 | rgb;
} }
/** /** Sign text fill (vanilla SignRenderer): the dye's text colour, darkened to 40 % unless glowing. */
* 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) { public static int signFillArgb(DyeColor dye, boolean glowing) {
int base = signTextColor(dye); int base = signTextColor(dye);
return glowing ? base : (0xFF000000 | (shade(base, 0.4) & 0xFFFFFF)); return glowing ? base : shade(base, 0.4);
} }
/** /** Outline of glowing sign text (vanilla {@code getDarkColor}): text colour at 40 %, black ink a light cream. */
* 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) { public static int signOutlineArgb(DyeColor dye) {
if ((dye == null ? DyeColor.BLACK : dye) == DyeColor.BLACK) return 0xFFF0EBCC; if (dye == null || dye == DyeColor.BLACK) return 0xFFF0EBCC;
return 0xFF000000 | (shade(signTextColor(dye), 0.4) & 0xFFFFFF); return shade(signTextColor(dye), 0.4);
} }
/** Multiplies the RGB channels of {@code base} by {@code tint} (per-channel, 0..255), keeping base alpha. */ /** Multiplies the RGB channels of {@code base} by {@code tint} (per-channel, 0..255), keeping base alpha. */
@@ -92,7 +80,6 @@ public final class ColorUtil {
return argb(a, r, g, b); return argb(a, r, g, b);
} }
/** Scales the RGB channels by {@code factor} (0..1), keeping alpha. Used for directional face shading. */
/** Texels with alpha at or below this are holes (alpha cutout) for every intersector and the extrusion. */ /** Texels with alpha at or below this are holes (alpha cutout) for every intersector and the extrusion. */
public static final int ALPHA_CUTOUT = 16; public static final int ALPHA_CUTOUT = 16;
@@ -101,6 +88,7 @@ public final class ColorUtil {
return alpha(argb) > ALPHA_CUTOUT; return alpha(argb) > ALPHA_CUTOUT;
} }
/** Scales the RGB channels by {@code factor}, keeping alpha. */
public static int shade(int argb, double factor) { public static int shade(int argb, double factor) {
int a = alpha(argb); int a = alpha(argb);
int r = clamp((int) (red(argb) * factor)); int r = clamp((int) (red(argb) * factor));
@@ -109,14 +97,18 @@ public final class ColorUtil {
return argb(a, r, g, b); return argb(a, r, g, b);
} }
private static int clamp(int v) { /** Additive blend (vanilla BlendFunction.ADDITIVE, ONE/ONE): RGB of {@code src} added to {@code dst}, clamped. */
public static int add(int dst, int src) {
return (clamp(red(dst) + red(src)) << 16)
| (clamp(green(dst) + green(src)) << 8)
| clamp(blue(dst) + blue(src));
}
public static int clamp(int v) {
return v < 0 ? 0 : Math.min(v, 255); return v < 0 ? 0 : Math.min(v, 255);
} }
/** /** Weighted average of two RGB colours (weights normalised by their sum); alpha ignored, result RGB only. */
* 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) { public static int mix(int rgbA, int rgbB, double weightA, double weightB) {
double total = weightA + weightB; double total = weightA + weightB;
int r = (int) ((red(rgbA) * weightA + red(rgbB) * weightB) / total); int r = (int) ((red(rgbA) * weightA + red(rgbB) * weightB) / total);
@@ -125,8 +117,6 @@ public final class ColorUtil {
return (r << 16) | (g << 8) | b; return (r << 16) | (g << 8) | b;
} }
// --- Gamma-correct (linear-light) averaging ---
private static final float[] SRGB_TO_LINEAR = new float[256]; private static final float[] SRGB_TO_LINEAR = new float[256];
static { static {
@@ -140,7 +130,7 @@ public final class ColorUtil {
return SRGB_TO_LINEAR[channel & 0xFF]; return SRGB_TO_LINEAR[channel & 0xFF];
} }
public static int toSrgb(double linear) { private static int toSrgb(double linear) {
double c = linear <= 0.0031308 ? linear * 12.92 : 1.055 * Math.pow(linear, 1 / 2.4) - 0.055; 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)); return clamp((int) Math.round(c * 255.0));
} }
@@ -1,40 +1,13 @@
package eu.mhsl.minecraft.pixelpics.render.util; package eu.mhsl.minecraft.pixelpics.render.util;
import org.bukkit.block.BlockFace;
import org.bukkit.util.Vector; import org.bukkit.util.Vector;
public class MathUtil { public final class MathUtil {
private MathUtil() {} private MathUtil() {}
private static Vector yawPitchRotation(Vector base, double angleYaw, double anglePitch) {
double oldX = base.getX();
double oldY = base.getY();
double oldZ = base.getZ();
double sinOne = Math.sin(angleYaw);
double sinTwo = Math.sin(anglePitch);
double cosOne = Math.cos(angleYaw);
double cosTwo = Math.cos(anglePitch);
double newX = oldX * cosOne * cosTwo - oldY * cosOne * sinTwo - oldZ * sinOne;
double newY = oldX * sinTwo + oldY * cosTwo;
double newZ = oldX * sinOne * cosTwo - oldY * sinOne * sinTwo + oldZ * cosOne;
return new Vector(newX, newY, newZ);
}
public static Vector doubleYawPitchRotation(
Vector base, double firstYaw, double firstPitch, double secondYaw, double secondPitch) {
return yawPitchRotation(yawPitchRotation(base, firstYaw, firstPitch), secondYaw, secondPitch);
}
/** Reflects {@code direction} across the plane with the given (unit) {@code normal}. */ /** Reflects {@code direction} across the plane with the given (unit) {@code normal}. */
public static Vector reflectVector(Vector direction, Vector normal) { public static Vector reflectVector(Vector direction, Vector normal) {
return direction.clone().subtract(normal.clone().multiply(2 * direction.dot(normal))); return direction.clone().subtract(normal.clone().multiply(2 * direction.dot(normal)));
} }
public static Vector toVector(BlockFace face) {
return new Vector(face.getModX(), face.getModY(), face.getModZ());
}
} }
@@ -0,0 +1,57 @@
package eu.mhsl.minecraft.pixelpics.render.util;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.MonthDay;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.function.Supplier;
/**
* Vanilla {@code net.minecraft.util.SpecialDates} (decompiled 26.3): the server's local date decides the
* christmas chest textures (ChestRenderer.xmasTextures = {@code isExtendedChristmas()}, 24–26 December) and the
* {@code minecraft:local_time} item-model property (chest / trapped chest items).
*
* <p>Testing: the JVM system property {@value #TEST_DATE_PROPERTY} ({@code yyyy-MM-dd}, e.g.
* {@code -Dpixelpics.testDate=2026-12-25}) replaces today's date (time of day stays the current one). Never set
* it in production.
*/
public final class SpecialDates {
public static final String TEST_DATE_PROPERTY = "pixelpics.testDate";
private static final List<MonthDay> CHRISTMAS_RANGE =
List.of(MonthDay.of(12, 24), MonthDay.of(12, 25), MonthDay.of(12, 26));
// The time of the photo being baked (set around a scene bake), so one photo never mixes two dates.
private static final ScopedValue<ZonedDateTime> PHOTO_TIME = ScopedValue.newInstance();
private SpecialDates() {}
/** Runs {@code task} with {@link #now()} fixed to {@code time} on this thread. */
public static <T> T at(ZonedDateTime time, Supplier<T> task) {
return ScopedValue.where(PHOTO_TIME, time).call(task::get);
}
/**
* The photo's time inside {@link #at}, else the current local date-time ({@code ZonedDateTime.now()}), or the
* test date at the current time of day.
*/
public static ZonedDateTime now() {
if (PHOTO_TIME.isBound()) return PHOTO_TIME.get();
ZonedDateTime now = ZonedDateTime.now();
String test = System.getProperty(TEST_DATE_PROPERTY);
if (test == null || test.isBlank()) return now;
try {
return ZonedDateTime.of(LocalDate.parse(test.trim()), LocalTime.from(now), ZoneId.systemDefault());
} catch (RuntimeException e) {
return now;
}
}
/** Vanilla {@code SpecialDates.isExtendedChristmas()}: today is 24, 25 or 26 December. */
public static boolean isExtendedChristmas() {
return CHRISTMAS_RANGE.contains(MonthDay.from(now()));
}
}
@@ -0,0 +1,67 @@
package eu.mhsl.minecraft.pixelpics.setup;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import eu.mhsl.minecraft.pixelpics.assets.ResourcePackLoader;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
/**
* The Minecraft version of the vanilla packs in {@code resourcepack/}: from the {@value #STAMP} written by the
* auto-download and {@code tools/build-vanilla-pack.sh}, else from a file name {@code vanilla-<version>.zip} (packs
* built by older versions of the script). Packs without either have no known version and are never replaced.
*/
public final class PackVersions {
/** Version stamp in the pack root: {@code {"version": "26.3", ...}}. */
public static final String STAMP = "pixelpics-pack.json";
private static final Pattern FILE_NAME = Pattern.compile("vanilla-(.+)\\.zip");
/** A zip in the pack folder; {@code version} is null when unknown. */
public record VersionedPack(Path path, String version) {}
private PackVersions() {}
public static String fileName(String version) {
return "vanilla-" + version + ".zip";
}
/** All {@code .zip} files below {@code dir} with their version, in load order. */
public static List<VersionedPack> scan(Path dir) {
if (!Files.isDirectory(dir)) return List.of();
try {
return ResourcePackLoader.zips(dir).stream()
.map(zip -> new VersionedPack(zip, version(zip)))
.toList();
} catch (IOException e) {
return List.of(); // an unreadable folder: the loader reports it
}
}
/** The pack's version, or null when it has no stamp and no {@code vanilla-<version>.zip} name. */
private static String version(Path zip) {
try (ZipFile file = new ZipFile(zip.toFile())) {
ZipEntry stamp = file.getEntry(STAMP);
if (stamp != null) {
try (InputStream in = file.getInputStream(stamp)) {
JsonElement root = JsonParser.parseString(new String(in.readAllBytes(), StandardCharsets.UTF_8));
String v = root.getAsJsonObject().get("version").getAsString();
if (!v.isBlank()) return v;
}
}
} catch (IOException | RuntimeException ignored) {
// not a zip or no readable stamp: fall back to the file name
}
Matcher m = FILE_NAME.matcher(zip.getFileName().toString());
return m.matches() ? m.group(1) : null;
}
}
@@ -0,0 +1,533 @@
package eu.mhsl.minecraft.pixelpics.setup;
import eu.mhsl.minecraft.pixelpics.Main;
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.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.entity.cem.DatapackVariants;
import eu.mhsl.minecraft.pixelpics.render.render.DefaultScreenRenderer;
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
import eu.mhsl.minecraft.pixelpics.utils.DebugLog;
import eu.mhsl.minecraft.pixelpics.utils.MainThread;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.ConnectException;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.file.FileConfiguration;
/**
* Builds the renderer off the main thread (start and {@code /pixelpic reload}), downloading or updating the vanilla
* pack as configured, and swaps it in on the main thread. During an update the old pack keeps rendering; replaced
* packs are closed once no photo renders with them any more.
*/
public final class SetupManager {
static final String DUMP_FILE = "vanilla_entity_models.json";
private static final String BUNDLED_DUMP = "cem/vanilla_entity_models.json";
private static final Pattern DUMP_VERSION = Pattern.compile("\"version\"\\s*:\\s*\"([^\"]+)\"");
private static final int SAMPLE = 8;
/** {@code version} null = unknown. */
public record PackInfo(String name, String version, boolean loaded) {}
/** How many of the server's blocks/items the pack has a block state/item definition for. */
public record Coverage(int total, int missing, List<String> sample) {}
/** What the current renderer was built from, for {@code /pixelpic info}. */
public record Report(
List<PackInfo> packs,
Coverage blocks,
Coverage items,
String dumpSource,
String dumpVersion,
int entityModels,
int datapackVariants,
boolean fontOk,
long millis) {}
private record Built(ResourcePack pack, DefaultScreenRenderer renderer, Report report) {}
private record Inputs(
String serverVersion,
boolean autoDownload,
boolean autoUpdate,
String manifestUrl,
long maxBytes,
List<File> worldFolders,
List<String> blocks,
List<String> items,
ForkJoinPool tracePool) {}
private record Dump(String version, boolean override) {}
private final Main plugin;
private final Logger log;
private final File packDir;
private final File downloadDir;
private final ExecutorService worker;
private volatile DefaultScreenRenderer renderer;
private volatile Report report;
/** The current background step (German, shown in messages and info), or null. */
private volatile String task;
/** The last setup problem an admin should fix (German), or null. */
private volatile String problem;
private volatile boolean running;
private volatile boolean closed;
private ResourcePack pack;
private final List<ResourcePack> retired = new ArrayList<>();
public SetupManager(Main plugin) {
this.plugin = plugin;
this.log = plugin.getLogger();
this.packDir = new File(plugin.getDataFolder(), "resourcepack");
this.downloadDir = new File(plugin.getDataFolder(), "download");
ThreadPoolExecutor executor =
new ThreadPoolExecutor(1, 1, 30, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), r -> {
Thread thread = new Thread(r, "PixelPics-setup");
thread.setDaemon(true);
return thread;
});
executor.allowCoreThreadTimeOut(true); // no idle thread once set up
this.worker = executor;
}
public DefaultScreenRenderer renderer() {
return renderer;
}
public Report report() {
return report;
}
public String task() {
return task;
}
public String problem() {
return problem;
}
public boolean running() {
return running;
}
/** Why photos are refused while there is no renderer (German, for players). */
public String unavailableMessage() {
String step = task;
if (step != null) return "PixelPics wird gerade eingerichtet (" + step + ") — versuch es gleich nochmal.";
if (running) return "PixelPics startet noch — versuch es gleich nochmal.";
return "PixelPics ist nicht einsatzbereit (kein Resource Pack) — bitte einem Admin melden.";
}
/**
* Starts a setup run on the setup thread (main thread only). {@code requester} (may be null) is told about
* progress and the result. False when a run is already going on.
*/
public boolean start(CommandSender requester) {
if (running || closed) return false;
running = true;
problem = null;
Inputs in = inputs();
worker.execute(() -> run(in, requester));
return true;
}
/** Closes packs replaced by a newer renderer once no photo renders any more (main thread). */
public void closeRetired(boolean force) {
if (retired.isEmpty()) return;
if (!force
&& (plugin.getRenderManager() == null
|| !plugin.getRenderManager().isIdle())) return;
retired.forEach(ResourcePack::close);
retired.clear();
}
/** On disable: stops a running download/build and closes all packs. */
public void close() {
closed = true;
worker.shutdownNow();
renderer = null;
if (pack != null) pack.close();
pack = null;
closeRetired(true);
}
private Inputs inputs() {
FileConfiguration config = plugin.getConfig();
List<String> blocks = new ArrayList<>();
List<String> items = new ArrayList<>();
for (Material m : Material.values()) {
if (m.isLegacy()) continue;
if (m.isBlock()) blocks.add(m.getKey().getKey());
if (m.isItem()) items.add(m.getKey().getKey());
}
return new Inputs(
Bukkit.getMinecraftVersion(),
config.getBoolean("resource-pack.auto-download", true),
config.getBoolean("resource-pack.auto-update", true),
config.getString(
"resource-pack.manifest-url",
"https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"),
Math.max(1, config.getLong("resource-pack.max-download-mb", 256)) << 20,
plugin.getServer().getWorlds().stream()
.map(World::getWorldFolder)
.toList(),
blocks,
items,
plugin.getRenderManager().tracePool());
}
private void run(Inputs in, CommandSender requester) {
try {
String server = in.serverVersion();
if (!packDir.isDirectory() && !packDir.mkdirs()) {
log.warning("Could not create the resource pack folder " + packDir);
}
cleanPartFiles();
List<PackVersions.VersionedPack> packs = PackVersions.scan(packDir.toPath());
List<PackVersions.VersionedPack> versioned =
packs.stream().filter(v -> v.version() != null).toList();
boolean matching = versioned.stream().anyMatch(v -> v.version().equals(server));
boolean download = false;
if (!versioned.isEmpty() && !matching) {
String names = String.join(
", ",
versioned.stream()
.map(v -> v.path().getFileName() + " (" + v.version() + ")")
.toList());
if (in.autoUpdate()) {
log.warning("The vanilla resource pack " + names + " is not for Minecraft " + server
+ ", the version this server runs: downloading the " + server + " pack (the old one keeps"
+ " rendering until then).");
download = true;
} else {
problem = "Resource Pack " + names + " passt nicht zu Minecraft " + server + " (auto-update aus).";
log.warning("The vanilla resource pack " + names + " is not for Minecraft " + server
+ ", the version this server runs. New blocks and items render as missing textures."
+ " resource-pack.auto-update is off: replace it with tools/build-vanilla-pack.sh " + server
+ " or turn auto-update on and run /pixelpic reload.");
}
}
Built built = build(in, packs, matching ? outdated(packs, server) : Set.of());
if (built != null) {
swap(built, requester, download ? "Alter Pack aktiv, lade den neuen herunter …" : null);
} else if (in.autoDownload()) {
log.info("No resource pack in " + packDir + ": downloading the vanilla assets of Minecraft " + server
+ " from Mojang (resource-pack.auto-download). Photos are refused until it is ready.");
tell(requester, "Kein Resource Pack vorhanden — lade Minecraft " + server + " herunter …");
download = true;
} else {
problem = "Kein Resource Pack (auto-download aus).";
log.severe("No resource pack in " + packDir + " and resource-pack.auto-download is off: photos are"
+ " disabled. Put a vanilla pack there (tools/build-vanilla-pack.sh " + server
+ "), or turn auto-download on, then run /pixelpic reload.");
tell(requester, "Kein Resource Pack gefunden und auto-download ist aus — Fotos bleiben deaktiviert.");
}
if (download) download(in, requester, built != null);
} catch (Throwable t) {
problem = "Einrichtung fehlgeschlagen: " + t;
log.log(Level.SEVERE, "PixelPics setup failed", t);
tell(requester, "Einrichtung fehlgeschlagen: " + t.getMessage() + " (Details im Server-Log)");
} finally {
task = null;
onMain(() -> running = false);
if (closed) running = false;
}
}
private void download(Inputs in, CommandSender requester, boolean hasOld) {
String server = in.serverVersion();
long t0 = System.nanoTime();
task = "Resource Pack " + server + " wird heruntergeladen";
VanillaPackDownloader downloader =
new VanillaPackDownloader("PixelPics/" + plugin.getPluginMeta().getVersion(), step -> {
task = step;
DebugLog.log("Pack download: " + step);
});
Path fresh;
try {
fresh = downloader.download(
server, in.manifestUrl(), in.maxBytes(), downloadDir.toPath(), packDir.toPath());
} catch (IOException | RuntimeException e) {
if (closed) return;
problem = "Download des Resource Packs " + server + " fehlgeschlagen: " + describe(e);
log.warning("Could not download the vanilla resource pack for Minecraft " + server + ": " + e
+ (hasOld
? ". Keeping the old pack."
: ". Photos stay disabled. Without internet access build the pack elsewhere with"
+ " tools/build-vanilla-pack.sh " + server + " and put the zip into " + packDir
+ ", then run /pixelpic reload (also retries the download)."));
tell(
requester,
"Download fehlgeschlagen: " + describe(e) + (hasOld ? " — der alte Pack bleibt aktiv." : ""));
return;
} finally {
downloadDir.delete();
}
if (closed) return;
log.info("Downloaded " + fresh.getFileName() + " in " + (System.nanoTime() - t0) / 1_000_000 + " ms.");
task = "Resource Pack wird geladen";
// still open by the old renderer: fine on Linux, else they stay but are left out
backupOutdated(outdated(PackVersions.scan(packDir.toPath()), server));
List<PackVersions.VersionedPack> packs = PackVersions.scan(packDir.toPath());
Built built = build(in, packs, outdated(packs, server));
if (built == null) {
problem = "Der heruntergeladene Pack " + fresh.getFileName() + " ließ sich nicht laden.";
log.severe("The downloaded pack " + fresh + " could not be loaded.");
return;
}
swap(built, requester, null);
}
/** A short German reason for a failed download. */
private static String describe(Exception e) {
return switch (e) {
case UnknownHostException u -> "Adresse " + u.getMessage() + " nicht auflösbar (kein Internet?)";
case SocketTimeoutException t -> "Zeitüberschreitung (" + t.getMessage() + ")";
case ConnectException c -> "keine Verbindung (" + c.getMessage() + ")";
default -> String.valueOf(e.getMessage());
};
}
/** The versioned vanilla packs of other versions (left out while one for {@code server} exists). */
private static Set<Path> outdated(List<PackVersions.VersionedPack> packs, String server) {
Set<Path> out = new HashSet<>();
for (PackVersions.VersionedPack v : packs) {
if (v.version() != null && !v.version().equals(server)) out.add(v.path());
}
return out;
}
private void backupOutdated(Set<Path> outdated) {
File backup = new File(plugin.getDataFolder(), "backup");
for (Path old : outdated) {
try {
Files.createDirectories(backup.toPath());
Path target = backup.toPath().resolve(old.getFileName());
Files.move(old, target, StandardCopyOption.REPLACE_EXISTING);
log.info("Moved the outdated pack " + old.getFileName() + " to " + target + ".");
} catch (IOException e) {
log.warning("Could not move the outdated pack " + old + " to " + backup + " (" + e
+ "); it stays unused while the current pack exists.");
}
}
}
private void cleanPartFiles() {
for (File dir : new File[] {packDir, downloadDir}) {
File[] parts = dir.listFiles((d, name) -> name.endsWith(".part"));
if (parts != null) for (File f : parts) f.delete();
}
}
/** Loads the packs (without {@code exclude}) and builds a renderer; null when there is no pack. */
private Built build(Inputs in, List<PackVersions.VersionedPack> scanned, Set<Path> exclude) {
long t0 = System.nanoTime();
Optional<ResourcePack> loaded = ResourcePackLoader.load(packDir, log, exclude);
if (loaded.isEmpty()) return null;
ResourcePack resourcePack = loaded.get();
try {
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 template = plugin.getResource("cem/cem_template_models.json")) {
int n = template == null ? 0 : cemLoader.load(template, log);
DebugLog.log("Loaded " + n + " CEM template entity models.");
} catch (Exception e) {
log.severe("Failed to load CEM entity models: " + e.getMessage());
}
Dump dump = loadDump(cemLoader, in.serverVersion());
int datapackVariants = DatapackVariants.load(cemLoader, in.worldFolders(), log);
if (datapackVariants > 0) log.info("Loaded " + datapackVariants + " datapack mob variants.");
SkinCache skinCache = new SkinCache();
BitmapFont font = FontLoader.load(resourcePack, textures, log);
if (font.isEmpty())
log.warning("The resource pack has no usable font: signs and text displays stay blank.");
CemBaker entityBaker = new CemBaker(cemLoader, textures, skinCache);
BlockEntityBaker blockEntityBaker = new BlockEntityBaker(cemLoader, textures, skinCache, font);
DefaultScreenRenderer screenRenderer = new DefaultScreenRenderer(
registry, tintProvider, textures, entityBaker, blockEntityBaker, log, in.tracePool());
Coverage blocks = coverage(resourcePack, in.blocks(), "assets/minecraft/blockstates/");
Coverage items = coverage(resourcePack, in.items(), "assets/minecraft/items/");
List<PackInfo> packs = scanned.stream()
.map(v -> new PackInfo(
packDir.toPath().relativize(v.path()).toString(), v.version(), !exclude.contains(v.path())))
.toList();
Report r = new Report(
packs,
blocks,
items,
dump.override() ? DUMP_FILE : "gebündelt",
dump.version(),
cemLoader.vanillaNames().size(),
datapackVariants,
!font.isEmpty(),
(System.nanoTime() - t0) / 1_000_000);
logReport(r, in.serverVersion());
return new Built(resourcePack, screenRenderer, r);
} catch (RuntimeException | Error e) {
resourcePack.close();
throw e;
}
}
/**
* Loads the vanilla entity model dump: {@code vanilla_entity_models.json} in the data folder overrides the bundled
* one unless only the bundled one matches the server version.
*/
private Dump loadDump(CemModelLoader cemLoader, String server) {
File override = new File(plugin.getDataFolder(), DUMP_FILE);
String bundledVersion = peekVersion(() -> plugin.getResource(BUNDLED_DUMP));
String overrideVersion = override.isFile() ? peekVersion(() -> new FileInputStream(override)) : null;
boolean useOverride = override.isFile() && (server.equals(overrideVersion) || !server.equals(bundledVersion));
if (override.isFile() && !useOverride) {
log.warning("Ignoring " + override + " (dump of Minecraft " + overrideVersion
+ "): the bundled dump matches this server (" + server + "). Delete the file.");
}
String version = useOverride ? overrideVersion : bundledVersion;
try (InputStream in = useOverride ? new FileInputStream(override) : plugin.getResource(BUNDLED_DUMP)) {
if (in == null) {
log.warning("No vanilla entity model dump found: mobs use the CEM template models.");
return new Dump(null, false);
}
int n = cemLoader.load(in, log);
DebugLog.log("Loaded the " + (useOverride ? DUMP_FILE : "bundled") + " entity model dump " + version + " ("
+ n + " models total).");
} catch (Exception e) {
log.severe("Failed to load the vanilla entity model dump: " + e.getMessage());
}
if (!server.equals(version)) {
log.warning("The entity model dump is for Minecraft " + version + " but the server runs " + server
+ ": new or changed mobs may render with old or missing models. A matching dump needs the"
+ " Minecraft client classes and a JDK, so PixelPics cannot create it at runtime: update PixelPics,"
+ " or run tools/update-vanilla-models.sh " + server + " plugins/PixelPics/ on a machine with the "
+ server + " client installed and restart.");
}
return new Dump(version, useOverride);
}
private interface StreamSource {
InputStream open() throws IOException;
}
/** The dump's {@code version} (its first key, so only the head is read), or null. */
private static String peekVersion(StreamSource source) {
try (InputStream in = source.open()) {
if (in == null) return null;
Matcher m = DUMP_VERSION.matcher(new String(in.readNBytes(512), StandardCharsets.UTF_8));
return m.find() ? m.group(1) : null;
} catch (IOException e) {
return null;
}
}
private static Coverage coverage(ResourcePack pack, List<String> keys, String folder) {
List<String> missing = keys.stream()
.filter(k -> !pack.exists(folder + k + ".json"))
.sorted()
.toList();
return new Coverage(
keys.size(), missing.size(), missing.stream().limit(SAMPLE).toList());
}
private void logReport(Report r, String server) {
String loaded = String.join(
", ",
r.packs().stream()
.filter(PackInfo::loaded)
.map(p -> p.name() + (p.version() != null ? " (" + p.version() + ")" : ""))
.toList());
log.info("Resource pack: " + (loaded.isEmpty() ? "folder pack" : loaded) + "; block states "
+ (r.blocks().total() - r.blocks().missing()) + "/" + r.blocks().total() + ", item models "
+ (r.items().total() - r.items().missing()) + "/" + r.items().total() + "; entity model dump "
+ r.dumpVersion() + " (" + r.dumpSource() + ", " + r.entityModels() + " models); built in "
+ r.millis() + " ms.");
if (r.blocks().missing() > 0 || r.items().missing() > 0) {
log.warning("The resource pack lacks " + r.blocks().missing() + " block states "
+ r.blocks().sample()
+ " and " + r.items().missing() + " item models "
+ r.items().sample() + " of this server: they"
+ " render as missing textures. The pack is probably for an older Minecraft version than "
+ server + "; a pack named " + PackVersions.fileName(server)
+ " or with a " + PackVersions.STAMP + " is updated automatically.");
}
}
/** Swaps the renderer in on the main thread; the old pack closes once no photo uses it. */
private void swap(Built built, CommandSender requester, String note) {
boolean scheduled = onMain(() -> {
if (closed) {
built.pack().close();
return;
}
if (pack != null) retired.add(pack);
pack = built.pack();
report = built.report();
renderer = built.renderer();
closeRetired(false);
if (requester != null) {
Report r = built.report();
requester.sendMessage(Component.text(
"PixelPics neu geladen in " + r.millis() + " ms"
+ (r.blocks().missing() + r.items().missing() > 0
? " — Pack unvollständig, siehe /pixelpic info."
: ".")
+ (note != null ? " " + note : ""),
NamedTextColor.GREEN));
}
});
if (!scheduled) built.pack().close();
}
private void tell(CommandSender requester, String message) {
if (requester == null) return;
onMain(() -> requester.sendMessage(Component.text(message, NamedTextColor.YELLOW)));
}
private boolean onMain(Runnable task) {
return !closed && MainThread.run(plugin, task);
}
}
@@ -0,0 +1,215 @@
package eu.mhsl.minecraft.pixelpics.setup;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InterruptedIOException;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.Collections;
import java.util.HexFormat;
import java.util.List;
import java.util.function.Consumer;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;
/**
* Builds the vanilla resource pack PixelPics renders from, like {@code tools/build-vanilla-pack.sh}: looks the
* version up in Mojang's version manifest, downloads that version's client jar (https only, size limit, sha1 from the
* manifest verified, connect/read timeouts) and keeps only the assets the renderer reads, plus a
* {@value PackVersions#STAMP} with the version.
*/
public final class VanillaPackDownloader {
/** The asset folders the renderer reads. */
private static final List<String> ASSET_DIRS = List.of(
"assets/minecraft/blockstates/",
"assets/minecraft/models/",
"assets/minecraft/textures/",
"assets/minecraft/items/",
"assets/minecraft/atlases/",
"assets/minecraft/font/");
private static final int CONNECT_TIMEOUT_MS = 10_000;
private static final int READ_TIMEOUT_MS = 30_000;
private static final long MAX_JSON_BYTES = 16L << 20;
private final String userAgent;
private final Consumer<String> progress;
/** {@code progress} receives short status lines (e.g. the downloaded share) from the download thread. */
public VanillaPackDownloader(String userAgent, Consumer<String> progress) {
this.userAgent = userAgent;
this.progress = progress;
}
/**
* Downloads the client jar of {@code version} into {@code tempDir} and writes {@code packDir/vanilla-<version>.zip}
* (atomically). Returns the pack. Interruptible between reads.
*/
public Path download(String version, String manifestUrl, long maxBytes, Path tempDir, Path packDir)
throws IOException {
JsonObject manifest = json(manifestUrl, null);
JsonObject entry = null;
for (JsonElement e : manifest.getAsJsonArray("versions")) {
JsonObject o = e.getAsJsonObject();
if (version.equals(o.get("id").getAsString())) entry = o;
}
if (entry == null) throw new IOException("Minecraft " + version + " is not listed in the version manifest");
JsonObject versionJson = json(
entry.get("url").getAsString(),
entry.has("sha1") ? entry.get("sha1").getAsString() : null);
JsonObject client = versionJson.getAsJsonObject("downloads").getAsJsonObject("client");
String url = client.get("url").getAsString();
String sha1 = client.get("sha1").getAsString();
long size = client.get("size").getAsLong();
if (size > maxBytes) {
throw new IOException("the client jar is " + mb(size) + " MB, more than resource-pack.max-download-mb ("
+ mb(maxBytes) + " MB)");
}
Files.createDirectories(tempDir);
Files.createDirectories(packDir);
// unique names (an old download of a disabled instance may still run); created with the default umask
String unique = Long.toHexString(System.nanoTime());
Path jar = tempDir.resolve("client-" + version + "-" + unique + ".jar.part");
Path zip = packDir.resolve("vanilla-" + version + "-" + unique + ".zip.part");
try {
progress.accept("Lade Minecraft-" + version + "-Client herunter (" + mb(size) + " MB)");
try (OutputStream out = Files.newOutputStream(jar)) {
String got = fetch(url, out, size, true);
if (!got.equalsIgnoreCase(sha1)) {
throw new IOException("sha1 mismatch for the client jar (expected " + sha1 + ", got " + got + ")");
}
}
if (Files.size(jar) != size) throw new IOException("client jar size differs from the manifest");
progress.accept("Extrahiere Assets aus dem Client");
int files = extract(jar, zip, version, sha1);
if (files < 1000) throw new IOException("the client jar holds only " + files + " asset files");
Path target = packDir.resolve(PackVersions.fileName(version));
Files.move(zip, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
return target;
} finally {
Files.deleteIfExists(jar);
Files.deleteIfExists(zip);
}
}
private int extract(Path jar, Path zip, String version, String sha1) throws IOException {
int files = 0;
try (ZipFile in = new ZipFile(jar.toFile());
ZipOutputStream out = new ZipOutputStream(new BufferedOutputStream(Files.newOutputStream(zip)))) {
for (ZipEntry e : Collections.list(in.entries())) {
String name = e.getName();
if (e.isDirectory()
|| name.contains("..")
|| ASSET_DIRS.stream().noneMatch(name::startsWith)) {
continue;
}
if (Thread.currentThread().isInterrupted()) throw new InterruptedIOException("interrupted");
out.putNextEntry(new ZipEntry(name));
try (InputStream data = in.getInputStream(e)) {
data.transferTo(out);
}
out.closeEntry();
files++;
}
JsonObject stamp = new JsonObject();
stamp.addProperty("version", version);
stamp.addProperty("client_sha1", sha1);
stamp.addProperty("created", Instant.now().toString());
stamp.addProperty("source", "PixelPics auto-download");
out.putNextEntry(new ZipEntry(PackVersions.STAMP));
out.write(stamp.toString().getBytes(StandardCharsets.UTF_8));
out.closeEntry();
}
return files;
}
private JsonObject json(String url, String sha1) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
String got = fetch(url, buffer, MAX_JSON_BYTES, false);
if (sha1 != null && !got.equalsIgnoreCase(sha1)) {
throw new IOException("sha1 mismatch for " + url);
}
try {
return JsonParser.parseString(buffer.toString(StandardCharsets.UTF_8))
.getAsJsonObject();
} catch (RuntimeException e) {
throw new IOException("unreadable json from " + url + ": " + e.getMessage());
}
}
/** Streams {@code url} into {@code out} (at most {@code limit} bytes) and returns the sha1 of the body. */
private String fetch(String url, OutputStream out, long limit, boolean report) throws IOException {
URI uri;
try {
uri = new URI(url);
} catch (URISyntaxException e) {
throw new IOException("invalid url " + url);
}
if (!"https".equalsIgnoreCase(uri.getScheme())) throw new IOException("only https urls are allowed: " + url);
HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection();
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
connection.setReadTimeout(READ_TIMEOUT_MS);
connection.setInstanceFollowRedirects(true); // same protocol only: never https -> http
connection.setRequestProperty("User-Agent", userAgent);
try {
int status = connection.getResponseCode();
if (status != HttpURLConnection.HTTP_OK) throw new IOException("HTTP " + status + " for " + url);
if (!"https".equalsIgnoreCase(connection.getURL().getProtocol())) {
throw new IOException("redirected away from https: " + connection.getURL());
}
long announced = connection.getContentLengthLong();
if (announced > limit) throw new IOException(url + " is larger than " + mb(limit) + " MB");
MessageDigest digest = sha1();
long total = 0;
int lastShare = -1;
byte[] buf = new byte[64 * 1024];
try (InputStream in = connection.getInputStream()) {
int n;
while ((n = in.read(buf)) > 0) {
if (Thread.currentThread().isInterrupted()) throw new InterruptedIOException("interrupted");
total += n;
if (total > limit) throw new IOException(url + " is larger than " + mb(limit) + " MB");
digest.update(buf, 0, n);
out.write(buf, 0, n);
int share = (int) (total * 4 / limit); // limit is the expected size for the jar
if (report && share != lastShare) {
lastShare = share;
progress.accept("Download " + mb(total) + " / " + mb(limit) + " MB");
}
}
}
return HexFormat.of().formatHex(digest.digest());
} finally {
connection.disconnect();
}
}
private static MessageDigest sha1() {
try {
return MessageDigest.getInstance("SHA-1");
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
private static long mb(long bytes) {
return (bytes + (1 << 20) - 1) >> 20;
}
}
@@ -12,47 +12,41 @@ import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.inventory.meta.MapMeta;
import org.bukkit.inventory.meta.SkullMeta; import org.bukkit.inventory.meta.SkullMeta;
import org.bukkit.inventory.meta.components.EquippableComponent; import org.bukkit.inventory.meta.components.EquippableComponent;
import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType; import org.bukkit.persistence.PersistentDataType;
/** /**
* Factory and identity helpers for the survival items: the camera and the film roll are custom-skinned * Camera and film roll are custom-skinned {@code PLAYER_HEAD}s (the skin needs no client resource pack), the photo is
* {@code PLAYER_HEAD}s (the texture renders client-side without a resource pack), the photo is the * a {@code FILLED_MAP}. Identity is carried by PDC markers, so renaming does not matter.
* 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 { public final class CameraItems {
/** Maximum film rolls a camera can hold; one roll yields one photo. */
public static final int MAX_FILM = 8; public static final int MAX_FILM = 8;
private static final int CAMERA_MODEL_DATA = 1001; private static final int CAMERA_MODEL_DATA = 1001;
private static final int FILM_MODEL_DATA = 1002; private static final int FILM_MODEL_DATA = 1002;
static final String CAMERA_TEXTURE_B64 = private static final String CAMERA_TEXTURE_B64 =
"eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZDlkMmNiZjAyZDMwOGI2MDY1YTZmZThjNjU3MWI2MzU2NjMzZjQxOTJlOGVjNzEyMTNjNzcwNzgwZTNkZTRlMiJ9fX0="; "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZDlkMmNiZjAyZDMwOGI2MDY1YTZmZThjNjU3MWI2MzU2NjMzZjQxOTJlOGVjNzEyMTNjNzcwNzgwZTNkZTRlMiJ9fX0=";
static final String FILM_TEXTURE_B64 = private static final String FILM_TEXTURE_B64 =
"eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvMTVkMGY0OGJlNzNkYmIwZDJjYjE1NTRjMmUzODZiNWNjM2FiMjFhNGRjYWU4ZmYzOGI3NzRhZDNkMDFkMGE1OSJ9fX0="; "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvMTVkMGY0OGJlNzNkYmIwZDJjYjE1NTRjMmUzODZiNWNjM2FiMjFhNGRjYWU4ZmYzOGI3NzRhZDNkMDFkMGE1OSJ9fX0=";
private CameraItems() {} private CameraItems() {}
// --- factories ---
/** A camera holding {@code filmCount} (clamped to 0..{@link #MAX_FILM}) loaded film rolls. */
public static ItemStack createCamera(int filmCount) { public static ItemStack createCamera(int filmCount) {
int count = Math.clamp(filmCount, 0, MAX_FILM); int count = Math.clamp(filmCount, 0, MAX_FILM);
ItemStack item = new ItemStack(Material.PLAYER_HEAD); ItemStack item = new ItemStack(Material.PLAYER_HEAD);
SkullMeta meta = (SkullMeta) item.getItemMeta(); SkullMeta meta = (SkullMeta) item.getItemMeta();
applyHead(meta, "pixelpics:camera", CAMERA_TEXTURE_B64); applyHead(meta, "pixelpics:camera", CAMERA_TEXTURE_B64);
meta.displayName(Component.text("Kamera", NamedTextColor.WHITE).decoration(TextDecoration.ITALIC, false)); meta.displayName(plain("Kamera", NamedTextColor.WHITE));
meta.setCustomModelData(CAMERA_MODEL_DATA); meta.setCustomModelData(CAMERA_MODEL_DATA);
// A camera carries per-item state (loaded film count), so it must not stack — otherwise loading // The camera carries per-item state (film count): a stack would duplicate or desync on loading/shooting.
// or shooting a stacked camera would duplicate or desync the whole stack.
meta.setMaxStackSize(1); meta.setMaxStackSize(1);
meta.getPersistentDataContainer().set(Main.getInstance().cameraMarker, PersistentDataType.BYTE, (byte) 1); meta.getPersistentDataContainer().set(Main.getInstance().cameraMarker, PersistentDataType.BYTE, (byte) 1);
meta.getPersistentDataContainer().set(Main.getInstance().filmCountKey, PersistentDataType.INTEGER, count); meta.getPersistentDataContainer().set(Main.getInstance().filmCountKey, PersistentDataType.INTEGER, count);
@@ -62,40 +56,61 @@ public final class CameraItems {
return item; return item;
} }
/** A single film roll. */
public static ItemStack createFilm() { public static ItemStack createFilm() {
ItemStack item = new ItemStack(Material.PLAYER_HEAD); ItemStack item = new ItemStack(Material.PLAYER_HEAD);
SkullMeta meta = (SkullMeta) item.getItemMeta(); SkullMeta meta = (SkullMeta) item.getItemMeta();
applyHead(meta, "pixelpics:film", FILM_TEXTURE_B64); applyHead(meta, "pixelpics:film", FILM_TEXTURE_B64);
meta.displayName(Component.text("Filmrolle", NamedTextColor.WHITE).decoration(TextDecoration.ITALIC, false)); meta.displayName(plain("Filmrolle", NamedTextColor.WHITE));
meta.setCustomModelData(FILM_MODEL_DATA); meta.setCustomModelData(FILM_MODEL_DATA);
// Lore leads with "Filmrolle" so the recipe book search finds it by name (see applyCameraLore). // Leads with "Filmrolle" for the recipe book search (see applyCameraLore).
meta.lore(List.of(Component.text("Filmrolle – lädt eine Kamera auf.", NamedTextColor.GRAY) meta.lore(List.of(plain("Filmrolle – lädt eine Kamera auf.", NamedTextColor.GRAY)));
.decoration(TextDecoration.ITALIC, false)));
meta.getPersistentDataContainer().set(Main.getInstance().filmMarker, PersistentDataType.BYTE, (byte) 1); meta.getPersistentDataContainer().set(Main.getInstance().filmMarker, PersistentDataType.BYTE, (byte) 1);
makeUnwearable(meta); makeUnwearable(meta);
item.setItemMeta(meta); item.setItemMeta(meta);
return item; return item;
} }
/** Display name applied to a developed photo map (white, like a normally renamed item). */ /**
public static Component photoDisplayName() { * Puts {@code item} into the player's inventory; what does not fit is dropped at their feet, like {@code /give}
return Component.text("Foto", NamedTextColor.WHITE).decoration(TextDecoration.ITALIC, false); * (no pickup delay, only that player may pick it up).
*/
public static void give(Player player, ItemStack item) {
for (ItemStack left : player.getInventory().addItem(item).values()) {
player.getWorld().dropItem(player.getLocation(), left, drop -> {
drop.setPickupDelay(0);
drop.setOwner(player.getUniqueId());
});
}
} }
/** Returns a copy of {@code camera} with its film count set to {@code newCount} and lore refreshed. */ /** Whether {@code item} is a photo on map {@code mapId}. */
public static boolean isPhotoOf(ItemStack item, int mapId) {
return isPhoto(item)
&& item.getItemMeta() instanceof MapMeta meta
&& meta.hasMapView()
&& meta.getMapView() != null
&& meta.getMapView().getId() == mapId;
}
/**
* The photo's name, set as ITEM_NAME: a CUSTOM_NAME would keep the recipe book from placing the photo (vanilla
* Inventory.isUsableForCrafting skips custom-named stacks for the copy recipe's material ingredient).
*/
public static Component photoDisplayName() {
return plain("Foto", NamedTextColor.WHITE);
}
/** A copy of {@code camera} with the given film count; stays component-equal to {@code createCamera(count)}. */
public static ItemStack withFilmCount(ItemStack camera, int newCount) { public static ItemStack withFilmCount(ItemStack camera, int newCount) {
int count = Math.clamp(newCount, 0, MAX_FILM); int count = Math.clamp(newCount, 0, MAX_FILM);
ItemStack copy = camera.clone(); ItemStack copy = camera.clone();
SkullMeta meta = (SkullMeta) copy.getItemMeta(); ItemMeta meta = copy.getItemMeta();
meta.getPersistentDataContainer().set(Main.getInstance().filmCountKey, PersistentDataType.INTEGER, count); meta.getPersistentDataContainer().set(Main.getInstance().filmCountKey, PersistentDataType.INTEGER, count);
applyCameraLore(meta, count); applyCameraLore(meta, count);
copy.setItemMeta(meta); copy.setItemMeta(meta);
return copy; return copy;
} }
// --- identity ---
public static boolean isCamera(ItemStack item) { public static boolean isCamera(ItemStack item) {
return hasMarker(item, Main.getInstance().cameraMarker); return hasMarker(item, Main.getInstance().cameraMarker);
} }
@@ -104,50 +119,46 @@ public final class CameraItems {
return hasMarker(item, Main.getInstance().filmMarker); return hasMarker(item, Main.getInstance().filmMarker);
} }
/** A photo is a filled map carrying our picture id. */
public static boolean isPhoto(ItemStack item) { public static boolean isPhoto(ItemStack item) {
if (item == null || item.getType() != Material.FILLED_MAP || !item.hasItemMeta()) return false; return item != null
return item.getItemMeta() && item.getType() == Material.FILLED_MAP
.getPersistentDataContainer() && item.hasItemMeta()
.has(Main.getInstance().pictureIdFlag, PersistentDataType.STRING); && item.getItemMeta()
.getPersistentDataContainer()
.has(Main.getInstance().pictureIdFlag, PersistentDataType.STRING);
} }
/** Loaded film on a camera, or 0 if the item is not a camera. */ /** Loaded film on a camera, 0 for any other item. */
public static int getFilmCount(ItemStack item) { public static int getFilmCount(ItemStack item) {
if (!isCamera(item)) return 0; if (!isCamera(item)) return 0;
Integer v = item.getItemMeta() return item.getItemMeta()
.getPersistentDataContainer() .getPersistentDataContainer()
.get(Main.getInstance().filmCountKey, PersistentDataType.INTEGER); .getOrDefault(Main.getInstance().filmCountKey, PersistentDataType.INTEGER, 0);
return v == null ? 0 : v;
} }
// --- internals ---
private static boolean hasMarker(ItemStack item, NamespacedKey key) { private static boolean hasMarker(ItemStack item, NamespacedKey key) {
if (item == null || item.getType() != Material.PLAYER_HEAD || !item.hasItemMeta()) return false; return item != null
PersistentDataContainer pdc = item.getItemMeta().getPersistentDataContainer(); && item.getType() == Material.PLAYER_HEAD
return pdc.has(key, PersistentDataType.BYTE); && item.hasItemMeta()
&& item.getItemMeta().getPersistentDataContainer().has(key, PersistentDataType.BYTE);
}
private static Component plain(String text, NamedTextColor color) {
return Component.text(text, color).decoration(TextDecoration.ITALIC, false);
} }
private static void applyCameraLore(ItemMeta meta, int count) { private static void applyCameraLore(ItemMeta meta, int count) {
// The first line carries the word "Kamera" so the recipe book search (which indexes the result // The recipe book search indexes the result's lore, not its custom name (every item here is a
// item's lore, not its custom display name — every item here is a PLAYER_HEAD named "Spielerkopf" // PLAYER_HEAD), so the first line carries the word "Kamera".
// by default) can find it when the player types "kamera".
meta.lore(List.of( meta.lore(List.of(
Component.text("Kamera für Sofortbildfotos", NamedTextColor.GRAY) plain("Kamera für Sofortbildfotos", NamedTextColor.GRAY),
.decoration(TextDecoration.ITALIC, false), plain("Film: " + count + " / " + MAX_FILM, count > 0 ? NamedTextColor.YELLOW : NamedTextColor.RED),
Component.text( plain("Rechtsklick: Foto aufnehmen", NamedTextColor.DARK_GRAY)));
"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: * Overrides the head's equippable component: no player slot accepts {@code SADDLE}, which blocks right-click,
* the slot is moved to {@code SADDLE} (no player slot accepts it, so the helmet slot rejects it — * shift-click and dispenser equipping; swapping is disabled as well.
* blocking right-click, shift-click and dispenser equipping) and right-click swap is disabled.
*/ */
private static void makeUnwearable(ItemMeta meta) { private static void makeUnwearable(ItemMeta meta) {
EquippableComponent eq = meta.getEquippable(); EquippableComponent eq = meta.getEquippable();
@@ -157,7 +168,6 @@ public final class CameraItems {
} }
private static void applyHead(SkullMeta meta, String seed, String textureB64) { 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)); UUID synthetic = UUID.nameUUIDFromBytes(seed.getBytes(StandardCharsets.UTF_8));
PlayerProfile profile = Bukkit.createProfileExact(synthetic, null); PlayerProfile profile = Bukkit.createProfileExact(synthetic, null);
profile.setProperty(new ProfileProperty("textures", textureB64)); profile.setProperty(new ProfileProperty("textures", textureB64));
@@ -11,19 +11,24 @@ import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
import org.bukkit.event.block.Action; import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
/** /**
* Takes a photo when a player right-clicks while holding a camera. Consumes one loaded film per shot; * Right-click with a camera takes a photo and uses one film. {@link PlayerInteractEvent} can fire twice per click
* with no film loaded it gives a short fail feedback. Guards against the well-known double-fire of * (off hand, air + block), hence the hand filter and the per-player cooldown.
* {@link PlayerInteractEvent} (off-hand fire + air/block) via a hand filter and a per-player cooldown.
*/ */
public class CameraListener implements Listener { public class CameraListener implements Listener {
private static final long COOLDOWN_MILLIS = 500; private static final long COOLDOWN_MILLIS = 500;
private final Map<UUID, Long> lastUse = new HashMap<>(); private final Map<UUID, Long> lastUse = new HashMap<>();
@EventHandler
public void onQuit(PlayerQuitEvent event) {
lastUse.remove(event.getPlayer().getUniqueId());
}
@EventHandler @EventHandler
public void onInteract(PlayerInteractEvent event) { public void onInteract(PlayerInteractEvent event) {
if (event.getHand() != EquipmentSlot.HAND) return; if (event.getHand() != EquipmentSlot.HAND) return;
@@ -33,26 +38,20 @@ public class CameraListener implements Listener {
ItemStack inHand = event.getItem(); ItemStack inHand = event.getItem();
if (!CameraItems.isCamera(inHand)) return; if (!CameraItems.isCamera(inHand)) return;
event.setCancelled(true);
Player player = event.getPlayer(); Player player = event.getPlayer();
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
if (now - lastUse.getOrDefault(player.getUniqueId(), 0L) < COOLDOWN_MILLIS) { if (now - lastUse.getOrDefault(player.getUniqueId(), 0L) < COOLDOWN_MILLIS) return;
event.setCancelled(true);
return;
}
lastUse.put(player.getUniqueId(), now); lastUse.put(player.getUniqueId(), now);
event.setCancelled(true);
int film = CameraItems.getFilmCount(inHand); int film = CameraItems.getFilmCount(inHand);
if (film <= 0) { if (film <= 0) {
// Dry "empty shutter" click — no film loaded.
player.playSound(player.getLocation(), Sound.BLOCK_DISPENSER_FAIL, 1f, 1.2f); player.playSound(player.getLocation(), Sound.BLOCK_DISPENSER_FAIL, 1f, 1.2f);
player.sendActionBar(Component.text("Kein Film geladen!", NamedTextColor.RED)); player.sendActionBar(Component.text("Kein Film geladen!", NamedTextColor.RED));
return; return;
} }
// Only charge film if the shot was actually accepted (renderer ready, not queue-rejected). if (PhotoService.takePhoto(player, true)) {
if (PhotoService.takePhoto(player)) {
player.getInventory().setItemInMainHand(CameraItems.withFilmCount(inHand, film - 1)); player.getInventory().setItemInMainHand(CameraItems.withFilmCount(inHand, film - 1));
} }
} }
@@ -4,7 +4,6 @@ import eu.mhsl.minecraft.pixelpics.Main;
import java.util.function.Predicate; import java.util.function.Predicate;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.Keyed; import org.bukkit.Keyed;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
@@ -21,12 +20,10 @@ import org.bukkit.inventory.meta.MapMeta;
import org.bukkit.persistence.PersistentDataType; import org.bukkit.persistence.PersistentDataType;
/** /**
* Drives the two dynamic recipes whose inputs carry variable NBT: <em>load film</em> * Load film (camera + film -> camera with one more film) and copy photo (photo + film -> a second photo on the same
* (camera + film → camera+1) and <em>copy photo</em> (photo + film → 2 photos sharing the same map). * map) have variable-NBT inputs, so the result is computed here: {@link PrepareItemCraftEvent} shows it and rejects
* {@link PrepareItemCraftEvent} computes the result preview and validates the loose * loose {@code MaterialChoice} matches, {@link CraftItemEvent} performs the craft by hand. Shift-click is refused, and
* {@code MaterialChoice} matches; {@link CraftItemEvent} performs the consumption/doubling manually * our items never feed any other recipe.
* (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 { public class CraftingListener implements Listener {
@@ -36,30 +33,16 @@ public class CraftingListener implements Listener {
NONE NONE
} }
/** Scanned crafting grid: the relevant items and whether any of our marked items are present. */ private record Scan(Kind kind, ItemStack camera, ItemStack photo, boolean hasOurItems) {}
private record Scan(Kind kind, ItemStack camera, ItemStack film, ItemStack photo, boolean hasOurItems) {}
@EventHandler @EventHandler
public void onPrepare(PrepareItemCraftEvent event) { public void onPrepare(PrepareItemCraftEvent event) {
CraftingInventory inv = event.getInventory(); CraftingInventory inv = event.getInventory();
Scan scan = scan(inv.getMatrix()); Scan scan = scan(inv.getMatrix());
if (scan.kind() != Kind.NONE) {
switch (scan.kind()) { inv.setResult(result(scan));
case LOAD -> { } else if (isDynamicRecipe(event.getRecipe()) || scan.hasOurItems()) {
int count = CameraItems.getFilmCount(scan.camera()); inv.setResult(null);
inv.setResult(
count >= CameraItems.MAX_FILM
? null
: single(CameraItems.withFilmCount(scan.camera(), count + 1)));
}
case COPY -> inv.setResult(buildPhotoCopy(scan.photo()));
case NONE -> {
// Block our loose MaterialChoice recipes from matching garbage, and stop our items
// from being consumed by any unrelated (vanilla) recipe.
if (isDynamicRecipe(event.getRecipe()) || scan.hasOurItems()) {
inv.setResult(null);
}
}
} }
} }
@@ -67,50 +50,33 @@ public class CraftingListener implements Listener {
public void onCraft(CraftItemEvent event) { public void onCraft(CraftItemEvent event) {
CraftingInventory inv = event.getInventory(); CraftingInventory inv = event.getInventory();
Scan scan = scan(inv.getMatrix()); Scan scan = scan(inv.getMatrix());
if (scan.kind() == Kind.NONE) { if (scan.kind() == Kind.NONE) {
// Defensive: never let our items be consumed by another recipe.
if (scan.hasOurItems()) event.setCancelled(true); if (scan.hasOurItems()) event.setCancelled(true);
return; return;
} }
event.setCancelled(true); event.setCancelled(true);
Player player = (Player) event.getWhoClicked(); Player player = (Player) event.getWhoClicked();
if (event.isShiftClick()) { if (event.isShiftClick()) {
player.sendActionBar(Component.text("Bitte einzeln herstellen.", NamedTextColor.YELLOW)); player.sendActionBar(Component.text("Bitte einzeln herstellen.", NamedTextColor.YELLOW));
return; return;
} }
ItemStack result; ItemStack result = result(scan);
if (result == null) return;
// Consume and hand out atomically within this click: a deferred grid write could be raced by a second click
// or by closing the inventory in the same tick (duplication). Setting the matrix re-runs the recipe match.
ItemStack[] matrix = inv.getMatrix(); ItemStack[] matrix = inv.getMatrix();
if (scan.kind() == Kind.LOAD) { if (scan.kind() == Kind.LOAD) consumeFirst(matrix, CameraItems::isCamera);
int count = CameraItems.getFilmCount(scan.camera()); consumeFirst(matrix, CameraItems::isFilm); // a copied photo keeps its original
if (count >= CameraItems.MAX_FILM) return; inv.setMatrix(matrix);
result = single(CameraItems.withFilmCount(scan.camera(), count + 1)); give(player, result);
consumeFirst(matrix, CameraItems::isCamera); player.playSound(player.getLocation(), Sound.UI_TOAST_IN, 0.7f, 1.4f);
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) { private static Scan scan(ItemStack[] matrix) {
int cameras = 0, films = 0, photos = 0, others = 0; int cameras = 0, films = 0, photos = 0, others = 0;
ItemStack camera = null, film = null, photo = null; ItemStack camera = null, photo = null;
for (ItemStack it : matrix) { for (ItemStack it : matrix) {
if (it == null || it.getType().isAir()) continue; if (it == null || it.getType().isAir()) continue;
if (CameraItems.isCamera(it)) { if (CameraItems.isCamera(it)) {
@@ -118,71 +84,65 @@ public class CraftingListener implements Listener {
camera = it; camera = it;
} else if (CameraItems.isFilm(it)) { } else if (CameraItems.isFilm(it)) {
films++; films++;
film = it;
} else if (CameraItems.isPhoto(it)) { } else if (CameraItems.isPhoto(it)) {
photos++; photos++;
photo = it; photo = it;
} else others++; } else others++;
} }
boolean ours = cameras + films + photos > 0;
Kind kind = Kind.NONE; Kind kind = Kind.NONE;
if (others == 0) { if (others == 0 && films == 1) {
if (cameras == 1 && films == 1 && photos == 0) kind = Kind.LOAD; if (cameras == 1 && photos == 0) kind = Kind.LOAD;
else if (photos == 1 && films == 1 && cameras == 0) kind = Kind.COPY; else if (photos == 1 && cameras == 0) kind = Kind.COPY;
} }
return new Scan(kind, camera, film, photo, ours); return new Scan(kind, camera, photo, cameras + films + photos > 0);
}
/** The crafted item, or null when the camera is full or the photo has no map. */
private static ItemStack result(Scan scan) {
if (scan.kind() == Kind.COPY) return photoCopy(scan.photo());
int count = CameraItems.getFilmCount(scan.camera());
if (count >= CameraItems.MAX_FILM) return null;
ItemStack camera = CameraItems.withFilmCount(scan.camera(), count + 1);
camera.setAmount(1); // the grid may hold an old camera stack
return camera;
} }
private static boolean isDynamicRecipe(Recipe recipe) { private static boolean isDynamicRecipe(Recipe recipe) {
if (!(recipe instanceof Keyed keyed)) return false; return recipe instanceof Keyed keyed
NamespacedKey key = keyed.getKey(); && (SurvivalRecipes.LOAD.equals(keyed.getKey()) || SurvivalRecipes.COPY.equals(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 photoCopy(ItemStack photo) {
private static ItemStack buildPhotoCopy(ItemStack photo) {
MapMeta src = (MapMeta) photo.getItemMeta(); MapMeta src = (MapMeta) photo.getItemMeta();
if (!src.hasMapView() || src.getMapView() == null) return null; if (!src.hasMapView() || src.getMapView() == null) return null;
ItemStack copy = new ItemStack(Material.FILLED_MAP); ItemStack copy = new ItemStack(Material.FILLED_MAP);
MapMeta dst = (MapMeta) copy.getItemMeta(); MapMeta dst = (MapMeta) copy.getItemMeta();
dst.setMapView(src.getMapView()); dst.setMapView(src.getMapView());
String pid = src.getPersistentDataContainer().get(Main.getInstance().pictureIdFlag, PersistentDataType.STRING); NamespacedKey key = Main.getInstance().pictureIdFlag;
if (pid != null) { dst.getPersistentDataContainer()
dst.getPersistentDataContainer().set(Main.getInstance().pictureIdFlag, PersistentDataType.STRING, pid); .set(
} key,
dst.displayName(CameraItems.photoDisplayName()); PersistentDataType.STRING,
src.getPersistentDataContainer().get(key, PersistentDataType.STRING));
dst.itemName(CameraItems.photoDisplayName());
if (src.hasCustomName()) dst.customName(src.customName()); // anvil-renamed photo
copy.setItemMeta(dst); copy.setItemMeta(dst);
return copy; return copy;
} }
/** Forces a crafting result to a single item — one craft yields exactly one camera, never a stack. */
private static ItemStack single(ItemStack item) {
item.setAmount(1);
return item;
}
private static void consumeFirst(ItemStack[] matrix, Predicate<ItemStack> pred) { private static void consumeFirst(ItemStack[] matrix, Predicate<ItemStack> pred) {
for (int i = 0; i < matrix.length; i++) { for (int i = 0; i < matrix.length; i++) {
ItemStack it = matrix[i]; ItemStack it = matrix[i];
if (it != null && !it.getType().isAir() && pred.test(it)) { if (it != null && pred.test(it)) {
int amt = it.getAmount(); if (it.getAmount() <= 1) matrix[i] = null;
if (amt <= 1) matrix[i] = null; else it.setAmount(it.getAmount() - 1);
else {
it.setAmount(amt - 1);
matrix[i] = it;
}
return; return;
} }
} }
} }
private static void give(Player player, ItemStack item) { private static void give(Player player, ItemStack item) {
ItemStack cursor = player.getItemOnCursor(); if (player.getItemOnCursor().getType().isAir()) player.setItemOnCursor(item);
if (cursor.getType().isAir()) { else CameraItems.give(player, item);
player.setItemOnCursor(item);
} else {
player.getInventory().addItem(item).values().forEach(left -> player.getWorld()
.dropItemNaturally(player.getLocation(), left));
}
} }
} }
@@ -0,0 +1,120 @@
package eu.mhsl.minecraft.pixelpics.survival;
import com.destroystokyo.paper.event.inventory.PrepareResultEvent;
import io.papermc.paper.event.player.CartographyItemEvent;
import org.bukkit.block.Crafter;
import org.bukkit.entity.LivingEntity;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockDispenseArmorEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.block.CrafterCraftEvent;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryDragEvent;
import org.bukkit.event.inventory.InventoryType;
import org.bukkit.inventory.AnvilInventory;
import org.bukkit.inventory.CartographyInventory;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
/**
* Keeps camera, film and photo out of the vanilla workstations that would copy or rewrite them for free. A photo may
* only be copied by the copy recipe (with film): the cartography table would clone it, and locking or scaling moves it
* to a fresh map id without the picture, so a photo in the table gets no result. The anvil only renames them (no
* books or other items combined in). The crafter cannot compute the load/copy results (it would hand out the static
* placeholders) and would feed our items into vanilla recipes such as map cloning, so it does not craft with them;
* the ingredients stay in the crafter. Camera and film cannot be placed as head blocks either: breaking the placed skull
* drops a plain head without our markers (from the main hand a camera takes a photo instead, but an off-hand camera
* or film would be placed). Their unwearable equippable component uses the saddle slot, which every saddleable mount
* accepts, so they are kept out of mount equipment slots (horse inventory, dispenser onto a pig or horse).
*/
public class ItemGuardListener implements Listener {
@EventHandler
public void onPrepareResult(PrepareResultEvent event) {
Inventory inv = event.getInventory();
if (inv instanceof CartographyInventory) {
if (CameraItems.isPhoto(inv.getItem(0))) event.setResult(null);
} else if (inv instanceof AnvilInventory) {
ItemStack first = inv.getItem(0), second = inv.getItem(1);
boolean combining = second != null && !second.isEmpty();
if (combining && (isOurs(first) || isOurs(second))) event.setResult(null);
}
}
@EventHandler
public void onCartographyTake(CartographyItemEvent event) {
if (event.getSlotType() == InventoryType.SlotType.RESULT
&& CameraItems.isPhoto(event.getInventory().getItem(0))) {
event.setCancelled(true);
}
}
@EventHandler
public void onCrafterCraft(CrafterCraftEvent event) {
if (!(event.getBlock().getState(false) instanceof Crafter crafter)) return;
for (ItemStack it : crafter.getInventory().getContents()) {
if (isOurs(it)) {
// An empty result makes the crafter fail like a missing recipe: nothing is consumed.
event.setResult(ItemStack.empty());
return;
}
}
}
@EventHandler
public void onPlace(BlockPlaceEvent event) {
ItemStack item = event.getItemInHand();
if (isWearable(item)) event.setCancelled(true);
}
@EventHandler
public void onDispenseEquip(BlockDispenseArmorEvent event) {
if (isWearable(event.getItem())) event.setCancelled(true);
}
@EventHandler
public void onMountClick(InventoryClickEvent event) {
if (!isMountInventory(event.getView().getTopInventory())) return;
boolean intoEquipment = event.getRawSlot() < MOUNT_EQUIPMENT_SLOTS;
ItemStack moved;
if (event.isShiftClick()) {
moved = event.getRawSlot() >= event.getView().getTopInventory().getSize() ? event.getCurrentItem() : null;
intoEquipment = true;
} else if (event.getClick() == ClickType.NUMBER_KEY) {
moved = event.getView().getBottomInventory().getItem(event.getHotbarButton());
} else if (event.getClick() == ClickType.SWAP_OFFHAND) {
moved = event.getWhoClicked().getInventory().getItemInOffHand();
} else {
moved = event.getCursor();
}
if (intoEquipment && isWearable(moved)) event.setCancelled(true);
}
@EventHandler
public void onMountDrag(InventoryDragEvent event) {
if (!isMountInventory(event.getView().getTopInventory()) || !isWearable(event.getOldCursor())) return;
for (int raw : event.getRawSlots()) {
if (raw < MOUNT_EQUIPMENT_SLOTS) {
event.setCancelled(true);
return;
}
}
}
/** Saddle and body armour are the first two slots of a mount's inventory menu, chest slots follow. */
private static final int MOUNT_EQUIPMENT_SLOTS = 2;
private static boolean isMountInventory(Inventory inv) {
return inv.getHolder(false) instanceof LivingEntity;
}
private static boolean isWearable(ItemStack item) {
return CameraItems.isCamera(item) || CameraItems.isFilm(item);
}
private static boolean isOurs(ItemStack item) {
return isWearable(item) || CameraItems.isPhoto(item);
}
}
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more