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
This commit is contained in:
1 parent
291d51891f
commit
0b781c3538
19 files changed
+731
-89
No files matched your search
@@ -11,15 +11,32 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.block.data.Bisected;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.block.data.type.Bed;
|
||||
import org.bukkit.block.data.type.Stairs;
|
||||
import org.bukkit.block.data.type.TrapDoor;
|
||||
|
||||
/**
|
||||
* Turns a {@link BlockData} into a baked {@link ResolvedModel} (blockstate -> variants -> flattened models ->
|
||||
* elements). Thread-safe; models are cached per BlockData and the baked elements per {@link Variant}.
|
||||
* elements). Thread-safe; models are cached per BlockData and choice of weighted variants, the baked elements per
|
||||
* {@link Variant}.
|
||||
*
|
||||
* <p>Weighted variant lists (stone, grass, dirt, sand, … in four rotations; bamboo/chorus/fire parts) are picked
|
||||
* like vanilla 26.3: blocks in the world with {@code random.setSeed(state.getSeed(pos))} ({@code Mth.getSeed},
|
||||
* doors/double plants by their lower half, beds by their head; {@code SectionCompiler} →
|
||||
* {@code ModelBlockRenderer.tesselateBlock}), then {@code WeightedList.getRandomOrThrow} = {@code nextInt(total
|
||||
* weight)}; multipart draws one {@code nextLong} and re-seeds every part with it ({@code MultiPartModel}). Block
|
||||
* models outside the world (block displays, minecart blocks; {@code BlockModelResolver}) use the fixed seed 42.
|
||||
*/
|
||||
public final class BlockModelRegistry {
|
||||
|
||||
private static final int FALLBACK_GREY = 0xFF7F7F7F;
|
||||
/** {@code BlockModelResolver.MODEL_SEED}: block models drawn outside the world (displays, minecart contents). */
|
||||
public static final long MODEL_SEED = 42L;
|
||||
// Combinations of weighted choices beyond this are cached in a map instead of an array.
|
||||
private static final int MAX_COMBO_ARRAY = 4096;
|
||||
|
||||
// Render nothing in the world: hand-only technical blocks, and liquids (vanilla RenderShape.INVISIBLE; drawn by
|
||||
// render.raytrace.FluidRenderer, block displays of them show nothing like vanilla).
|
||||
@@ -37,7 +54,7 @@ public final class BlockModelRegistry {
|
||||
private final ModelResolver modelResolver;
|
||||
private final ModelBaker baker;
|
||||
|
||||
private final Map<BlockData, ResolvedModel> cache = new ConcurrentHashMap<>();
|
||||
private final Map<BlockData, States> cache = new ConcurrentHashMap<>();
|
||||
private final Map<ResourceLocation, ResolvedModel> modelCache = new ConcurrentHashMap<>();
|
||||
private final Map<Variant, List<Element>> variantCache = new ConcurrentHashMap<>();
|
||||
|
||||
@@ -57,9 +74,210 @@ public final class BlockModelRegistry {
|
||||
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) {
|
||||
ResolvedModel model = cache.get(data);
|
||||
return model != null ? model : cache.computeIfAbsent(data, this::resolve);
|
||||
return get(data, MODEL_SEED);
|
||||
}
|
||||
|
||||
/** The model of {@code data} with the weighted variants picked by {@code seed} (vanilla's model random seed). */
|
||||
public ResolvedModel get(BlockData data, long seed) {
|
||||
States states = states(data);
|
||||
return states.fixed != null ? states.fixed : states.pick(this, seed);
|
||||
}
|
||||
|
||||
/**
|
||||
* The model of {@code data} with the FIRST entry of every weighted list: block items drawn from their blockstate
|
||||
* (vanilla draws the item model, which for weighted blocks is the first, unrotated variant's model).
|
||||
*/
|
||||
public ResolvedModel firstVariants(BlockData data) {
|
||||
States states = states(data);
|
||||
return states.fixed != null ? states.fixed : states.model(this, 0);
|
||||
}
|
||||
|
||||
private States states(BlockData data) {
|
||||
States states = cache.get(data);
|
||||
return states != null ? states : cache.computeIfAbsent(data, this::resolveStates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vanilla {@code BlockState.getSeed(pos)}: {@code Mth.getSeed(pos)}; doors and double plants
|
||||
* ({@code DoublePlantBlock}: tall grass/flowers/seagrass, small dripleaf, pitcher crop) use their lower half,
|
||||
* beds their head ({@code AbstractBedBlock}).
|
||||
*/
|
||||
public static long seed(BlockData data, int x, int y, int z) {
|
||||
int[] d = seedOffset(data);
|
||||
return mthSeed(x + d[0], y + d[1], z + d[2]);
|
||||
}
|
||||
|
||||
/** The position offset of {@link #seed}: lower half of doors/double plants, head of a bed foot. */
|
||||
private static int[] seedOffset(BlockData data) {
|
||||
if (data instanceof Bisected b
|
||||
&& !(data instanceof Stairs)
|
||||
&& !(data instanceof TrapDoor)
|
||||
&& b.getHalf() == Bisected.Half.TOP) {
|
||||
return new int[] {0, -1, 0};
|
||||
}
|
||||
if (data instanceof Bed bed && bed.getPart() == Bed.Part.FOOT) {
|
||||
BlockFace f = bed.getFacing();
|
||||
return new int[] {f.getModX(), 0, f.getModZ()};
|
||||
}
|
||||
return new int[3];
|
||||
}
|
||||
|
||||
/** {@code Mth.getSeed(x, y, z)}, including its int overflow of {@code x * 3129871}. */
|
||||
static long mthSeed(int x, int y, int z) {
|
||||
long seed = (long) (x * 3129871) ^ z * 116129781L ^ y;
|
||||
seed = seed * seed * 42317861L + seed * 11L;
|
||||
return seed >> 16;
|
||||
}
|
||||
|
||||
/**
|
||||
* The picked entry of every part, as a mixed-radix index over the parts' list sizes (first part most
|
||||
* significant): {@code ModelBlockRenderer} seeds the random, {@code WeightedVariants} draws
|
||||
* {@code nextInt(totalWeight)}, {@code MultiPartModel} first draws a {@code nextLong} and re-seeds with it before
|
||||
* every part.
|
||||
*/
|
||||
static long comboIndex(boolean multipart, BlockStateResolver.Choice[] parts, long seed) {
|
||||
if (!multipart) {
|
||||
// Hot path (stone, grass, dirt, … in every chunk): one list, the first draw of a fresh seed.
|
||||
BlockStateResolver.Choice c = parts[0];
|
||||
return c.index(Lcg.firstNextInt(seed, c.totalWeight()));
|
||||
}
|
||||
Lcg random = new Lcg(seed);
|
||||
long partSeed = multipart ? random.nextLong() : 0;
|
||||
long index = 0;
|
||||
for (BlockStateResolver.Choice c : parts) {
|
||||
if (multipart) random.setSeed(partSeed);
|
||||
// WeightedVariants draws even for a one-entry list, SingleVariant does not: the same pick either way.
|
||||
int pick = c.variants().size() == 1 ? 0 : c.index(random.nextInt(c.totalWeight()));
|
||||
index = index * c.variants().size() + pick;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/** Vanilla's {@code LegacyRandomSource}/{@code SingleThreadedRandomSource} (java.util.Random's LCG). */
|
||||
private static final class Lcg {
|
||||
private static final long MULTIPLIER = 0x5DEECE66DL;
|
||||
private static final long MASK = (1L << 48) - 1;
|
||||
private long seed;
|
||||
|
||||
Lcg(long seed) {
|
||||
setSeed(seed);
|
||||
}
|
||||
|
||||
void setSeed(long seed) {
|
||||
this.seed = (seed ^ MULTIPLIER) & MASK;
|
||||
}
|
||||
|
||||
/** {@code setSeed(seed); nextInt(bound)} without an instance. */
|
||||
static int firstNextInt(long seed, int bound) {
|
||||
long s = (seed ^ MULTIPLIER) & MASK;
|
||||
s = s * MULTIPLIER + 11L & MASK;
|
||||
int sample = (int) (s >> 17);
|
||||
if ((bound & bound - 1) == 0) return (int) ((long) bound * sample >> 31);
|
||||
int modulo = sample % bound;
|
||||
while (sample - modulo + (bound - 1) < 0) {
|
||||
s = s * MULTIPLIER + 11L & MASK;
|
||||
sample = (int) (s >> 17);
|
||||
modulo = sample % bound;
|
||||
}
|
||||
return modulo;
|
||||
}
|
||||
|
||||
private int next(int bits) {
|
||||
seed = seed * MULTIPLIER + 11L & MASK;
|
||||
return (int) (seed >> 48 - bits);
|
||||
}
|
||||
|
||||
/** {@code BitRandomSource.nextInt(bound)}. */
|
||||
int nextInt(int bound) {
|
||||
if ((bound & bound - 1) == 0) return (int) ((long) bound * next(31) >> 31);
|
||||
int sample, modulo;
|
||||
do {
|
||||
sample = next(31);
|
||||
modulo = sample % bound;
|
||||
} while (sample - modulo + (bound - 1) < 0);
|
||||
return modulo;
|
||||
}
|
||||
|
||||
long nextLong() {
|
||||
int upper = next(32);
|
||||
int lower = next(32);
|
||||
return ((long) upper << 32) + lower;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The model choices of one BlockData: {@code fixed} when nothing is random (every choice has one entry),
|
||||
* else the choices and the models per combination of picked entries (built on first use).
|
||||
*/
|
||||
private static final class States {
|
||||
final ResolvedModel fixed;
|
||||
final BlockData data;
|
||||
final boolean multipart;
|
||||
final BlockStateResolver.Choice[] parts;
|
||||
final ResolvedModel[] combos; // index = mixed radix over the parts' picks; null when too many
|
||||
final Map<Long, ResolvedModel> comboMap;
|
||||
final int seedDx, seedDy, seedDz; // seedOffset of data
|
||||
|
||||
States(ResolvedModel fixed) {
|
||||
this.seedDx = this.seedDy = this.seedDz = 0;
|
||||
this.fixed = fixed;
|
||||
this.data = null;
|
||||
this.multipart = false;
|
||||
this.parts = null;
|
||||
this.combos = null;
|
||||
this.comboMap = null;
|
||||
}
|
||||
|
||||
States(BlockData data, BlockStateResolver.StateModel model) {
|
||||
int[] d = seedOffset(data);
|
||||
this.seedDx = d[0];
|
||||
this.seedDy = d[1];
|
||||
this.seedDz = d[2];
|
||||
this.fixed = null;
|
||||
this.data = data;
|
||||
this.multipart = model.multipart();
|
||||
this.parts = model.parts().toArray(BlockStateResolver.Choice[]::new);
|
||||
long count = 1;
|
||||
for (BlockStateResolver.Choice c : parts)
|
||||
count = Math.min(count * c.variants().size(), Long.MAX_VALUE / 64);
|
||||
this.combos = count <= MAX_COMBO_ARRAY ? new ResolvedModel[(int) count] : null;
|
||||
this.comboMap = combos == null ? new ConcurrentHashMap<>() : null;
|
||||
}
|
||||
|
||||
ResolvedModel pick(BlockModelRegistry registry, long seed) {
|
||||
return model(registry, comboIndex(multipart, parts, seed));
|
||||
}
|
||||
|
||||
/** The model of a combination index (0 = the first entry of every list). */
|
||||
ResolvedModel model(BlockModelRegistry registry, long index) {
|
||||
if (combos != null) {
|
||||
ResolvedModel m = combos[(int) index];
|
||||
if (m == null) combos[(int) index] = m = registry.resolve(data, variants(index)); // immutable, racy ok
|
||||
return m;
|
||||
}
|
||||
long key = index;
|
||||
return comboMap.computeIfAbsent(key, k -> registry.resolve(data, variants(k)));
|
||||
}
|
||||
|
||||
/** The picked variant of every part for a combination index. */
|
||||
private List<Variant> variants(long index) {
|
||||
Variant[] picked = new Variant[parts.length];
|
||||
for (int i = parts.length - 1; i >= 0; i--) {
|
||||
int n = parts[i].variants().size();
|
||||
picked[i] = parts[i].variants().get((int) (index % n));
|
||||
index /= n;
|
||||
}
|
||||
return List.of(picked);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,11 +295,21 @@ public final class BlockModelRegistry {
|
||||
return variantCache.computeIfAbsent(variant, v -> baker.bake(modelResolver.resolve(v.model()), v));
|
||||
}
|
||||
|
||||
private ResolvedModel resolve(BlockData data) {
|
||||
Material material = data.getMaterial();
|
||||
if (INVISIBLE_MATERIALS.contains(material)) return ResolvedModel.EMPTY;
|
||||
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));
|
||||
}
|
||||
|
||||
List<Variant> variants = blockStateResolver.resolve(data);
|
||||
/** The model of {@code data} from the picked {@code variants} (one per part). */
|
||||
private ResolvedModel resolve(BlockData data, List<Variant> variants) {
|
||||
Material material = data.getMaterial();
|
||||
List<Element> elements;
|
||||
if (variants.size() == 1) {
|
||||
elements = bake(variants.getFirst()); // shared with other states of the same variant; never mutated
|
||||
|
||||
@@ -9,16 +9,41 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
|
||||
/** Resolves a {@link BlockData} to the model variants of its {@code blockstates/<name>.json} (variants or multipart). */
|
||||
/**
|
||||
* Resolves a {@link BlockData} to the model choices of its {@code blockstates/<name>.json}: the matching entry of
|
||||
* {@code variants}, or every applying {@code multipart} case. Each choice is a weighted list (vanilla
|
||||
* {@code WeightedVariants}; a single object = one entry); {@link BlockModelRegistry} picks per position.
|
||||
*/
|
||||
public final class BlockStateResolver {
|
||||
|
||||
/** A weighted variant list (vanilla {@code WeightedVariants}); a single variant has one entry. */
|
||||
public record Choice(List<Variant> variants, int[] weights, int totalWeight) {
|
||||
|
||||
/** {@code WeightedList.Selector.get}: the entry whose weight range contains {@code selection}. */
|
||||
public int index(int selection) {
|
||||
for (int i = 0; i < weights.length; i++) {
|
||||
selection -= weights[i];
|
||||
if (selection < 0) return i;
|
||||
}
|
||||
return weights.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The model of a block state: {@code multipart} (vanilla {@code MultiPartModel}: every part picks with the
|
||||
* same seed) or a single {@code variants} entry ({@code parts} has one choice); no parts = no model.
|
||||
*/
|
||||
public record StateModel(boolean multipart, List<Choice> parts) {
|
||||
static final StateModel NONE = new StateModel(false, List.of());
|
||||
}
|
||||
|
||||
private final AssetReader reader;
|
||||
|
||||
public BlockStateResolver(AssetReader reader) {
|
||||
this.reader = reader;
|
||||
}
|
||||
|
||||
public List<Variant> resolve(BlockData data) {
|
||||
public StateModel resolve(BlockData data) {
|
||||
// e.g. minecraft:oak_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]
|
||||
String state = data.getAsString(false);
|
||||
int open = state.indexOf('[');
|
||||
@@ -36,7 +61,7 @@ public final class BlockStateResolver {
|
||||
}
|
||||
|
||||
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")) {
|
||||
return resolveVariants(root.getAsJsonObject("variants"), props);
|
||||
@@ -44,26 +69,26 @@ public final class BlockStateResolver {
|
||||
if (root.has("multipart")) {
|
||||
return resolveMultipart(root.getAsJsonArray("multipart"), props);
|
||||
}
|
||||
return List.of();
|
||||
return StateModel.NONE;
|
||||
}
|
||||
|
||||
private static 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()) {
|
||||
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 static List<Variant> resolveMultipart(JsonArray multipart, Map<String, String> props) {
|
||||
List<Variant> result = new ArrayList<>();
|
||||
private static StateModel resolveMultipart(JsonArray multipart, Map<String, String> props) {
|
||||
List<Choice> result = new ArrayList<>();
|
||||
for (JsonElement caseEl : multipart) {
|
||||
JsonObject caseObj = caseEl.getAsJsonObject();
|
||||
if (caseObj.has("when") && !whenMatches(caseObj.get("when"), props)) continue;
|
||||
result.add(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. */
|
||||
@@ -108,12 +133,21 @@ public final class BlockStateResolver {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Weighted variant lists: the first entry. */
|
||||
private static JsonObject firstOf(JsonElement element) {
|
||||
if (element.isJsonArray()) {
|
||||
return element.getAsJsonArray().get(0).getAsJsonObject();
|
||||
/** A variant object or a weighted list of them ({@code "weight"}, default 1). */
|
||||
private static Choice choice(JsonElement element) {
|
||||
if (!element.isJsonArray())
|
||||
return new Choice(List.of(parseVariant(element.getAsJsonObject())), new int[] {1}, 1);
|
||||
JsonArray array = element.getAsJsonArray();
|
||||
List<Variant> variants = new ArrayList<>(array.size());
|
||||
int[] weights = new int[array.size()];
|
||||
int total = 0;
|
||||
for (int i = 0; i < array.size(); i++) {
|
||||
JsonObject obj = array.get(i).getAsJsonObject();
|
||||
variants.add(parseVariant(obj));
|
||||
weights[i] = obj.has("weight") ? Math.max(1, obj.get("weight").getAsInt()) : 1;
|
||||
total += weights[i];
|
||||
}
|
||||
return element.getAsJsonObject();
|
||||
return new Choice(List.copyOf(variants), weights, total);
|
||||
}
|
||||
|
||||
private static Variant parseVariant(JsonObject obj) {
|
||||
|
||||
@@ -22,10 +22,10 @@ public abstract class ImageCache<K> {
|
||||
return result == MISSING ? Optional.empty() : Optional.of(result);
|
||||
}
|
||||
|
||||
/** The top-left {@code w×h} pixels of an image as ARGB rows. */
|
||||
protected static int[][] pixels(BufferedImage img, int w, int h) {
|
||||
/** 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(0, y, w, 1, null, 0, w);
|
||||
for (int y = 0; y < h; y++) out[y] = img.getRGB(x0, y0 + y, w, 1, null, 0, w);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -211,16 +211,22 @@ public final class ModelBaker {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The texture of a face: like vanilla 26.3 {@code TextureSlots.getMaterial} the face's {@code texture} always
|
||||
* names a texture slot, with or without the leading {@code #} (heavy_core uses {@code "all"}); slot values
|
||||
* starting with {@code #} refer to other slots. An unresolved slot or a missing/unreadable texture is the
|
||||
* magenta-black missing texture ({@link TextureCache#MISSINGNO}), not a hole.
|
||||
*/
|
||||
private int[][] resolveTexture(String ref, Map<String, String> vars) {
|
||||
if (ref == null) return null;
|
||||
String current = ref;
|
||||
if (ref == null || ref.isEmpty()) return null;
|
||||
String current = ref.startsWith("#") ? ref : "#" + ref;
|
||||
int guard = 0;
|
||||
while (current.startsWith("#") && guard++ < 16) {
|
||||
current = vars.get(current.substring(1));
|
||||
if (current == null) return null;
|
||||
if (current == null) return TextureCache.MISSINGNO;
|
||||
}
|
||||
if (current.startsWith("#")) return null;
|
||||
return textures.get(ResourceLocation.parse(current)).orElse(null);
|
||||
if (current.startsWith("#")) return TextureCache.MISSINGNO;
|
||||
return textures.get(ResourceLocation.parse(current)).orElse(TextureCache.MISSINGNO);
|
||||
}
|
||||
|
||||
private static int axisIndex(String axis) {
|
||||
|
||||
@@ -5,6 +5,8 @@ import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.logging.Logger;
|
||||
@@ -12,7 +14,12 @@ import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Builds a {@link ResourcePack} from the plugin's {@code resourcepack/} folder: unpacked packs (the folder itself or
|
||||
* a direct sub-folder with {@code assets/minecraft}) and {@code .zip} packs anywhere below it, in that priority.
|
||||
* a direct sub-folder with {@code assets/minecraft}) and {@code .zip} packs anywhere below it. Priority (the first
|
||||
* pack containing an asset wins) is deterministic, independent of the file system's listing order: the folder
|
||||
* itself, then its sub-folders sorted by name, then the zips sorted by their path relative to the folder ('/'
|
||||
* separated, plain {@code String} order: case-sensitive, digits before letters). To let a pack override another,
|
||||
* give it a name that sorts first (e.g. {@code 0-custom.zip} before {@code vanilla.zip}). The packs are logged in
|
||||
* that order.
|
||||
*/
|
||||
public final class ResourcePackLoader {
|
||||
|
||||
@@ -20,6 +27,10 @@ public final class ResourcePackLoader {
|
||||
|
||||
private ResourcePackLoader() {}
|
||||
|
||||
private static String relative(Path root, Path path) {
|
||||
return root.relativize(path).toString().replace(File.separatorChar, '/');
|
||||
}
|
||||
|
||||
public static Optional<ResourcePack> load(File resourcePackDir, Logger logger) {
|
||||
if (!resourcePackDir.isDirectory()) {
|
||||
return Optional.empty();
|
||||
@@ -31,6 +42,7 @@ public final class ResourcePackLoader {
|
||||
dirCandidates.add(resourcePackDir.toPath());
|
||||
File[] children = resourcePackDir.listFiles(File::isDirectory);
|
||||
if (children != null) {
|
||||
Arrays.sort(children, Comparator.comparing(File::getName));
|
||||
for (File child : children) dirCandidates.add(child.toPath());
|
||||
}
|
||||
for (Path candidate : dirCandidates) {
|
||||
@@ -40,9 +52,11 @@ public final class ResourcePackLoader {
|
||||
}
|
||||
}
|
||||
|
||||
try (Stream<Path> walk = Files.walk(resourcePackDir.toPath())) {
|
||||
Path root = resourcePackDir.toPath();
|
||||
try (Stream<Path> walk = Files.walk(root)) {
|
||||
List<Path> zips = walk.filter(Files::isRegularFile)
|
||||
.filter(p -> p.getFileName().toString().toLowerCase().endsWith(".zip"))
|
||||
.sorted(Comparator.comparing(p -> relative(root, p)))
|
||||
.toList();
|
||||
for (Path zip : zips) {
|
||||
try {
|
||||
|
||||
@@ -1,18 +1,41 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Optional;
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
/** Pack textures as ARGB grids by id (unflipped; the UV origin is top-left like vanilla). */
|
||||
/**
|
||||
* Pack textures as ARGB grids by id (unflipped; the UV origin is top-left like vanilla). Animated textures (a
|
||||
* {@code .mcmeta} with an {@code animation} section) keep the frame vanilla shows first.
|
||||
*/
|
||||
public final class TextureCache extends ImageCache<ResourceLocation> {
|
||||
|
||||
/**
|
||||
* Vanilla {@code MissingTextureAtlasSprite} (16x16): magenta #F800F8 / black quarters, black top-left. Shared:
|
||||
* never mutate.
|
||||
*/
|
||||
public static final int[][] MISSINGNO = missingno();
|
||||
|
||||
private final ResourcePack pack;
|
||||
|
||||
public TextureCache(ResourcePack pack) {
|
||||
this.pack = pack;
|
||||
}
|
||||
|
||||
private static int[][] missingno() {
|
||||
int[][] t = new int[16][16];
|
||||
for (int y = 0; y < 16; y++) {
|
||||
for (int x = 0; x < 16; x++) t[y][x] = (y < 8 ^ x < 8) ? 0xFFF800F8 : 0xFF000000;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int[][] load(ResourceLocation id) {
|
||||
var bytes = pack.read(AssetPaths.texture(id));
|
||||
@@ -27,9 +50,77 @@ public final class TextureCache extends ImageCache<ResourceLocation> {
|
||||
|
||||
int width = img.getWidth();
|
||||
int height = img.getHeight();
|
||||
// Animated strips keep their first frame, but only real animations (with a .mcmeta like vanilla): tall
|
||||
// entity textures (64×128 witch/strider) are full textures.
|
||||
boolean animated = height > width && height % width == 0 && pack.exists(AssetPaths.textureMeta(id));
|
||||
return pixels(img, width, animated ? width : height);
|
||||
JsonObject animation = animation(id);
|
||||
if (animation == null) return pixels(img, 0, 0, width, height);
|
||||
try {
|
||||
return firstFrame(img, animation, width, height);
|
||||
} catch (RuntimeException e) {
|
||||
return null; // unreadable animation metadata: vanilla uses the missing texture
|
||||
}
|
||||
}
|
||||
|
||||
/** The {@code animation} section of the texture's {@code .mcmeta}, or null (none, or not parseable). */
|
||||
private JsonObject animation(ResourceLocation id) {
|
||||
Optional<byte[]> meta = pack.read(AssetPaths.textureMeta(id));
|
||||
if (meta.isEmpty()) return null;
|
||||
try {
|
||||
JsonElement root = JsonParser.parseString(new String(meta.get(), StandardCharsets.UTF_8));
|
||||
if (!root.isJsonObject()) return null;
|
||||
JsonElement anim = root.getAsJsonObject().get("animation");
|
||||
return anim != null && anim.isJsonObject() ? anim.getAsJsonObject() : null;
|
||||
} catch (RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame vanilla's animation starts with (26.3 {@code SpriteResourceLoader} + {@code SpriteContents}): frame
|
||||
* size from {@code width}/{@code height} (each defaulting to the image's; both absent = the smaller image side),
|
||||
* frames numbered row by row; the first valid entry of {@code frames} (index in range, time > 0), else frame 0
|
||||
* (also with at most one valid entry: then vanilla does not animate and the atlas keeps the top-left frame).
|
||||
* An image that is not a multiple of the frame size is the missing texture in vanilla (null here).
|
||||
*/
|
||||
private static int[][] firstFrame(BufferedImage img, JsonObject anim, int width, int height) {
|
||||
Integer fw = positiveInt(anim, "width"), fh = positiveInt(anim, "height");
|
||||
int frameW, frameH;
|
||||
if (fw != null) {
|
||||
frameW = fw;
|
||||
frameH = fh != null ? fh : height;
|
||||
} else if (fh != null) {
|
||||
frameW = width;
|
||||
frameH = fh;
|
||||
} else {
|
||||
frameW = frameH = Math.min(width, height);
|
||||
}
|
||||
if (width % frameW != 0 || height % frameH != 0) return null;
|
||||
int perRow = width / frameW;
|
||||
int total = perRow * (height / frameH);
|
||||
int defaultTime = anim.has("frametime") ? anim.get("frametime").getAsInt() : 1;
|
||||
int index = 0, valid = 0;
|
||||
JsonElement frames = anim.get("frames");
|
||||
if (frames != null && frames.isJsonArray()) {
|
||||
int first = 0;
|
||||
for (JsonElement f : (JsonArray) frames) {
|
||||
int i, time = defaultTime;
|
||||
if (f.isJsonObject()) {
|
||||
JsonObject o = f.getAsJsonObject();
|
||||
i = o.get("index").getAsInt();
|
||||
if (o.has("time")) time = o.get("time").getAsInt();
|
||||
} else {
|
||||
i = f.getAsInt();
|
||||
}
|
||||
if (time > 0 && i >= 0 && i < total && valid++ == 0) first = i;
|
||||
}
|
||||
// One valid frame or none: no animation, the atlas keeps the top-left frame.
|
||||
if (valid > 1) index = first;
|
||||
}
|
||||
return pixels(img, index % perRow * frameW, index / perRow * frameH, frameW, frameH);
|
||||
}
|
||||
|
||||
private static Integer positiveInt(JsonObject o, String key) {
|
||||
if (!o.has(key)) return null;
|
||||
int v = o.get(key).getAsInt();
|
||||
if (v <= 0) throw new IllegalArgumentException(key + " must be positive");
|
||||
return v;
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ public final class ObjectBaker implements EntityBaker<ObjectState> {
|
||||
switch (s.kind()) {
|
||||
case BLOCK -> {
|
||||
// Block model plus the block-entity renderer's parts (chest bodies, lectern book, bell, …).
|
||||
items.bakeBlock(s.block(), s.toWorld(), cubes);
|
||||
items.bakeBlock(s.block(), s.modelSeed(), s.toWorld(), cubes);
|
||||
items.bakeBlockEntity(s.blockEntity(), s.toWorld(), cubes);
|
||||
}
|
||||
case ITEM -> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
|
||||
import java.util.List;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
|
||||
@@ -24,15 +25,23 @@ public record ObjectState(
|
||||
int lineWidth, // TEXT: wrap width in font px
|
||||
int textArgb, // TEXT: text colour (alpha = text opacity)
|
||||
int backgroundArgb, // TEXT: background colour (0 = none)
|
||||
boolean shadow) { // TEXT: drop shadow
|
||||
boolean shadow, // TEXT: drop shadow
|
||||
long modelSeed) { // BLOCK: random seed of weighted blockstate variants
|
||||
|
||||
/** A block outside the world (display, minecart block): vanilla's fixed model seed. */
|
||||
public static ObjectState block(Affine toWorld, BlockData block, BlockEntityState blockEntity) {
|
||||
return block(toWorld, block, blockEntity, BlockModelRegistry.MODEL_SEED);
|
||||
}
|
||||
|
||||
/** A block with the model seed {@code modelSeed} (falling blocks: {@code getSeed} of their start position). */
|
||||
public static ObjectState block(Affine toWorld, BlockData block, BlockEntityState blockEntity, long modelSeed) {
|
||||
return new ObjectState(
|
||||
Kind.BLOCK, toWorld, block, blockEntity, null, null, false, null, List.of(), 0, 0, 0, false);
|
||||
Kind.BLOCK, toWorld, block, blockEntity, null, null, false, null, List.of(), 0, 0, 0, false, modelSeed);
|
||||
}
|
||||
|
||||
public static ObjectState item(Affine toWorld, ItemSpec item, String context, boolean dropped) {
|
||||
return new ObjectState(Kind.ITEM, toWorld, null, null, item, context, dropped, null, List.of(), 0, 0, 0, false);
|
||||
return new ObjectState(
|
||||
Kind.ITEM, toWorld, null, null, item, context, dropped, null, List.of(), 0, 0, 0, false, 0);
|
||||
}
|
||||
|
||||
public static ObjectState text(
|
||||
@@ -50,12 +59,13 @@ public record ObjectState(
|
||||
lineWidth,
|
||||
textArgb,
|
||||
backgroundArgb,
|
||||
shadow);
|
||||
shadow,
|
||||
0);
|
||||
}
|
||||
|
||||
public static ObjectState leash(double[] points) {
|
||||
return new ObjectState(
|
||||
Kind.LEASH, Affine.identity(), null, null, null, null, false, points, List.of(), 0, 0, 0, false);
|
||||
Kind.LEASH, Affine.identity(), null, null, null, null, false, points, List.of(), 0, 0, 0, false, 0);
|
||||
}
|
||||
|
||||
public enum Kind {
|
||||
|
||||
@@ -92,7 +92,10 @@ public final class ItemGeometry {
|
||||
int before = out.size();
|
||||
if (info.special != null) bakeSpecial(item, info, m, out);
|
||||
if (out.size() == before && !info.layers.isEmpty()) bakeGenerated(info, m, out);
|
||||
if (out.size() == before && item.block() != null) bakeBlock(item.block(), m, out);
|
||||
if (out.size() == before && item.block() != null) {
|
||||
// An item, not a placed block: the first (unrotated) variant like its item model, no random pick.
|
||||
bakeModel(registry.firstVariants(item.block()), item.block(), m, out);
|
||||
}
|
||||
// Placed block-entity fallback only for block items (a shield carries its patterns as a banner state).
|
||||
if (out.size() == before && item.block() != null && item.blockEntity() != null) {
|
||||
bakeBlockEntity(item.blockEntity(), m, out);
|
||||
@@ -123,10 +126,13 @@ public final class ItemGeometry {
|
||||
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]³. */
|
||||
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;
|
||||
bakeModel(registry.get(data), data, toWorld, out);
|
||||
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. */
|
||||
@@ -468,7 +474,7 @@ public final class ItemGeometry {
|
||||
int[][] composed = null;
|
||||
for (int i = 0; i < info.layers.size(); i++) {
|
||||
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;
|
||||
if (composed == null) {
|
||||
composed = tint != 0 ? TextureOps.tinted(t, tint) : TextureOps.deepCopy(t);
|
||||
|
||||
@@ -40,9 +40,8 @@ public record CameraMedium(int fluid, int fogColor, double fogStart, double fogE
|
||||
return fluid != FluidRenderer.NONE;
|
||||
}
|
||||
|
||||
static CameraMedium at(WorldSnapshot snapshot, Vector eye, BiomeTintProvider tints) {
|
||||
static CameraMedium at(WorldSnapshot snapshot, Vector eye, int fluid, BiomeTintProvider tints) {
|
||||
double x = eye.getX(), y = eye.getY(), z = eye.getZ();
|
||||
int fluid = FluidRenderer.cameraFluid(snapshot::getBlockData, x, y, z);
|
||||
if (fluid == FluidRenderer.LAVA) return new CameraMedium(fluid, 0x991A00, 0.25, 1.0);
|
||||
if (fluid != FluidRenderer.WATER) return AIR;
|
||||
|
||||
|
||||
@@ -194,6 +194,7 @@ public final class FluidRenderer {
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -26,10 +26,13 @@ public final class SnapshotRaytracer {
|
||||
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.
|
||||
// Bounded so a stack of glass costs at most a few extra intersection rounds per ray.
|
||||
// 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 = 6;
|
||||
private static final double MIN_TRANSMITTANCE = 0.03;
|
||||
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;
|
||||
@@ -79,9 +82,12 @@ public final class SnapshotRaytracer {
|
||||
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);
|
||||
/**
|
||||
* 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) {
|
||||
return CameraMedium.at(snapshot, origin, fluid, tintProvider);
|
||||
}
|
||||
|
||||
public int trace(
|
||||
@@ -223,9 +229,11 @@ public final class SnapshotRaytracer {
|
||||
private int steps;
|
||||
private boolean done;
|
||||
|
||||
// The ray met water (a water cell, or the camera is in water): surfaces may need the underwater light.
|
||||
// Checked when composing, after the whole walk.
|
||||
private boolean sawWater;
|
||||
// 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(
|
||||
@@ -252,7 +260,7 @@ public final class SnapshotRaytracer {
|
||||
this.dz = direction.getZ();
|
||||
this.blocks = snapshot::getBlockData;
|
||||
this.dda = new VoxelDDA(ox, oy, oz, dx, dy, dz);
|
||||
this.sawWater = medium.fluid() == FluidRenderer.WATER;
|
||||
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. */
|
||||
@@ -269,15 +277,18 @@ public final class SnapshotRaytracer {
|
||||
advance();
|
||||
continue;
|
||||
}
|
||||
ResolvedModel model = registry.get(data);
|
||||
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);
|
||||
if (type == FluidRenderer.WATER) sawWater = true;
|
||||
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) {
|
||||
@@ -341,10 +352,10 @@ public final class SnapshotRaytracer {
|
||||
|
||||
/**
|
||||
* {@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 met water.
|
||||
* (back along the ray), when the ray is in water there.
|
||||
*/
|
||||
int underwaterLight(int color, double t) {
|
||||
if (!sawWater) return color;
|
||||
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));
|
||||
|
||||
@@ -112,12 +112,16 @@ public class DefaultScreenRenderer {
|
||||
int superW = resolution.width() * SSAA;
|
||||
int superH = resolution.height() * SSAA;
|
||||
World world = eyeLocation.getWorld();
|
||||
boolean inFluid = FluidRenderer.cameraFluid(
|
||||
world::getBlockData, eyeLocation.getX(), eyeLocation.getY(), eyeLocation.getZ())
|
||||
!= FluidRenderer.NONE;
|
||||
RayMap rayMap = RayMap.of(eyeLocation, superW, superH, inFluid ? V_FOV_DEG * FLUID_FOV_SCALE : V_FOV_DEG);
|
||||
// Determined once, here: the FOV needs it before the snapshot exists, and execute's fog uses the same value.
|
||||
int cameraFluid = FluidRenderer.cameraFluid(
|
||||
world::getBlockData, eyeLocation.getX(), eyeLocation.getY(), eyeLocation.getZ());
|
||||
RayMap rayMap = RayMap.of(
|
||||
eyeLocation,
|
||||
superW,
|
||||
superH,
|
||||
cameraFluid != FluidRenderer.NONE ? V_FOV_DEG * FLUID_FOV_SCALE : V_FOV_DEG);
|
||||
FrustumBounds bounds = FrustumBounds.of(eyeLocation, rayMap, MAX_DISTANCE);
|
||||
WorldSnapshot snapshot = SnapshotBuilder.build(world, bounds, logger);
|
||||
WorldSnapshot snapshot = SnapshotBuilder.build(world, eyeLocation, bounds, logger);
|
||||
Collection<Entity> nearby = bounds.nearbyEntities(world);
|
||||
List<EntityState> entities = EntitySnapshotBuilder.build(nearby, shooter);
|
||||
List<BlockEntityState> blockEntities = BlockEntitySnapshotBuilder.build(eyeLocation, bounds);
|
||||
@@ -133,6 +137,7 @@ public class DefaultScreenRenderer {
|
||||
snapshot,
|
||||
rayMap,
|
||||
eyeLocation.toVector(),
|
||||
cameraFluid,
|
||||
resolution.width(),
|
||||
resolution.height(),
|
||||
sky,
|
||||
@@ -173,7 +178,7 @@ public class DefaultScreenRenderer {
|
||||
job.objects(),
|
||||
objectBaker));
|
||||
|
||||
CameraMedium medium = raytracer.cameraMedium(snapshot, origin);
|
||||
CameraMedium medium = raytracer.cameraMedium(snapshot, origin, job.cameraFluid());
|
||||
BiomeBlend tints = raytracer.biomeBlend(snapshot);
|
||||
int[] superBuf = new int[rayMap.size()];
|
||||
runParallel(() -> IntStream.range(0, rayMap.size()).parallel().forEach(i -> {
|
||||
|
||||
@@ -11,13 +11,18 @@ 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, the time for date-dependent assets). {@link DefaultScreenRenderer#execute} runs it off the main thread.
|
||||
* A prepared render: everything captured on the main thread (world snapshot, ray map, camera origin and the fluid
|
||||
* it is in, sky state, entity/block-entity/decoration/object states, the time for date-dependent assets).
|
||||
* {@link DefaultScreenRenderer#execute} runs it off the main thread.
|
||||
*
|
||||
* @param cameraFluid {@code FluidRenderer.cameraFluid} at the origin (NONE/WATER/LAVA), determined once in
|
||||
* {@code prepare}: it sets the FOV of the ray map and the fog
|
||||
*/
|
||||
public record RenderJob(
|
||||
WorldSnapshot snapshot,
|
||||
RayMap rayMap,
|
||||
Vector origin,
|
||||
int cameraFluid,
|
||||
int width,
|
||||
int height,
|
||||
SkyContext sky,
|
||||
|
||||
+16
-3
@@ -1,5 +1,6 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.snapshot;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.ObjectState;
|
||||
@@ -76,9 +77,21 @@ public final class ObjectSnapshotBuilder {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (e instanceof FallingBlock || e instanceof TNTPrimed) {
|
||||
BlockData data = e instanceof FallingBlock fb ? fb.getBlockData() : Material.TNT.createBlockData();
|
||||
return block(at.mul(Affine.translation(-0.5, 0, -0.5)), data);
|
||||
if (e instanceof FallingBlock fb) {
|
||||
// FallingBlockRenderer: weighted variants seeded by getSeed(startPos) (the block it fell from; Paper's
|
||||
// origin = the spawn position at the block centre).
|
||||
BlockData data = fb.getBlockData();
|
||||
if (data == null || data.getMaterial().isAir()) return null;
|
||||
Location start = fb.getOrigin() != null ? fb.getOrigin() : fb.getLocation();
|
||||
long seed = BlockModelRegistry.seed(data, start.getBlockX(), start.getBlockY(), start.getBlockZ());
|
||||
return ObjectState.block(
|
||||
at.mul(Affine.translation(-0.5, 0, -0.5)),
|
||||
data,
|
||||
BlockEntitySnapshotBuilder.fromBlockData(data),
|
||||
seed);
|
||||
}
|
||||
if (e instanceof TNTPrimed) {
|
||||
return block(at.mul(Affine.translation(-0.5, 0, -0.5)), Material.TNT.createBlockData());
|
||||
}
|
||||
if (e instanceof Item it) {
|
||||
// GROUND transform, lifted by the baker. The spin (vanilla: age/20 + random bobOffset) is frozen from
|
||||
|
||||
@@ -4,28 +4,40 @@ import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.ChunkSnapshot;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
|
||||
/**
|
||||
* Captures the world region covered by the camera frustum into a {@link WorldSnapshot}. MUST run on the main
|
||||
* thread. Only already-loaded chunks are captured (no forced loading), rays into unloaded areas hit sky.
|
||||
*
|
||||
* <p>Chunk snapshots always hold the full world height, so the snapshot keeps all of it: the sky light under water
|
||||
* counts the water column up to its surface ({@code FluidRenderer.skyLevel}), which may lie above the frustum. The
|
||||
* chunks around the camera ({@link #CAMERA_MARGIN}) are captured too, also those behind it: the underwater fog
|
||||
* blends the biomes around the eye.
|
||||
*/
|
||||
public final class SnapshotBuilder {
|
||||
|
||||
/** Safety cap on captured chunks (memory/latency). */
|
||||
private static final int MAX_CHUNKS = 4096;
|
||||
|
||||
/**
|
||||
* Blocks around the eye always captured: the water fog's Gaussian biome blend reads quart cells up to 12 blocks
|
||||
* (+3 within the cell) from the eye ({@code CameraMedium}).
|
||||
*/
|
||||
private static final int CAMERA_MARGIN = 16;
|
||||
|
||||
private SnapshotBuilder() {}
|
||||
|
||||
public static WorldSnapshot build(World world, FrustumBounds bounds, Logger logger) {
|
||||
int minY = Math.max(world.getMinHeight(), (int) Math.floor(bounds.minY) - 1);
|
||||
int maxY = Math.min(world.getMaxHeight(), (int) Math.ceil(bounds.maxY) + 1);
|
||||
public static WorldSnapshot build(World world, Location eye, FrustumBounds bounds, Logger logger) {
|
||||
int minY = world.getMinHeight();
|
||||
int maxY = world.getMaxHeight();
|
||||
|
||||
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;
|
||||
int minCX = (int) Math.floor(Math.min(bounds.minX, eye.getX() - CAMERA_MARGIN)) >> 4;
|
||||
int maxCX = (int) Math.floor(Math.max(bounds.maxX, eye.getX() + CAMERA_MARGIN)) >> 4;
|
||||
int minCZ = (int) Math.floor(Math.min(bounds.minZ, eye.getZ() - CAMERA_MARGIN)) >> 4;
|
||||
int maxCZ = (int) Math.floor(Math.max(bounds.maxZ, eye.getZ() + CAMERA_MARGIN)) >> 4;
|
||||
|
||||
Map<Long, ChunkSnapshot> chunks = new HashMap<>();
|
||||
int skipped = 0;
|
||||
|
||||
@@ -3,6 +3,7 @@ package eu.mhsl.minecraft.pixelpics.render.tint;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -211,7 +212,7 @@ public final class BiomeClimate {
|
||||
color(effects, "foliage_color", NONE),
|
||||
color(effects, "dry_foliage_color", NONE),
|
||||
modifier != null && modifier.isJsonPrimitive() ? modifier.getAsString() : null,
|
||||
color(attributes, "minecraft:visual/water_fog_color", DEFAULT_WATER_FOG_COLOR),
|
||||
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) {
|
||||
@@ -221,8 +222,8 @@ public final class BiomeClimate {
|
||||
|
||||
/**
|
||||
* 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/add/subtract/multiply/
|
||||
* minimum/maximum).
|
||||
* {@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);
|
||||
@@ -231,8 +232,16 @@ public final class BiomeClimate {
|
||||
if (el.isJsonPrimitive()) return el.getAsDouble();
|
||||
if (!el.isJsonObject()) return base;
|
||||
JsonObject o = el.getAsJsonObject();
|
||||
double a = o.get("argument").getAsDouble();
|
||||
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;
|
||||
@@ -246,6 +255,106 @@ public final class BiomeClimate {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An RGB colour environment attribute of a biome applied to the default {@code base}: a plain colour overrides
|
||||
* it, an object {@code {"argument": a, "modifier": op}} applies vanilla's {@code ColorModifier} (26.3
|
||||
* {@code AttributeModifier.RGB_COLOR_LIBRARY}): override, alpha_blend (argument ARGB), add, subtract, multiply
|
||||
* (argument RGB), blend_to_gray ({@code {brightness, factor}}). Channels as floats 0..1 like vanilla.
|
||||
*/
|
||||
static int rgbAttribute(JsonObject attributes, String key, int base) {
|
||||
JsonElement el = attributes.get(key);
|
||||
if (el == null) return base;
|
||||
try {
|
||||
if (!el.isJsonObject()) return toRgb(rgb(el));
|
||||
JsonObject o = el.getAsJsonObject();
|
||||
JsonElement arg = o.get("argument");
|
||||
String op = o.has("modifier") ? o.get("modifier").getAsString() : "override";
|
||||
float[] c = rgb(new JsonPrimitive(base));
|
||||
float[] r =
|
||||
switch (op) {
|
||||
case "override" -> rgb(arg);
|
||||
case "alpha_blend" -> {
|
||||
float[] a = argb(arg); // ARGB.alphaBlend: lerp towards the argument by its alpha
|
||||
yield a[3] == 0 ? c : lerp(a[3], c, new float[] {a[0], a[1], a[2]});
|
||||
}
|
||||
case "add" -> {
|
||||
float[] a = rgb(arg);
|
||||
yield new float[] {
|
||||
Math.min(c[0] + a[0], 1), Math.min(c[1] + a[1], 1), Math.min(c[2] + a[2], 1)
|
||||
};
|
||||
}
|
||||
case "subtract" -> {
|
||||
float[] a = rgb(arg);
|
||||
yield new float[] {
|
||||
Math.max(c[0] - a[0], 0), Math.max(c[1] - a[1], 0), Math.max(c[2] - a[2], 0)
|
||||
};
|
||||
}
|
||||
case "multiply" -> {
|
||||
float[] a = rgb(arg);
|
||||
yield new float[] {c[0] * a[0], c[1] * a[1], c[2] * a[2]};
|
||||
}
|
||||
case "blend_to_gray" -> {
|
||||
JsonObject a = arg.getAsJsonObject();
|
||||
float brightness = a.get("brightness").getAsFloat();
|
||||
float factor = a.get("factor").getAsFloat();
|
||||
float g = Math.clamp((c[0] * 0.3F + c[1] * 0.59F + c[2] * 0.11F) * brightness, 0, 1);
|
||||
yield lerp(factor, c, new float[] {g, g, g});
|
||||
}
|
||||
// Unknown operation: vanilla rejects the biome; keep the default.
|
||||
default -> c;
|
||||
};
|
||||
return toRgb(r);
|
||||
} catch (RuntimeException e) {
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
private static float[] lerp(float t, float[] from, float[] to) {
|
||||
return new float[] {
|
||||
from[0] + t * (to[0] - from[0]), from[1] + t * (to[1] - from[1]), from[2] + t * (to[2] - from[2])
|
||||
};
|
||||
}
|
||||
|
||||
/** ExtraCodecs.STRING_RGB_VEC3_COLOR: "#rrggbb", a packed int or [r, g, b] floats. */
|
||||
private static float[] rgb(JsonElement el) {
|
||||
if (el.isJsonArray()) {
|
||||
var a = el.getAsJsonArray();
|
||||
return new float[] {
|
||||
a.get(0).getAsFloat(), a.get(1).getAsFloat(), a.get(2).getAsFloat()
|
||||
};
|
||||
}
|
||||
int v = el.getAsJsonPrimitive().isNumber() ? el.getAsInt() : hex(el.getAsString(), 6);
|
||||
return new float[] {(v >> 16 & 0xFF) / 255F, (v >> 8 & 0xFF) / 255F, (v & 0xFF) / 255F};
|
||||
}
|
||||
|
||||
/** ExtraCodecs.STRING_ARGB_VEC4_COLOR as {r, g, b, a}: "#aarrggbb", a packed int or [r, g, b, a] floats. */
|
||||
private static float[] argb(JsonElement el) {
|
||||
if (el.isJsonArray()) {
|
||||
var a = el.getAsJsonArray();
|
||||
return new float[] {
|
||||
a.get(0).getAsFloat(),
|
||||
a.get(1).getAsFloat(),
|
||||
a.get(2).getAsFloat(),
|
||||
a.get(3).getAsFloat()
|
||||
};
|
||||
}
|
||||
int v = el.getAsJsonPrimitive().isNumber() ? el.getAsInt() : hex(el.getAsString(), 8);
|
||||
return new float[] {(v >> 16 & 0xFF) / 255F, (v >> 8 & 0xFF) / 255F, (v & 0xFF) / 255F, (v >>> 24) / 255F};
|
||||
}
|
||||
|
||||
private static int hex(String s, int digits) {
|
||||
if (!s.startsWith("#") || s.length() != digits + 1) throw new IllegalArgumentException(s);
|
||||
return (int) Long.parseLong(s.substring(1), 16);
|
||||
}
|
||||
|
||||
private static int toRgb(float[] c) {
|
||||
return channel(c[0]) << 16 | channel(c[1]) << 8 | channel(c[2]);
|
||||
}
|
||||
|
||||
private static int channel(float v) {
|
||||
return Math.clamp(Math.round(v * 255), 0, 255);
|
||||
}
|
||||
|
||||
/** Colors are either "#rrggbb" strings or packed integers, depending on the data version. */
|
||||
private static int color(JsonObject obj, String key, int fallback) {
|
||||
JsonElement el = obj.get(key);
|
||||
|
||||
+4
-1
@@ -7,6 +7,7 @@ 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.raytrace.FluidRenderer;
|
||||
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
|
||||
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
|
||||
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
|
||||
@@ -104,7 +105,9 @@ final class Harness {
|
||||
Location loc = new Location(null, cam.getX(), cam.getY(), cam.getZ());
|
||||
loc.setDirection(look.clone().subtract(cam));
|
||||
RayMap rayMap = DefaultScreenRenderer.buildRayMap(loc, w * SSAA, h * SSAA);
|
||||
return new RenderJob(world, rayMap, cam, w, h, NOON, mobs, bes, decos, objects, SpecialDates.now());
|
||||
// The harness scenes have no fluids: the camera is always in air.
|
||||
return new RenderJob(world, rayMap, cam, FluidRenderer.NONE, 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}. */
|
||||
|
||||
@@ -286,6 +286,101 @@ public class PPTest extends JavaPlugin {
|
||||
// Block displays: end portal/gateway (unshaded), piston head (no skull), a non-uniformly scaled + rotated
|
||||
// log (normals), grass block (side overlay); shulkers without and with colour.
|
||||
scene("display_fixes", this::displayFixes, 4.5, 3.2, -5, 4.5, 0.8, 1.5);
|
||||
// F3b fixes. fix_deep_*: 7x7 shaft, 12 deep, camera 6 under the surface looking straight down (the frustum
|
||||
// ends at the eye: sky light must still count the water above it) and sideways (same floor, reference).
|
||||
scene("fix_deep_down", this::deepShaft, 3.5, -6, 3.2, 3.5, -12, 3.9);
|
||||
scene("fix_deep_side", this::deepShaft, 0.4, -6, 3.5, 6.4, -9, 3.5);
|
||||
// fix_layers: two/three water blocks with air between (4/6 water faces) in front of red walls; a stained
|
||||
// glass pane under a water block with water behind it vs the same pane without water behind (the pane is in
|
||||
// front of the water: not dimmed by the underwater light); a white stained glass + water stack.
|
||||
scene("fix_layers", this::fluidLayers, 4, 1.8, -4.5, 4, 1.2, 3);
|
||||
// fix_fog_swamp: swamp tank, camera near its west wall looking east: the biomes behind the camera (outside
|
||||
// the frustum) are part of the water fog blend.
|
||||
scene("fix_fog_swamp", this::fogSwamp, -3.5, -3, 0.5, 12, -4, 0.5);
|
||||
// fix_weighted: weighted blockstate variants picked per position (stone, grass, dirt, sand, netherrack,
|
||||
// bedrock, mycelium, podzol fields), a stone/grass block display (seed 42); fix_textures: heavy_core (face
|
||||
// texture "all" without '#'), animated textures (fire first frame = frames[0], sea lantern, magma, prismarine).
|
||||
scene("fix_weighted", this::weighted, 7.5, 6, -5, 7.5, 0, 3);
|
||||
scene("fix_textures", this::texturesScene, 3.5, 2.2, -3, 3.5, 0.6, 1);
|
||||
}
|
||||
|
||||
private void deepShaft(Location o) {
|
||||
biome(o, Biome.PLAINS);
|
||||
for (int x = -1; x <= 7; x++)
|
||||
for (int z = -1; z <= 7; z++)
|
||||
for (int y = -13; y <= -1; y++) {
|
||||
boolean wall = x == -1 || x == 7 || z == -1 || z == 7;
|
||||
put(o, x, y, z, wall ? "stone_bricks" : y == -13 ? "sand" : "water");
|
||||
}
|
||||
String[] marks = {"red_concrete", "yellow_concrete", "lime_concrete", "white_concrete"};
|
||||
for (int i = 0; i < marks.length; i++) put(o, 1 + i * 1, -12, 2 + (i % 2) * 2, marks[i]);
|
||||
put(o, 5, -12, 5, "gold_block");
|
||||
}
|
||||
|
||||
private void fluidLayers(Location o) {
|
||||
biome(o, Biome.PLAINS);
|
||||
// Two water blocks (4 faces) and three (6 faces) with air gaps, red/blue walls behind.
|
||||
put(o, 0, 1, 1, "water");
|
||||
put(o, 0, 1, 3, "water");
|
||||
for (int y = 0; y <= 2; y++) for (int x = -1; x <= 1; x++) put(o, x, y, 6, "red_concrete");
|
||||
put(o, 2, 1, 0, "water");
|
||||
put(o, 2, 1, 2, "water");
|
||||
put(o, 2, 1, 4, "water");
|
||||
for (int y = 0; y <= 2; y++) put(o, 2, y, 6, "blue_concrete");
|
||||
// Stained glass pane under a water block, water behind (x=4) / nothing behind (x=6).
|
||||
put(o, 4, 1, 1, "light_blue_stained_glass_pane[north=false,south=false,east=true,west=true]");
|
||||
put(o, 4, 2, 1, "water");
|
||||
put(o, 4, 1, 3, "water");
|
||||
put(o, 6, 1, 1, "light_blue_stained_glass_pane[north=false,south=false,east=true,west=true]");
|
||||
put(o, 6, 2, 1, "water");
|
||||
for (int y = 0; y <= 3; y++) for (int x = 3; x <= 7; x++) put(o, x, y, 6, "yellow_concrete");
|
||||
// White stained glass, water, white stained glass, water in a row.
|
||||
put(o, 9, 1, 0, "white_stained_glass");
|
||||
put(o, 9, 1, 1, "water");
|
||||
put(o, 9, 1, 3, "white_stained_glass");
|
||||
put(o, 9, 1, 4, "water");
|
||||
for (int y = 0; y <= 2; y++) put(o, 9, y, 6, "lime_concrete");
|
||||
}
|
||||
|
||||
private void fogSwamp(Location o) {
|
||||
for (int x = -40; x < 40; x += 2)
|
||||
for (int z = -40; z < 40; z += 2)
|
||||
for (int y = -16; y < 16; y += 2)
|
||||
w.setBiome(o.getBlockX() + x, o.getBlockY() + y, o.getBlockZ() + z, Biome.SWAMP);
|
||||
for (int x = -5; x <= 17; x++)
|
||||
for (int z = -8; z <= 8; z++)
|
||||
for (int y = -9; y <= -1; y++) {
|
||||
boolean wall = x == -5 || x == 17 || z == -8 || z == 8;
|
||||
put(o, x, y, z, wall ? "stone_bricks" : y == -9 ? "sand" : "water");
|
||||
}
|
||||
String[] pillars = {"red_concrete", "yellow_concrete", "lime_concrete", "white_concrete"};
|
||||
for (int i = 0; i < pillars.length; i++)
|
||||
for (int y = -8; y <= -4; y++) put(o, 2 + i * 4, y, -3 + (i % 2) * 6, pillars[i]);
|
||||
}
|
||||
|
||||
private void weighted(Location o) {
|
||||
biome(o, Biome.PLAINS);
|
||||
String[] fields = {"stone", "grass_block[snowy=false]", "dirt", "sand", "netherrack", "bedrock",
|
||||
"mycelium[snowy=false]", "podzol[snowy=false]"};
|
||||
for (int i = 0; i < fields.length; i++)
|
||||
for (int x = 0; x < 4; x++)
|
||||
for (int z = 0; z < 4; z++) put(o, (i % 4) * 4 + x, -1, (i / 4) * 4 + z, fields[i]);
|
||||
BlockDisplay stone = spawn(o, 4, 0, 9, 0, BlockDisplay.class);
|
||||
stone.setBlock(Bukkit.createBlockData("minecraft:stone"));
|
||||
BlockDisplay grass = spawn(o, 10, 0, 9, 0, BlockDisplay.class);
|
||||
grass.setBlock(Bukkit.createBlockData("minecraft:grass_block[snowy=false]"));
|
||||
}
|
||||
|
||||
private void texturesScene(Location o) {
|
||||
put(o, 0, 0, 1, "heavy_core");
|
||||
put(o, 1, 0, 1, "netherrack");
|
||||
put(o, 1, 1, 1, "fire");
|
||||
put(o, 2, 0, 1, "soul_sand");
|
||||
put(o, 2, 1, 1, "soul_fire");
|
||||
put(o, 3, 0, 1, "sea_lantern");
|
||||
put(o, 4, 0, 1, "magma_block");
|
||||
put(o, 5, 0, 1, "prismarine");
|
||||
put(o, 6, 0, 1, "lava");
|
||||
}
|
||||
|
||||
private void tintBlocks(Location o) {
|
||||
|
||||
Reference in new issue
Block a user