diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ModelResolver.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ModelResolver.java index 30f9dd1..224fbdb 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ModelResolver.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ModelResolver.java @@ -20,9 +20,12 @@ public final class ModelResolver { } public FlatModel resolve(ResourceLocation modelId) { - return resolve(modelId, 0); + return resolve(modelId, 0).model(); } + /** A flattened model; {@code complete} = false when its parent chain was cut at {@link #MAX_DEPTH} (cycle). */ + private record Resolved(FlatModel model, boolean complete) {} + /** A texture value: a plain reference, or the {@code sprite} of a 26.x texture object; null otherwise. */ public static String textureRef(JsonElement v) { if (v == null) return null; @@ -34,23 +37,31 @@ public final class ModelResolver { } // 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. - private FlatModel resolve(ResourceLocation modelId, int depth) { + // 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 cached; + if (cached != null) return new Resolved(cached, true); ModelFileDto dto = reader.readJson(AssetPaths.model(modelId), ModelFileDto.class).orElse(null); FlatModel resolved; + boolean complete = true; if (dto == null) { resolved = new FlatModel(new HashMap<>(), null); } else { Map textures = new HashMap<>(); List elements = dto.elements; - if (dto.parent != null && depth < MAX_DEPTH && !dto.parent.startsWith("builtin/")) { - FlatModel parent = resolve(ResourceLocation.parse(dto.parent), depth + 1); - textures.putAll(parent.textures()); - if (elements == null || elements.isEmpty()) elements = parent.elements(); + if (dto.parent != null && !dto.parent.startsWith("builtin/")) { + if (depth < MAX_DEPTH) { + Resolved parent = resolve(ResourceLocation.parse(dto.parent), depth + 1); + complete = parent.complete(); + textures.putAll(parent.model().textures()); + if (elements == null || elements.isEmpty()) + elements = parent.model().elements(); + } else { + complete = false; + } } if (dto.textures != null) { dto.textures.forEach((key, value) -> { @@ -60,7 +71,8 @@ public final class ModelResolver { } resolved = new FlatModel(textures, elements); } + if (!complete) return new Resolved(resolved, false); FlatModel raced = cache.putIfAbsent(modelId, resolved); - return raced != null ? raced : resolved; + return new Resolved(raced != null ? raced : resolved, true); } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/RenderManager.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/RenderManager.java index cb24387..cb8dedd 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/RenderManager.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/RenderManager.java @@ -3,7 +3,9 @@ package eu.mhsl.minecraft.pixelpics.render; import java.util.List; import java.util.Set; import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.ForkJoinWorkerThread; @@ -13,10 +15,12 @@ import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.Supplier; import org.bukkit.Bukkit; import org.bukkit.plugin.IllegalPluginAccessException; import org.bukkit.plugin.Plugin; @@ -143,6 +147,46 @@ public final class RenderManager { }); } + /** + * From a dispatched job's {@code work}: runs {@code task} on the main thread (next tick) and waits for its result. + * Lets a job capture its world snapshot only once it is its turn, so queued photos hold no snapshot. Returns null + * without running {@code task} once {@code cancelled} is set (timeout, shutdown), the plugin is disabled or the + * worker is interrupted. + */ + public T onMainThread(Supplier task, AtomicBoolean cancelled) { + CompletableFuture result = new CompletableFuture<>(); + try { + Bukkit.getScheduler().runTask(plugin, () -> { + if (cancelled.get()) { + result.complete(null); + return; + } + try { + result.complete(task.get()); + } catch (Throwable t) { + result.completeExceptionally(t); + } + }); + } catch (IllegalPluginAccessException e) { + return null; // disabled meanwhile + } + 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; + } + /** * 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 diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityScene.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityScene.java index dc7fa71..dff57af 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityScene.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityScene.java @@ -20,7 +20,6 @@ public final class EntityScene { private static final int LEAF_SIZE = 4; private static final int SAH_BINS = 16; private static final int MAX_SAH_DEPTH = 40; - private static volatile boolean warned; // Cubes in BVH leaf order, their bake-order rank (tie-break) and world AABBs (minX..maxZ, 6 per cube). private final EntityCube[] cubes; @@ -71,10 +70,17 @@ public final class EntityScene { List objects, ObjectBaker objectBaker) { List baked = new ArrayList<>(); - addAll(states, baker, baked); - addAll(blockEntities, beBaker, baked); - addAll(decorations, decoBaker, baked); - addAll(objects, objectBaker, baked); + List failures = new ArrayList<>(); + addAll(states, baker, baked, failures); + addAll(blockEntities, beBaker, baked, failures); + addAll(decorations, decoBaker, baked, failures); + addAll(objects, objectBaker, baked, failures); + if (!failures.isEmpty()) { + // Once per photo: one bad entity/asset must never fail the whole photo, but should not go unnoticed. + Logger.getLogger("PixelPics") + .warning("PixelPics: skipped " + failures.size() + " entities that failed to bake, first: " + + failures.getFirst()); + } EntityCube[] all = baked.toArray(new EntityCube[0]); int n = all.length; @@ -84,19 +90,19 @@ public final class EntityScene { maxDepth = build(all); } - /** Bakes each state into {@code out} (in bake order = tie-break rank). A null baker (path disabled) is skipped. */ - private static void addAll(List states, EntityBaker baker, List out) { + /** + * 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 void addAll( + List states, EntityBaker baker, List out, List failures) { if (baker == null) return; for (S s : states) { RenderedEntity e; try { e = baker.bake(s); } catch (RuntimeException ex) { - // One bad entity/asset must never fail the whole photo. - if (!warned) { - warned = true; - Logger.getLogger("PixelPics").warning("PixelPics: skipped an entity that failed to bake: " + ex); - } + failures.add(ex); continue; } if (e != null) out.addAll(e.cubes); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/DatapackVariants.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/DatapackVariants.java index f8db261..a2a6a0b 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/DatapackVariants.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/DatapackVariants.java @@ -48,7 +48,7 @@ public final class DatapackVariants { if (packs == null) continue; for (File pack : packs) { try { - n += pack.isDirectory() ? scanDir(models, pack.toPath()) : scanZip(models, pack); + n += pack.isDirectory() ? scanDir(models, pack.toPath(), log) : scanZip(models, pack, log); } catch (IOException | RuntimeException e) { log.warning("Skipping datapack " + pack + ": " + e.getMessage()); } @@ -57,42 +57,56 @@ public final class DatapackVariants { return n; } - private static int scanDir(CemModelLoader models, Path root) throws IOException { + private static int scanDir(CemModelLoader models, Path root, Logger log) throws IOException { int n = 0; try (Stream files = Files.walk(root)) { for (Path f : (Iterable) 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)) { - if (register(models, rel, in)) n++; + 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) throws IOException { + 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)) { - if (register(models, ze.getName(), in)) n++; + register(models, m, in); + n++; + } catch (IOException | RuntimeException e) { + log.warning("Skipping variant " + ze.getName() + " in " + zip + ": " + e.getMessage()); } } } return n; } - private static boolean register(CemModelLoader models, String path, InputStream in) { + /** 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 false; + if (!m.matches()) return null; String type = m.group(2); - if (type.endsWith("_sound") || type.equals("painting")) return false; // no mob texture + 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(type, key, def); - return true; + models.addVariant(m.group(2), key, def); } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/ItemGeometry.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/ItemGeometry.java index 774bd9c..3f8ef88 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/ItemGeometry.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/ItemGeometry.java @@ -24,6 +24,7 @@ import eu.mhsl.minecraft.pixelpics.render.tint.TintResolver; import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; import eu.mhsl.minecraft.pixelpics.render.util.SpecialDates; import java.text.SimpleDateFormat; +import java.time.LocalDate; import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Date; @@ -55,7 +56,8 @@ public final class ItemGeometry { private final TextureCache textures; private final CemModelLoader models; private final BlockEntityBaker blockEntities; - private final Map infos = new ConcurrentHashMap<>(); + // Per "id#context"; date-dependent definitions (local_time selects) remember the day they were resolved for. + private final Map infos = new ConcurrentHashMap<>(); private final Map> tinted = new IdentityHashMap<>(); private final Map> extrusions = new IdentityHashMap<>(); private final Map> composedSprites = new ConcurrentHashMap<>(); @@ -157,20 +159,30 @@ public final class ItemGeometry { 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) { - // Keyed by date: minecraft:local_time selects (christmas chest items) change with the day. ZonedDateTime now = SpecialDates.now(); - return infos.computeIfAbsent(id + "#" + context + "#" + now.toLocalDate(), k -> { - try { - return resolve(id, context, now); - } catch (RuntimeException e) { - // Unexpected item-definition format (newer version?): the plain sprite. - return EMPTY; - } - }); + LocalDate today = now.toLocalDate(); + String key = id + "#" + context; + DatedInfo cached = infos.get(key); + if (cached != null && (cached.day == null || cached.day.equals(today))) return cached.info; + // minecraft:local_time selects (christmas chest items) change with the day: such entries are replaced on a + // new day instead of adding one per day. + boolean[] dated = {false}; + Info info; + try { + info = resolve(id, context, now, dated); + } catch (RuntimeException e) { + // Unexpected item-definition format (newer version?): the plain sprite. + info = EMPTY; + } + infos.put(key, new DatedInfo(info, dated[0] ? today : null)); + return info; } - private Info resolve(String id, String context, ZonedDateTime now) { + private Info resolve(String id, String context, ZonedDateTime now, boolean[] dated) { String modelRef = null, special = null; JsonObject specialModel = null; Affine specialXf = null; @@ -178,7 +190,7 @@ public final class ItemGeometry { Optional def = reader.readJsonObject("assets/minecraft/items/" + id + ".json"); if (def.isPresent() && def.get().has("model")) { Affine[] outer = {null}; - JsonObject node = pick(def.get().getAsJsonObject("model"), context, now, outer); + JsonObject node = pick(def.get().getAsJsonObject("model"), context, now, outer, dated); if (node != null) { String type = str(node, "type"); if (type.endsWith("special")) { @@ -237,9 +249,10 @@ public final class ItemGeometry { /** * 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). + * 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) { + private static JsonObject pick( + JsonObject node, String context, ZonedDateTime now, Affine[] outer, boolean[] dated) { for (int depth = 0; node != null && depth < 16; depth++) { String type = str(node, "type").replace("minecraft:", ""); if (!type.equals("model") && node.has("transformation")) { @@ -254,6 +267,7 @@ public final class ItemGeometry { case "select" -> { JsonObject chosen = null; String property = str(node, "property"); + if (property.endsWith("local_time")) dated[0] = true; String value = property.endsWith("display_context") ? context : property.endsWith("local_time") ? localTime(node, now) : null; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/BiomeBlend.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/BiomeBlend.java new file mode 100644 index 0000000..fe6694c --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/BiomeBlend.java @@ -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 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)); + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/SnapshotRaytracer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/SnapshotRaytracer.java index 8e56685..df4c3fd 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/SnapshotRaytracer.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/SnapshotRaytracer.java @@ -11,10 +11,7 @@ import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider; import eu.mhsl.minecraft.pixelpics.render.tint.TintResolver; import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; import eu.mhsl.minecraft.pixelpics.render.util.MathUtil; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; import org.bukkit.Material; -import org.bukkit.block.Biome; import org.bukkit.block.data.BlockData; import org.bukkit.util.Vector; @@ -25,8 +22,6 @@ import org.bukkit.util.Vector; */ 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. private static final double[] AO_BRIGHTNESS = {0.55, 0.70, 0.85, 1.0}; @@ -63,7 +58,6 @@ public final class SnapshotRaytracer { private final double maxDistance; private final int reflectionDepth; private final int maxSteps; - private final Map tintCache = new ConcurrentHashMap<>(); public SnapshotRaytracer( BlockModelRegistry registry, @@ -80,6 +74,11 @@ public final class SnapshotRaytracer { this.maxSteps = (int) (maxDistance * 3) + 3; } + /** The per-photo biome tint blend over {@code snapshot}; once per photo. */ + public BiomeBlend biomeBlend(WorldSnapshot snapshot) { + return new BiomeBlend(snapshot, tintProvider); + } + /** The medium (air or fluid with its fog) the camera at {@code origin} is in; once per photo. */ public CameraMedium cameraMedium(WorldSnapshot snapshot, Vector origin) { return CameraMedium.at(snapshot, origin, tintProvider); @@ -91,8 +90,9 @@ public final class SnapshotRaytracer { Vector direction, SkyContext sky, EntityScene scene, - CameraMedium medium) { - return trace(snapshot, origin, direction, sky, scene, reflectionDepth, medium); + CameraMedium medium, + BiomeBlend tints) { + return trace(snapshot, origin, direction, sky, scene, reflectionDepth, medium, tints); } private int trace( @@ -102,11 +102,12 @@ public final class SnapshotRaytracer { SkyContext sky, EntityScene scene, int depth, - CameraMedium medium) { + CameraMedium medium, + BiomeBlend tints) { double ox = origin.getX(), oy = origin.getY(), oz = origin.getZ(); double dx = direction.getX(), dy = direction.getY(), dz = direction.getZ(); - BlockCursor blocks = new BlockCursor(snapshot, origin, direction, sky, scene, depth, medium); + BlockCursor blocks = new BlockCursor(snapshot, origin, direction, sky, scene, depth, medium, tints); boolean entities = scene != null && !scene.isEmpty(); // Translucent surfaces in front of the final one, front to back (shaded colour, alpha 0..1 or ADDITIVE, @@ -215,6 +216,7 @@ public final class SnapshotRaytracer { 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; @@ -233,8 +235,10 @@ public final class SnapshotRaytracer { SkyContext sky, EntityScene scene, int depth, - CameraMedium medium) { + CameraMedium medium, + BiomeBlend tints) { this.snapshot = snapshot; + this.tints = tints; this.direction = direction; this.sky = sky; this.scene = scene; @@ -285,7 +289,7 @@ public final class SnapshotRaytracer { minT = hit.t(); continue; } - return new BlockHit(shadeAndTint(hit, data, snapshot, bx, by, bz), hit.t(), false); + return new BlockHit(shadeAndTint(hit, data, snapshot, tints, bx, by, bz), hit.t(), false); } return null; } @@ -305,7 +309,7 @@ public final class SnapshotRaytracer { boolean water = type == FluidRenderer.WATER; double light = 1; if (water) { - BiomeTint tint = blendedTint(snapshot, bx, by, bz); + 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( @@ -319,7 +323,8 @@ public final class SnapshotRaytracer { 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); + 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); @@ -375,10 +380,11 @@ public final class SnapshotRaytracer { } } - private int shadeAndTint(FaceHit hit, BlockData data, WorldSnapshot snapshot, int bx, int by, int bz) { + private int shadeAndTint( + FaceHit hit, BlockData data, WorldSnapshot snapshot, BiomeBlend tints, int bx, int by, int bz) { int color = hit.color(); if (hit.tintIndex() >= 0) { - BiomeTint tint = blendedTint(snapshot, bx, by, bz); + BiomeTint tint = tints.at(bx, by, bz); if (tint != null) { int tintColor = TintResolver.resolve(data, hit.tintIndex(), tint); if (tintColor != -1) color = ColorUtil.multiply(color, tintColor); @@ -453,42 +459,6 @@ public final class SnapshotRaytracer { return m != Material.AIR && m.isOccluding(); } - /** Biome tint averaged over the (2r+1)² columns around the block (vanilla biome blend, radius 2); cached per column. */ - private BiomeTint blendedTint(WorldSnapshot snapshot, int bx, int by, int bz) { - long key = (((long) bx) & 0xFFFFFFFFL) | (((long) bz) << 32); - BiomeTint cached = tintCache.get(key); - if (cached != null) return cached; - - long[] g = new long[3], f = new long[3], d = new long[3], w = new long[3]; - int n = 0; - for (int dx = -BIOME_BLEND_RADIUS; dx <= BIOME_BLEND_RADIUS; dx++) { - for (int dz = -BIOME_BLEND_RADIUS; dz <= BIOME_BLEND_RADIUS; dz++) { - Biome biome = snapshot.getBiome(bx + dx, by, bz + dz); - if (biome == null) continue; - BiomeTint t = tintProvider.forBiome(biome); - accumulate(g, t.grass()); - accumulate(f, t.foliage()); - accumulate(d, t.dryFoliage()); - accumulate(w, t.water()); - n++; - } - } - if (n == 0) return null; - BiomeTint result = new BiomeTint(average(g, n), average(f, n), average(d, n), average(w, n)); - tintCache.put(key, result); - return result; - } - - private static void accumulate(long[] acc, int argb) { - acc[0] += 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)); - } - private static double shadeFactor(Vector normal) { return shadeFactor(normal.getX(), normal.getY(), normal.getZ()); } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java index 946a0a6..090823e 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java @@ -12,6 +12,7 @@ import eu.mhsl.minecraft.pixelpics.render.entity.ObjectState; import eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker; import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker; import eu.mhsl.minecraft.pixelpics.render.entity.cem.ItemGeometry; +import eu.mhsl.minecraft.pixelpics.render.raytrace.BiomeBlend; import eu.mhsl.minecraft.pixelpics.render.raytrace.CameraMedium; import eu.mhsl.minecraft.pixelpics.render.raytrace.FluidRenderer; import eu.mhsl.minecraft.pixelpics.render.raytrace.SnapshotRaytracer; @@ -26,9 +27,9 @@ import eu.mhsl.minecraft.pixelpics.render.snapshot.SnapshotBuilder; import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot; import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider; import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; +import eu.mhsl.minecraft.pixelpics.render.util.SpecialDates; import java.awt.image.BufferedImage; import java.awt.image.DataBufferInt; -import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.UUID; @@ -114,8 +115,7 @@ public class DefaultScreenRenderer { boolean inFluid = FluidRenderer.cameraFluid( world::getBlockData, eyeLocation.getX(), eyeLocation.getY(), eyeLocation.getZ()) != FluidRenderer.NONE; - List rayMap = - buildRayMap(eyeLocation, superW, superH, inFluid ? V_FOV_DEG * FLUID_FOV_SCALE : V_FOV_DEG); + RayMap rayMap = RayMap.of(eyeLocation, superW, superH, inFluid ? V_FOV_DEG * FLUID_FOV_SCALE : V_FOV_DEG); FrustumBounds bounds = FrustumBounds.of(eyeLocation, rayMap, MAX_DISTANCE); WorldSnapshot snapshot = SnapshotBuilder.build(world, bounds, logger); Collection nearby = bounds.nearbyEntities(world); @@ -139,7 +139,8 @@ public class DefaultScreenRenderer { entities, blockEntities, decorations, - objects); + objects, + SpecialDates.now()); } /** Traces every (super)ray in parallel, then downsamples gamma-correctly. Safe off the main thread. */ @@ -155,24 +156,29 @@ public class DefaultScreenRenderer { int finalW = job.width(); int finalH = job.height(); int superW = finalW * SSAA; - List rayMap = job.rayMap(); + RayMap rayMap = job.rayMap(); WorldSnapshot snapshot = job.snapshot(); Vector origin = job.origin(); SkyContext sky = job.sky(); - EntityScene scene = new EntityScene( - job.entities(), - entityBaker, - job.blockEntities(), - blockEntityBaker, - job.decorations(), - decorationBaker, - job.objects(), - objectBaker); + // Date-dependent assets (christmas chests, local_time item models) use the photo's time throughout. + EntityScene scene = SpecialDates.at( + job.time(), + () -> new EntityScene( + job.entities(), + entityBaker, + job.blockEntities(), + blockEntityBaker, + job.decorations(), + decorationBaker, + job.objects(), + objectBaker)); CameraMedium medium = raytracer.cameraMedium(snapshot, origin); + BiomeBlend tints = raytracer.biomeBlend(snapshot); int[] superBuf = new int[rayMap.size()]; runParallel(() -> IntStream.range(0, rayMap.size()).parallel().forEach(i -> { - if (!stopped(cancelled)) superBuf[i] = raytracer.trace(snapshot, origin, rayMap.get(i), sky, scene, medium); + 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); @@ -214,38 +220,8 @@ public class DefaultScreenRenderer { } } - /** - * 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 {@code eye} (no roll), so looking straight up or down keeps the yaw orientation. - */ - public static List buildRayMap(Location eye, int width, int height) { - return buildRayMap(eye, width, height, V_FOV_DEG); - } - - /** {@link #buildRayMap(Location, int, int)} with the vertical FOV {@code vFovDeg}. */ - public static List buildRayMap(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; - - List rayMap = new ArrayList<>(width * height); - for (int row = 0; row < height; row++) { - double v = (1 - 2 * (row + 0.5) / height) * tanY; - for (int col = 0; col < width; col++) { - double u = (2 * (col + 0.5) / width - 1) * tanX; - rayMap.add(new Vector( - forward.getX() + u * right.getX() + v * up.getX(), - forward.getY() + u * right.getY() + v * up.getY(), - forward.getZ() + u * right.getZ() + v * up.getZ()) - .normalize()); - } - } - return rayMap; + /** {@link RayMap#of} with the default vertical FOV. */ + public static RayMap buildRayMap(Location eye, int width, int height) { + return RayMap.of(eye, width, height, V_FOV_DEG); } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/RayMap.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/RayMap.java new file mode 100644 index 0000000..c5163d2 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/RayMap.java @@ -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(); + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/RenderJob.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/RenderJob.java index 9960846..a237b38 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/RenderJob.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/RenderJob.java @@ -6,16 +6,17 @@ import eu.mhsl.minecraft.pixelpics.render.entity.EntityState; import eu.mhsl.minecraft.pixelpics.render.entity.ObjectState; import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext; import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot; +import java.time.ZonedDateTime; import java.util.List; import org.bukkit.util.Vector; /** * A prepared render: everything captured on the main thread (world snapshot, ray map, camera origin, sky state, - * entity/block-entity/decoration/object states). {@link DefaultScreenRenderer#execute} runs it off the main thread. + * entity/block-entity/decoration/object states, the time for date-dependent assets). {@link DefaultScreenRenderer#execute} runs it off the main thread. */ public record RenderJob( WorldSnapshot snapshot, - List rayMap, + RayMap rayMap, Vector origin, int width, int height, @@ -23,4 +24,5 @@ public record RenderJob( List entities, List blockEntities, List decorations, - List objects) {} + List objects, + ZonedDateTime time) {} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java index 0c20d9e..d03f218 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java @@ -21,7 +21,6 @@ import org.bukkit.Material; import org.bukkit.World; import org.bukkit.block.Banner; import org.bukkit.block.Beacon; -import org.bukkit.block.Block; import org.bukkit.block.BlockFace; import org.bukkit.block.BlockState; import org.bukkit.block.Campfire; @@ -437,12 +436,18 @@ public final class BlockEntitySnapshotBuilder { (ColorUtil.blue(a) + ColorUtil.blue(b)) / 2); } - /** TheEndGatewayBlockEntity.shouldRenderFace ≈ the neighbour is not a full opaque cube; "+"-joined. */ + /** + * 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) { - Block block = ts.getBlock(); + World world = ts.getWorld(); + int x = ts.getX(), y = ts.getY(), z = ts.getZ(); StringBuilder sb = new StringBuilder(); for (BlockFace f : GATEWAY_FACES) { - if (block.getRelative(f).getType().isOccluding()) continue; + 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)); } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/EntitySnapshotBuilder.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/EntitySnapshotBuilder.java index 876901c..78cb40a 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/EntitySnapshotBuilder.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/EntitySnapshotBuilder.java @@ -29,6 +29,7 @@ import org.bukkit.Keyed; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Tag; +import org.bukkit.World; import org.bukkit.attribute.Attribute; import org.bukkit.attribute.AttributeInstance; import org.bukkit.block.BlockFace; @@ -555,9 +556,18 @@ public final class EntitySnapshotBuilder { return m.invoke(e); } - /** Facing of the bed the entity sleeps in (LivingEntity.getBedOrientation = the bed block's FACING), or null. */ + /** + * 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) { - return e.getLocation().getBlock().getBlockData() instanceof Bed bed ? bed.getFacing() : null; + 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). */ diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/FrustumBounds.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/FrustumBounds.java index 1eec5a4..8d897e3 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/FrustumBounds.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/FrustumBounds.java @@ -1,7 +1,7 @@ package eu.mhsl.minecraft.pixelpics.render.snapshot; +import eu.mhsl.minecraft.pixelpics.render.render.RayMap; import java.util.Collection; -import java.util.List; import org.bukkit.Location; import org.bukkit.World; import org.bukkit.entity.Entity; @@ -25,11 +25,12 @@ public final class FrustumBounds { this.maxZ = maxZ; } - public static FrustumBounds of(Location eye, List rayMap, double maxDistance) { + public static FrustumBounds of(Location eye, RayMap rayMap, double maxDistance) { double ox = eye.getX(), oy = eye.getY(), oz = eye.getZ(); double minX = ox, minY = oy, minZ = oz; double maxX = ox, maxY = oy, maxZ = oz; - for (Vector ray : rayMap) { + for (int i = 0, n = rayMap.size(); i < n; i++) { + Vector ray = rayMap.get(i); double fx = ox + ray.getX() * maxDistance; double fy = oy + ray.getY() * maxDistance; double fz = oz + ray.getZ() * maxDistance; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/util/SpecialDates.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/util/SpecialDates.java index b15c96c..e4ea58f 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/util/SpecialDates.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/util/SpecialDates.java @@ -6,6 +6,7 @@ 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 @@ -23,10 +24,22 @@ public final class SpecialDates { private static final List 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 PHOTO_TIME = ScopedValue.newInstance(); + private SpecialDates() {} - /** The current local date-time ({@code ZonedDateTime.now()}), or the test date at the current time of day. */ + /** Runs {@code task} with {@link #now()} fixed to {@code time} on this thread. */ + public static T at(ZonedDateTime time, Supplier 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; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PhotoService.java b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PhotoService.java index d9fc9c2..a87a3f3 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PhotoService.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PhotoService.java @@ -66,9 +66,10 @@ public final class PhotoService { boolean dispatched = false; try { - // The world snapshot is taken now, on the main thread; tracing happens later off-thread. - RenderJob job = renderer.prepare( - player.getEyeLocation(), new Resolution(MapImageDither.SIZE, MapImageDither.SIZE), user); + // The view is fixed now; the world snapshot is taken (on the main thread) only when the render starts, so + // photos waiting in the queue hold no snapshot. + Location eye = player.getEyeLocation(); + Resolution resolution = new Resolution(MapImageDither.SIZE, MapImageDither.SIZE); // The map is handed over right away and shows blank film until the picture develops. The blank film is // also stored, so the map never turns into a vanilla world map if the render is lost (crash). @@ -96,6 +97,8 @@ public final class PhotoService { manager.dispatch( user, cancelled -> { + RenderJob job = manager.onMainThread(() -> renderer.prepare(eye, resolution, user), cancelled); + if (job == null) return null; BufferedImage image = renderer.execute(job, cancelled); if (cancelled.get()) return null; return new RenderOutput(image, MapImageDither.dither(image)); diff --git a/tools/Harness.java b/tools/Harness.java index f284d05..2f8b1c5 100644 --- a/tools/Harness.java +++ b/tools/Harness.java @@ -5,10 +5,12 @@ 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.RayMap; import eu.mhsl.minecraft.pixelpics.render.render.RenderJob; import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext; import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot; import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider; +import eu.mhsl.minecraft.pixelpics.render.util.SpecialDates; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.block.data.BlockData; @@ -101,8 +103,8 @@ final class Harness { List bes, List decos, List objects) { Location loc = new Location(null, cam.getX(), cam.getY(), cam.getZ()); loc.setDirection(look.clone().subtract(cam)); - List rayMap = DefaultScreenRenderer.buildRayMap(loc, w * SSAA, h * SSAA); - return new RenderJob(world, rayMap, cam, w, h, NOON, mobs, bes, decos, objects); + RayMap rayMap = DefaultScreenRenderer.buildRayMap(loc, w * SSAA, h * SSAA); + return new RenderJob(world, rayMap, cam, w, h, NOON, mobs, bes, decos, objects, SpecialDates.now()); } /** Distance at which a box of extent {@code ext} (at least 0.5) fills the view, scaled {@code mul}, plus {@code add}. */