phase(N.5): retirement amendment — InstancedMeshRenderer + StaticMeshRenderer + WbFoundationFlag deleted
Final cross-cutting review of N.5 found that Task 15's deletion of mesh_instanced.vert/.frag left InstancedMeshRenderer orphaned — ACDREAM_USE_WB_FOUNDATION=0 silently rendered terrain+sky only with no entities. The SHIP commit's "[x] ACDREAM_USE_WB_FOUNDATION=0 still works" claim was inaccurate. Resolution: formal retirement of the legacy renderer path within N.5 instead of deferring to N.6. Deleted: - src/AcDream.App/Rendering/InstancedMeshRenderer.cs - src/AcDream.App/Rendering/StaticMeshRenderer.cs - src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs GameWindow simplified — capability detection is unconditional, missing bindless throws NotSupportedException with a clear message at startup. WbDrawDispatcher + mesh_modern shader load are mandatory after init. No escape hatch. GpuWorldState simplified — WbFoundationFlag.IsEnabled guards on AddLandblock/RemoveLandblock removed; adapter calls are unconditional when the adapter is non-null. PendingSpawnIntegrationTests updated — WbFoundationFlag.ForTestsOnly_ForceEnable static ctor removed (flag is gone; adapter calls are unconditional). The ApplyLoadedTerrain physics-data loop was also simplified: the EnsureUploaded sub-loop that fed InstancedMeshRenderer is gone; _pendingCellMeshes is now explicitly cleared to prevent unbounded accumulation (the worker thread still populates it, but WB handles EnvCell geometry through its own pipeline). Spec §2 Decision 5 + §10 Out-of-Scope updated. Plan ship-amendment section added. Roadmap updated (N.5 ships with retirement; N.6 scope narrowed to perf-only). CLAUDE.md "WB integration cribs" updated. Perf baseline doc updated. WbDrawDispatcher class summary docstring corrected to describe the as-shipped SSBO + multi-draw-indirect path. ISSUES.md #51 updated (terrain not in N.5 scope; deferred to N.7). Bindless support is now a hard requirement. Modern desktop GPUs universally expose GL_ARB_bindless_texture + GL_ARB_shader_draw_parameters; if a user hits the NotSupportedException, that's a real bug report worth investigating, not a silent fallback. Build: 0 errors, 0 warnings. Tests: 71/71 (Wb+MatrixComposition+TextureCacheBindless filter). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
55ecec683f
commit
dcae2b6b94
13 changed files with 211 additions and 1140 deletions
|
|
@ -25,17 +25,16 @@ public sealed class GameWindow : IDisposable
|
|||
private DatCollection? _dats;
|
||||
private float _lastMouseX;
|
||||
private float _lastMouseY;
|
||||
private InstancedMeshRenderer? _staticMesh;
|
||||
private Shader? _meshShader;
|
||||
private TextureCache? _textureCache;
|
||||
/// <summary>Phase N.4: WB-backed rendering pipeline adapter. Non-null only
|
||||
/// when <c>ACDREAM_USE_WB_FOUNDATION=1</c> is set; null otherwise.</summary>
|
||||
/// <summary>Phase N.4+: WB-backed rendering pipeline adapter. Always non-null
|
||||
/// after <c>OnLoad</c> completes (modern path is mandatory as of N.5).</summary>
|
||||
private AcDream.App.Rendering.Wb.WbMeshAdapter? _wbMeshAdapter;
|
||||
private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter;
|
||||
private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher;
|
||||
/// <summary>Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters
|
||||
/// support. Non-null only when both extensions are present and WbFoundation
|
||||
/// is enabled. Passed to TextureCache and (later) WbDrawDispatcher.</summary>
|
||||
/// support. Required at startup — missing bindless throws
|
||||
/// <see cref="NotSupportedException"/> in <c>OnLoad</c>.</summary>
|
||||
private AcDream.App.Rendering.Wb.BindlessSupport? _bindlessSupport;
|
||||
private SamplerCache? _samplerCache;
|
||||
private DebugLineRenderer? _debugLines;
|
||||
|
|
@ -970,10 +969,6 @@ public sealed class GameWindow : IDisposable
|
|||
Path.Combine(shadersDir, "terrain.vert"),
|
||||
Path.Combine(shadersDir, "terrain.frag"));
|
||||
|
||||
// mesh_instanced is the default; Task 10 (N.5) moves the final shader
|
||||
// selection to after capability detection so mesh_modern can be chosen
|
||||
// when bindless + ARB_shader_draw_parameters are available. See below.
|
||||
|
||||
// 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`
|
||||
|
|
@ -1423,43 +1418,41 @@ public sealed class GameWindow : IDisposable
|
|||
_heightTable = heightTable;
|
||||
_surfaceCache = new Dictionary<uint, AcDream.Core.Terrain.SurfaceInfo>();
|
||||
|
||||
// N.5: detect ARB_bindless_texture + ARB_shader_draw_parameters when WB
|
||||
// foundation is on. Store the BindlessSupport for TextureCache + future
|
||||
// WbDrawDispatcher. Mesh shader load stays as mesh_instanced for now —
|
||||
// Task 10 swaps to mesh_modern after the dispatcher is rewired.
|
||||
if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled)
|
||||
// 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 (AcDream.App.Rendering.Wb.BindlessSupport.TryCreate(_gl, out var bindless))
|
||||
if (bindless!.HasShaderDrawParameters(_gl))
|
||||
{
|
||||
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 dispatch path will not activate");
|
||||
}
|
||||
_bindlessSupport = bindless;
|
||||
Console.WriteLine("[N.5] modern path capabilities present (bindless + ARB_shader_draw_parameters)");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("[N.5] GL_ARB_bindless_texture not present — modern dispatch path will not activate");
|
||||
Console.WriteLine("[N.5] GL_ARB_shader_draw_parameters not present — modern path not available");
|
||||
}
|
||||
}
|
||||
|
||||
// N.5 Task 10/15: load mesh_modern when both extensions are present.
|
||||
// If bindless is missing _meshShader stays null, _wbDrawDispatcher won't
|
||||
// be constructed (its guard requires _bindlessSupport non-null), and
|
||||
// rendering falls back to InstancedMeshRenderer — but only when
|
||||
// _meshShader is non-null (see _staticMesh construction below).
|
||||
if (_bindlessSupport is not null)
|
||||
else
|
||||
{
|
||||
_meshShader = new Shader(_gl,
|
||||
Path.Combine(shadersDir, "mesh_modern.vert"),
|
||||
Path.Combine(shadersDir, "mesh_modern.frag"));
|
||||
Console.WriteLine("[N.5] mesh_modern shader loaded");
|
||||
Console.WriteLine("[N.5] GL_ARB_bindless_texture not present — modern path not available");
|
||||
}
|
||||
// else: bindless missing — _meshShader stays null.
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
// Mesh shader always loads (modern path is the only path).
|
||||
_meshShader = new Shader(_gl,
|
||||
Path.Combine(shadersDir, "mesh_modern.vert"),
|
||||
Path.Combine(shadersDir, "mesh_modern.frag"));
|
||||
Console.WriteLine("[N.5] mesh_modern shader loaded");
|
||||
|
||||
_textureCache = new TextureCache(_gl, _dats, _bindlessSupport);
|
||||
// Two persistent GL sampler objects (Repeat + ClampToEdge) so
|
||||
|
|
@ -1469,17 +1462,14 @@ public sealed class GameWindow : IDisposable
|
|||
// references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132.
|
||||
_samplerCache = new SamplerCache(_gl);
|
||||
|
||||
// Phase N.4 — WB rendering pipeline foundation. Constructed only when
|
||||
// ACDREAM_USE_WB_FOUNDATION=1 is set; otherwise the legacy renderer
|
||||
// path stays in charge. The full ObjectMeshManager bring-up lives in
|
||||
// WbMeshAdapter (Task 9): OpenGLGraphicsDevice + DefaultDatReaderWriter
|
||||
// + ObjectMeshManager. WbMeshAdapter opens its own file handles for
|
||||
// the dat files (independent of our DatCollection).
|
||||
if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled)
|
||||
// Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is
|
||||
// mandatory as of N.5 ship amendment: WbMeshAdapter + WbDrawDispatcher
|
||||
// always construct. WbMeshAdapter owns ObjectMeshManager and opens its
|
||||
// own file handles for the dat files (independent of our DatCollection).
|
||||
{
|
||||
var wbLogger = Microsoft.Extensions.Logging.Abstractions.NullLogger<AcDream.App.Rendering.Wb.WbMeshAdapter>.Instance;
|
||||
_wbMeshAdapter = new AcDream.App.Rendering.Wb.WbMeshAdapter(_gl, _datDir, _dats, wbLogger);
|
||||
Console.WriteLine("[N.4] WbFoundation flag is ENABLED — routing static content through ObjectMeshManager.");
|
||||
Console.WriteLine("[N.4+N.5] WB foundation + modern path active — routing all content through ObjectMeshManager.");
|
||||
}
|
||||
|
||||
// Phase N.4 Task 12: construct LandblockSpawnAdapter under the feature flag
|
||||
|
|
@ -1488,68 +1478,51 @@ public sealed class GameWindow : IDisposable
|
|||
// one that carries the adapter so AddLandblock/RemoveLandblock notify WB.
|
||||
// Phase N.4 Task 17: also construct EntitySpawnAdapter for server-spawned
|
||||
// per-instance content under the same flag.
|
||||
// N.5 mandatory path: spawn adapters + dispatcher always construct.
|
||||
// _wbMeshAdapter, _meshShader, _textureCache, and _bindlessSupport are
|
||||
// all guaranteed non-null here (startup throws above if any are missing).
|
||||
{
|
||||
AcDream.App.Rendering.Wb.LandblockSpawnAdapter? wbSpawnAdapter = null;
|
||||
AcDream.App.Rendering.Wb.EntitySpawnAdapter? wbEntitySpawnAdapter = null;
|
||||
if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled && _wbMeshAdapter is not null)
|
||||
var wbSpawnAdapter = new AcDream.App.Rendering.Wb.LandblockSpawnAdapter(_wbMeshAdapter!);
|
||||
// Sequencer factory: look up Setup + MotionTable from dats and build
|
||||
// an AnimationSequencer. Falls back to a no-op sequencer when the
|
||||
// entity has no motion table (static props, etc.). Uses _animLoader
|
||||
// which is initialised earlier in OnLoad; it is non-null here.
|
||||
var capturedDats = _dats;
|
||||
var capturedAnimLoader = _animLoader;
|
||||
AcDream.Core.Physics.AnimationSequencer SequencerFactory(AcDream.Core.World.WorldEntity e)
|
||||
{
|
||||
wbSpawnAdapter = new AcDream.App.Rendering.Wb.LandblockSpawnAdapter(_wbMeshAdapter);
|
||||
// Sequencer factory: look up Setup + MotionTable from dats and build
|
||||
// an AnimationSequencer. Falls back to a no-op sequencer when the
|
||||
// entity has no motion table (static props, etc.). Uses _animLoader
|
||||
// which is initialised at line 1004; it is non-null here because
|
||||
// OnLoad wires _dats + _animLoader before this block runs.
|
||||
var capturedDats = _dats;
|
||||
var capturedAnimLoader = _animLoader;
|
||||
AcDream.Core.Physics.AnimationSequencer SequencerFactory(AcDream.Core.World.WorldEntity e)
|
||||
if (capturedDats is not null && capturedAnimLoader is not null)
|
||||
{
|
||||
if (capturedDats is not null && capturedAnimLoader is not null)
|
||||
var setup = capturedDats.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
|
||||
if (setup is not null)
|
||||
{
|
||||
var setup = capturedDats.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
|
||||
if (setup is not null)
|
||||
uint mtableId = (uint)setup.DefaultMotionTable;
|
||||
if (mtableId != 0)
|
||||
{
|
||||
uint mtableId = (uint)setup.DefaultMotionTable;
|
||||
if (mtableId != 0)
|
||||
{
|
||||
var mtable = capturedDats.Get<DatReaderWriter.DBObjs.MotionTable>(mtableId);
|
||||
if (mtable is not null)
|
||||
return new AcDream.Core.Physics.AnimationSequencer(setup, mtable, capturedAnimLoader);
|
||||
}
|
||||
// Setup exists but no motion table — no-op sequencer.
|
||||
return new AcDream.Core.Physics.AnimationSequencer(
|
||||
setup,
|
||||
new DatReaderWriter.DBObjs.MotionTable(),
|
||||
capturedAnimLoader);
|
||||
var mtable = capturedDats.Get<DatReaderWriter.DBObjs.MotionTable>(mtableId);
|
||||
if (mtable is not null)
|
||||
return new AcDream.Core.Physics.AnimationSequencer(setup, mtable, capturedAnimLoader);
|
||||
}
|
||||
// Setup exists but no motion table — no-op sequencer.
|
||||
return new AcDream.Core.Physics.AnimationSequencer(
|
||||
setup,
|
||||
new DatReaderWriter.DBObjs.MotionTable(),
|
||||
capturedAnimLoader);
|
||||
}
|
||||
// Complete fallback: empty setup + empty motion table + null loader.
|
||||
return new AcDream.Core.Physics.AnimationSequencer(
|
||||
new DatReaderWriter.DBObjs.Setup(),
|
||||
new DatReaderWriter.DBObjs.MotionTable(),
|
||||
new NullAnimLoader());
|
||||
}
|
||||
wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter(
|
||||
_textureCache, SequencerFactory, _wbMeshAdapter);
|
||||
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
|
||||
// Complete fallback: empty setup + empty motion table + null loader.
|
||||
return new AcDream.Core.Physics.AnimationSequencer(
|
||||
new DatReaderWriter.DBObjs.Setup(),
|
||||
new DatReaderWriter.DBObjs.MotionTable(),
|
||||
new NullAnimLoader());
|
||||
}
|
||||
var wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter(
|
||||
_textureCache!, SequencerFactory, _wbMeshAdapter!);
|
||||
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
|
||||
_worldState = new AcDream.App.Streaming.GpuWorldState(wbSpawnAdapter, wbEntitySpawnAdapter);
|
||||
}
|
||||
|
||||
// Task 15: _meshShader is null when bindless is missing; skip constructing
|
||||
// _staticMesh in that case. All downstream _staticMesh usages are already
|
||||
// null-safe (null-conditional operators or explicit null guards).
|
||||
if (_meshShader is not null && _textureCache is not null)
|
||||
_staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache, _wbMeshAdapter);
|
||||
|
||||
if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled
|
||||
&& _wbMeshAdapter is not null && _wbEntitySpawnAdapter is not null
|
||||
&& _bindlessSupport is not null)
|
||||
{
|
||||
// _meshShader is non-null here: the _bindlessSupport guard implies
|
||||
// the if(_bindlessSupport is not null) block above ran and assigned it.
|
||||
// _textureCache is always non-null (assigned unconditionally above).
|
||||
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
|
||||
_gl, _meshShader!, _textureCache!, _wbMeshAdapter, _wbEntitySpawnAdapter, _bindlessSupport);
|
||||
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!);
|
||||
}
|
||||
|
||||
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
|
||||
|
|
@ -2075,7 +2048,7 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
if (_dats is null || _staticMesh is null) return;
|
||||
if (_dats is null) return;
|
||||
if (spawn.Position is null || spawn.SetupTableId is null)
|
||||
{
|
||||
// Can't place a mesh without both. Most of these are inventory
|
||||
|
|
@ -2410,10 +2383,9 @@ public sealed class GameWindow : IDisposable
|
|||
continue;
|
||||
}
|
||||
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
|
||||
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||||
_staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes);
|
||||
if (dumpClothing)
|
||||
{
|
||||
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||||
int tris = 0; int subs = 0;
|
||||
foreach (var sm in subMeshes) { tris += sm.Indices.Length / 3; subs++; }
|
||||
dumpClothingTotalTris += tris;
|
||||
|
|
@ -5244,44 +5216,25 @@ public sealed class GameWindow : IDisposable
|
|||
portalPlanes, origin.X, origin.Y);
|
||||
}
|
||||
|
||||
// Upload every GfxObj referenced by this landblock's entities.
|
||||
// EnsureUploaded is idempotent so duplicates across landblocks are free.
|
||||
if (_staticMesh is not null)
|
||||
// N.5: WbMeshAdapter.Tick() handles GPU upload for all GfxObj meshes via
|
||||
// ObjectMeshManager.PrepareMeshDataAsync. The legacy EnsureUploaded loop
|
||||
// (and _pendingCellMeshes drain) are retired with InstancedMeshRenderer.
|
||||
// Cache GfxObj physics data (BSP trees) for the physics engine — this
|
||||
// loop is physics-only, not renderer-side.
|
||||
foreach (var entity in lb.Entities)
|
||||
{
|
||||
// Task 8: drain any pending EnvCell room-mesh sub-meshes first.
|
||||
// The worker thread pre-built these CPU-side and stored them in
|
||||
// _pendingCellMeshes. We must upload them here (render thread) before
|
||||
// the per-MeshRef loop below tries to look them up via GfxObjMesh.Build,
|
||||
// which would fail because EnvCell ids (0xAAAA01xx) aren't real GfxObj
|
||||
// dat ids. EnsureUploaded is idempotent so calling it here then seeing
|
||||
// the same id again in the loop below is safe.
|
||||
foreach (var entity in lb.Entities)
|
||||
foreach (var meshRef in entity.MeshRefs)
|
||||
{
|
||||
foreach (var meshRef in entity.MeshRefs)
|
||||
{
|
||||
if (_pendingCellMeshes.TryRemove(meshRef.GfxObjId, out var cellSubMeshes))
|
||||
_staticMesh.EnsureUploaded(meshRef.GfxObjId, cellSubMeshes);
|
||||
}
|
||||
}
|
||||
|
||||
// Now upload regular GfxObj sub-meshes (stabs, scenery, interior stabs).
|
||||
// Skip any ids already uploaded (includes the cell meshes just drained).
|
||||
foreach (var entity in lb.Entities)
|
||||
{
|
||||
foreach (var meshRef in entity.MeshRefs)
|
||||
{
|
||||
// Skip EnvCell synthetic ids — already handled above (or already
|
||||
// uploaded on a prior tick). GfxObj ids are 0x01xxxxxx; Setup ids
|
||||
// are 0x02xxxxxx; anything else is not a GfxObj dat record.
|
||||
if ((meshRef.GfxObjId & 0xFF000000u) != 0x01000000u) continue;
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(meshRef.GfxObjId);
|
||||
if (gfx is null) continue;
|
||||
_physicsDataCache.CacheGfxObj(meshRef.GfxObjId, gfx);
|
||||
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||||
_staticMesh.EnsureUploaded(meshRef.GfxObjId, subMeshes);
|
||||
}
|
||||
if ((meshRef.GfxObjId & 0xFF000000u) != 0x01000000u) continue;
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(meshRef.GfxObjId);
|
||||
if (gfx is null) continue;
|
||||
_physicsDataCache.CacheGfxObj(meshRef.GfxObjId, gfx);
|
||||
}
|
||||
}
|
||||
// Drain _pendingCellMeshes to prevent unbounded accumulation.
|
||||
// The data is no longer consumed (WB handles EnvCell geometry through
|
||||
// its own pipeline), but the worker thread still populates this dict.
|
||||
_pendingCellMeshes.Clear();
|
||||
|
||||
// Task 7: register static entities into the ShadowObjectRegistry so the
|
||||
// Transition system can find and collide against them during movement.
|
||||
|
|
@ -6386,20 +6339,11 @@ public sealed class GameWindow : IDisposable
|
|||
animatedIds.Add(k);
|
||||
}
|
||||
|
||||
if (_wbDrawDispatcher is not null)
|
||||
{
|
||||
_wbDrawDispatcher.Draw(camera, _worldState.LandblockEntries, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibility?.VisibleCellIds,
|
||||
animatedEntityIds: animatedIds);
|
||||
}
|
||||
else
|
||||
{
|
||||
_staticMesh?.Draw(camera, _worldState.LandblockEntries, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibility?.VisibleCellIds,
|
||||
animatedEntityIds: animatedIds);
|
||||
}
|
||||
// N.5: WbDrawDispatcher is always non-null (modern path mandatory).
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibility?.VisibleCellIds,
|
||||
animatedEntityIds: animatedIds);
|
||||
|
||||
// Phase G.1 / E.3: draw all live particles after opaque
|
||||
// scene geometry so alpha blending composites correctly.
|
||||
|
|
@ -8781,11 +8725,10 @@ public sealed class GameWindow : IDisposable
|
|||
_liveSession?.Dispose();
|
||||
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
|
||||
_wbDrawDispatcher?.Dispose();
|
||||
_staticMesh?.Dispose();
|
||||
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first
|
||||
_samplerCache?.Dispose();
|
||||
_textureCache?.Dispose();
|
||||
_wbMeshAdapter?.Dispose(); // Phase N.4 WB foundation — null when flag off
|
||||
_wbMeshAdapter?.Dispose(); // Phase N.4+N.5 WB foundation (mandatory modern path)
|
||||
|
||||
_meshShader?.Dispose();
|
||||
_terrain?.Dispose();
|
||||
|
|
|
|||
|
|
@ -1,596 +0,0 @@
|
|||
// src/AcDream.App/Rendering/InstancedMeshRenderer.cs
|
||||
//
|
||||
// True instanced rendering for static-object meshes.
|
||||
// Groups entities by GfxObjId. All instance model matrices are written into
|
||||
// a single shared instance VBO once per frame. Each sub-mesh is drawn with
|
||||
// DrawElementsInstanced — one GL draw call per (GfxObj × sub-mesh) instead
|
||||
// of one per entity. For a scene with N unique GfxObjs and M total entities
|
||||
// this reduces draw calls from M*subMeshes to N*subMeshes.
|
||||
//
|
||||
// Matrix layout:
|
||||
// System.Numerics.Matrix4x4 is row-major. Written to the float[] buffer in
|
||||
// natural memory order (M11..M44). The GLSL shader reads 4 vec4 attributes
|
||||
// (aInstanceRow0-3) and constructs mat4(row0, row1, row2, row3). Because
|
||||
// GLSL mat4() takes column vectors, the rows of the C# matrix become the
|
||||
// columns of the GLSL mat4 — which is the same transpose that UniformMatrix4
|
||||
// with transpose=false produces. Visual result is identical to the old
|
||||
// SetMatrix4("uModel", ...) path.
|
||||
//
|
||||
// Architecture note: public API matches StaticMeshRenderer so GameWindow only
|
||||
// needs to update the shader and uniform setup at the call sites.
|
||||
using System.Numerics;
|
||||
using System.Runtime.InteropServices;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.Meshing;
|
||||
using AcDream.Core.Terrain;
|
||||
using AcDream.Core.World;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
public sealed unsafe class InstancedMeshRenderer : IDisposable
|
||||
{
|
||||
private readonly GL _gl;
|
||||
private readonly Shader _shader;
|
||||
private readonly TextureCache _textures;
|
||||
|
||||
/// <summary>
|
||||
/// Optional WB adapter. Held but currently unused — Phase N.4 Adjustment 2
|
||||
/// (2026-05-08) reverted Task 9's renderer-level routing. Tier-routing decisions
|
||||
/// (atlas vs per-instance) belong at the spawn-callback layer (Task 11
|
||||
/// LandblockSpawnAdapter for atlas-tier; Task 17 EntitySpawnAdapter for
|
||||
/// per-instance), not in the renderer which is intentionally tier-blind. The
|
||||
/// constructor parameter is preserved so GameWindow's wire-up doesn't shift
|
||||
/// when later tasks need adapter access.
|
||||
/// </summary>
|
||||
private readonly WbMeshAdapter? _wbMeshAdapter;
|
||||
|
||||
// One GPU bundle per unique GfxObj id. Each GfxObj can have multiple sub-meshes.
|
||||
private readonly Dictionary<uint, List<SubMeshGpu>> _gpuByGfxObj = new();
|
||||
|
||||
// Shared instance VBO — filled every frame with all instance model matrices.
|
||||
private readonly uint _instanceVbo;
|
||||
|
||||
// Per-frame scratch: reused float buffer for instance matrix data.
|
||||
// 16 floats per mat4. Grown on demand; never shrunk.
|
||||
private float[] _instanceBuffer = new float[256 * 16]; // start at 256 instances
|
||||
|
||||
// ── Instance grouping scratch ─────────────────────────────────────────────
|
||||
//
|
||||
// Reused every frame to avoid per-frame allocation.
|
||||
//
|
||||
// **Group key = (GfxObjId, PaletteOverrideHash, SurfaceOverridesHash).**
|
||||
//
|
||||
// An earlier implementation grouped on <c>GfxObjId</c> alone and resolved
|
||||
// the per-sub-mesh texture from the first instance in the group — which
|
||||
// is fine for scenery where every tree shares the same palette, but
|
||||
// utterly broken for NPCs: every humanoid uses the same base body
|
||||
// GfxObjs and they all piled into one group, so the first NPC's palette
|
||||
// was used for every NPC in the frame. Frustum culling + iteration
|
||||
// order meant that "first NPC" changed as the camera turned — producing
|
||||
// the "NPC clothing changes when I turn" symptom.
|
||||
//
|
||||
// Now we also key by the entity's PaletteOverride + per-MeshRef
|
||||
// SurfaceOverrides signature so only entities that decode to the
|
||||
// SAME texture for every sub-mesh can share a batch. Entities with
|
||||
// unique appearance fall to single-instance groups (still correct,
|
||||
// marginally slower than true instancing).
|
||||
private readonly Dictionary<GroupKey, InstanceGroup> _groups = new();
|
||||
|
||||
private readonly record struct GroupKey(uint GfxObjId, ulong TextureSignature);
|
||||
|
||||
public InstancedMeshRenderer(GL gl, Shader shader, TextureCache textures,
|
||||
WbMeshAdapter? wbMeshAdapter = null)
|
||||
{
|
||||
_gl = gl;
|
||||
_shader = shader;
|
||||
_textures = textures;
|
||||
_wbMeshAdapter = wbMeshAdapter;
|
||||
|
||||
_instanceVbo = _gl.GenBuffer();
|
||||
}
|
||||
|
||||
// ── Upload ────────────────────────────────────────────────────────────────
|
||||
|
||||
public void EnsureUploaded(uint gfxObjId, IReadOnlyList<GfxObjSubMesh> subMeshes)
|
||||
{
|
||||
if (_gpuByGfxObj.ContainsKey(gfxObjId))
|
||||
return;
|
||||
|
||||
// Phase N.4 Adjustment 2 (2026-05-08): renderer is tier-blind. Tier-routing
|
||||
// (atlas vs per-instance) lives at the spawn-callback layer (Tasks 11 + 17),
|
||||
// not here. Smoke-test of the original Task 9 routing showed it caught
|
||||
// characters / NPCs (server-spawned, per-instance tier) along with static
|
||||
// scenery, because EnsureUploaded is called from both spawn paths.
|
||||
var list = new List<SubMeshGpu>(subMeshes.Count);
|
||||
foreach (var sm in subMeshes)
|
||||
list.Add(UploadSubMesh(sm));
|
||||
_gpuByGfxObj[gfxObjId] = list;
|
||||
}
|
||||
|
||||
private SubMeshGpu UploadSubMesh(GfxObjSubMesh sm)
|
||||
{
|
||||
uint vao = _gl.GenVertexArray();
|
||||
_gl.BindVertexArray(vao);
|
||||
|
||||
// ── Vertex buffer (positions, normals, UVs) ───────────────────────────
|
||||
uint vbo = _gl.GenBuffer();
|
||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo);
|
||||
fixed (void* p = sm.Vertices)
|
||||
_gl.BufferData(BufferTargetARB.ArrayBuffer,
|
||||
(nuint)(sm.Vertices.Length * sizeof(Vertex)), p, BufferUsageARB.StaticDraw);
|
||||
|
||||
uint stride = (uint)sizeof(Vertex);
|
||||
_gl.EnableVertexAttribArray(0);
|
||||
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
|
||||
_gl.EnableVertexAttribArray(1);
|
||||
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
|
||||
_gl.EnableVertexAttribArray(2);
|
||||
_gl.VertexAttribPointer(2, 2, VertexAttribPointerType.Float, false, stride, (void*)(6 * sizeof(float)));
|
||||
// Note: location 3 (uint TerrainLayer) is NOT used by mesh_instanced.vert;
|
||||
// that slot is reserved for per-instance mat4 row 0 from the instance VBO.
|
||||
|
||||
// ── Index buffer ──────────────────────────────────────────────────────
|
||||
uint ebo = _gl.GenBuffer();
|
||||
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ebo);
|
||||
fixed (void* p = sm.Indices)
|
||||
_gl.BufferData(BufferTargetARB.ElementArrayBuffer,
|
||||
(nuint)(sm.Indices.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw);
|
||||
|
||||
// ── Per-instance model matrix (locations 3-6) ─────────────────────────
|
||||
// Bind the shared instance VBO. The VAO captures this binding at each
|
||||
// attribute location. At draw time we re-call VertexAttribPointer with
|
||||
// the per-group byte offset (to address different groups in the VBO
|
||||
// without DrawElementsInstancedBaseInstance).
|
||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
|
||||
// mat4 = 4 × vec4, stride = 64 bytes, divisor = 1 (advance once per instance)
|
||||
for (uint row = 0; row < 4; row++)
|
||||
{
|
||||
uint loc = 3 + row;
|
||||
_gl.EnableVertexAttribArray(loc);
|
||||
_gl.VertexAttribPointer(loc, 4, VertexAttribPointerType.Float, false, 64, (void*)(row * 16));
|
||||
_gl.VertexAttribDivisor(loc, 1);
|
||||
}
|
||||
|
||||
_gl.BindVertexArray(0);
|
||||
|
||||
return new SubMeshGpu
|
||||
{
|
||||
Vao = vao,
|
||||
Vbo = vbo,
|
||||
Ebo = ebo,
|
||||
IndexCount = sm.Indices.Length,
|
||||
SurfaceId = sm.SurfaceId,
|
||||
Translucency = sm.Translucency,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Draw ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public void Draw(ICamera camera,
|
||||
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> landblockEntries,
|
||||
FrustumPlanes? frustum = null,
|
||||
uint? neverCullLandblockId = null,
|
||||
HashSet<uint>? visibleCellIds = null,
|
||||
// L-fix1 (2026-04-28): set of entity ids that should bypass the
|
||||
// landblock-level frustum cull. Animated entities (other
|
||||
// players, NPCs, monsters) are always rendered if their
|
||||
// landblock is loaded — without this they vanish whenever the
|
||||
// camera rotates away from their landblock, even though
|
||||
// they're within visible distance of the player. Pass null /
|
||||
// empty to keep the previous "cull everything by landblock"
|
||||
// behavior.
|
||||
HashSet<uint>? animatedEntityIds = null)
|
||||
{
|
||||
_shader.Use();
|
||||
|
||||
var vp = camera.View * camera.Projection;
|
||||
_shader.SetMatrix4("uViewProjection", vp);
|
||||
|
||||
// Phase G: lighting + ambient + fog are owned by the
|
||||
// SceneLighting UBO (binding=1) uploaded once per frame by
|
||||
// GameWindow. The instanced mesh fragment shader reads it
|
||||
// directly — no per-draw uniform uploads needed.
|
||||
|
||||
// ── Collect and group instances ───────────────────────────────────────
|
||||
CollectGroups(landblockEntries, frustum, neverCullLandblockId, visibleCellIds, animatedEntityIds);
|
||||
|
||||
// ── Build and upload the instance buffer ──────────────────────────────
|
||||
// Count total instances.
|
||||
int totalInstances = 0;
|
||||
foreach (var grp in _groups.Values)
|
||||
totalInstances += grp.Count;
|
||||
|
||||
// Grow the scratch buffer if needed.
|
||||
int needed = totalInstances * 16;
|
||||
if (_instanceBuffer.Length < needed)
|
||||
_instanceBuffer = new float[needed + 256 * 16]; // extra headroom
|
||||
|
||||
// Write all groups contiguously. Record each group's starting offset
|
||||
// (in units of instances, not bytes) so we can address them at draw time.
|
||||
int instanceOffset = 0;
|
||||
foreach (var grp in _groups.Values)
|
||||
{
|
||||
grp.BufferOffset = instanceOffset;
|
||||
foreach (ref readonly var inst in CollectionsMarshal.AsSpan(grp.Entries))
|
||||
WriteMatrix(_instanceBuffer, instanceOffset++ * 16, inst.Model);
|
||||
}
|
||||
|
||||
// Upload all instance data in a single DynamicDraw call.
|
||||
if (totalInstances > 0)
|
||||
{
|
||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
|
||||
fixed (void* p = _instanceBuffer)
|
||||
_gl.BufferData(BufferTargetARB.ArrayBuffer,
|
||||
(nuint)(totalInstances * 16 * sizeof(float)), p, BufferUsageARB.DynamicDraw);
|
||||
}
|
||||
|
||||
// ── Pass 1: Opaque + ClipMap ──────────────────────────────────────────
|
||||
// Diagnostic: ACDREAM_NO_CULL=1 disables backface culling entirely.
|
||||
if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_NO_CULL"), "1", StringComparison.Ordinal))
|
||||
{
|
||||
_gl.Disable(EnableCap.CullFace);
|
||||
}
|
||||
foreach (var (key, grp) in _groups)
|
||||
{
|
||||
if (!_gpuByGfxObj.TryGetValue(key.GfxObjId, out var subMeshes))
|
||||
continue;
|
||||
|
||||
bool hasOpaqueSubMesh = false;
|
||||
foreach (var sub in subMeshes)
|
||||
{
|
||||
if (sub.Translucency == TranslucencyKind.Opaque ||
|
||||
sub.Translucency == TranslucencyKind.ClipMap)
|
||||
{
|
||||
hasOpaqueSubMesh = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasOpaqueSubMesh) continue;
|
||||
|
||||
// For this group, instance data starts at grp.BufferOffset in the VBO.
|
||||
// We need to tell the VAO to read from that offset.
|
||||
uint byteOffset = (uint)(grp.BufferOffset * 64); // 64 bytes per mat4
|
||||
|
||||
foreach (var sub in subMeshes)
|
||||
{
|
||||
if (sub.Translucency != TranslucencyKind.Opaque &&
|
||||
sub.Translucency != TranslucencyKind.ClipMap)
|
||||
continue;
|
||||
|
||||
_shader.SetInt("uTranslucencyKind", (int)sub.Translucency);
|
||||
|
||||
// Bind VAO + re-point instance attributes to the group's slice
|
||||
// in the shared VBO. This updates the VAO's stored offset for
|
||||
// locations 3-6 without touching the vertex or index bindings.
|
||||
_gl.BindVertexArray(sub.Vao);
|
||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
|
||||
for (uint row = 0; row < 4; row++)
|
||||
{
|
||||
_gl.VertexAttribPointer(3 + row, 4, VertexAttribPointerType.Float,
|
||||
false, 64, (void*)(byteOffset + row * 16));
|
||||
}
|
||||
|
||||
// Resolve texture from the first instance (all instances in this
|
||||
// group share the same GfxObj so they have compatible overrides
|
||||
// only in the degenerate case of mixed-palette entities using the
|
||||
// same GfxObj — rare enough to accept the approximation here).
|
||||
if (grp.Count == 0) continue;
|
||||
var firstEntry = grp.Entries[0];
|
||||
uint tex = ResolveTex(firstEntry.Entity, firstEntry.MeshRef, sub);
|
||||
_gl.ActiveTexture(TextureUnit.Texture0);
|
||||
_gl.BindTexture(TextureTarget.Texture2D, tex);
|
||||
|
||||
_gl.DrawElementsInstanced(PrimitiveType.Triangles,
|
||||
(uint)sub.IndexCount,
|
||||
DrawElementsType.UnsignedInt,
|
||||
(void*)0,
|
||||
(uint)grp.Count);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pass 2: Translucent (AlphaBlend, Additive, InvAlpha) ─────────────
|
||||
_gl.Enable(EnableCap.Blend);
|
||||
_gl.DepthMask(false);
|
||||
// Diagnostic: ACDREAM_NO_CULL=1 disables backface culling (used 2026-05-01
|
||||
// to test if our mesh winding (0,i,i+1) vs ACME's (i+1,i,0) is causing
|
||||
// visible polygons to be culled, especially around the neck/coat seam).
|
||||
if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_NO_CULL"), "1", StringComparison.Ordinal))
|
||||
{
|
||||
_gl.Disable(EnableCap.CullFace);
|
||||
}
|
||||
else
|
||||
{
|
||||
_gl.Enable(EnableCap.CullFace);
|
||||
_gl.CullFace(TriangleFace.Back);
|
||||
_gl.FrontFace(FrontFaceDirection.Ccw);
|
||||
}
|
||||
|
||||
foreach (var (key, grp) in _groups)
|
||||
{
|
||||
if (!_gpuByGfxObj.TryGetValue(key.GfxObjId, out var subMeshes))
|
||||
continue;
|
||||
|
||||
bool hasTranslucentSubMesh = false;
|
||||
foreach (var sub in subMeshes)
|
||||
{
|
||||
if (sub.Translucency != TranslucencyKind.Opaque &&
|
||||
sub.Translucency != TranslucencyKind.ClipMap)
|
||||
{
|
||||
hasTranslucentSubMesh = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasTranslucentSubMesh) continue;
|
||||
|
||||
uint byteOffset = (uint)(grp.BufferOffset * 64);
|
||||
|
||||
foreach (var sub in subMeshes)
|
||||
{
|
||||
if (sub.Translucency == TranslucencyKind.Opaque ||
|
||||
sub.Translucency == TranslucencyKind.ClipMap)
|
||||
continue;
|
||||
|
||||
switch (sub.Translucency)
|
||||
{
|
||||
case TranslucencyKind.Additive:
|
||||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
|
||||
break;
|
||||
case TranslucencyKind.InvAlpha:
|
||||
_gl.BlendFunc(BlendingFactor.OneMinusSrcAlpha, BlendingFactor.SrcAlpha);
|
||||
break;
|
||||
default: // AlphaBlend
|
||||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||||
break;
|
||||
}
|
||||
|
||||
_shader.SetInt("uTranslucencyKind", (int)sub.Translucency);
|
||||
|
||||
_gl.BindVertexArray(sub.Vao);
|
||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
|
||||
for (uint row = 0; row < 4; row++)
|
||||
{
|
||||
_gl.VertexAttribPointer(3 + row, 4, VertexAttribPointerType.Float,
|
||||
false, 64, (void*)(byteOffset + row * 16));
|
||||
}
|
||||
|
||||
if (grp.Count == 0) continue;
|
||||
var firstEntry = grp.Entries[0];
|
||||
uint tex = ResolveTex(firstEntry.Entity, firstEntry.MeshRef, sub);
|
||||
_gl.ActiveTexture(TextureUnit.Texture0);
|
||||
_gl.BindTexture(TextureTarget.Texture2D, tex);
|
||||
|
||||
_gl.DrawElementsInstanced(PrimitiveType.Triangles,
|
||||
(uint)sub.IndexCount,
|
||||
DrawElementsType.UnsignedInt,
|
||||
(void*)0,
|
||||
(uint)grp.Count);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore default GL state.
|
||||
_gl.DepthMask(true);
|
||||
_gl.Disable(EnableCap.Blend);
|
||||
_gl.Disable(EnableCap.CullFace);
|
||||
_gl.BindVertexArray(0);
|
||||
}
|
||||
|
||||
// ── Grouping ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Iterates all visible landblock entries and groups every (entity, meshRef)
|
||||
/// pair by GfxObjId. Clears previous frame's groups before filling.
|
||||
/// </summary>
|
||||
private void CollectGroups(
|
||||
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> landblockEntries,
|
||||
FrustumPlanes? frustum,
|
||||
uint? neverCullLandblockId,
|
||||
HashSet<uint>? visibleCellIds,
|
||||
HashSet<uint>? animatedEntityIds)
|
||||
{
|
||||
foreach (var grp in _groups.Values)
|
||||
grp.Entries.Clear();
|
||||
|
||||
foreach (var entry in landblockEntries)
|
||||
{
|
||||
// L-fix1 (2026-04-28): the landblock cull decision is now
|
||||
// PER-LANDBLOCK boolean, not a continue. We still need to
|
||||
// walk the entity list because animated entities (in
|
||||
// animatedEntityIds) bypass the cull and render anyway.
|
||||
bool landblockVisible = frustum is null
|
||||
|| entry.LandblockId == neverCullLandblockId
|
||||
|| FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax);
|
||||
|
||||
// Fast path: no animated entities globally → if landblock is
|
||||
// culled, skip the whole entity list (preserves the original
|
||||
// O(visible-landblocks) cost when the caller doesn't care
|
||||
// about animated bypass).
|
||||
if (!landblockVisible && (animatedEntityIds is null || animatedEntityIds.Count == 0))
|
||||
continue;
|
||||
|
||||
foreach (var entity in entry.Entities)
|
||||
{
|
||||
if (entity.MeshRefs.Count == 0)
|
||||
continue;
|
||||
|
||||
// L-fix1: when the landblock is frustum-culled, only
|
||||
// render entities flagged as animated. This keeps
|
||||
// remote players / NPCs / monsters visible even when
|
||||
// their landblock rotates out of the view frustum.
|
||||
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
|
||||
if (!landblockVisible && !isAnimated)
|
||||
continue;
|
||||
|
||||
// Step 4: portal visibility filter. If we have a visible cell set,
|
||||
// skip interior entities whose parent cell isn't visible.
|
||||
// visibleCellIds == null means camera is outdoors → show all interiors.
|
||||
if (entity.ParentCellId.HasValue && visibleCellIds is not null
|
||||
&& !visibleCellIds.Contains(entity.ParentCellId.Value))
|
||||
continue;
|
||||
|
||||
var entityRoot =
|
||||
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
|
||||
Matrix4x4.CreateTranslation(entity.Position);
|
||||
|
||||
// Hash the entity's PaletteOverride once — shared by every
|
||||
// MeshRef on this entity, so we compute it outside the loop.
|
||||
ulong palHash = HashPaletteOverride(entity.PaletteOverride);
|
||||
|
||||
foreach (var meshRef in entity.MeshRefs)
|
||||
{
|
||||
if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var cachedMeshes))
|
||||
continue;
|
||||
|
||||
var model = meshRef.PartTransform * entityRoot;
|
||||
|
||||
// Texture signature = palette hash ^ surface-overrides hash.
|
||||
// Two instances can share a batch only when their ResolveTex
|
||||
// would return identical handles for every sub-mesh — that
|
||||
// means identical palette AND identical surface overrides.
|
||||
ulong surfHash = HashSurfaceOverrides(meshRef.SurfaceOverrides);
|
||||
ulong texSig = palHash ^ surfHash;
|
||||
var key = new GroupKey(meshRef.GfxObjId, texSig);
|
||||
|
||||
if (!_groups.TryGetValue(key, out var group))
|
||||
{
|
||||
group = new InstanceGroup();
|
||||
_groups[key] = group;
|
||||
}
|
||||
|
||||
group.Entries.Add(new InstanceEntry(model, entity, meshRef));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ulong HashPaletteOverride(AcDream.Core.World.PaletteOverride? p)
|
||||
{
|
||||
if (p is null) return 0UL;
|
||||
ulong h = 0xCBF29CE484222325UL;
|
||||
const ulong prime = 0x100000001B3UL;
|
||||
h = (h ^ p.BasePaletteId) * prime;
|
||||
foreach (var sp in p.SubPalettes)
|
||||
{
|
||||
h = (h ^ sp.SubPaletteId) * prime;
|
||||
h = (h ^ sp.Offset) * prime;
|
||||
h = (h ^ sp.Length) * prime;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Order-independent hash of a SurfaceOverrides dictionary. XOR of each
|
||||
/// (key, value) pair keeps the result stable regardless of Dictionary
|
||||
/// iteration order, so two instances whose override maps contain the
|
||||
/// same pairs will hash identically.
|
||||
/// </summary>
|
||||
private static ulong HashSurfaceOverrides(IReadOnlyDictionary<uint, uint>? overrides)
|
||||
{
|
||||
if (overrides is null || overrides.Count == 0) return 0UL;
|
||||
ulong acc = 0UL;
|
||||
foreach (var kvp in overrides)
|
||||
{
|
||||
ulong pair = ((ulong)kvp.Key << 32) | kvp.Value;
|
||||
acc ^= pair;
|
||||
}
|
||||
// Fold with a prime so the zero case doesn't collide with "empty".
|
||||
return (acc ^ 0xCBF29CE484222325UL) * 0x100000001B3UL;
|
||||
}
|
||||
|
||||
// ── Matrix write ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Writes a System.Numerics Matrix4x4 into <paramref name="buf"/> starting
|
||||
/// at <paramref name="offset"/> as 16 consecutive floats in row-major order
|
||||
/// (the C# natural memory layout). The GLSL shader reads each 4-float row
|
||||
/// as a column of the mat4 — identical to what UniformMatrix4(transpose=false)
|
||||
/// produces for the uniform path.
|
||||
/// </summary>
|
||||
private static void WriteMatrix(float[] buf, int offset, in Matrix4x4 m)
|
||||
{
|
||||
buf[offset + 0] = m.M11; buf[offset + 1] = m.M12; buf[offset + 2] = m.M13; buf[offset + 3] = m.M14;
|
||||
buf[offset + 4] = m.M21; buf[offset + 5] = m.M22; buf[offset + 6] = m.M23; buf[offset + 7] = m.M24;
|
||||
buf[offset + 8] = m.M31; buf[offset + 9] = m.M32; buf[offset + 10] = m.M33; buf[offset + 11] = m.M34;
|
||||
buf[offset + 12] = m.M41; buf[offset + 13] = m.M42; buf[offset + 14] = m.M43; buf[offset + 15] = m.M44;
|
||||
}
|
||||
|
||||
// ── Texture resolution ────────────────────────────────────────────────────
|
||||
|
||||
private uint ResolveTex(WorldEntity entity, MeshRef meshRef, SubMeshGpu sub)
|
||||
{
|
||||
uint overrideOrigTex = 0;
|
||||
bool hasOrigTexOverride = meshRef.SurfaceOverrides is not null
|
||||
&& meshRef.SurfaceOverrides.TryGetValue(sub.SurfaceId, out overrideOrigTex);
|
||||
uint? origTexOverride = hasOrigTexOverride ? overrideOrigTex : (uint?)null;
|
||||
|
||||
if (entity.PaletteOverride is not null)
|
||||
{
|
||||
return _textures.GetOrUploadWithPaletteOverride(
|
||||
sub.SurfaceId, origTexOverride, entity.PaletteOverride);
|
||||
}
|
||||
else if (hasOrigTexOverride)
|
||||
{
|
||||
return _textures.GetOrUploadWithOrigTextureOverride(sub.SurfaceId, overrideOrigTex);
|
||||
}
|
||||
else
|
||||
{
|
||||
return _textures.GetOrUpload(sub.SurfaceId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Disposal ──────────────────────────────────────────────────────────────
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var subs in _gpuByGfxObj.Values)
|
||||
{
|
||||
foreach (var sub in subs)
|
||||
{
|
||||
_gl.DeleteBuffer(sub.Vbo);
|
||||
_gl.DeleteBuffer(sub.Ebo);
|
||||
_gl.DeleteVertexArray(sub.Vao);
|
||||
}
|
||||
}
|
||||
_gl.DeleteBuffer(_instanceVbo);
|
||||
_gpuByGfxObj.Clear();
|
||||
_groups.Clear();
|
||||
}
|
||||
|
||||
// ── Private types ─────────────────────────────────────────────────────────
|
||||
|
||||
private sealed class SubMeshGpu
|
||||
{
|
||||
public uint Vao;
|
||||
public uint Vbo;
|
||||
public uint Ebo;
|
||||
public int IndexCount;
|
||||
public uint SurfaceId;
|
||||
public TranslucencyKind Translucency;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// All instances of one GfxObj for this frame, plus their starting offset
|
||||
/// in the shared instance VBO (in units of instances, not bytes).
|
||||
/// </summary>
|
||||
private sealed class InstanceGroup
|
||||
{
|
||||
public readonly List<InstanceEntry> Entries = new();
|
||||
public int BufferOffset;
|
||||
|
||||
public int Count => Entries.Count;
|
||||
}
|
||||
|
||||
private readonly struct InstanceEntry
|
||||
{
|
||||
public readonly Matrix4x4 Model;
|
||||
public readonly WorldEntity Entity;
|
||||
public readonly MeshRef MeshRef;
|
||||
|
||||
public InstanceEntry(Matrix4x4 model, WorldEntity entity, MeshRef meshRef)
|
||||
{
|
||||
Model = model;
|
||||
Entity = entity;
|
||||
MeshRef = meshRef;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,293 +0,0 @@
|
|||
// src/AcDream.App/Rendering/StaticMeshRenderer.cs
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Meshing;
|
||||
using AcDream.Core.Terrain;
|
||||
using AcDream.Core.World;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
public sealed unsafe class StaticMeshRenderer : IDisposable
|
||||
{
|
||||
private readonly GL _gl;
|
||||
private readonly Shader _shader;
|
||||
private readonly TextureCache _textures;
|
||||
|
||||
// One GPU bundle per unique GfxObj id. Each GfxObj can have multiple sub-meshes.
|
||||
private readonly Dictionary<uint, List<SubMeshGpu>> _gpuByGfxObj = new();
|
||||
|
||||
public StaticMeshRenderer(GL gl, Shader shader, TextureCache textures)
|
||||
{
|
||||
_gl = gl;
|
||||
_shader = shader;
|
||||
_textures = textures;
|
||||
}
|
||||
|
||||
public void EnsureUploaded(uint gfxObjId, IReadOnlyList<GfxObjSubMesh> subMeshes)
|
||||
{
|
||||
if (_gpuByGfxObj.ContainsKey(gfxObjId))
|
||||
return;
|
||||
|
||||
var list = new List<SubMeshGpu>(subMeshes.Count);
|
||||
foreach (var sm in subMeshes)
|
||||
list.Add(UploadSubMesh(sm));
|
||||
_gpuByGfxObj[gfxObjId] = list;
|
||||
}
|
||||
|
||||
private SubMeshGpu UploadSubMesh(GfxObjSubMesh sm)
|
||||
{
|
||||
uint vao = _gl.GenVertexArray();
|
||||
_gl.BindVertexArray(vao);
|
||||
|
||||
uint vbo = _gl.GenBuffer();
|
||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo);
|
||||
fixed (void* p = sm.Vertices)
|
||||
_gl.BufferData(BufferTargetARB.ArrayBuffer,
|
||||
(nuint)(sm.Vertices.Length * sizeof(Vertex)), p, BufferUsageARB.StaticDraw);
|
||||
|
||||
uint ebo = _gl.GenBuffer();
|
||||
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ebo);
|
||||
fixed (void* p = sm.Indices)
|
||||
_gl.BufferData(BufferTargetARB.ElementArrayBuffer,
|
||||
(nuint)(sm.Indices.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw);
|
||||
|
||||
uint stride = (uint)sizeof(Vertex);
|
||||
_gl.EnableVertexAttribArray(0);
|
||||
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
|
||||
_gl.EnableVertexAttribArray(1);
|
||||
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
|
||||
_gl.EnableVertexAttribArray(2);
|
||||
_gl.VertexAttribPointer(2, 2, VertexAttribPointerType.Float, false, stride, (void*)(6 * sizeof(float)));
|
||||
_gl.EnableVertexAttribArray(3);
|
||||
_gl.VertexAttribIPointer(3, 1, VertexAttribIType.UnsignedInt, stride, (void*)(8 * sizeof(float)));
|
||||
|
||||
_gl.BindVertexArray(0);
|
||||
|
||||
return new SubMeshGpu
|
||||
{
|
||||
Vao = vao,
|
||||
Vbo = vbo,
|
||||
Ebo = ebo,
|
||||
IndexCount = sm.Indices.Length,
|
||||
SurfaceId = sm.SurfaceId,
|
||||
// Capture translucency at upload time so the draw loop never
|
||||
// has to look it up from external state.
|
||||
Translucency = sm.Translucency,
|
||||
};
|
||||
}
|
||||
|
||||
public void Draw(ICamera camera,
|
||||
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> landblockEntries,
|
||||
FrustumPlanes? frustum = null,
|
||||
uint? neverCullLandblockId = null)
|
||||
{
|
||||
_shader.Use();
|
||||
_shader.SetMatrix4("uView", camera.View);
|
||||
_shader.SetMatrix4("uProjection", camera.Projection);
|
||||
|
||||
// ── Pass 1: Opaque + ClipMap ──────────────────────────────────────────
|
||||
// Depth write on (default). No blending. ClipMap surfaces use the
|
||||
// alpha-discard path in the fragment shader (uTranslucencyKind == 1).
|
||||
foreach (var entry in landblockEntries)
|
||||
{
|
||||
// Per-landblock frustum cull. Never cull the player's landblock.
|
||||
if (frustum is not null &&
|
||||
entry.LandblockId != neverCullLandblockId &&
|
||||
!FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax))
|
||||
continue;
|
||||
|
||||
foreach (var entity in entry.Entities)
|
||||
{
|
||||
if (entity.MeshRefs.Count == 0)
|
||||
continue;
|
||||
|
||||
foreach (var meshRef in entity.MeshRefs)
|
||||
{
|
||||
if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var subMeshes))
|
||||
continue;
|
||||
|
||||
var entityRoot =
|
||||
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
|
||||
Matrix4x4.CreateTranslation(entity.Position);
|
||||
var model = meshRef.PartTransform * entityRoot;
|
||||
_shader.SetMatrix4("uModel", model);
|
||||
|
||||
foreach (var sub in subMeshes)
|
||||
{
|
||||
// Skip translucent sub-meshes in the first pass.
|
||||
if (sub.Translucency != TranslucencyKind.Opaque &&
|
||||
sub.Translucency != TranslucencyKind.ClipMap)
|
||||
continue;
|
||||
|
||||
_shader.SetInt("uTranslucencyKind", (int)sub.Translucency);
|
||||
|
||||
uint tex = ResolveTex(entity, meshRef, sub);
|
||||
_gl.ActiveTexture(TextureUnit.Texture0);
|
||||
_gl.BindTexture(TextureTarget.Texture2D, tex);
|
||||
|
||||
_gl.BindVertexArray(sub.Vao);
|
||||
_gl.DrawElements(PrimitiveType.Triangles, (uint)sub.IndexCount, DrawElementsType.UnsignedInt, (void*)0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pass 2: Translucent (AlphaBlend, Additive, InvAlpha) ─────────────
|
||||
// Depth test on so translucents composite correctly behind opaque geometry.
|
||||
// Depth write OFF so translucents don't occlude each other or downstream
|
||||
// opaque draws. Blend function is set per-draw based on TranslucencyKind.
|
||||
//
|
||||
// NOTE: translucent draws are NOT sorted by depth — overlapping translucent
|
||||
// surfaces can composite in the wrong order. Portal-sized billboards don't
|
||||
// overlap in practice so this is acceptable and avoids a larger refactor.
|
||||
_gl.Enable(EnableCap.Blend);
|
||||
_gl.DepthMask(false);
|
||||
|
||||
// Phase 9.2: enable back-face culling for the translucent pass so
|
||||
// closed-shell translucents (lifestone crystal, glow gems, any
|
||||
// convex blended mesh) don't draw their back faces over their
|
||||
// front faces in arbitrary iteration order. Without this, the
|
||||
// 58 triangles of the lifestone crystal composited with an
|
||||
// "inside-out" look where the user saw through one face into
|
||||
// the hollow interior. With back-face culling on, back faces are
|
||||
// dropped at rasterization time, front faces composite as-is,
|
||||
// and depth ordering within the front-facing subset is a
|
||||
// non-issue for closed convex-ish shells. Matches WorldBuilder's
|
||||
// per-batch CullMode handling in
|
||||
// references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/
|
||||
// BaseObjectRenderManager.cs:361-365.
|
||||
//
|
||||
// Our fan triangulation emits pos-side polygons as
|
||||
// (0, i, i+1) which is CCW in standard OpenGL conventions, so
|
||||
// GL_BACK + CCW front is the correct state. Neg-side polygons
|
||||
// (if any) use reversed winding and get culled here — that's a
|
||||
// known limitation and matches the opaque-pass behavior since
|
||||
// neg-side polys are virtually never translucent in AC content.
|
||||
_gl.Enable(EnableCap.CullFace);
|
||||
_gl.CullFace(TriangleFace.Back);
|
||||
_gl.FrontFace(FrontFaceDirection.Ccw);
|
||||
|
||||
foreach (var entry in landblockEntries)
|
||||
{
|
||||
// Same per-landblock frustum cull for pass 2.
|
||||
if (frustum is not null &&
|
||||
entry.LandblockId != neverCullLandblockId &&
|
||||
!FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax))
|
||||
continue;
|
||||
|
||||
foreach (var entity in entry.Entities)
|
||||
{
|
||||
if (entity.MeshRefs.Count == 0)
|
||||
continue;
|
||||
|
||||
foreach (var meshRef in entity.MeshRefs)
|
||||
{
|
||||
if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var subMeshes))
|
||||
continue;
|
||||
|
||||
var entityRoot =
|
||||
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
|
||||
Matrix4x4.CreateTranslation(entity.Position);
|
||||
var model = meshRef.PartTransform * entityRoot;
|
||||
_shader.SetMatrix4("uModel", model);
|
||||
|
||||
foreach (var sub in subMeshes)
|
||||
{
|
||||
if (sub.Translucency == TranslucencyKind.Opaque ||
|
||||
sub.Translucency == TranslucencyKind.ClipMap)
|
||||
continue;
|
||||
|
||||
// Set per-draw blend function.
|
||||
switch (sub.Translucency)
|
||||
{
|
||||
case TranslucencyKind.Additive:
|
||||
// src*a + dst — portal swirls, glows
|
||||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
|
||||
break;
|
||||
|
||||
case TranslucencyKind.InvAlpha:
|
||||
// src*(1-a) + dst*a
|
||||
_gl.BlendFunc(BlendingFactor.OneMinusSrcAlpha, BlendingFactor.SrcAlpha);
|
||||
break;
|
||||
|
||||
default: // AlphaBlend
|
||||
// src*a + dst*(1-a)
|
||||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||||
break;
|
||||
}
|
||||
|
||||
_shader.SetInt("uTranslucencyKind", (int)sub.Translucency);
|
||||
|
||||
uint tex = ResolveTex(entity, meshRef, sub);
|
||||
_gl.ActiveTexture(TextureUnit.Texture0);
|
||||
_gl.BindTexture(TextureTarget.Texture2D, tex);
|
||||
|
||||
_gl.BindVertexArray(sub.Vao);
|
||||
_gl.DrawElements(PrimitiveType.Triangles, (uint)sub.IndexCount, DrawElementsType.UnsignedInt, (void*)0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore default GL state for subsequent renderers (terrain etc.).
|
||||
_gl.DepthMask(true);
|
||||
_gl.Disable(EnableCap.Blend);
|
||||
_gl.Disable(EnableCap.CullFace);
|
||||
|
||||
_gl.BindVertexArray(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the GL texture id for a sub-mesh, honouring palette and
|
||||
/// texture overrides carried on the entity and the mesh-ref.
|
||||
/// </summary>
|
||||
private uint ResolveTex(WorldEntity entity, MeshRef meshRef, SubMeshGpu sub)
|
||||
{
|
||||
uint overrideOrigTex = 0;
|
||||
bool hasOrigTexOverride = meshRef.SurfaceOverrides is not null
|
||||
&& meshRef.SurfaceOverrides.TryGetValue(sub.SurfaceId, out overrideOrigTex);
|
||||
uint? origTexOverride = hasOrigTexOverride ? overrideOrigTex : (uint?)null;
|
||||
|
||||
if (entity.PaletteOverride is not null)
|
||||
{
|
||||
return _textures.GetOrUploadWithPaletteOverride(
|
||||
sub.SurfaceId, origTexOverride, entity.PaletteOverride);
|
||||
}
|
||||
else if (hasOrigTexOverride)
|
||||
{
|
||||
return _textures.GetOrUploadWithOrigTextureOverride(sub.SurfaceId, overrideOrigTex);
|
||||
}
|
||||
else
|
||||
{
|
||||
return _textures.GetOrUpload(sub.SurfaceId);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var subs in _gpuByGfxObj.Values)
|
||||
{
|
||||
foreach (var sub in subs)
|
||||
{
|
||||
_gl.DeleteBuffer(sub.Vbo);
|
||||
_gl.DeleteBuffer(sub.Ebo);
|
||||
_gl.DeleteVertexArray(sub.Vao);
|
||||
}
|
||||
}
|
||||
_gpuByGfxObj.Clear();
|
||||
}
|
||||
|
||||
private sealed class SubMeshGpu
|
||||
{
|
||||
public uint Vao;
|
||||
public uint Vbo;
|
||||
public uint Ebo;
|
||||
public int IndexCount;
|
||||
public uint SurfaceId;
|
||||
/// <summary>
|
||||
/// Cached from GfxObjSubMesh.Translucency at upload time.
|
||||
/// Avoids any per-draw lookup into external state.
|
||||
/// </summary>
|
||||
public TranslucencyKind Translucency;
|
||||
}
|
||||
}
|
||||
|
|
@ -13,26 +13,29 @@ namespace AcDream.App.Rendering.Wb;
|
|||
/// <summary>
|
||||
/// Draws entities using WB's <see cref="ObjectRenderData"/> (a single global
|
||||
/// VAO/VBO/IBO under modern rendering) with acdream's <see cref="TextureCache"/>
|
||||
/// for texture resolution and <see cref="AcSurfaceMetadataTable"/> for
|
||||
/// for bindless texture resolution and <see cref="AcSurfaceMetadataTable"/> for
|
||||
/// translucency classification.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Atlas-tier</b> entities (<c>ServerGuid == 0</c>): mesh data comes from WB's
|
||||
/// <see cref="ObjectMeshManager"/> via <see cref="WbMeshAdapter.TryGetRenderData"/>.
|
||||
/// Textures resolve through <see cref="TextureCache.GetOrUpload"/> using the batch's
|
||||
/// <c>SurfaceId</c>.
|
||||
/// Textures resolve through the bindless-suffixed
|
||||
/// <see cref="TextureCache.GetOrUploadBindless"/> variants, returning 64-bit
|
||||
/// resident handles stored in the per-group SSBO.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Per-instance-tier</b> entities (<c>ServerGuid != 0</c>): mesh data also from
|
||||
/// WB, but textures resolve through <see cref="TextureCache"/> with palette and
|
||||
/// surface overrides applied. <see cref="AnimatedEntityState"/> is currently
|
||||
/// WB, but textures resolve through
|
||||
/// <see cref="TextureCache.GetOrUploadWithPaletteOverrideBindless"/> with palette
|
||||
/// and surface overrides applied. <see cref="AnimatedEntityState"/> is currently
|
||||
/// unused at draw time — GameWindow's spawn path already bakes AnimPartChanges +
|
||||
/// GfxObjDegradeResolver (Issue #47 close-detail mesh) into <c>MeshRefs</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>GL strategy (N.5):</b> <c>glMultiDrawElementsIndirect</c> with SSBOs.
|
||||
/// <b>GL strategy (N.5 — mandatory):</b> <c>glMultiDrawElementsIndirect</c> with SSBOs
|
||||
/// and <c>GL_ARB_bindless_texture</c> + <c>GL_ARB_shader_draw_parameters</c>.
|
||||
/// All visible (entity, batch) pairs are bucketed by <see cref="GroupKey"/>;
|
||||
/// each group becomes one <c>DrawElementsIndirectCommand</c>. Three GPU buffers
|
||||
/// are uploaded per frame: instance matrices (SSBO binding 0), per-group batch
|
||||
|
|
@ -42,17 +45,17 @@ namespace AcDream.App.Rendering.Wb;
|
|||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Shader:</b> <c>mesh_modern</c> when bindless + ARB_shader_draw_parameters
|
||||
/// are available (N.5 path). Falls back to <c>mesh_instanced</c> when the GPU
|
||||
/// lacks those extensions.
|
||||
/// <b>Shader:</b> <c>mesh_modern</c> (bindless + <c>gl_DrawIDARB</c> /
|
||||
/// <c>gl_BaseInstanceARB</c>). Missing bindless/draw-parameters throws
|
||||
/// <see cref="NotSupportedException"/> at startup — there is no legacy fallback.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Modern rendering assumption:</b> WB's <c>_useModernRendering</c> path (GL
|
||||
/// 4.3 + bindless) puts every mesh in a single shared VAO/VBO/IBO and uses
|
||||
/// <c>FirstIndex</c> + <c>BaseVertex</c> per batch. The dispatcher honors those
|
||||
/// offsets via <c>DrawElementsInstancedBaseVertex(BaseInstance)</c>. The legacy
|
||||
/// per-mesh-VAO path also works since FirstIndex/BaseVertex are zero there.
|
||||
/// offsets inside each <c>DrawElementsIndirectCommand</c> via
|
||||
/// <c>glMultiDrawElementsIndirect</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Process-lifetime cache of <c>ACDREAM_USE_WB_FOUNDATION</c> env var.
|
||||
/// Read once at static-init time; all consumers import this rather than
|
||||
/// re-reading the env var per call (env-var lookups on Windows are not
|
||||
/// free at hot-path cadence).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Default-on as of Phase N.4 ship (2026-05-08).</b> The WB foundation
|
||||
/// (<c>WbMeshAdapter</c> + <c>WbDrawDispatcher</c>) is the production
|
||||
/// rendering path. Set <c>ACDREAM_USE_WB_FOUNDATION=0</c> to fall back
|
||||
/// to the legacy <c>InstancedMeshRenderer</c> path — kept as an escape
|
||||
/// hatch until N.6 fully replaces it.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Per-instance customized content (server <c>CreateObject</c> entities
|
||||
/// with palette / texture overrides) routes through
|
||||
/// <see cref="TextureCache.GetOrUploadWithPaletteOverride"/> regardless
|
||||
/// of the flag — the flag controls which DRAW path consumes those
|
||||
/// textures.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class WbFoundationFlag
|
||||
{
|
||||
private static bool _isEnabled =
|
||||
System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_FOUNDATION") != "0";
|
||||
|
||||
public static bool IsEnabled => _isEnabled;
|
||||
|
||||
/// <summary>
|
||||
/// FOR TESTS ONLY. Forces <see cref="IsEnabled"/> to <c>true</c> so
|
||||
/// integration tests can exercise the WB adapter path without having to
|
||||
/// set the env var before static initialisation. Never call from
|
||||
/// production code.
|
||||
/// </summary>
|
||||
internal static void ForTestsOnly_ForceEnable() => _isEnabled = true;
|
||||
}
|
||||
|
|
@ -144,7 +144,7 @@ public sealed class GpuWorldState
|
|||
}
|
||||
|
||||
_loaded[landblock.LandblockId] = landblock;
|
||||
if (WbFoundationFlag.IsEnabled && _wbSpawnAdapter is not null)
|
||||
if (_wbSpawnAdapter is not null)
|
||||
_wbSpawnAdapter.OnLandblockLoaded(_loaded[landblock.LandblockId]);
|
||||
RebuildFlatView();
|
||||
}
|
||||
|
|
@ -195,7 +195,7 @@ public sealed class GpuWorldState
|
|||
|
||||
public void RemoveLandblock(uint landblockId)
|
||||
{
|
||||
if (WbFoundationFlag.IsEnabled && _wbSpawnAdapter is not null)
|
||||
if (_wbSpawnAdapter is not null)
|
||||
_wbSpawnAdapter.OnLandblockUnloaded(landblockId);
|
||||
|
||||
// Rescue persistent entities before removal. These get appended
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue