diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 3f851f0..c2aae70 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -19,17 +19,8 @@ public sealed class GameWindow : IDisposable
private GL? _gl;
private IInputContext? _input;
private TerrainModernRenderer? _terrain;
- // Phase N.5b benchmark toggle (TEMPORARY — removed in Task 9 along with TerrainChunkRenderer):
- // when ACDREAM_LEGACY_TERRAIN=1, route Draw through the legacy renderer
- // for direct perf comparison. Both renderers are constructed and fed
- // AddLandblock/RemoveLandblock; only one is drawn per frame.
- private TerrainChunkRenderer? _terrainLegacy;
- private bool _useLegacyTerrain;
- private Shader? _shader;
/// Phase N.5b: terrain_modern.vert/.frag program. Owned by
- /// at draw time but allocated + disposed here. Lives
- /// in parallel with (legacy terrain.vert/.frag) until
- /// Task 9 deletes the legacy renderer.
+ /// at draw time but allocated + disposed here.
private Shader? _terrainModernShader;
private CameraController? _cameraController;
private IMouse? _capturedMouse;
@@ -985,13 +976,10 @@ public sealed class GameWindow : IDisposable
_gl.Enable(EnableCap.DepthTest);
string shadersDir = Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders");
- _shader = new Shader(_gl,
- Path.Combine(shadersDir, "terrain.vert"),
- Path.Combine(shadersDir, "terrain.frag"));
// Phase N.5b: terrain_modern shader pair — bindless texture handles +
- // glMultiDrawElementsIndirect dispatch path. Loaded in parallel with
- // the legacy `_shader`; Task 9 will retire the legacy program.
+ // glMultiDrawElementsIndirect dispatch path. The only terrain shader
+ // since Task 9 retired the legacy terrain.vert/.frag program.
_terrainModernShader = new Shader(_gl,
Path.Combine(shadersDir, "terrain_modern.vert"),
Path.Combine(shadersDir, "terrain_modern.frag"));
@@ -1451,10 +1439,6 @@ public sealed class GameWindow : IDisposable
_terrain = new TerrainModernRenderer(_gl, _bindlessSupport, _terrainModernShader!, terrainAtlas);
- // Phase N.5b benchmark toggle (TEMPORARY — see field declaration).
- _useLegacyTerrain = Environment.GetEnvironmentVariable("ACDREAM_LEGACY_TERRAIN") == "1";
- _terrainLegacy = new TerrainChunkRenderer(_gl, _shader!, terrainAtlas);
-
int centerX = (int)((centerLandblockId >> 24) & 0xFFu);
int centerY = (int)((centerLandblockId >> 16) & 0xFFu);
@@ -1612,7 +1596,6 @@ public sealed class GameWindow : IDisposable
_lightingSink.UnregisterOwner(ent.Id);
}
_terrain?.RemoveLandblock(id);
- _terrainLegacy?.RemoveLandblock(id); // Phase N.5b benchmark toggle (TEMPORARY).
_physicsEngine.RemoveLandblock(id);
_cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu);
});
@@ -4762,7 +4745,7 @@ public sealed class GameWindow : IDisposable
float localY = spawn.LocalPosition.Y;
// Prefer the physics engine's terrain sampler (TerrainSurface.SampleZ)
// — it uses the same AC2D render split-direction formula the
- // TerrainChunkRenderer uses for the visible terrain mesh. This
+ // TerrainModernRenderer uses for the visible terrain mesh. This
// guarantees trees are placed on the SAME Z height the player
// walks on. If physics hasn't registered this landblock yet,
// fall back to the local bilinear sample.
@@ -5133,7 +5116,6 @@ public sealed class GameWindow : IDisposable
var meshData = AcDream.Core.Terrain.LandblockMesh.Build(
lb.Heightmap, lbXu, lbYu, _heightTable, _blendCtx, _surfaceCache);
_terrain.AddLandblock(lb.LandblockId, meshData, origin);
- _terrainLegacy?.AddLandblock(lb.LandblockId, meshData, origin); // Phase N.5b benchmark toggle (TEMPORARY).
// Step 4: drain pending LoadedCells from the worker thread.
while (_pendingCells.TryTake(out var cell))
@@ -6358,11 +6340,7 @@ public sealed class GameWindow : IDisposable
// (gated on ACDREAM_WB_DIAG=1, same env var as [WB-DIAG]). Stopwatch
// is cheap; only the periodic Console.WriteLine is gated.
_terrainCpuStopwatch.Restart();
- // Phase N.5b benchmark toggle (TEMPORARY): pick renderer per ACDREAM_LEGACY_TERRAIN.
- if (_useLegacyTerrain)
- _terrainLegacy?.Draw(camera, frustum, neverCullLandblockId: playerLb);
- else
- _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
+ _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
_terrainCpuStopwatch.Stop();
// Multiply by 100 then divide by 100 in the diag print to keep
// 0.01 µs precision in the long-typed sample buffer. Terrain Draw
@@ -8788,7 +8766,7 @@ public sealed class GameWindow : IDisposable
double cpuMedUs = cpuMedHundredthsUs / 100.0;
double cpuP95Us = cpuP95HundredthsUs / 100.0;
Console.WriteLine(
- $"[TERRAIN-DIAG{(_useLegacyTerrain ? "/legacy" : "/modern")}] cpu_us={cpuMedUs:F2}m/{cpuP95Us:F2}p95 " +
+ $"[TERRAIN-DIAG] cpu_us={cpuMedUs:F2}m/{cpuP95Us:F2}p95 " +
$"draws={_terrain?.VisibleSlots ?? 0}/frame " +
$"visible={_terrain?.VisibleSlots ?? 0} " +
$"loaded={_terrain?.LoadedSlots ?? 0} " +
@@ -8843,8 +8821,6 @@ public sealed class GameWindow : IDisposable
_meshShader?.Dispose();
_terrain?.Dispose();
- _terrainLegacy?.Dispose(); // Phase N.5b benchmark toggle (TEMPORARY).
- _shader?.Dispose();
_terrainModernShader?.Dispose();
_sceneLightingUbo?.Dispose();
_particleRenderer?.Dispose();
diff --git a/src/AcDream.App/Rendering/Shaders/terrain.frag b/src/AcDream.App/Rendering/Shaders/terrain.frag
deleted file mode 100644
index 479939d..0000000
--- a/src/AcDream.App/Rendering/Shaders/terrain.frag
+++ /dev/null
@@ -1,149 +0,0 @@
-#version 430 core
-// Per-cell terrain blending (Phase 3c.4) — ported from WorldBuilder's
-// Landscape.frag, trimmed of editor-specific features (grid, brush,
-// walkable-slope highlighting). Phase G extends this with the shared
-// SceneLighting UBO driving per-vertex sun bake + fragment-stage fog
-// + lightning flash.
-
-in vec2 vBaseUV;
-in vec3 vWorldNormal;
-in vec3 vWorldPos;
-in vec3 vLightingRGB;
-in vec4 vOverlay0;
-in vec4 vOverlay1;
-in vec4 vOverlay2;
-in vec4 vRoad0;
-in vec4 vRoad1;
-flat in float vBaseTexIdx;
-
-out vec4 fragColor;
-
-uniform sampler2DArray uTerrain; // 33+ layers — TerrainAtlas.GlTexture
-uniform sampler2DArray uAlpha; // 8+ layers — TerrainAtlas.GlAlphaTexture
-
-// Shared scene-lighting UBO — fog + flash are consumed here; the per-vertex
-// AdjustPlanes bake already incorporated sun + ambient.
-struct Light {
- vec4 posAndKind;
- vec4 dirAndRange;
- vec4 colorAndIntensity;
- vec4 coneAngleEtc;
-};
-layout(std140, binding = 1) uniform SceneLighting {
- Light uLights[8];
- vec4 uCellAmbient;
- vec4 uFogParams;
- vec4 uFogColor;
- vec4 uCameraAndTime;
-};
-
-// Per-texture tiling repeat count across a cell. WorldBuilder uses
-// uTexTiling[36] uploaded from the dats; we default to 1.0 (one tile per
-// cell, 8 tiles across a landblock).
-const float TILE = 1.0;
-
-// Three-layer alpha-weighted composite.
-vec4 maskBlend3(vec4 t0, vec4 t1, vec4 t2, float h0, float h1, float h2) {
- float a0 = h0 == 0.0 ? 1.0 : t0.a;
- float a1 = h1 == 0.0 ? 1.0 : t1.a;
- float a2 = h2 == 0.0 ? 1.0 : t2.a;
- float aR = 1.0 - (a0 * a1 * a2);
- float aRsafe = max(aR, 1e-6);
- a0 = 1.0 - a0;
- a1 = 1.0 - a1;
- a2 = 1.0 - a2;
- vec3 r0 = (a0 * t0.rgb + (1.0 - a0) * a1 * t1.rgb + (1.0 - a1) * a2 * t2.rgb);
- return vec4(r0 / aRsafe, aR);
-}
-
-vec4 combineOverlays(vec2 baseUV, vec4 pOverlay0, vec4 pOverlay1, vec4 pOverlay2) {
- float h0 = pOverlay0.z < 0.0 ? 0.0 : 1.0;
- float h1 = pOverlay1.z < 0.0 ? 0.0 : 1.0;
- float h2 = pOverlay2.z < 0.0 ? 0.0 : 1.0;
- vec4 t0 = vec4(0.0), t1 = vec4(0.0), t2 = vec4(0.0);
-
- if (h0 > 0.0) {
- t0 = texture(uTerrain, vec3(baseUV * TILE, pOverlay0.z));
- if (pOverlay0.w >= 0.0) {
- vec4 a = texture(uAlpha, vec3(pOverlay0.xy, pOverlay0.w));
- t0.a = a.a;
- }
- }
- if (h1 > 0.0) {
- t1 = texture(uTerrain, vec3(baseUV * TILE, pOverlay1.z));
- if (pOverlay1.w >= 0.0) {
- vec4 a = texture(uAlpha, vec3(pOverlay1.xy, pOverlay1.w));
- t1.a = a.a;
- }
- }
- if (h2 > 0.0) {
- t2 = texture(uTerrain, vec3(baseUV * TILE, pOverlay2.z));
- if (pOverlay2.w >= 0.0) {
- vec4 a = texture(uAlpha, vec3(pOverlay2.xy, pOverlay2.w));
- t2.a = a.a;
- }
- }
- return maskBlend3(t0, t1, t2, h0, h1, h2);
-}
-
-vec4 combineRoad(vec2 baseUV, vec4 pRoad0, vec4 pRoad1) {
- float h0 = pRoad0.z < 0.0 ? 0.0 : 1.0;
- float h1 = pRoad1.z < 0.0 ? 0.0 : 1.0;
- vec4 result = vec4(0.0);
- if (h0 > 0.0) {
- result = texture(uTerrain, vec3(baseUV * TILE, pRoad0.z));
- if (pRoad0.w >= 0.0) {
- vec4 a0 = texture(uAlpha, vec3(pRoad0.xy, pRoad0.w));
- result.a = 1.0 - a0.a;
- if (h1 > 0.0 && pRoad1.w >= 0.0) {
- vec4 a1 = texture(uAlpha, vec3(pRoad1.xy, pRoad1.w));
- result.a = 1.0 - (a0.a * a1.a);
- }
- }
- }
- return result;
-}
-
-vec3 applyFog(vec3 lit, vec3 worldPos) {
- int mode = int(uFogParams.w);
- if (mode == 0) return lit;
- float d = length(worldPos - uCameraAndTime.xyz);
- float fogStart = uFogParams.x;
- float fogEnd = uFogParams.y;
- float span = max(1e-3, fogEnd - fogStart);
- float fog = clamp((d - fogStart) / span, 0.0, 1.0);
- return mix(lit, uFogColor.xyz, fog);
-}
-
-void main() {
- vec4 baseColor = vec4(0.0);
- if (vBaseTexIdx >= 0.0) {
- baseColor = texture(uTerrain, vec3(vBaseUV * TILE, vBaseTexIdx));
- }
-
- vec4 overlays = vec4(0.0);
- if (vOverlay0.z >= 0.0)
- overlays = combineOverlays(vBaseUV, vOverlay0, vOverlay1, vOverlay2);
-
- vec4 roads = vec4(0.0);
- if (vRoad0.z >= 0.0)
- roads = combineRoad(vBaseUV, vRoad0, vRoad1);
-
- // Composite: base × (1 - ovlA) × (1 - rdA) + ovl × ovlA × (1 - rdA) + road × rdA
- vec3 baseMasked = baseColor.rgb * ((1.0 - overlays.a) * (1.0 - roads.a));
- vec3 ovlMasked = overlays.rgb * (overlays.a * (1.0 - roads.a));
- vec3 roadMasked = roads.rgb * roads.a;
- vec3 rgb = clamp(baseMasked + ovlMasked + roadMasked, 0.0, 1.0);
-
- // Apply the per-vertex baked sun+ambient.
- vec3 lit = rgb * min(vLightingRGB, vec3(1.0));
-
- // Lightning flash — additive.
- float flash = uFogParams.z;
- lit += flash * vec3(0.6, 0.6, 0.75);
-
- // Atmospheric fog.
- lit = applyFog(lit, vWorldPos);
-
- fragColor = vec4(lit, 1.0);
-}
diff --git a/src/AcDream.App/Rendering/Shaders/terrain.vert b/src/AcDream.App/Rendering/Shaders/terrain.vert
deleted file mode 100644
index 11e691d..0000000
--- a/src/AcDream.App/Rendering/Shaders/terrain.vert
+++ /dev/null
@@ -1,147 +0,0 @@
-#version 430 core
-layout(location = 0) in vec3 aPos;
-layout(location = 1) in vec3 aNormal;
-layout(location = 2) in uvec4 aPacked0; // bytes: baseTex, baseAlpha(255), ovl0Tex, ovl0Alpha
-layout(location = 3) in uvec4 aPacked1; // bytes: ovl1Tex, ovl1Alpha, ovl2Tex, ovl2Alpha
-layout(location = 4) in uvec4 aPacked2; // bytes: road0Tex, road0Alpha, road1Tex, road1Alpha
-layout(location = 5) in uvec4 aPacked3; // bits: rot fields + splitDir (see below)
-
-uniform mat4 uView;
-uniform mat4 uProjection;
-
-// Phase G.1+G.2: sky/scene UBO. Terrain reads uLights[0] for the sun
-// (slot 0 is reserved) plus uCellAmbient for outdoor ambient; the fog
-// fields are consumed by the fragment stage.
-struct Light {
- vec4 posAndKind;
- vec4 dirAndRange;
- vec4 colorAndIntensity;
- vec4 coneAngleEtc;
-};
-layout(std140, binding = 1) uniform SceneLighting {
- Light uLights[8];
- vec4 uCellAmbient;
- vec4 uFogParams;
- vec4 uFogColor;
- vec4 uCameraAndTime;
-};
-
-out vec2 vBaseUV;
-out vec3 vWorldNormal;
-out vec3 vWorldPos;
-out vec3 vLightingRGB; // pre-computed sun+ambient contribution for retail-style AdjustPlanes bake
-// Per-layer "UV.xy in cell-local 0..1 space, tex index .z, alpha index .w".
-// Negative .z means "layer not present, skip it in the fragment shader."
-out vec4 vOverlay0;
-out vec4 vOverlay1;
-out vec4 vOverlay2;
-out vec4 vRoad0;
-out vec4 vRoad1;
-flat out float vBaseTexIdx;
-
-// Retail's N·L floor from FUN_00532440 lines 2119/2138/2157/2176 at
-// chunk_00530000.c (AdjustPlanes). The decompile reads:
-// if (fVar3 < DAT_00796344) fVar3 = DAT_00796344;
-// applied to the clamped Lambert result BEFORE it's multiplied into
-// dirColor. DAT_00796344's exact literal isn't pinned by the decompile
-// but every other "floor" use in retail clamps negatives to zero (the
-// physically-correct Lambert half-space). Our previous 0.08 was a
-// defensive guess from early acdream days that made back-lit terrain
-// visibly brighter than retail (user-observed 2026-04-24 "acdream
-// warmer / less blue than retail"). Reverting to 0.0 matches retail
-// per the decompile and lets ambient fill in the back side.
-// Cross-ref: docs/research/2026-04-24-lambert-brightness-split.md.
-const float MIN_FACTOR = 0.0;
-
-// Port of WorldBuilder's Landscape.vert unpackOverlayLayer: sentinel-check
-// 255 → -1 (shader skips), then rotate the cell-local UV by the overlay's
-// 90° rotation count.
-vec4 unpackOverlayLayer(uint texIdxU, uint alphaIdxU, uint rotIdx, vec2 baseUV) {
- float texIdx = float(texIdxU);
- float alphaIdx = float(alphaIdxU);
- if (texIdx >= 254.0) texIdx = -1.0;
- if (alphaIdx >= 254.0) alphaIdx = -1.0;
-
- vec2 rotatedUV = baseUV;
- if (rotIdx == 1u) rotatedUV = vec2(1.0 - baseUV.y, baseUV.x);
- else if (rotIdx == 2u) rotatedUV = vec2(1.0 - baseUV.x, 1.0 - baseUV.y);
- else if (rotIdx == 3u) rotatedUV = vec2( baseUV.y, 1.0 - baseUV.x);
-
- return vec4(rotatedUV.x, rotatedUV.y, texIdx, alphaIdx);
-}
-
-void main() {
- // Unpack rotation fields from aPacked3. Bit layout (data3):
- // .x (byte 0): bits 0-1 rotBase (unused), 2-3 rotOvl0, 4-5 rotOvl1, 6-7 rotOvl2
- // .y (byte 1): bits 0-1 rotRd0 (= data3 bit 8-9),
- // bits 2-3 rotRd1 (= data3 bit 10-11),
- // bit 4 splitDir (= data3 bit 12)
- uint rotOvl0 = (aPacked3.x >> 2u) & 3u;
- uint rotOvl1 = (aPacked3.x >> 4u) & 3u;
- uint rotOvl2 = (aPacked3.x >> 6u) & 3u;
- uint rotRd0 = aPacked3.y & 3u;
- uint rotRd1 = (aPacked3.y >> 2u) & 3u;
- uint splitDir= (aPacked3.y >> 4u) & 1u;
-
- // Derive which of the 4 cell corners this vertex represents from
- // gl_VertexID % 6. The CPU-side LandblockMesh emits vertices in a
- // specific order for each split direction; the tables below must stay
- // in lockstep with LandblockMesh.Build's SWtoNE/SEtoNW branches.
- // 2026-04-21 fix: geometry re-derived to match ACE's ConstructPolygons
- // convention. SWtoNE (cut BL→TR, y=x diagonal) now maps to the {BL,BR,TR}
- // + {BL,TR,TL} triangle pair; SEtoNW (cut BR→TL, x+y=1 diagonal) maps to
- // {BL,BR,TL} + {BR,TR,TL}.
- int vIdx = gl_VertexID % 6;
- int corner = 0;
- if (splitDir == 0u) {
- // SWtoNE order: BL, BR, TR, BL, TR, TL → corners 0, 1, 2, 0, 2, 3
- if (vIdx == 0) corner = 0;
- else if (vIdx == 1) corner = 1;
- else if (vIdx == 2) corner = 2;
- else if (vIdx == 3) corner = 0;
- else if (vIdx == 4) corner = 2;
- else corner = 3;
- } else {
- // SEtoNW order: BL, BR, TL, BR, TR, TL → corners 0, 1, 3, 1, 2, 3
- if (vIdx == 0) corner = 0;
- else if (vIdx == 1) corner = 1;
- else if (vIdx == 2) corner = 3;
- else if (vIdx == 3) corner = 1;
- else if (vIdx == 4) corner = 2;
- else corner = 3;
- }
-
- vec2 baseUV;
- if (corner == 0) baseUV = vec2(0.0, 1.0);
- else if (corner == 1) baseUV = vec2(1.0, 1.0);
- else if (corner == 2) baseUV = vec2(1.0, 0.0);
- else baseUV = vec2(0.0, 0.0);
-
- vBaseUV = baseUV;
- vWorldPos = aPos;
- vWorldNormal = normalize(aNormal);
-
- // Retail AdjustPlanes bake (r13 §7):
- // L = max(N · -sunDir, MIN_FACTOR)
- // vertex.color = sun_color * L + ambient_color
- //
- // Slot 0 of the UBO is the sun (directional). We read its forward
- // vector and pre-multiplied color, apply the ambient floor, layer
- // in the scene ambient separately.
- vec3 sunDir = uLights[0].dirAndRange.xyz;
- vec3 sunCol = uLights[0].colorAndIntensity.xyz * uLights[0].colorAndIntensity.w;
- float L = max(dot(vWorldNormal, -sunDir), MIN_FACTOR);
- vLightingRGB = sunCol * L + uCellAmbient.xyz;
-
- float baseTex = float(aPacked0.x);
- if (baseTex >= 254.0) baseTex = -1.0;
- vBaseTexIdx = baseTex;
-
- vOverlay0 = unpackOverlayLayer(aPacked0.z, aPacked0.w, rotOvl0, baseUV);
- vOverlay1 = unpackOverlayLayer(aPacked1.x, aPacked1.y, rotOvl1, baseUV);
- vOverlay2 = unpackOverlayLayer(aPacked1.z, aPacked1.w, rotOvl2, baseUV);
- vRoad0 = unpackOverlayLayer(aPacked2.x, aPacked2.y, rotRd0, baseUV);
- vRoad1 = unpackOverlayLayer(aPacked2.z, aPacked2.w, rotRd1, baseUV);
-
- gl_Position = uProjection * uView * vec4(aPos, 1.0);
-}
diff --git a/src/AcDream.App/Rendering/Shaders/terrain_modern.vert b/src/AcDream.App/Rendering/Shaders/terrain_modern.vert
index 2f2f822..473cba5 100644
--- a/src/AcDream.App/Rendering/Shaders/terrain_modern.vert
+++ b/src/AcDream.App/Rendering/Shaders/terrain_modern.vert
@@ -41,6 +41,18 @@ out vec4 vRoad0;
out vec4 vRoad1;
flat out float vBaseTexIdx;
+// Retail's N·L floor from FUN_00532440 lines 2119/2138/2157/2176 at
+// chunk_00530000.c (AdjustPlanes). The decompile reads:
+// if (fVar3 < DAT_00796344) fVar3 = DAT_00796344;
+// applied to the clamped Lambert result BEFORE it's multiplied into
+// dirColor. DAT_00796344's exact literal isn't pinned by the decompile
+// but every other "floor" use in retail clamps negatives to zero (the
+// physically-correct Lambert half-space). Our previous 0.08 was a
+// defensive guess from early acdream days that made back-lit terrain
+// visibly brighter than retail (user-observed 2026-04-24 "acdream
+// warmer / less blue than retail"). Reverting to 0.0 matches retail
+// per the decompile and lets ambient fill in the back side.
+// Cross-ref: docs/research/2026-04-24-lambert-brightness-split.md.
const float MIN_FACTOR = 0.0;
vec4 unpackOverlayLayer(uint texIdxU, uint alphaIdxU, uint rotIdx, vec2 baseUV) {
@@ -58,6 +70,11 @@ vec4 unpackOverlayLayer(uint texIdxU, uint alphaIdxU, uint rotIdx, vec2 baseUV)
}
void main() {
+ // Unpack rotation fields from aPacked3. Bit layout (data3):
+ // .x (byte 0): bits 0-1 rotBase (unused), 2-3 rotOvl0, 4-5 rotOvl1, 6-7 rotOvl2
+ // .y (byte 1): bits 0-1 rotRd0 (= data3 bit 8-9),
+ // bits 2-3 rotRd1 (= data3 bit 10-11),
+ // bit 4 splitDir (= data3 bit 12)
uint rotOvl0 = (aPacked3.x >> 2u) & 3u;
uint rotOvl1 = (aPacked3.x >> 4u) & 3u;
uint rotOvl2 = (aPacked3.x >> 6u) & 3u;
@@ -65,6 +82,14 @@ void main() {
uint rotRd1 = (aPacked3.y >> 2u) & 3u;
uint splitDir= (aPacked3.y >> 4u) & 1u;
+ // Derive which of the 4 cell corners this vertex represents from
+ // gl_VertexID % 6. The CPU-side LandblockMesh emits vertices in a
+ // specific order for each split direction; the tables below must stay
+ // in lockstep with LandblockMesh.Build's SWtoNE/SEtoNW branches.
+ // 2026-04-21 fix: geometry re-derived to match ACE's ConstructPolygons
+ // convention. SWtoNE (cut BL→TR, y=x diagonal) now maps to the {BL,BR,TR}
+ // + {BL,TR,TL} triangle pair; SEtoNW (cut BR→TL, x+y=1 diagonal) maps to
+ // {BL,BR,TL} + {BR,TR,TL}.
int vIdx = gl_VertexID % 6;
int corner = 0;
if (splitDir == 0u) {
diff --git a/src/AcDream.App/Rendering/TerrainChunkRenderer.cs b/src/AcDream.App/Rendering/TerrainChunkRenderer.cs
deleted file mode 100644
index cd2df6a..0000000
--- a/src/AcDream.App/Rendering/TerrainChunkRenderer.cs
+++ /dev/null
@@ -1,454 +0,0 @@
-using System.Numerics;
-using AcDream.Core.Terrain;
-using Silk.NET.OpenGL;
-
-namespace AcDream.App.Rendering;
-
-///
-/// Chunk-based terrain renderer matching ACME's architecture. Each 16x16
-/// landblock region gets its own VAO/VBO/EBO with pre-allocated max-size
-/// buffers. Landblocks are added/removed incrementally via glBufferSubData
-/// instead of rebuilding the entire buffer.
-///
-/// Attribute layout (same as TerrainRenderer, see TerrainVertex):
-/// location 0: vec3 aPos (3 floats, world space)
-/// location 1: vec3 aNormal (3 floats)
-/// location 2: uvec4 aPacked0 (4 bytes, Data0)
-/// location 3: uvec4 aPacked1 (4 bytes, Data1)
-/// location 4: uvec4 aPacked2 (4 bytes, Data2)
-/// location 5: uvec4 aPacked3 (4 bytes, Data3)
-///
-public sealed unsafe class TerrainChunkRenderer : IDisposable
-{
- // -------------------------------------------------------------------------
- // Constants
- // -------------------------------------------------------------------------
-
- /// Number of landblocks per chunk dimension (matching ACME).
- public const int ChunkSizeInLandblocks = 16;
-
- /// Max landblock slots per chunk (16x16 = 256).
- public const int SlotsPerChunk = ChunkSizeInLandblocks * ChunkSizeInLandblocks;
-
- /// Vertices per landblock: 64 cells x 6 verts = 384.
- public const int VerticesPerLandblock = LandblockMesh.VerticesPerLandblock;
-
- /// Indices per landblock (trivial 0..383, same count as vertices).
- public const int IndicesPerLandblock = VerticesPerLandblock;
-
- /// Byte size of one TerrainVertex (40 bytes).
- private static readonly int VertexSize = sizeof(TerrainVertex);
-
- /// Max VBO size per chunk: 256 slots x 384 verts x 40 bytes = ~3.75 MB.
- private static readonly nuint MaxVboBytes =
- (nuint)(SlotsPerChunk * VerticesPerLandblock * VertexSize);
-
- /// Max EBO size per chunk: 256 slots x 384 indices x 4 bytes = ~393 KB.
- private static readonly nuint MaxEboBytes =
- (nuint)(SlotsPerChunk * IndicesPerLandblock * sizeof(uint));
-
- // -------------------------------------------------------------------------
- // Fields
- // -------------------------------------------------------------------------
-
- private readonly GL _gl;
- private readonly Shader _shader;
- private readonly TerrainAtlas _atlas;
-
- /// Active chunks keyed by (chunkX, chunkY) packed into a ulong.
- private readonly Dictionary _chunks = new();
-
- /// Reverse map: landblockId -> chunkId, for fast RemoveLandblock.
- private readonly Dictionary _landblockToChunk = new();
-
- // -------------------------------------------------------------------------
- // Construction
- // -------------------------------------------------------------------------
-
- public TerrainChunkRenderer(GL gl, Shader shader, TerrainAtlas atlas)
- {
- _gl = gl;
- _shader = shader;
- _atlas = atlas;
- }
-
- // -------------------------------------------------------------------------
- // Public API
- // -------------------------------------------------------------------------
-
- ///
- /// Add (or replace) a landblock's terrain mesh. Vertices are baked to world
- /// space using , then uploaded to the correct
- /// chunk buffer slot via glBufferSubData.
- ///
- public void AddLandblock(uint landblockId, LandblockMeshData meshData, Vector3 worldOrigin)
- {
- // If this landblock already exists, remove it first.
- if (_landblockToChunk.ContainsKey(landblockId))
- RemoveLandblock(landblockId);
-
- // Determine chunk coordinates and slot index.
- // Landblock ID format: 0xXXYYnnnn (X at bits 24-31, Y at bits 16-23).
- int lbX = (int)(landblockId >> 24) & 0xFF;
- int lbY = (int)(landblockId >> 16) & 0xFF;
- int chunkX = lbX / ChunkSizeInLandblocks;
- int chunkY = lbY / ChunkSizeInLandblocks;
- ulong chunkId = PackChunkId(chunkX, chunkY);
-
- int localX = lbX % ChunkSizeInLandblocks;
- int localY = lbY % ChunkSizeInLandblocks;
- int slotIndex = localX * ChunkSizeInLandblocks + localY;
-
- // Create chunk on demand.
- if (!_chunks.TryGetValue(chunkId, out var chunk))
- {
- chunk = CreateChunk(chunkX, chunkY);
- _chunks[chunkId] = chunk;
- }
-
- // Bake world-space vertices.
- var worldVerts = new TerrainVertex[meshData.Vertices.Length];
- float zMin = float.MaxValue, zMax = float.MinValue;
- for (int i = 0; i < meshData.Vertices.Length; i++)
- {
- var v = meshData.Vertices[i];
- var worldPos = v.Position + worldOrigin;
- worldVerts[i] = new TerrainVertex(worldPos, v.Normal, v.Data0, v.Data1, v.Data2, v.Data3);
- if (worldPos.Z < zMin) zMin = worldPos.Z;
- if (worldPos.Z > zMax) zMax = worldPos.Z;
- }
- if (zMin == float.MaxValue) { zMin = 0f; zMax = 0f; }
-
- // Upload vertices into the slot's region of the VBO.
- nint vboOffset = (nint)(slotIndex * VerticesPerLandblock * VertexSize);
- _gl.BindBuffer(BufferTargetARB.ArrayBuffer, chunk.Vbo);
- fixed (void* p = worldVerts)
- {
- _gl.BufferSubData(BufferTargetARB.ArrayBuffer, vboOffset,
- (nuint)(worldVerts.Length * VertexSize), p);
- }
- _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
-
- // Track the slot.
- chunk.Slots[slotIndex] = new LandblockSlot
- {
- LandblockId = landblockId,
- WorldOrigin = worldOrigin,
- MinZ = zMin,
- MaxZ = zMax,
- };
- chunk.Occupied.Add(slotIndex);
- _landblockToChunk[landblockId] = chunkId;
-
- // Rebuild the EBO for this chunk (only includes occupied slots).
- RebuildChunkEbo(chunk);
-
- // Update chunk AABB.
- UpdateChunkBounds(chunk);
- }
-
- ///
- /// Remove a landblock from its chunk. If the chunk becomes empty, dispose it.
- ///
- public void RemoveLandblock(uint landblockId)
- {
- if (!_landblockToChunk.TryGetValue(landblockId, out var chunkId))
- return;
-
- _landblockToChunk.Remove(landblockId);
-
- if (!_chunks.TryGetValue(chunkId, out var chunk))
- return;
-
- // Find which slot this landblock occupies.
- int slotIndex = -1;
- foreach (var s in chunk.Occupied)
- {
- if (chunk.Slots[s].LandblockId == landblockId)
- {
- slotIndex = s;
- break;
- }
- }
- if (slotIndex < 0)
- return;
-
- // Zero out the VBO region for this slot (optional but clean).
- nint vboOffset = (nint)(slotIndex * VerticesPerLandblock * VertexSize);
- nuint vboSize = (nuint)(VerticesPerLandblock * VertexSize);
- var zeros = new byte[VerticesPerLandblock * VertexSize];
- _gl.BindBuffer(BufferTargetARB.ArrayBuffer, chunk.Vbo);
- fixed (void* p = zeros)
- {
- _gl.BufferSubData(BufferTargetARB.ArrayBuffer, vboOffset, vboSize, p);
- }
- _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
-
- chunk.Slots[slotIndex] = default;
- chunk.Occupied.Remove(slotIndex);
-
- if (chunk.Occupied.Count == 0)
- {
- // Chunk is empty -- dispose GPU resources.
- chunk.Dispose(_gl);
- _chunks.Remove(chunkId);
- }
- else
- {
- RebuildChunkEbo(chunk);
- UpdateChunkBounds(chunk);
- }
- }
-
- ///
- /// Draw all visible terrain chunks. One glDrawElements per non-empty chunk.
- /// Frustum culling is performed at the chunk AABB level.
- ///
- public void Draw(ICamera camera, FrustumPlanes? frustum = null, uint? neverCullLandblockId = null)
- {
- if (_chunks.Count == 0)
- return;
-
- // Determine which chunk the never-cull landblock lives in.
- ulong? neverCullChunkId = null;
- if (neverCullLandblockId is not null && _landblockToChunk.TryGetValue(neverCullLandblockId.Value, out var ncId))
- neverCullChunkId = ncId;
-
- _shader.Use();
- _shader.SetMatrix4("uView", camera.View);
- _shader.SetMatrix4("uProjection", camera.Projection);
-
- // Phase G: light direction + ambient + fog come from the shared
- // SceneLighting UBO (binding=1) uploaded by GameWindow once per
- // frame. Terrain bakes per-vertex AdjustPlanes lighting (r13 §7)
- // from the UBO's slot-0 sun + uCellAmbient, then the fragment
- // stage adds fog + lightning flash. No per-program uniforms here.
-
- // Terrain atlas on unit 0, alpha atlas on unit 1.
- _gl.ActiveTexture(TextureUnit.Texture0);
- _gl.BindTexture(TextureTarget.Texture2DArray, _atlas.GlTexture);
- _gl.ActiveTexture(TextureUnit.Texture1);
- _gl.BindTexture(TextureTarget.Texture2DArray, _atlas.GlAlphaTexture);
-
- int terrainLoc = _gl.GetUniformLocation(_shader.Program, "uTerrain");
- if (terrainLoc >= 0) _gl.Uniform1(terrainLoc, 0);
- int alphaLoc = _gl.GetUniformLocation(_shader.Program, "uAlpha");
- if (alphaLoc >= 0) _gl.Uniform1(alphaLoc, 1);
-
- foreach (var (chunkId, chunk) in _chunks)
- {
- if (chunk.IndexCount == 0)
- continue;
-
- // Chunk-level frustum cull.
- if (frustum is not null && chunkId != neverCullChunkId)
- {
- if (!FrustumCuller.IsAabbVisible(frustum.Value, chunk.AabbMin, chunk.AabbMax))
- continue;
- }
-
- _gl.BindVertexArray(chunk.Vao);
- _gl.DrawElements(
- PrimitiveType.Triangles,
- (uint)chunk.IndexCount,
- DrawElementsType.UnsignedInt,
- (void*)0);
- }
-
- _gl.BindVertexArray(0);
- }
-
- public void Dispose()
- {
- foreach (var chunk in _chunks.Values)
- chunk.Dispose(_gl);
-
- _chunks.Clear();
- _landblockToChunk.Clear();
- }
-
- // -------------------------------------------------------------------------
- // Private helpers
- // -------------------------------------------------------------------------
-
- private static ulong PackChunkId(int chunkX, int chunkY)
- => ((ulong)(uint)chunkX << 32) | (uint)chunkY;
-
- ///
- /// Allocate a new chunk with max-size VBO and empty EBO, plus a configured VAO.
- ///
- private ChunkData CreateChunk(int chunkX, int chunkY)
- {
- var chunk = new ChunkData
- {
- ChunkX = chunkX,
- ChunkY = chunkY,
- Vao = _gl.GenVertexArray(),
- Vbo = _gl.GenBuffer(),
- Ebo = _gl.GenBuffer(),
- };
-
- // Pre-allocate VBO to max size with DynamicDraw.
- _gl.BindBuffer(BufferTargetARB.ArrayBuffer, chunk.Vbo);
- _gl.BufferData(BufferTargetARB.ArrayBuffer, MaxVboBytes, null, BufferUsageARB.DynamicDraw);
- _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
-
- // Pre-allocate EBO (empty initially, will be rebuilt on first AddLandblock).
- _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, chunk.Ebo);
- _gl.BufferData(BufferTargetARB.ElementArrayBuffer, MaxEboBytes, null, BufferUsageARB.DynamicDraw);
- _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0);
-
- // Configure VAO with the same attribute layout as the old TerrainRenderer.
- ConfigureVao(chunk);
-
- return chunk;
- }
-
- ///
- /// Set up vertex attribute pointers on the chunk's VAO. Identical layout
- /// to the old TerrainRenderer.
- ///
- private void ConfigureVao(ChunkData chunk)
- {
- _gl.BindVertexArray(chunk.Vao);
- _gl.BindBuffer(BufferTargetARB.ArrayBuffer, chunk.Vbo);
- _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, chunk.Ebo);
-
- uint stride = (uint)VertexSize;
-
- // location 0: Position (12 bytes)
- _gl.EnableVertexAttribArray(0);
- _gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
- // location 1: Normal (12 bytes, offset 12)
- _gl.EnableVertexAttribArray(1);
- _gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
-
- // location 2..5: Data0..Data3 as uvec4 byte attributes (4 bytes each, offsets 24, 28, 32, 36).
- nint dataOffset = 6 * sizeof(float); // 24 bytes
- _gl.EnableVertexAttribArray(2);
- _gl.VertexAttribIPointer(2, 4, VertexAttribIType.UnsignedByte, stride, (void*)dataOffset);
- _gl.EnableVertexAttribArray(3);
- _gl.VertexAttribIPointer(3, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 4));
- _gl.EnableVertexAttribArray(4);
- _gl.VertexAttribIPointer(4, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 8));
- _gl.EnableVertexAttribArray(5);
- _gl.VertexAttribIPointer(5, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 12));
-
- _gl.BindVertexArray(0);
- }
-
- ///
- /// Rebuild the EBO for a chunk, emitting rebased indices only for occupied
- /// slots. Each slot's indices are offset by (slotIndex * VerticesPerLandblock)
- /// so they point to the correct region of the VBO.
- ///
- private void RebuildChunkEbo(ChunkData chunk)
- {
- int totalIndices = chunk.Occupied.Count * IndicesPerLandblock;
- var indices = new uint[totalIndices];
-
- int writePos = 0;
- foreach (var slotIndex in chunk.Occupied)
- {
- uint vertexBase = (uint)(slotIndex * VerticesPerLandblock);
- for (uint i = 0; i < IndicesPerLandblock; i++)
- indices[writePos++] = vertexBase + i;
- }
-
- _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, chunk.Ebo);
- fixed (void* p = indices)
- {
- _gl.BufferSubData(BufferTargetARB.ElementArrayBuffer, 0,
- (nuint)(totalIndices * sizeof(uint)), p);
- }
- _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0);
-
- chunk.IndexCount = totalIndices;
- }
-
- ///
- /// Recompute the chunk's world-space AABB from all occupied landblock slots.
- ///
- private static void UpdateChunkBounds(ChunkData chunk)
- {
- float minX = float.MaxValue, minY = float.MaxValue, minZ = float.MaxValue;
- float maxX = float.MinValue, maxY = float.MinValue, maxZ = float.MinValue;
-
- foreach (var slotIndex in chunk.Occupied)
- {
- var slot = chunk.Slots[slotIndex];
- float ox = slot.WorldOrigin.X;
- float oy = slot.WorldOrigin.Y;
-
- if (ox < minX) minX = ox;
- if (oy < minY) minY = oy;
- if (slot.MinZ < minZ) minZ = slot.MinZ;
-
- float ex = ox + LandblockMesh.LandblockSize;
- float ey = oy + LandblockMesh.LandblockSize;
- if (ex > maxX) maxX = ex;
- if (ey > maxY) maxY = ey;
- if (slot.MaxZ > maxZ) maxZ = slot.MaxZ;
- }
-
- if (minX == float.MaxValue)
- {
- chunk.AabbMin = Vector3.Zero;
- chunk.AabbMax = Vector3.Zero;
- }
- else
- {
- chunk.AabbMin = new Vector3(minX, minY, minZ);
- chunk.AabbMax = new Vector3(maxX, maxY, maxZ);
- }
- }
-
- // -------------------------------------------------------------------------
- // Inner types
- // -------------------------------------------------------------------------
-
- ///
- /// Per-landblock slot tracking within a chunk's VBO.
- ///
- private struct LandblockSlot
- {
- public uint LandblockId;
- public Vector3 WorldOrigin;
- public float MinZ;
- public float MaxZ;
- }
-
- ///
- /// GPU resources and metadata for a single 16x16 terrain chunk.
- ///
- private sealed class ChunkData
- {
- public int ChunkX;
- public int ChunkY;
-
- // GPU handles.
- public uint Vao;
- public uint Vbo;
- public uint Ebo;
-
- /// Per-slot landblock data. Indexed by (localX * 16 + localY).
- public readonly LandblockSlot[] Slots = new LandblockSlot[SlotsPerChunk];
-
- /// Set of occupied slot indices within this chunk.
- public readonly HashSet Occupied = new();
-
- /// Current number of valid indices in the EBO (set by RebuildChunkEbo).
- public int IndexCount;
-
- /// World-space AABB for chunk-level frustum culling.
- public Vector3 AabbMin;
- public Vector3 AabbMax;
-
- public void Dispose(GL gl)
- {
- gl.DeleteVertexArray(Vao);
- gl.DeleteBuffer(Vbo);
- gl.DeleteBuffer(Ebo);
- }
- }
-}
diff --git a/src/AcDream.App/Rendering/TerrainRenderer.cs b/src/AcDream.App/Rendering/TerrainRenderer.cs
deleted file mode 100644
index 15bee67..0000000
--- a/src/AcDream.App/Rendering/TerrainRenderer.cs
+++ /dev/null
@@ -1,247 +0,0 @@
-using System.Numerics;
-using AcDream.Core.Terrain;
-using Silk.NET.OpenGL;
-
-namespace AcDream.App.Rendering;
-
-///
-/// Draws the Phase 3c per-cell terrain mesh. All loaded landblocks share a
-/// single VBO + EBO + VAO. Vertex positions are baked in world space so no
-/// uModel uniform is needed. The VAO is bound once per frame; each visible
-/// landblock gets one glDrawElements call into its sub-range of the shared EBO.
-///
-/// Attribute layout (see TerrainVertex for the byte layout):
-/// location 0: vec3 aPos (3 floats, world space)
-/// location 1: vec3 aNormal (3 floats)
-/// location 2: uvec4 aPacked0 (4 bytes, Data0)
-/// location 3: uvec4 aPacked1 (4 bytes, Data1)
-/// location 4: uvec4 aPacked2 (4 bytes, Data2)
-/// location 5: uvec4 aPacked3 (4 bytes, Data3)
-///
-public sealed unsafe class TerrainRenderer : IDisposable
-{
- private readonly GL _gl;
- private readonly Shader _shader;
- private readonly TerrainAtlas _atlas;
-
- // Logical per-landblock data (CPU side).
- private readonly Dictionary _entries = new();
-
- // Shared GPU buffers — rebuilt whenever a landblock is added or removed.
- private uint _vao;
- private uint _vbo;
- private uint _ebo;
- private bool _gpuDirty = true; // true = buffers need rebuilding before next Draw
-
- public TerrainRenderer(GL gl, Shader shader, TerrainAtlas atlas)
- {
- _gl = gl;
- _shader = shader;
- _atlas = atlas;
-
- _vao = _gl.GenVertexArray();
- _vbo = _gl.GenBuffer();
- _ebo = _gl.GenBuffer();
- ConfigureVao();
- }
-
- public void AddLandblock(uint landblockId, LandblockMeshData meshData, Vector3 worldOrigin)
- {
- if (_entries.ContainsKey(landblockId))
- _entries.Remove(landblockId);
-
- // Bake world-space positions: offset every vertex by worldOrigin.
- var worldVerts = new TerrainVertex[meshData.Vertices.Length];
- float zMin = float.MaxValue, zMax = float.MinValue;
- for (int i = 0; i < meshData.Vertices.Length; i++)
- {
- var v = meshData.Vertices[i];
- var worldPos = v.Position + worldOrigin;
- worldVerts[i] = new TerrainVertex(worldPos, v.Normal, v.Data0, v.Data1, v.Data2, v.Data3);
- if (worldPos.Z < zMin) zMin = worldPos.Z;
- if (worldPos.Z > zMax) zMax = worldPos.Z;
- }
- if (zMin == float.MaxValue) { zMin = 0f; zMax = 0f; }
-
- _entries[landblockId] = new LandblockEntry
- {
- LandblockId = landblockId,
- WorldOrigin = worldOrigin,
- Vertices = worldVerts,
- Indices = meshData.Indices, // local 0..N-1; will be rebased on rebuild
- MinZ = zMin,
- MaxZ = zMax,
- };
-
- _gpuDirty = true;
- }
-
- public void RemoveLandblock(uint landblockId)
- {
- if (_entries.Remove(landblockId))
- _gpuDirty = true;
- }
-
- public void Draw(ICamera camera, FrustumPlanes? frustum = null, uint? neverCullLandblockId = null)
- {
- if (_entries.Count == 0)
- return;
-
- if (_gpuDirty)
- RebuildGpuBuffers();
-
- _shader.Use();
- _shader.SetMatrix4("uView", camera.View);
- _shader.SetMatrix4("uProjection", camera.Projection);
-
- // Terrain atlas on unit 0, alpha atlas on unit 1.
- _gl.ActiveTexture(TextureUnit.Texture0);
- _gl.BindTexture(TextureTarget.Texture2DArray, _atlas.GlTexture);
- _gl.ActiveTexture(TextureUnit.Texture1);
- _gl.BindTexture(TextureTarget.Texture2DArray, _atlas.GlAlphaTexture);
-
- int terrainLoc = _gl.GetUniformLocation(_shader.Program, "uTerrain");
- if (terrainLoc >= 0) _gl.Uniform1(terrainLoc, 0);
- int alphaLoc = _gl.GetUniformLocation(_shader.Program, "uAlpha");
- if (alphaLoc >= 0) _gl.Uniform1(alphaLoc, 1);
-
- // Bind the shared VAO once for the entire frame.
- _gl.BindVertexArray(_vao);
-
- foreach (var entry in _entries.Values)
- {
- // Per-landblock frustum cull using world-space AABB.
- if (frustum is not null && entry.LandblockId != neverCullLandblockId)
- {
- var aabbMin = new Vector3(entry.WorldOrigin.X, entry.WorldOrigin.Y, entry.MinZ);
- var aabbMax = new Vector3(entry.WorldOrigin.X + 192f, entry.WorldOrigin.Y + 192f, entry.MaxZ);
- if (!FrustumCuller.IsAabbVisible(frustum.Value, aabbMin, aabbMax))
- continue;
- }
-
- // Draw only this landblock's sub-range in the shared EBO.
- // EboOffset is in bytes (uint = 4 bytes).
- _gl.DrawElements(
- PrimitiveType.Triangles,
- (uint)entry.IndexCount,
- DrawElementsType.UnsignedInt,
- (void*)(entry.EboByteOffset));
- }
-
- _gl.BindVertexArray(0);
- }
-
- public void Dispose()
- {
- _gl.DeleteVertexArray(_vao);
- _gl.DeleteBuffer(_vbo);
- _gl.DeleteBuffer(_ebo);
- _entries.Clear();
- }
-
- // -------------------------------------------------------------------------
- // Private helpers
- // -------------------------------------------------------------------------
-
- private void ConfigureVao()
- {
- _gl.BindVertexArray(_vao);
- _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
- _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _ebo);
-
- uint stride = (uint)sizeof(TerrainVertex);
-
- // location 0: Position (12 bytes)
- _gl.EnableVertexAttribArray(0);
- _gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
- // location 1: Normal (12 bytes, offset 12)
- _gl.EnableVertexAttribArray(1);
- _gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
-
- // location 2..5: Data0..Data3 as uvec4 byte attributes (4 bytes each,
- // offsets 24, 28, 32, 36).
- nint dataOffset = 6 * sizeof(float); // 24 bytes
- _gl.EnableVertexAttribArray(2);
- _gl.VertexAttribIPointer(2, 4, VertexAttribIType.UnsignedByte, stride, (void*)dataOffset);
- _gl.EnableVertexAttribArray(3);
- _gl.VertexAttribIPointer(3, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 4));
- _gl.EnableVertexAttribArray(4);
- _gl.VertexAttribIPointer(4, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 8));
- _gl.EnableVertexAttribArray(5);
- _gl.VertexAttribIPointer(5, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 12));
-
- _gl.BindVertexArray(0);
- }
-
- ///
- /// Concatenate all loaded landblocks into a single VBO + EBO and upload.
- /// Called on the cold path (landblock load / unload), not per frame.
- ///
- private void RebuildGpuBuffers()
- {
- // Measure totals.
- int totalVerts = 0;
- int totalIndices = 0;
- foreach (var e in _entries.Values)
- {
- totalVerts += e.Vertices.Length;
- totalIndices += e.Indices.Length;
- }
-
- var allVerts = new TerrainVertex[totalVerts];
- var allIndices = new uint[totalIndices];
-
- int vertBase = 0;
- int indexBase = 0;
-
- foreach (var entry in _entries.Values)
- {
- // Copy world-space vertices.
- entry.Vertices.CopyTo(allVerts, vertBase);
-
- // Rebase local indices (0..N-1) → absolute (vertBase..vertBase+N-1).
- for (int i = 0; i < entry.Indices.Length; i++)
- allIndices[indexBase + i] = (uint)(vertBase + entry.Indices[i]);
-
- // Record where this landblock's indices live in the EBO (byte offset).
- entry.EboByteOffset = (nint)(indexBase * sizeof(uint));
- entry.IndexCount = entry.Indices.Length;
-
- vertBase += entry.Vertices.Length;
- indexBase += entry.Indices.Length;
- }
-
- // Upload to GPU.
- _gl.BindVertexArray(_vao);
-
- _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
- fixed (void* p = allVerts)
- _gl.BufferData(BufferTargetARB.ArrayBuffer,
- (nuint)(totalVerts * sizeof(TerrainVertex)), p, BufferUsageARB.DynamicDraw);
-
- _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _ebo);
- fixed (void* p = allIndices)
- _gl.BufferData(BufferTargetARB.ElementArrayBuffer,
- (nuint)(totalIndices * sizeof(uint)), p, BufferUsageARB.DynamicDraw);
-
- _gl.BindVertexArray(0);
- _gpuDirty = false;
- }
-
- // -------------------------------------------------------------------------
- // Data types
- // -------------------------------------------------------------------------
-
- private sealed class LandblockEntry
- {
- public uint LandblockId;
- public Vector3 WorldOrigin;
- public TerrainVertex[] Vertices = Array.Empty();
- public uint[] Indices = Array.Empty();
- public float MinZ;
- public float MaxZ;
- // Set by RebuildGpuBuffers:
- public nint EboByteOffset;
- public int IndexCount;
- }
-}