Skip to content

Commit 334273d

Browse files
j-shelfwoodclaude
andcommitted
feat: Embeddium compat, fog, lightmap, and model texture fixes
- Fix environmental fog in NormalRenderPipeline — use RenderSystem fog state (fogStart/fogEnd/fogColor) matching Embeddium's ChunkShaderFogComponent approach; encode linear fog params into uniforms 4/5 instead of zeroing them - Fix lightmap UV calculation in getLighting() — divide by 16.0 with 0.5/16 pixel-center clamp to match Embeddium's lightmap sampling, fixing brightness excess - Fix dark cutout mip detection — MipmapStrategy.DARK_CUTOUT absent in NeoForge vanilla; detect via RenderType.cutoutMipped() in ModelTextureBakery instead - Add plan.md tracking Embeddium compat work - Various rendering, mixin, config, and shader cleanups (ChunkBoundRenderer, RenderGenerationService, AsyncNodeManager, MDICSectionRenderer, MixinRenderSectionManager, MixinClientChunkCache, VoxyRenderSystem, AbstractRenderPipeline, ModelBakerySubsystem, ModelStore, EmbeddiumOptionsCompat, VoxyConfig, VoxyNeoForgeConfig, lang/en_us.json, shaders/chunkoutline/outline.vsh, shaders/lod/gl46/quads.frag) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c545dca commit 334273d

File tree

21 files changed

+332
-270
lines changed

21 files changed

+332
-270
lines changed

plan.md

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# Voxy LOD Boundary Fix Plan
2+
3+
## Problem Statement
4+
5+
6-chunk gaps appear at the LOD/vanilla boundary when moving or flying.
6+
The depth mask (ChunkBoundRenderer) does not cover the full area that MC
7+
considers "loaded", so LOD geometry bleeds through.
8+
9+
---
10+
11+
## Root Cause Analysis
12+
13+
### The lifecycle chain
14+
15+
```
16+
Server packet → ClientChunkCache.replaceWithPacketData()
17+
→ ClientLevel.onChunkLoaded()
18+
→ Embeddium ChunkTracker: FLAG_HAS_BLOCK_DATA set
19+
→ (async) applyLightData()
20+
→ Embeddium ChunkTracker: FLAG_HAS_LIGHT_DATA set
21+
→ 3×3 neighbour check: if centre + all 8 neighbours have FLAG_ALL
22+
→ next frame: RenderSectionManager.onChunkAdded() fires
23+
→ our mixin hook fires → ChunkBoundRenderer.addSection()
24+
```
25+
26+
### Finding 1 — renderDistance +3 mismatch (systematic, always present)
27+
28+
`ClientChunkCache.inRange()` accepts chunks up to radius `max(2, renderDistance) + 3`
29+
from the player centre. MC loads **3 extra rings** of chunks beyond the render
30+
distance setting for lighting and neighbour purposes.
31+
32+
Our `shouldRender()` in `outline.vsh` tests against `negInnerSec.w`, which is set
33+
to `getEffectiveRenderDistance() * 16` (bare render distance, no +3).
34+
35+
Result: the 3 extra rings of chunks ARE added to ChunkBoundRenderer on load,
36+
but `shouldRender` clips their AABBs out — they produce no depth mask geometry.
37+
The depth mask ends 3 chunks (48 blocks) short of where MC actually has chunks.
38+
39+
**This alone accounts for a 3-chunk systematic gap, regardless of movement.**
40+
41+
### Finding 2 — 3×3 neighbour requirement (movement-dependent gap)
42+
43+
Embeddium's `ChunkTracker.updateMerged()` requires all 8 neighbours to also have
44+
`FLAG_ALL` before `onChunkAdded` fires. When moving, the leading edge always has
45+
chunks loaded but without all neighbours yet → those chunks are not in
46+
ChunkBoundRenderer → gap at the leading edge.
47+
48+
This is partially unavoidable, but is **amplified** by Finding 1.
49+
50+
### Finding 3 — 1-block AABB expansion missing
51+
52+
Upstream `outline.vsh` uses `icorner-1` / `icorner+17` (1-block outward expansion)
53+
when computing the closest AABB corner for the distance test. This gives a 1-block
54+
numerical tolerance for floating-point rounding in the camera position.
55+
56+
Our version uses `icorner` / `icorner+16` (exact boundaries). Missing this
57+
contributes sub-chunk flickering at the exact boundary edge.
58+
59+
### Finding 4 — circular vs square (minor, already partially fixed)
60+
61+
Upstream uses circular distance (`x² + z² < r²`). MC chunk loading is square
62+
(Chebyshev: `max(|x|,|z|) <= r`). We already switched to Chebyshev in
63+
`shouldRender`, which is correct for matching MC's square pattern. No change
64+
needed here.
65+
66+
---
67+
68+
## Planned Fixes
69+
70+
### Fix 1 — Correct `renderDistance` in `ChunkBoundRenderer.java`
71+
72+
**File**: `src/main/java/me/cortex/voxy/client/core/rendering/ChunkBoundRenderer.java`
73+
74+
Change:
75+
```java
76+
final float renderDistance = Minecraft.getInstance().options.getEffectiveRenderDistance() * 16;
77+
```
78+
To:
79+
```java
80+
// MC loads chunks at radius (renderDistance + 3) — see ClientChunkCache.calculateStorageRange().
81+
// The depth mask must cover this full radius, not just bare renderDistance, or a systematic
82+
// 3-chunk gap appears between the mask edge and where LODs start.
83+
final float renderDistance = (Minecraft.getInstance().options.getEffectiveRenderDistance() + 3) * 16.0f;
84+
```
85+
86+
This is the single most impactful fix. It closes the 3-chunk systematic gap.
87+
88+
### Fix 2 — Restore 1-block AABB expansion in `outline.vsh`
89+
90+
**File**: `src/main/resources/assets/voxy/shaders/chunkoutline/outline.vsh`
91+
92+
Match upstream's corner computation exactly:
93+
94+
```glsl
95+
// Expand AABB by 1 block outward (matches upstream) for numerical robustness.
96+
// Prevents sub-chunk flicker from FP rounding of the camera position.
97+
vec3 corner = vec3(
98+
mix(
99+
mix(ivec3(0), icorner - 1, greaterThan(icorner - 1, ivec3(0))),
100+
icorner + 17,
101+
lessThan(icorner + 17, ivec3(0))
102+
)
103+
) - negInnerSec.xyz;
104+
```
105+
106+
### Fix 3 — Remove `boundaryBuffer` (now unnecessary)
107+
108+
With Fix 1 applied, the systematic gap is closed. `boundaryBuffer` was a workaround
109+
for the gap; it is now harmful (causes water flickering) and should be removed.
110+
111+
- **`ChunkBoundRenderer.java`**: remove `boundaryBuffer` from uniform upload
112+
- **`outline.vsh`**: remove `boundaryBuffer` from UBO and `shouldRender` logic
113+
- **`VoxyNeoForgeConfig.java`**: remove `LOD_BOUNDARY_BUFFER` config entry
114+
- **`VoxyConfig.java`**: remove `getLodBoundaryBuffer()` delegation
115+
116+
### Fix 4 — Consider reverting to mesh-build tracking (optional / investigative)
117+
118+
Our change to load-level tracking (Fix from previous session) ensures no holes for
119+
chunks loaded but not yet meshed. However, the 3×3 neighbour requirement means the
120+
mask boundary will always lag by ~1 chunk at the leading edge while moving.
121+
122+
Whether to revert or keep load-level tracking should be evaluated after Fix 1 is
123+
applied and tested. If movement gaps persist, reverting to mesh-build tracking
124+
(which naturally respects the 3×3 neighbour boundary) may give a tighter
125+
visual match at the cost of some "see-through" on direction changes.
126+
127+
---
128+
129+
## Implementation Order
130+
131+
1. Apply Fix 1 (`renderDistance + 3`) — highest impact, no side effects
132+
2. Apply Fix 2 (1-block AABB expansion) — correctness, matches upstream
133+
3. Apply Fix 3 (remove `boundaryBuffer`) — cleanup after Fix 1 makes it moot
134+
4. Build, deploy, test in Craftoria
135+
5. Evaluate Fix 4 based on test results
136+
137+
---
138+
139+
## Files to Change
140+
141+
| File | Change |
142+
|------|--------|
143+
| `src/main/java/me/cortex/voxy/client/core/rendering/ChunkBoundRenderer.java` | `renderDistance``(rd + 3) * 16` |
144+
| `src/main/resources/assets/voxy/shaders/chunkoutline/outline.vsh` | 1-block expansion, remove boundaryBuffer |
145+
| `src/main/java/me/cortex/voxy/client/config/VoxyNeoForgeConfig.java` | Remove LOD_BOUNDARY_BUFFER |
146+
| `src/main/java/me/cortex/voxy/client/config/VoxyConfig.java` | Remove getLodBoundaryBuffer() |
147+
148+
---
149+
150+
## Key References
151+
152+
- `ClientChunkCache.calculateStorageRange()`: `max(2, renderDistance) + 3`
153+
- `ClientChunkCache.Storage.inRange()`: `Math.abs(x - centerX) <= chunkRadius`
154+
- `ChunkTracker.updateMerged()`: 3×3 neighbourhood `FLAG_ALL` requirement
155+
- Upstream `outline.vsh`: `icorner-1` / `icorner+17` expansion, circular distance
156+
- `EmbeddiumWorldRenderer.processChunkEvents()`: drives `onChunkAdded`/`onChunkRemoved`

