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
This commit is contained in:
1 parent
e08bfc81f3
commit
a890370cc5
16 files changed
+364
-163
No files matched your search
@@ -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<String, String> textures = new HashMap<>();
|
||||
List<ModelFileDto.ElementDto> elements = dto.elements;
|
||||
if (dto.parent != null && depth < MAX_DEPTH && !dto.parent.startsWith("builtin/")) {
|
||||
FlatModel parent = resolve(ResourceLocation.parse(dto.parent), depth + 1);
|
||||
textures.putAll(parent.textures());
|
||||
if (elements == null || elements.isEmpty()) elements = parent.elements();
|
||||
if (dto.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);
|
||||
}
|
||||
}
|
||||
@@ -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> T onMainThread(Supplier<T> task, AtomicBoolean cancelled) {
|
||||
CompletableFuture<T> 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
|
||||
|
||||
@@ -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<ObjectState> objects,
|
||||
ObjectBaker objectBaker) {
|
||||
List<EntityCube> baked = new ArrayList<>();
|
||||
addAll(states, baker, baked);
|
||||
addAll(blockEntities, beBaker, baked);
|
||||
addAll(decorations, decoBaker, baked);
|
||||
addAll(objects, objectBaker, baked);
|
||||
List<RuntimeException> failures = new ArrayList<>();
|
||||
addAll(states, baker, baked, failures);
|
||||
addAll(blockEntities, beBaker, baked, failures);
|
||||
addAll(decorations, decoBaker, baked, failures);
|
||||
addAll(objects, objectBaker, baked, failures);
|
||||
if (!failures.isEmpty()) {
|
||||
// Once per photo: one bad entity/asset must never fail the whole photo, but should not go unnoticed.
|
||||
Logger.getLogger("PixelPics")
|
||||
.warning("PixelPics: skipped " + failures.size() + " entities that failed to bake, first: "
|
||||
+ failures.getFirst());
|
||||
}
|
||||
EntityCube[] all = baked.toArray(new EntityCube[0]);
|
||||
int n = all.length;
|
||||
|
||||
@@ -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 <S> void addAll(List<S> states, EntityBaker<S> baker, List<EntityCube> 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 <S> void addAll(
|
||||
List<S> states, EntityBaker<S> baker, List<EntityCube> out, List<RuntimeException> failures) {
|
||||
if (baker == null) return;
|
||||
for (S s : states) {
|
||||
RenderedEntity e;
|
||||
try {
|
||||
e = baker.bake(s);
|
||||
} catch (RuntimeException ex) {
|
||||
// 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);
|
||||
|
||||
@@ -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<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)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<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[][], List<Box>> extrusions = new IdentityHashMap<>();
|
||||
private final Map<Info, Optional<int[][]>> 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<JsonObject> def = reader.readJsonObject("assets/minecraft/items/" + id + ".json");
|
||||
if (def.isPresent() && def.get().has("model")) {
|
||||
Affine[] outer = {null};
|
||||
JsonObject node = pick(def.get().getAsJsonObject("model"), context, now, outer);
|
||||
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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<Long, BiomeTint> 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());
|
||||
}
|
||||
|
||||
+24
-48
@@ -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<Vector> 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<Entity> 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<Vector> 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<Vector> 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<Vector> 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<Vector> 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);
|
||||
}
|
||||
}
|
||||
@@ -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,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<Vector> rayMap,
|
||||
RayMap rayMap,
|
||||
Vector origin,
|
||||
int width,
|
||||
int height,
|
||||
@@ -23,4 +24,5 @@ public record RenderJob(
|
||||
List<EntityState> entities,
|
||||
List<BlockEntityState> blockEntities,
|
||||
List<DecorationState> decorations,
|
||||
List<ObjectState> objects) {}
|
||||
List<ObjectState> objects,
|
||||
ZonedDateTime time) {}
|
||||
+9
-4
@@ -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));
|
||||
}
|
||||
|
||||
+12
-2
@@ -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). */
|
||||
|
||||
@@ -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<Vector> 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;
|
||||
|
||||
@@ -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<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() {}
|
||||
|
||||
/** 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> 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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
+4
-2
@@ -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<BlockEntityState> bes, List<DecorationState> decos, List<ObjectState> objects) {
|
||||
Location loc = new Location(null, cam.getX(), cam.getY(), cam.getZ());
|
||||
loc.setDirection(look.clone().subtract(cam));
|
||||
List<Vector> 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}. */
|
||||
|
||||
Reference in new issue
Block a user