diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 273f4d4..f8edcaa 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -18,8 +18,13 @@ public sealed class GameWindow : IDisposable
private IWindow? _window;
private GL? _gl;
private IInputContext? _input;
- private TerrainChunkRenderer? _terrain;
+ private TerrainModernRenderer? _terrain;
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.
+ private Shader? _terrainModernShader;
private CameraController? _cameraController;
private IMouse? _capturedMouse;
private DatCollection? _dats;
@@ -68,6 +73,15 @@ public sealed class GameWindow : IDisposable
private string _lastNearestObjLabel = "-";
private bool _lastColliding;
+ // Phase N.5b: CPU timing for [TERRAIN-DIAG] under ACDREAM_WB_DIAG=1
+ // (parallel diagnostic to [WB-DIAG] in WbDrawDispatcher — same env var
+ // gate so flipping one switch turns on both dispatcher rollups). Mirrors
+ // the rolling-256-sample buffer pattern from WbDrawDispatcher.
+ private readonly System.Diagnostics.Stopwatch _terrainCpuStopwatch = new();
+ private readonly long[] _terrainCpuSamples = new long[256]; // microseconds
+ private int _terrainCpuSampleCursor;
+ private long _terrainLastDiagTick;
+
// Phase A.1: streaming fields replacing the one-shot _entities list.
private AcDream.App.Streaming.LandblockStreamer? _streamer;
private AcDream.App.Streaming.GpuWorldState _worldState = new();
@@ -969,6 +983,13 @@ public sealed class GameWindow : IDisposable
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.
+ _terrainModernShader = new Shader(_gl,
+ Path.Combine(shadersDir, "terrain_modern.vert"),
+ Path.Combine(shadersDir, "terrain_modern.frag"));
+
// Phase G.1/G.2: shared scene-lighting UBO. Stays bound at
// binding=1 for the lifetime of the process — every shader that
// declares `layout(std140, binding = 1) uniform SceneLighting`
@@ -1385,10 +1406,44 @@ public sealed class GameWindow : IDisposable
// TimeSync arrives.
WorldTime.SyncFromServer(AcDream.Core.World.DerethDateTime.DayTicks / 16.0); // = 476.25 = Midsong (noon)
- // Build the terrain atlas once from the Region dat.
- var terrainAtlas = AcDream.App.Rendering.TerrainAtlas.Build(_gl, _dats);
+ // N.5: detect ARB_bindless_texture + ARB_shader_draw_parameters BEFORE
+ // building the terrain atlas / renderer — both consume BindlessSupport
+ // (atlas via Texture2DArray bindless handles, renderer for SSBO uploads).
+ // The modern path (SSBO + glMultiDrawElementsIndirect + bindless textures)
+ // is mandatory as of Phase N.5 — missing extensions throw at startup with
+ // a clear error so users can file a real bug report rather than silently
+ // falling back to a half-working renderer.
+ if (AcDream.App.Rendering.Wb.BindlessSupport.TryCreate(_gl, out var bindless))
+ {
+ if (bindless!.HasShaderDrawParameters(_gl))
+ {
+ _bindlessSupport = bindless;
+ Console.WriteLine("[N.5] modern path capabilities present (bindless + ARB_shader_draw_parameters)");
+ }
+ else
+ {
+ Console.WriteLine("[N.5] GL_ARB_shader_draw_parameters not present — modern path not available");
+ }
+ }
+ else
+ {
+ Console.WriteLine("[N.5] GL_ARB_bindless_texture not present — modern path not available");
+ }
- _terrain = new TerrainChunkRenderer(_gl, _shader, terrainAtlas);
+ if (_bindlessSupport is null)
+ {
+ throw new NotSupportedException(
+ "acdream requires GL_ARB_bindless_texture + GL_ARB_shader_draw_parameters " +
+ "(GL 4.3+ with bindless support). Your GPU/driver does not expose these extensions. " +
+ "If this is unexpected, please file a bug report with your GPU vendor + driver version.");
+ }
+
+ // Build the terrain atlas once from the Region dat. Phase N.5b: the
+ // atlas exposes bindless handles for the modern terrain path, so
+ // BindlessSupport is threaded through.
+ var terrainAtlas = AcDream.App.Rendering.TerrainAtlas.Build(_gl, _dats, _bindlessSupport);
+
+ _terrain = new TerrainModernRenderer(_gl, _bindlessSupport, _terrainModernShader!, terrainAtlas);
int centerX = (int)((centerLandblockId >> 24) & 0xFFu);
int centerY = (int)((centerLandblockId >> 16) & 0xFFu);
@@ -1418,35 +1473,8 @@ public sealed class GameWindow : IDisposable
_heightTable = heightTable;
_surfaceCache = new Dictionary();
- // N.5: detect ARB_bindless_texture + ARB_shader_draw_parameters.
- // The modern path (SSBO + glMultiDrawElementsIndirect + bindless textures)
- // is mandatory as of Phase N.5 — missing extensions throw at startup with
- // a clear error so users can file a real bug report rather than silently
- // falling back to a half-working renderer.
- if (AcDream.App.Rendering.Wb.BindlessSupport.TryCreate(_gl, out var bindless))
- {
- if (bindless!.HasShaderDrawParameters(_gl))
- {
- _bindlessSupport = bindless;
- Console.WriteLine("[N.5] modern path capabilities present (bindless + ARB_shader_draw_parameters)");
- }
- else
- {
- Console.WriteLine("[N.5] GL_ARB_shader_draw_parameters not present — modern path not available");
- }
- }
- else
- {
- Console.WriteLine("[N.5] GL_ARB_bindless_texture not present — modern path not available");
- }
-
- if (_bindlessSupport is null)
- {
- throw new NotSupportedException(
- "acdream requires GL_ARB_bindless_texture + GL_ARB_shader_draw_parameters " +
- "(GL 4.3+ with bindless support). Your GPU/driver does not expose these extensions. " +
- "If this is unexpected, please file a bug report with your GPU vendor + driver version.");
- }
+ // (Bindless detection moved above — must precede TerrainAtlas.Build /
+ // TerrainModernRenderer ctor so they can consume BindlessSupport.)
// Mesh shader always loads (modern path is the only path).
_meshShader = new Shader(_gl,
@@ -6314,7 +6342,15 @@ public sealed class GameWindow : IDisposable
goto SkipWorldGeometry;
}
+ // Phase N.5b: wrap Draw in CPU stopwatch for [TERRAIN-DIAG] rollup
+ // (gated on ACDREAM_WB_DIAG=1, same env var as [WB-DIAG]). Stopwatch
+ // is cheap; only the periodic Console.WriteLine is gated.
+ _terrainCpuStopwatch.Restart();
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
+ _terrainCpuStopwatch.Stop();
+ _terrainCpuSamples[_terrainCpuSampleCursor] = (long)(_terrainCpuStopwatch.Elapsed.TotalMicroseconds);
+ _terrainCpuSampleCursor = (_terrainCpuSampleCursor + 1) % _terrainCpuSamples.Length;
+ MaybeFlushTerrainDiag();
// Conditional depth clear: when camera is inside a building, clear
// depth (not color) so interior geometry writes fresh Z values on top
@@ -8713,6 +8749,51 @@ public sealed class GameWindow : IDisposable
}
}
+ /// Phase N.5b: emits [TERRAIN-DIAG] once per ~5s under
+ /// ACDREAM_WB_DIAG=1. Mirrors WbDrawDispatcher.MaybeFlushDiag:
+ /// rolling 256-sample buffer of microseconds, median + p95 reported.
+ /// Sample buffer is NOT cleared on flush — it's a moving window so the
+ /// next 5s window already has 256 frames of recent history.
+ private void MaybeFlushTerrainDiag()
+ {
+ if (!string.Equals(Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", StringComparison.Ordinal))
+ return;
+
+ long now = Environment.TickCount64;
+ if (now - _terrainLastDiagTick <= 5000) return;
+
+ long cpuMedUs = TerrainDiagMedianMicros(_terrainCpuSamples);
+ long cpuP95Us = TerrainDiagPercentile95Micros(_terrainCpuSamples);
+ Console.WriteLine(
+ $"[TERRAIN-DIAG] cpu_ms={cpuMedUs / 1000.0:F2}/{cpuP95Us / 1000.0:F2} " +
+ $"draws={_terrain?.VisibleSlots ?? 0}/frame " +
+ $"visible={_terrain?.VisibleSlots ?? 0} " +
+ $"loaded={_terrain?.LoadedSlots ?? 0} " +
+ $"capacity={_terrain?.CapacitySlots ?? 0}");
+ _terrainLastDiagTick = now;
+ }
+
+ private static long TerrainDiagMedianMicros(long[] samples)
+ {
+ var copy = (long[])samples.Clone();
+ Array.Sort(copy);
+ int nz = 0;
+ foreach (var v in copy) if (v > 0) nz++;
+ if (nz == 0) return 0;
+ return copy[copy.Length - nz / 2];
+ }
+
+ private static long TerrainDiagPercentile95Micros(long[] samples)
+ {
+ var copy = (long[])samples.Clone();
+ Array.Sort(copy);
+ int nz = 0;
+ foreach (var v in copy) if (v > 0) nz++;
+ if (nz == 0) return 0;
+ int idx = copy.Length - 1 - (int)(nz * 0.05);
+ return copy[idx];
+ }
+
private void OnClosing()
{
// Phase A.1: join the streamer worker thread before tearing down GL
@@ -8733,6 +8814,7 @@ public sealed class GameWindow : IDisposable
_meshShader?.Dispose();
_terrain?.Dispose();
_shader?.Dispose();
+ _terrainModernShader?.Dispose();
_sceneLightingUbo?.Dispose();
_particleRenderer?.Dispose();
_debugLines?.Dispose();