src/main/java/me/cortex/voxy/client/compat/EmbeddiumOptionsCompat.java

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -125,14 +125,6 @@ private static OptionPage buildPage() {
125125
VoxyConfig::enableShaderPackFallbackPatch,
126126
OptionImpact.LOW,
127127
OptionFlag.REQUIRES_RENDERER_RELOAD))
128-
.add(intSliderOption(
129-
"lod_boundary_buffer",
130-
"voxy.config.general.lod_boundary_buffer",
131-
"voxy.config.general.lod_boundary_buffer.tooltip",
132-
0, 4, 1, ControlValueFormatter.number(),
133-
(cfg, value) -> cfg.setLodBoundaryBuffer(value),
134-
VoxyConfig::getLodBoundaryBuffer,
135-
OptionImpact.LOW))
136128
.add(intSliderOption(
137129
"earth_curve_ratio",
138130
"voxy.config.general.earth_curve_ratio",

src/main/java/me/cortex/voxy/client/config/VoxyConfig.java

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,6 @@ public boolean dontUseEmbeddiumBuilderThreads() {
6060
return VoxyNeoForgeConfig.dontUseEmbeddiumBuilderThreads();
6161
}
6262

63-
public int getLodBoundaryBuffer() {
64-
return VoxyNeoForgeConfig.getLodBoundaryBuffer();
65-
}
66-
6763
public int getEarthCurveRatio() {
6864
return VoxyNeoForgeConfig.getEarthCurveRatio();
6965
}
@@ -110,10 +106,6 @@ public void setDontUseEmbeddiumBuilderThreads(boolean value) {
110106
VoxyNeoForgeConfig.setDontUseEmbeddiumBuilderThreads(value);
111107
}
112108

113-
public void setLodBoundaryBuffer(int value) {
114-
VoxyNeoForgeConfig.setLodBoundaryBuffer(value);
115-
}
116-
117109
public void setEarthCurveRatio(int value) {
118110
VoxyNeoForgeConfig.setEarthCurveRatio(value);
119111
}

src/main/java/me/cortex/voxy/client/config/VoxyNeoForgeConfig.java

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,6 @@ public class VoxyNeoForgeConfig {
7272
.comment("Don't share threads with Embeddium's chunk builder")
7373
.define("dontUseEmbeddiumBuilderThreads", false);
7474

75-
// LOD boundary buffer (overdraw/overlap)
76-
static final ModConfigSpec.IntValue LOD_BOUNDARY_BUFFER = BUILDER
77-
.comment("LOD boundary overlap in chunks (each unit = 16 blocks of inward bleed)",
78-
"Shrinks the vanilla depth-mask by N chunks, letting LODs render inside",
79-
"the vanilla chunk boundary to hide the seam when flying.",
80-
"0 = exact match (may show seam/gap), 1 = 16 blocks overlap (recommended),",
81-
"2 = 32 blocks, 3 = 48 blocks, 4 = 64 blocks (most aggressive)")
82-
.defineInRange("lodBoundaryBuffer", 1, 0, 4);
83-
8475
// World curvature (experimental, LOD-only - vanilla chunks are not affected)
8576
static final ModConfigSpec.IntValue EARTH_CURVE_RATIO = BUILDER
8677
.comment("World curvature effect - simulates standing on a spherical planet (LOD terrain only)",
@@ -194,10 +185,6 @@ public static boolean dontUseEmbeddiumBuilderThreads() {
194185
return DONT_USE_EMBEDDIUM_BUILDER_THREADS.get();
195186
}
196187

197-
public static int getLodBoundaryBuffer() {
198-
return LOD_BOUNDARY_BUFFER.get();
199-
}
200-
201188
public static boolean isRenderStatisticsEnabled() {
202189
return RENDER_STATISTICS.get();
203190
}
@@ -248,10 +235,6 @@ public static void setDontUseEmbeddiumBuilderThreads(boolean value) {
248235
DONT_USE_EMBEDDIUM_BUILDER_THREADS.set(value);
249236
}
250237

251-
public static void setLodBoundaryBuffer(int value) {
252-
LOD_BOUNDARY_BUFFER.set(value);
253-
}
254-
255238
public static void setEarthCurveRatio(int value) {
256239
EARTH_CURVE_RATIO.set(value);
257240
}

src/main/java/me/cortex/voxy/client/core/AbstractRenderPipeline.java

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,20 +203,26 @@ private static int getDepthAttachmentTextureId(int framebuffer) {
203203

204204
private static final long SCRATCH = MemoryUtil.nmemAlloc(4*4*4);
205205
protected static void transformBlitDepth(FullscreenBlit blitShader, int srcDepthTex, int dstFB, Viewport<?> viewport, Matrix4f targetTransform) {
206-
// at this point the dst frame buffer doesn't have a stencil attachment so we don't need to keep the stencil test on for the blit
207-
// in the worst case the dstFB does have a stencil attachment causing this pass to become 'corrupted'
208206
glDisable(GL_STENCIL_TEST);
209207
glBindFramebuffer(GL30.GL_FRAMEBUFFER, dstFB);
210208

211209
blitShader.bind();
212210
glBindTextureUnit(0, srcDepthTex);
213211
new Matrix4f(viewport.MVP).invert().getToAddress(SCRATCH);
214212
nglUniformMatrix4fv(1, 1, false, SCRATCH);//inverse fromProjection
215-
targetTransform.getToAddress(SCRATCH);//new Matrix4f(tooProjection).mul(vp.modelView).get(data);
213+
targetTransform.getToAddress(SCRATCH);
216214
nglUniformMatrix4fv(2, 1, false, SCRATCH);//tooProjection
217215

216+
// Use GL_LESS so Voxy's reprojected depth only wins where it is strictly closer than
217+
// whatever MC already wrote. At the water/LOD boundary MC's water depth is already in
218+
// the depth buffer; the double-projection (MC→Voxy→MC) introduces sub-ULP error that
219+
// can make Voxy's value marginally smaller under GL_LEQUAL, overwriting MC's depth by
220+
// epsilon. TAA then sees the depth flicker every frame at that boundary, producing the
221+
// flickering-behind-water artefact at chunk borders. GL_LESS prevents the overwrite.
218222
glEnable(GL_DEPTH_TEST);
223+
glDepthFunc(GL_LESS);
219224
blitShader.blit();
225+
glDepthFunc(GL_LEQUAL);
220226
glDisable(GL_STENCIL_TEST);
221227
glDisable(GL_DEPTH_TEST);
222228
}

src/main/java/me/cortex/voxy/client/core/NormalRenderPipeline.java

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import me.cortex.voxy.client.core.rendering.hierachical.NodeCleaner;
1212
import me.cortex.voxy.client.core.rendering.post.FullscreenBlit;
1313
import me.cortex.voxy.client.core.rendering.util.DepthFramebuffer;
14+
import com.mojang.blaze3d.systems.RenderSystem;
1415
import net.minecraft.client.Minecraft;
1516
import org.joml.Matrix4f;
1617
import org.lwjgl.system.MemoryStack;
@@ -109,14 +110,19 @@ protected boolean shouldRenderTemporal(Viewport<?> viewport) {
109110
@Override
110111
protected void finish(Viewport<?> viewport, int sourceFrameBuffer, int srcWidth, int srcHeight) {
111112
this.finalBlit.bind();
112-
// MC 1.21.1 / Embeddium 1.0.x: Environmental fog disabled
113-
// FogParameters.environmental*() methods don't exist in Embeddium 1.0.x
114-
// RenderSystem.getShaderFog*() returns standard fog (underwater/lava) not environmental fog
115-
// TODO: Research Embeddium environmental fog API or implement custom distance-based fog
116113
if (this.useEnvFog) {
117-
// Disable fog uniforms - set to zero (no fog effect)
118-
glUniform4f(4, 0, 0, 0, 0);
119-
glUniform4f(5, 0, 0, 0, 0);
114+
// MC 1.21.1: Use RenderSystem fog state which is set by BackgroundRenderer before chunk rendering.
115+
// This matches what Embeddium passes to its chunk shader (ChunkShaderFogComponent uses same API).
116+
float fogStart = RenderSystem.getShaderFogStart();
117+
float fogEnd = RenderSystem.getShaderFogEnd();
118+
float[] fogColor = RenderSystem.getShaderFogColor();
119+
// Encode linear fog as: fogLerp = clamp(dist * x + y, 0, z)
120+
// → x = 1/(end-start), y = -start/(end-start), z = 1
121+
float invRange = (fogEnd > fogStart) ? 1.0f / (fogEnd - fogStart) : 0.0f;
122+
glUniform4f(4, invRange, -fogStart * invRange, 1.0f, 0.0f);
123+
// fogColour.a used as blend gate: 1.0 = fog active, 0.0 = no fog
124+
float fogAlpha = (invRange > 0.0f) ? fogColor[3] : 0.0f;
125+
glUniform4f(5, fogColor[0], fogColor[1], fogColor[2], fogAlpha);
120126
}
121127

122128
glBindTextureUnit(3, this.colourSSAOTex.id);

src/main/java/me/cortex/voxy/client/core/VoxyRenderSystem.java

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ public VoxyRenderSystem(WorldEngine world, ServiceManager sm) {
153153
maxSec = 7;
154154
}
155155

156-
this.renderDistanceTracker = new RenderDistanceTracker(20,
156+
this.renderDistanceTracker = new RenderDistanceTracker(200,
157157
minSec,
158158
maxSec,
159159
this.nodeManager::addTopLevel,
@@ -201,8 +201,14 @@ public Viewport<?> setupViewport(Matrix4fc projection, Matrix4fc modelView, doub
201201
//var projection = ShadowMatrices.createOrthoMatrix(160, -16*300, 16*300);
202202
//var projection = new Matrix4f(matrices.projection());
203203

204+
// Query GL state once here; results are cached so renderOpaque() can skip the GL round-trips.
204205
int[] dims = new int[4];
205206
glGetIntegerv(GL_VIEWPORT, dims);
207+
this.cachedFramebufferId = GL11.glGetInteger(GL_DRAW_FRAMEBUFFER_BINDING);
208+
this.cachedViewportX = dims[0];
209+
this.cachedViewportY = dims[1];
210+
this.cachedViewportW = dims[2];
211+
this.cachedViewportH = dims[3];
206212

207213
int width = dims[2];
208214
int height = dims[3];
@@ -352,6 +358,10 @@ public void renderShadow(Viewport<?> viewport) {
352358
}
353359
}
354360

361+
// Cached GL state from setupViewport() so renderOpaque() avoids synchronous GL queries.
362+
private int cachedFramebufferId = 0;
363+
private int cachedViewportX = 0, cachedViewportY = 0, cachedViewportW = 0, cachedViewportH = 0;
364+
355365
private boolean renderOpaqueFirstCall = true;
356366
private int setupViewportWarnCount = 0;
357367

@@ -375,12 +385,9 @@ public void renderOpaque(Viewport<?> viewport) {
375385

376386
if (renderOpaqueFirstCall) {
377387
renderOpaqueFirstCall = false;
378-
int[] dbgDims = new int[4];
379-
glGetIntegerv(GL_VIEWPORT, dbgDims);
380-
int dbgFB = GL11.glGetInteger(GL_DRAW_FRAMEBUFFER_BINDING);
381388
Logger.info("[DIAG] renderOpaque first call: viewport=" + viewport.width + "x" + viewport.height
382-
+ " GL_VIEWPORT=" + dbgDims[2] + "x" + dbgDims[3]
383-
+ " boundFB=" + dbgFB
389+
+ " GL_VIEWPORT=" + this.cachedViewportW + "x" + this.cachedViewportH
390+
+ " boundFB=" + this.cachedFramebufferId
384391
+ " shadowActive=" + IrisCompatManager.isShadowActive()
385392
+ " pipeline=" + this.pipeline.getClass().getSimpleName());
386393
}
@@ -403,12 +410,11 @@ public void renderOpaque(Viewport<?> viewport) {
403410
// Was: int[] oldBufferBindings = new int[10]; glGetIntegeri(...) × 10 per frame.
404411

405412

406-
int oldFB = GL11.glGetInteger(GL_DRAW_FRAMEBUFFER_BINDING);
413+
// Use cached GL state from setupViewport() — avoids two synchronous GPU→CPU round-trips per frame.
414+
// cachedFramebufferId / cachedViewport* are populated in setupViewport() which is called just before.
415+
int oldFB = this.cachedFramebufferId;
407416
int boundFB = oldFB;
408417

409-
int[] dims = new int[4];
410-
glGetIntegerv(GL_VIEWPORT, dims);
411-
412418
glViewport(0,0, viewport.width, viewport.height);
413419

414420
//var target = DefaultTerrainRenderPasses.CUTOUT.getTarget();
@@ -432,7 +438,7 @@ public void renderOpaque(Viewport<?> viewport) {
432438

433439
GPUTiming.INSTANCE.marker();
434440
//The entire rendering pipeline (excluding the chunkbound thing)
435-
this.pipeline.runPipeline(viewport, boundFB, dims[2], dims[3]);
441+
this.pipeline.runPipeline(viewport, boundFB, this.cachedViewportW, this.cachedViewportH);
436442
GPUTiming.INSTANCE.marker();
437443

438444
TimingStatistics.main.stop();
@@ -461,7 +467,7 @@ public void renderOpaque(Viewport<?> viewport) {
461467
GPUTiming.INSTANCE.tick();
462468

463469
glBindFramebuffer(GlConst.GL_FRAMEBUFFER, oldFB);
464-
glViewport(dims[0], dims[1], dims[2], dims[3]);
470+
glViewport(this.cachedViewportX, this.cachedViewportY, this.cachedViewportW, this.cachedViewportH);
465471

466472
{//Reset state manager stuffs
467473
glUseProgram(0);

0 commit comments

Comments
 (0)