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:
+ *
+ * - GPU pipeline: lit per pixel in a shader from a world-space volume. Meshes are
+ * uncoloured, so merged quads are fine as long as the greedy UV patch is also applied to
+ * ColorLight's copy of the terrain shader (see {@code SodiumShaderLoaderMixin}).
+ * - Vertex colours (fallback when the pipeline is off, unsupported, or an Iris shader
+ * pack is active): ColorLight bakes the colour into each block's model quads while Sodium
+ * meshes them. Greedy quads never go through the model, so they would come out uncoloured.
+ * In that mode blocks that sit next to coloured light are left to the normal per-block path;
+ * everything in light-free areas still merges.
+ *
+ */
+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;
+ }
}
}