diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f2e75a..10e3baf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed +- **Merged faces rendering as one flat colour with ColorLight (GPU pipeline)**: ColorLight redirects + Sodium's terrain pipelines to its own copy of the shader (`colorlight:blocks/colored_terrain`), so + the greedy UV patch never reached it. The patch now also applies to that shader. Two related + problems in the injected code are fixed with it: the atlas sampler is now picked by name + (`u_BlockTex`) instead of "first `sampler2D`", which in ColorLight's copy is the 16x16 lightmap, + and the face marker carried in vertex alpha is cleared from ColorLight's `v_ColorBase` too. +- **ColorLight in vertex-colour mode** (pipeline switched off, unsupported Sodium build, or an Iris + pack active): merged quads bypass the model where ColorLight bakes its colour, so they came out + uncoloured. Blocks next to coloured light now stay on the per-block path in that mode; light-free + areas still merge. No effect while the GPU pipeline is active or ColorLight is absent. + +### Performance (Sodium path) +- The per-thread work state is kept between section builds instead of being removed after each one, + which reallocated about 200 KiB of arrays per section. +- Face visibility, AO merge keys and per-corner smooth lighting read neighbouring blocks, light and + occlusion through a lazily filled 18x18x18 per-section cache instead of the world slice. +- Face layers (model walk, sprite lookup) are cached per block state and face until resources reload. +- Per-block eligibility checks are memoised for runs of the same block state. +- The merge sweep groups faces with a small hash table instead of scanning every group per cell. + ## [0.6.1] ### Fixed diff --git a/src/client/java/hi/sierra/greedy_meshing/client/GreedyColorLightCompat.java b/src/client/java/hi/sierra/greedy_meshing/client/GreedyColorLightCompat.java new file mode 100644 index 0000000..651099b --- /dev/null +++ b/src/client/java/hi/sierra/greedy_meshing/client/GreedyColorLightCompat.java @@ -0,0 +1,97 @@ +package hi.sierra.greedy_meshing.client; + +import net.fabricmc.loader.api.FabricLoader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; + +/** + * Soft compatibility with ColorLight (reflection only, no compile-time dependency). + * + *

ColorLight has two ways of colouring light:

