From a63269ca7c1d63e6827fe12acf370ec427685713 Mon Sep 17 00:00:00 2001 From: AshenSolYT Date: Sat, 7 Nov 2026 23:31:31 +0400 Subject: [PATCH] Added colorlight compat and fixed flat textures colorlight compatibility was added by checking if colorlight is installed new version or older, by hooking into colorlight so no files from colorlight is ever used and why the flat textures happen and colorlights breaking sodium:blocks/block_layer_opaque colorLight replaces sodium's terrain shader "sodium:blocks/block_layer_opaque" with its own copy "colorlight:blocks/colored_terrain", so the greedy UV patch never applied and merged faces sampled one texel, rendering as a flat texture on some surfaces --- CHANGELOG.md | 21 +++ .../client/GreedyColorLightCompat.java | 97 +++++++++++++ .../greedy_meshing/client/GreedyLighting.java | 95 ++++++++----- .../client/GreedySectionCache.java | 131 ++++++++++++++++++ .../client/GreedySpriteSupport.java | 8 ++ .../client/sodium/GreedySodiumWorkState.java | 35 +++++ .../SodiumChunkBuilderMeshingTaskMixin.java | 117 ++++++++++++---- .../sodium/SodiumShaderLoaderMixin.java | 40 +++++- .../sierra/greedy_meshing/GreedyMesher.java | 37 ++++- 9 files changed, 511 insertions(+), 70 deletions(-) create mode 100644 src/client/java/hi/sierra/greedy_meshing/client/GreedyColorLightCompat.java create mode 100644 src/client/java/hi/sierra/greedy_meshing/client/GreedySectionCache.java 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; + } } }