+ * + */ +public final class GreedyColorLightCompat { + private static final Logger LOGGER = LoggerFactory.getLogger("greedy_meshing"); + private static final boolean LOADED = FabricLoader.getInstance().isModLoaded("colorlight"); + + private static volatile boolean resolved; + private static volatile boolean broken; + private static MethodHandle gpuActive; // () -> boolean + private static MethodHandle engineGet; // () -> ColorLightEngine (may be null) + private static MethodHandle lightFreeAround; // (ColorLightEngine, int, int, int) -> boolean + + private GreedyColorLightCompat() { + } + + /** + * True when this block must keep going through the model path because ColorLight has to bake + * coloured light into its vertices and there is coloured light around it. + */ + public static boolean mustStayPerBlock(int worldX, int worldY, int worldZ) { + if (!LOADED || broken) { + return false; + } + if (!resolved) { + resolve(); + if (broken) { + return false; + } + } + try { + if ((boolean) gpuActive.invokeExact()) { + return false; + } + Object engine = engineGet.invoke(); + if (engine == null) { + return false; + } + return !(boolean) lightFreeAround.invoke(engine, worldX, worldY, worldZ); + } catch (Throwable t) { + markBroken(t); + return false; + } + } + + private static synchronized void resolve() { + if (resolved) { + return; + } + try { + ClassLoader loader = GreedyColorLightCompat.class.getClassLoader(); + MethodHandles.Lookup lookup = MethodHandles.publicLookup(); + Class gpu = Class.forName("me.mrhikmen.colorlight.client.core.render.gpu.ColorLightGpu", true, loader); + Class holder = Class.forName("me.mrhikmen.colorlight.client.core.light.engine.ColorLightEngineHolder", true, loader); + Class engine = Class.forName("me.mrhikmen.colorlight.client.core.light.engine.ColorLightEngine", true, loader); + gpuActive = lookup.findStatic(gpu, "isActive", MethodType.methodType(boolean.class)); + engineGet = lookup.findStatic(holder, "get", MethodType.methodType(engine)); + lightFreeAround = lookup.findVirtual(engine, "isLightFreeAround", + MethodType.methodType(boolean.class, int.class, int.class, int.class)) + .asType(MethodType.methodType(boolean.class, Object.class, int.class, int.class, int.class)); + resolved = true; + } catch (Throwable t) { + markBroken(t); + } + } + + private static void markBroken(Throwable t) { + if (!broken) { + broken = true; + resolved = true; + LOGGER.warn("Greedy Meshing: ColorLight detected but its API could not be read ({}); " + + "vertex-colour mode will not keep lit blocks per-block.", t.toString()); + } + } +} diff --git a/src/client/java/hi/sierra/greedy_meshing/client/GreedyLighting.java b/src/client/java/hi/sierra/greedy_meshing/client/GreedyLighting.java index e93d679..7aab303 100644 --- a/src/client/java/hi/sierra/greedy_meshing/client/GreedyLighting.java +++ b/src/client/java/hi/sierra/greedy_meshing/client/GreedyLighting.java @@ -107,30 +107,29 @@ public static void computeTileLighting( BlockPos.MutableBlockPos samplePos = scratch.mutablePos; - samplePos.set(c0x, c0y, c0z); - BlockState state0 = world.getBlockState(samplePos); - int i = greedyMeshing$getLightColor(world, state0, samplePos); + BlockState state0 = sampleState(world, scratch, samplePos, c0x, c0y, c0z); + int i = sampleLight(world, scratch, samplePos, state0, c0x, c0y, c0z); float f = state0.getShadeBrightness(world, samplePos); - samplePos.set(c1x, c1y, c1z); - BlockState state1 = world.getBlockState(samplePos); - int j = greedyMeshing$getLightColor(world, state1, samplePos); + BlockState state1 = sampleState(world, scratch, samplePos, c1x, c1y, c1z); + int j = sampleLight(world, scratch, samplePos, state1, c1x, c1y, c1z); float g = state1.getShadeBrightness(world, samplePos); - samplePos.set(c2x, c2y, c2z); - BlockState state2 = world.getBlockState(samplePos); - int k = greedyMeshing$getLightColor(world, state2, samplePos); + BlockState state2 = sampleState(world, scratch, samplePos, c2x, c2y, c2z); + int k = sampleLight(world, scratch, samplePos, state2, c2x, c2y, c2z); float h = state2.getShadeBrightness(world, samplePos); - samplePos.set(c3x, c3y, c3z); - BlockState state3 = world.getBlockState(samplePos); - int l = greedyMeshing$getLightColor(world, state3, samplePos); + BlockState state3 = sampleState(world, scratch, samplePos, c3x, c3y, c3z); + int l = sampleLight(world, scratch, samplePos, state3, c3x, c3y, c3z); float m = state3.getShadeBrightness(world, samplePos); - boolean open0 = isOpen(world, samplePos, c0x + face.getStepX(), c0y + face.getStepY(), c0z + face.getStepZ()); - boolean open1 = isOpen(world, samplePos, c1x + face.getStepX(), c1y + face.getStepY(), c1z + face.getStepZ()); - boolean open2 = isOpen(world, samplePos, c2x + face.getStepX(), c2y + face.getStepY(), c2z + face.getStepZ()); - boolean open3 = isOpen(world, samplePos, c3x + face.getStepX(), c3y + face.getStepY(), c3z + face.getStepZ()); + int fsx = face.getStepX(); + int fsy = face.getStepY(); + int fsz = face.getStepZ(); + boolean open0 = isOpenAt(world, scratch, samplePos, c0x + fsx, c0y + fsy, c0z + fsz); + boolean open1 = isOpenAt(world, scratch, samplePos, c1x + fsx, c1y + fsy, c1z + fsz); + boolean open2 = isOpenAt(world, scratch, samplePos, c2x + fsx, c2y + fsy, c2z + fsz); + boolean open3 = isOpenAt(world, scratch, samplePos, c3x + fsx, c3y + fsy, c3z + fsz); float n; int o; @@ -138,10 +137,12 @@ public static void computeTileLighting( n = f; o = i; } else { - samplePos.set(c0x + corner2.getStepX(), c0y + corner2.getStepY(), c0z + corner2.getStepZ()); - BlockState diagonalState = world.getBlockState(samplePos); + int dx = c0x + corner2.getStepX(); + int dy = c0y + corner2.getStepY(); + int dz = c0z + corner2.getStepZ(); + BlockState diagonalState = sampleState(world, scratch, samplePos, dx, dy, dz); n = diagonalState.getShadeBrightness(world, samplePos); - o = greedyMeshing$getLightColor(world, diagonalState, samplePos); + o = sampleLight(world, scratch, samplePos, diagonalState, dx, dy, dz); } float p; @@ -150,10 +151,12 @@ public static void computeTileLighting( p = f; q = i; } else { - samplePos.set(c0x + corner3.getStepX(), c0y + corner3.getStepY(), c0z + corner3.getStepZ()); - BlockState diagonalState = world.getBlockState(samplePos); + int dx = c0x + corner3.getStepX(); + int dy = c0y + corner3.getStepY(); + int dz = c0z + corner3.getStepZ(); + BlockState diagonalState = sampleState(world, scratch, samplePos, dx, dy, dz); p = diagonalState.getShadeBrightness(world, samplePos); - q = greedyMeshing$getLightColor(world, diagonalState, samplePos); + q = sampleLight(world, scratch, samplePos, diagonalState, dx, dy, dz); } float r; @@ -162,10 +165,12 @@ public static void computeTileLighting( r = f; s = i; } else { - samplePos.set(c1x + corner2.getStepX(), c1y + corner2.getStepY(), c1z + corner2.getStepZ()); - BlockState diagonalState = world.getBlockState(samplePos); + int dx = c1x + corner2.getStepX(); + int dy = c1y + corner2.getStepY(); + int dz = c1z + corner2.getStepZ(); + BlockState diagonalState = sampleState(world, scratch, samplePos, dx, dy, dz); r = diagonalState.getShadeBrightness(world, samplePos); - s = greedyMeshing$getLightColor(world, diagonalState, samplePos); + s = sampleLight(world, scratch, samplePos, diagonalState, dx, dy, dz); } float t; @@ -174,15 +179,16 @@ public static void computeTileLighting( t = f; u = i; } else { - samplePos.set(c1x + corner3.getStepX(), c1y + corner3.getStepY(), c1z + corner3.getStepZ()); - BlockState diagonalState = world.getBlockState(samplePos); + int dx = c1x + corner3.getStepX(); + int dy = c1y + corner3.getStepY(); + int dz = c1z + corner3.getStepZ(); + BlockState diagonalState = sampleState(world, scratch, samplePos, dx, dy, dz); t = diagonalState.getShadeBrightness(world, samplePos); - u = greedyMeshing$getLightColor(world, diagonalState, samplePos); + u = sampleLight(world, scratch, samplePos, diagonalState, dx, dy, dz); } - samplePos.set(faceX, faceY, faceZ); - BlockState faceState = world.getBlockState(samplePos); - int v = greedyMeshing$getLightColor(world, faceState, samplePos); + BlockState faceState = sampleState(world, scratch, samplePos, faceX, faceY, faceZ); + int v = sampleLight(world, scratch, samplePos, faceState, faceX, faceY, faceZ); float w = faceState.getShadeBrightness(world, samplePos); float ao0 = (m + f + p + w) * 0.25F; @@ -352,6 +358,31 @@ private static boolean isOpen(BlockAndTintGetter world, BlockPos.MutableBlockPos //?} } + /** Positions the shared scratch pos at (x, y, z) and returns the state there, via the section cache when set. */ + private static BlockState sampleState(BlockAndTintGetter world, Scratch scratch, BlockPos.MutableBlockPos pos, + int x, int y, int z) { + pos.set(x, y, z); + GreedySectionCache cache = scratch.cache; + return cache != null ? cache.state(x, y, z) : world.getBlockState(pos); + } + + private static int sampleLight(BlockAndTintGetter world, Scratch scratch, BlockPos.MutableBlockPos pos, + BlockState state, int x, int y, int z) { + GreedySectionCache cache = scratch.cache; + return cache != null ? cache.light(x, y, z) : greedyMeshing$getLightColor(world, state, pos); + } + + private static boolean isOpenAt(BlockAndTintGetter world, Scratch scratch, BlockPos.MutableBlockPos pos, + int x, int y, int z) { + GreedySectionCache cache = scratch.cache; + return cache != null ? !cache.occludes(x, y, z) : isOpen(world, pos, x, y, z); + } + + /** Packed light for an already-resolved state at {@code pos}. */ + public static int lightAt(BlockAndTintGetter world, BlockState state, BlockPos pos) { + return greedyMeshing$getLightColor(world, state, pos); + } + /** Packed block/sky light at a face sample, using the version-correct light API. */ public static int mergeLightKey(BlockAndTintGetter world, BlockPos pos) { return greedyMeshing$getLightColor(world, world.getBlockState(pos), pos); @@ -377,5 +408,7 @@ public static final class Scratch { // Set once per section build. False inside a Sable sub-level, whose own shader applies // directional shading dynamically — baking ours in on top would double it (issue #4). public boolean applyDirectionalShade = true; + // Optional per-section neighbour cache (Sodium path). Null means read straight from the world. + public GreedySectionCache cache; } } diff --git a/src/client/java/hi/sierra/greedy_meshing/client/GreedySectionCache.java b/src/client/java/hi/sierra/greedy_meshing/client/GreedySectionCache.java new file mode 100644 index 0000000..0f2cae3 --- /dev/null +++ b/src/client/java/hi/sierra/greedy_meshing/client/GreedySectionCache.java @@ -0,0 +1,131 @@ +package hi.sierra.greedy_meshing.client; + +import net.minecraft.core.BlockPos; +//? if UNOBFUSCATED { +/*import net.minecraft.client.renderer.block.BlockAndTintGetter; +*///?} else { +import net.minecraft.world.level.BlockAndTintGetter; +//?} +import net.minecraft.world.level.block.state.BlockState; + +import java.util.Arrays; + +/** + * Per-build-thread cache of block states, packed light and light-occlusion for the section being + * meshed plus a one-block border (18x18x18 cells). + * + *

Face visibility, the AO merge keys and the per-corner smooth lighting all look at the same + * neighbouring cells over and over (every cell is read by up to ~27 different faces). Reading them + * through the world slice each time dominated chunk-build cost on large merged sections. Cells are + * filled lazily, so a section that only touches a few cells pays for only those cells. Positions + * outside the padded box simply fall through to the world.

+ */ +public final class GreedySectionCache { + private static final int PAD = 18; + private static final int CELLS = PAD * PAD * PAD; + private static final int LIGHT_UNKNOWN = Integer.MIN_VALUE; + + private final BlockState[] states = new BlockState[CELLS]; + private final int[] light = new int[CELLS]; + /** 0 = unknown, 1 = does not occlude, 2 = occludes (see {@link #occludes}). */ + private final byte[] occluder = new byte[CELLS]; + private final BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + + private BlockAndTintGetter world; + private int baseX; + private int baseY; + private int baseZ; + + public void begin(BlockAndTintGetter world, int baseX, int baseY, int baseZ) { + this.world = world; + this.baseX = baseX - 1; + this.baseY = baseY - 1; + this.baseZ = baseZ - 1; + Arrays.fill(states, null); + Arrays.fill(light, LIGHT_UNKNOWN); + Arrays.fill(occluder, (byte) 0); + } + + /** Drops the world reference so a parked build thread does not keep a section slice alive. */ + public void end() { + this.world = null; + } + + private int slot(int x, int y, int z) { + int lx = x - baseX; + int ly = y - baseY; + int lz = z - baseZ; + if ((lx | ly | lz) < 0 || lx >= PAD || ly >= PAD || lz >= PAD) { + return -1; + } + return (ly * PAD + lz) * PAD + lx; + } + + public BlockState state(int x, int y, int z) { + int s = slot(x, y, z); + if (s < 0) { + pos.set(x, y, z); + return world.getBlockState(pos); + } + BlockState st = states[s]; + if (st == null) { + pos.set(x, y, z); + st = world.getBlockState(pos); + states[s] = st; + } + return st; + } + + /** Packed block/sky light at the cell, with the version-correct light API. */ + public int light(int x, int y, int z) { + int s = slot(x, y, z); + if (s < 0) { + BlockState st = state(x, y, z); + return GreedyLighting.lightAt(world, st, pos); + } + int value = light[s]; + if (value == LIGHT_UNKNOWN) { + BlockState st = state(x, y, z); + pos.set(x, y, z); + value = GreedyLighting.lightAt(world, st, pos); + light[s] = value; + } + return value; + } + + /** + * Whether the cell blocks light the way ambient occlusion cares about. This is the same + * predicate the AO merge key and the smooth-lighting "open corner" test use. + */ + public boolean occludes(int x, int y, int z) { + int s = slot(x, y, z); + if (s < 0) { + return computeOccludes(state(x, y, z), x, y, z); + } + byte v = occluder[s]; + if (v == 0) { + v = computeOccludes(state(x, y, z), x, y, z) ? (byte) 2 : (byte) 1; + occluder[s] = v; + } + return v == 2; + } + + private boolean computeOccludes(BlockState neighbor, int x, int y, int z) { + pos.set(x, y, z); + //? if >=26.3 { + /*// 26.3 narrowed isViewBlocking to the camera near-plane check; vanilla and Sodium AO + // now test isLightPermeable(), which is the same solid-and-dampening predicate. + return !neighbor.isLightPermeable(); + *///?} else { + return neighbor.isViewBlocking(world, pos) + //? if UNOBFUSCATED { + /*&& neighbor.getLightDampening() != 0 + *///?} else if >=1.21.2 { + && neighbor.getLightBlock() != 0 + //?} else { + /*&& neighbor.getLightBlock(world, pos) != 0 + *///?} + ; + //?} + } +} diff --git a/src/client/java/hi/sierra/greedy_meshing/client/GreedySpriteSupport.java b/src/client/java/hi/sierra/greedy_meshing/client/GreedySpriteSupport.java index 0d3d674..f048a0d 100644 --- a/src/client/java/hi/sierra/greedy_meshing/client/GreedySpriteSupport.java +++ b/src/client/java/hi/sierra/greedy_meshing/client/GreedySpriteSupport.java @@ -55,8 +55,16 @@ public static boolean supportsGreedySpriteSizes(BlockState state) { return result; } + private static volatile int generation; + + /** Bumped whenever cached per-state verdicts (and anything derived from the models) go stale. */ + public static int generation() { + return generation; + } + /** Dropped when config changes, since the oriented-block verdict depends on a config flag. */ public static void clearCache() { + generation++; CACHE.clear(); } diff --git a/src/client/java/hi/sierra/greedy_meshing/client/sodium/GreedySodiumWorkState.java b/src/client/java/hi/sierra/greedy_meshing/client/sodium/GreedySodiumWorkState.java index c0293da..d6e9eee 100644 --- a/src/client/java/hi/sierra/greedy_meshing/client/sodium/GreedySodiumWorkState.java +++ b/src/client/java/hi/sierra/greedy_meshing/client/sodium/GreedySodiumWorkState.java @@ -15,6 +15,7 @@ import hi.sierra.greedy_meshing.client.GreedyLighting; import hi.sierra.greedy_meshing.client.GreedyRuntimeState; +import hi.sierra.greedy_meshing.client.GreedySectionCache; import java.util.Arrays; @@ -31,11 +32,16 @@ public final class GreedySodiumWorkState { private long sectionKey = Long.MIN_VALUE; private int eligibleCount; private boolean captureDebug; + // One-entry memo for the per-block eligibility verdict: neighbouring blocks are very often the + // same state, and the verdict is a per-state constant (both lookups ignore the position). + private BlockState lastVerdictState; + private boolean lastVerdict; // Pre-allocated scratch objects for emitMergedQuad / emitTiledQuads to avoid per-call allocation public final float[] scratchCorners = new float[12]; public final GreedyLighting.Scratch scratchLighting = new GreedyLighting.Scratch(); public final BlockPos.MutableBlockPos scratchTintPos = new BlockPos.MutableBlockPos(); + public final GreedySectionCache sectionCache = new GreedySectionCache(); public final int[][] scratchCornerBlocks = new int[4][3]; public final float[] scratchBrightness = new float[4]; public final int[] scratchLightmap = new int[4]; @@ -52,9 +58,38 @@ public void reset(ChunkBuildContext context) { this.sectionKey = Long.MIN_VALUE; this.eligibleCount = 0; this.captureDebug = GreedyConfig.debugWireframe() || GreedyConfig.debugTrianglesHud() || GreedyConfig.debugComparison(); + this.lastVerdictState = null; Arrays.fill(this.sectionStates, null); } + /** + * Called when a section build finishes. The state object is kept on its worker thread (it holds + * ~200 KiB of reusable arrays that used to be reallocated for every section), so only the + * references into the finished build are dropped here. + */ + public void release() { + this.buildContext = null; + this.world = null; + this.blockRenderer = null; + this.sectionOrigin = null; + this.lastVerdictState = null; + this.sectionCache.end(); + this.scratchLighting.cache = null; + } + + public boolean lastVerdictFor(BlockState state) { + return state == lastVerdictState && lastVerdictState != null && lastVerdict; + } + + public boolean hasVerdictFor(BlockState state) { + return state == lastVerdictState && lastVerdictState != null; + } + + public void rememberVerdict(BlockState state, boolean verdict) { + this.lastVerdictState = state; + this.lastVerdict = verdict; + } + public BlockState[] sectionStates() { return sectionStates; } diff --git a/src/client/java/hi/sierra/greedy_meshing/mixin/client/sodium/SodiumChunkBuilderMeshingTaskMixin.java b/src/client/java/hi/sierra/greedy_meshing/mixin/client/sodium/SodiumChunkBuilderMeshingTaskMixin.java index d36fe7d..c4c5d0e 100644 --- a/src/client/java/hi/sierra/greedy_meshing/mixin/client/sodium/SodiumChunkBuilderMeshingTaskMixin.java +++ b/src/client/java/hi/sierra/greedy_meshing/mixin/client/sodium/SodiumChunkBuilderMeshingTaskMixin.java @@ -62,11 +62,13 @@ import hi.sierra.greedy_meshing.GreedyConfig; import hi.sierra.greedy_meshing.GreedyEligibility; import hi.sierra.greedy_meshing.GreedyMesher; +import hi.sierra.greedy_meshing.client.GreedyColorLightCompat; import hi.sierra.greedy_meshing.client.GreedyDebugStore; import hi.sierra.greedy_meshing.client.GreedyEmissiveSupport; import hi.sierra.greedy_meshing.client.GreedyLighting; import hi.sierra.greedy_meshing.client.GreedyPerformanceStats; import hi.sierra.greedy_meshing.client.GreedyRuntimeState; +import hi.sierra.greedy_meshing.client.GreedySectionCache; import hi.sierra.greedy_meshing.client.GreedySpriteSupport; import hi.sierra.greedy_meshing.client.sodium.GreedySodiumWorkState; import org.slf4j.Logger; @@ -116,7 +118,9 @@ public abstract class SodiumChunkBuilderMeshingTaskMixin { CancellationToken cancellationToken, CallbackInfoReturnable cir ) { - GREEDY_MESHING$STATE.remove(); + // Keep the (large, reusable) work state on its worker thread and just drop the references + // into this build. Removing it reallocated ~200 KiB of arrays for every single section. + GREEDY_MESHING$STATE.get().release(); } //? if UNOBFUSCATED { @@ -150,8 +154,13 @@ public abstract class SodiumChunkBuilderMeshingTaskMixin { return; } - if (!GreedyEligibility.isGreedyOpaqueCube(state, work.world(), worldPos) - || !GreedySpriteSupport.supportsGreedySpriteSizes(state)) { + if (!greedyMeshing$isEligible(work, state, worldPos)) { + renderer.renderModel(model, state, worldPos, modelOffset); + return; + } + // ColorLight in vertex-colour mode bakes its light into the model quads, which merged quads + // bypass: keep blocks next to coloured light on the normal path (no-op in GPU mode). + if (GreedyColorLightCompat.mustStayPerBlock(worldPos.getX(), worldPos.getY(), worldPos.getZ())) { renderer.renderModel(model, state, worldPos, modelOffset); return; } @@ -200,8 +209,13 @@ public abstract class SodiumChunkBuilderMeshingTaskMixin { return; } - if (!GreedyEligibility.isGreedyOpaqueCube(state, work.world(), worldPos) - || !GreedySpriteSupport.supportsGreedySpriteSizes(state)) { + if (!greedyMeshing$isEligible(work, state, worldPos)) { + renderer.renderModel(model, state, worldPos, modelOffset); + return; + } + // ColorLight in vertex-colour mode bakes its light into the model quads, which merged quads + // bypass: keep blocks next to coloured light on the normal path (no-op in GPU mode). + if (GreedyColorLightCompat.mustStayPerBlock(worldPos.getX(), worldPos.getY(), worldPos.getZ())) { renderer.renderModel(model, state, worldPos, modelOffset); return; } @@ -250,8 +264,13 @@ public abstract class SodiumChunkBuilderMeshingTaskMixin { return; } - if (!GreedyEligibility.isGreedyOpaqueCube(state, work.world(), worldPos) - || !GreedySpriteSupport.supportsGreedySpriteSizes(state)) { + if (!greedyMeshing$isEligible(work, state, worldPos)) { + renderer.renderModel(model, state, worldPos, modelOffset); + return; + } + // ColorLight in vertex-colour mode bakes its light into the model quads, which merged quads + // bypass: keep blocks next to coloured light on the normal path (no-op in GPU mode). + if (GreedyColorLightCompat.mustStayPerBlock(worldPos.getX(), worldPos.getY(), worldPos.getZ())) { renderer.renderModel(model, state, worldPos, modelOffset); return; } @@ -271,6 +290,17 @@ public abstract class SodiumChunkBuilderMeshingTaskMixin { } //?} + @Unique + private static boolean greedyMeshing$isEligible(GreedySodiumWorkState work, BlockState state, BlockPos worldPos) { + if (work.hasVerdictFor(state)) { + return work.lastVerdictFor(state); + } + boolean verdict = GreedyEligibility.isGreedyOpaqueCube(state, work.world(), worldPos) + && GreedySpriteSupport.supportsGreedySpriteSizes(state); + work.rememberVerdict(state, verdict); + return verdict; + } + /** * Water's fluid quads go through Sodium's own FluidRenderer.render(...), a code path entirely * separate from BlockRenderer.renderModel above — BlockState.getRenderShape() for water is @@ -390,7 +420,10 @@ public abstract class SodiumChunkBuilderMeshingTaskMixin { !GreedyRuntimeState.isInSableSubLevel(baseX >> 4, baseZ >> 4); BitSet[] visibleFaces = GREEDY_MESHING$VISIBLE.get(); long[][] mergeKeys = work.faceMergeKeys(); - populateFaceVisibility(work.world(), work.sectionStates(), visibleFaces, mergeKeys, baseX, baseY, baseZ); + GreedySectionCache sectionCache = work.sectionCache; + sectionCache.begin(work.world(), baseX, baseY, baseZ); + work.scratchLighting.cache = sectionCache; + populateFaceVisibility(work.world(), sectionCache, work.sectionStates(), visibleFaces, mergeKeys, baseX, baseY, baseZ); List merged = GreedyMesher.meshWithKeys( work.sectionStates(), visibleFaces, mergeKeys ); @@ -506,7 +539,45 @@ private static GreedyDebugStore.DebugQuad toDebugQuad( } @Unique + private static final java.util.concurrent.ConcurrentHashMap[]> GREEDY_MESHING$LAYER_CACHE = + new java.util.concurrent.ConcurrentHashMap<>(); + @Unique + private static volatile int GREEDY_MESHING$LAYER_GENERATION = -1; + + /** + * Resolving a face walked the baked model and allocated a RandomSource and lists for every merged + * quad. The answer only depends on (state, face) until resources reload or settings change, which + * is exactly when {@link GreedySpriteSupport#generation()} moves. + */ + @Unique + @SuppressWarnings("unchecked") private static List greedyMeshing$resolveFaceLayers(BlockState state, Direction face) { + int generation = GreedySpriteSupport.generation(); + if (generation != GREEDY_MESHING$LAYER_GENERATION) { + GREEDY_MESHING$LAYER_CACHE.clear(); + GREEDY_MESHING$LAYER_GENERATION = generation; + } + List[] perFace = GREEDY_MESHING$LAYER_CACHE.get(state); + if (perFace == null) { + perFace = new List[6]; + List[] existing = GREEDY_MESHING$LAYER_CACHE.putIfAbsent(state, perFace); + if (existing != null) { + perFace = existing; + } + } + int fi = face.ordinal(); + List cached = perFace[fi]; + if (cached == null) { + cached = List.copyOf(greedyMeshing$resolveFaceLayersUncached(state, face)); + if (generation == GreedySpriteSupport.generation()) { + perFace[fi] = cached; + } + } + return cached; + } + + @Unique + private static List greedyMeshing$resolveFaceLayersUncached(BlockState state, Direction face) { if (state.is(Blocks.WATER)) { return greedyMeshing$resolveWaterFaceLayers(state); } @@ -599,6 +670,7 @@ private static GreedyDebugStore.DebugQuad toDebugQuad( @Unique private static void populateFaceVisibility( BlockAndTintGetter world, + GreedySectionCache cache, BlockState[] sectionStates, BitSet[] visibleFaces, long[][] faceMergeKeys, @@ -632,7 +704,7 @@ private static void populateFaceVisibility( } samplePos.set(worldX + face.getStepX(), worldY + face.getStepY(), worldZ + face.getStepZ()); - BlockState neighbor = world.getBlockState(samplePos); + BlockState neighbor = cache.state(samplePos.getX(), samplePos.getY(), samplePos.getZ()); //? if >=1.21.2 { if (Block.shouldRenderFace(state, neighbor, face)) { //?} else { @@ -660,7 +732,7 @@ private static void populateFaceVisibility( // per sub-quad. mergeKey = GreedyConfig.aggressiveGreedy() ? 0 - : computeAoKey(world, samplePos, worldX, worldY, worldZ, face); + : computeAoKey(cache, worldX, worldY, worldZ, face); } faceMergeKeys[face.ordinal()][idx] = mergeKey; } @@ -727,33 +799,18 @@ private static boolean isFlatWaterSurface( } @Unique - private static int computeAoKey(BlockAndTintGetter world, BlockPos.MutableBlockPos pos, + private static int computeAoKey(GreedySectionCache cache, int worldX, int worldY, int worldZ, Direction face) { int[][] offsets = GREEDY_MESHING$AO_OFFSETS[face.ordinal()]; int key = 0; for (int i = 0; i < 8; i++) { - pos.set(worldX + offsets[i][0], worldY + offsets[i][1], worldZ + offsets[i][2]); - BlockState neighbor = world.getBlockState(pos); - //? if >=26.3 { - /*// 26.3 narrowed isViewBlocking to the camera near-plane check; vanilla and Sodium AO - // now test isLightPermeable(), which is the same solid-and-dampening predicate. - if (!neighbor.isLightPermeable()) { - *///?} else { - if (neighbor.isViewBlocking(world, pos) - //? if UNOBFUSCATED { - /*&& neighbor.getLightDampening() != 0 - *///?} else if >=1.21.2 { - && neighbor.getLightBlock() != 0 - //?} else { - /*&& neighbor.getLightBlock(world, pos) != 0 - *///?} - ) { - //?} + // occludes() is the same predicate the old inline test used (isLightPermeable on 26.3, + // isViewBlocking + light dampening before), evaluated once per cell instead of per face. + if (cache.occludes(worldX + offsets[i][0], worldY + offsets[i][1], worldZ + offsets[i][2])) { key |= (1 << i); } } - pos.set(worldX + face.getStepX(), worldY + face.getStepY(), worldZ + face.getStepZ()); - int packedLight = GreedyLighting.mergeLightKey(world, pos); + int packedLight = cache.light(worldX + face.getStepX(), worldY + face.getStepY(), worldZ + face.getStepZ()); key |= ((packedLight >>> 4) & 0xF) << 8; key |= ((packedLight >>> 20) & 0xF) << 12; return key; diff --git a/src/client/java/hi/sierra/greedy_meshing/mixin/client/sodium/SodiumShaderLoaderMixin.java b/src/client/java/hi/sierra/greedy_meshing/mixin/client/sodium/SodiumShaderLoaderMixin.java index 4c5f81e..f4d28c5 100644 --- a/src/client/java/hi/sierra/greedy_meshing/mixin/client/sodium/SodiumShaderLoaderMixin.java +++ b/src/client/java/hi/sierra/greedy_meshing/mixin/client/sodium/SodiumShaderLoaderMixin.java @@ -55,7 +55,13 @@ public abstract class SodiumShaderLoaderMixin { // by the greedy path and merged faces render as a single flat colour (issue #5). boolean milkshadeTerrainShader = "milkshade".equals(namespace) && "sodium/block_layer_opaque".equals(path); - if (!sodiumTerrainShader && !milkshadeTerrainShader) { + // ColorLight's GPU lighting pipeline redirects Sodium's terrain pipelines to its own copy of + // the shader (colorlight:blocks/colored_terrain), so Sodium's resource never gets requested. + // Without patching the copy too, merged faces keep the sprite-centre UV and render as a single + // flat colour. The copy keeps Sodium's v_TexCoord/u_BlockTex interface and vertex format. + boolean colorLightTerrainShader = "colorlight".equals(namespace) + && "blocks/colored_terrain".equals(path); + if (!sodiumTerrainShader && !milkshadeTerrainShader && !colorLightTerrainShader) { return; } String source = cir.getReturnValue(); @@ -130,9 +136,18 @@ public abstract class SodiumShaderLoaderMixin { // Add assignments at the end of main() int lastBrace = source.lastIndexOf('}'); if (lastBrace < 0) return source; + // ColorLight forwards the raw vertex colour (v_ColorBase) and multiplies it in itself, so the + // face marker in its alpha has to be cleared there as well (v_Color is handled in the fragment). + String clearMarker = source.contains("v_ColorBase") + ? "#ifndef OIT_ALPHA_ONLY\n" + + " if (_gm_id > 233.5 && _gm_id < 251.5) v_ColorBase.a = 1.0;\n" + + "#endif\n" + : ""; source = source.substring(0, lastBrace) - + "\n v_BlockPos = _vert_position;\n" - + " v_GreedyFaceId = _vert_color.a * 255.0;\n" + + "\n float _gm_id = _vert_color.a * 255.0;\n" + + " v_BlockPos = _vert_position;\n" + + " v_GreedyFaceId = _gm_id;\n" + + clearMarker + source.substring(lastBrace); return source; @@ -149,10 +164,23 @@ public abstract class SodiumShaderLoaderMixin { } // Find the block texture sampler name - String samplerName = "u_BlockTex"; + // Prefer u_BlockTex by name: ColorLight's copy declares u_LightTex first, and sizing sprites + // from the 16x16 lightmap instead of the atlas collapses every merged face to one texel. + String samplerName = null; + String firstSampler = null; Matcher m = GREEDY_MESHING$SAMPLER_PATTERN.matcher(source); - if (m.find()) { - samplerName = m.group(1); + while (m.find()) { + String candidate = m.group(1); + if ("u_BlockTex".equals(candidate)) { + samplerName = candidate; + break; + } + if (firstSampler == null && !"u_LightTex".equals(candidate)) { + firstSampler = candidate; + } + } + if (samplerName == null) { + samplerName = firstSampler != null ? firstSampler : "u_BlockTex"; } // 1. Add varying declarations before void main() diff --git a/src/main/java/hi/sierra/greedy_meshing/GreedyMesher.java b/src/main/java/hi/sierra/greedy_meshing/GreedyMesher.java index 1654cbc..60a6398 100644 --- a/src/main/java/hi/sierra/greedy_meshing/GreedyMesher.java +++ b/src/main/java/hi/sierra/greedy_meshing/GreedyMesher.java @@ -292,6 +292,10 @@ private static List binaryKeyedSweep( int cellBase = depth << 8; int nGroups = 0; int lastGid = -1; + int stamp = bw.nextStamp(); + int[] hStamp = bw.hashStamp; + long[] hKey = bw.hashKey; + int[] hVal = bw.hashVal; for (int c = 0; c < cellCount; c++) { int bi = depthCells[cellBase + c]; @@ -302,12 +306,18 @@ private static List binaryKeyedSweep( long ck = ((long) System.identityHashCode(block) << 32) | (faceKeys[bi] & 0xFFFFFFFFL); int gid = -1; - // Runs of one block state dominate, so check the previous group before scanning. + int slot = -1; + // Runs of one block state dominate, so check the previous group first. Otherwise + // probe a small open-addressed table: a slice has at most 256 cells, so a 512-slot + // table stays at most half full and lookups stay O(1) where the old linear scan was + // O(groups) per cell (it dominated sections with many AO/light variants). if (lastGid >= 0 && keys[lastGid] == ck) { gid = lastGid; } else { - for (int g = 0; g < nGroups; g++) { - if (keys[g] == ck) { gid = g; break; } + slot = (int) ((ck * 0x9E3779B97F4A7C15L) >>> (64 - BinarySweepState.HASH_BITS)); + while (hStamp[slot] == stamp) { + if (hKey[slot] == ck) { gid = hVal[slot]; break; } + slot = (slot + 1) & BinarySweepState.HASH_MASK; } } if (gid == -1) { @@ -324,6 +334,9 @@ private static List binaryKeyedSweep( gid = nGroups++; keys[gid] = ck; origins[gid] = bi; + hStamp[slot] = stamp; + hKey[slot] = ck; + hVal[slot] = gid; for (int r = 0; r < SECTION_SIZE; r++) rows[gid * SECTION_SIZE + r] = 0; } lastGid = gid; @@ -470,5 +483,23 @@ private static final class BinarySweepState { /** Visible block indices bucketed by depth slice: slot {@code depth * 256 + n}. */ final int[] depthCells = new int[SECTION_SIZE * SECTION_SIZE * SECTION_SIZE]; final int[] depthCounts = new int[SECTION_SIZE]; + + /** Open-addressed group lookup for one depth slice; 512 slots for at most 256 cells. */ + static final int HASH_BITS = 9; + static final int HASH_SIZE = 1 << HASH_BITS; + static final int HASH_MASK = HASH_SIZE - 1; + final int[] hashStamp = new int[HASH_SIZE]; + final long[] hashKey = new long[HASH_SIZE]; + final int[] hashVal = new int[HASH_SIZE]; + private int stamp; + + /** A fresh stamp invalidates every slot at once, so the table is never cleared per slice. */ + int nextStamp() { + if (++stamp == Integer.MAX_VALUE) { + java.util.Arrays.fill(hashStamp, 0); + stamp = 1; + } + return stamp; + } } }