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>
765 lines
32 KiB
C#
765 lines
32 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
using System.Runtime.InteropServices;
|
||
using AcDream.Core.Meshing;
|
||
using AcDream.Core.Terrain;
|
||
using AcDream.Core.World;
|
||
using Chorizite.OpenGLSDLBackend.Lib;
|
||
using Silk.NET.OpenGL;
|
||
|
||
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 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 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.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 — 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
|
||
/// metadata/texture handles (SSBO binding 1), and the indirect draw commands.
|
||
/// Two <c>glMultiDrawElementsIndirect</c> calls cover the opaque and transparent
|
||
/// passes respectively — one GL call per pass regardless of group count.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// <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 inside each <c>DrawElementsIndirectCommand</c> via
|
||
/// <c>glMultiDrawElementsIndirect</c>.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed unsafe class WbDrawDispatcher : IDisposable
|
||
{
|
||
private readonly GL _gl;
|
||
private readonly Shader _shader;
|
||
private readonly TextureCache _textures;
|
||
private readonly WbMeshAdapter _meshAdapter;
|
||
private readonly EntitySpawnAdapter _entitySpawnAdapter;
|
||
|
||
private readonly BindlessSupport _bindless;
|
||
|
||
// SSBO buffer ids
|
||
private uint _instanceSsbo;
|
||
private uint _batchSsbo;
|
||
private uint _indirectBuffer;
|
||
|
||
// Per-frame scratch arrays — Tasks 9-10 fully wire these.
|
||
private float[] _instanceData = new float[256 * 16]; // mat4 floats per instance
|
||
private BatchData[] _batchData = new BatchData[256];
|
||
private DrawElementsIndirectCommand[] _indirectCommands = new DrawElementsIndirectCommand[256];
|
||
|
||
private int _opaqueDrawCount;
|
||
private int _transparentDrawCount;
|
||
private int _transparentByteOffset;
|
||
|
||
// std430 layout: ulong TextureHandle (uvec2) at offset 0, uint TextureLayer
|
||
// at offset 8, uint Flags at offset 12. Total 16 bytes.
|
||
// Pack=8 (not 4) because std430's uvec2 requires 8-byte alignment — Pack=4
|
||
// works today by accident (TextureHandle is the first field, so offset 0 is
|
||
// always 8-byte aligned), but adding a 4-byte field before TextureHandle
|
||
// without bumping Pack would silently misalign the GPU struct.
|
||
[StructLayout(LayoutKind.Sequential, Pack = 8)]
|
||
private struct BatchData
|
||
{
|
||
public ulong TextureHandle; // bindless handle (uvec2 in GLSL)
|
||
public uint TextureLayer;
|
||
public uint Flags;
|
||
}
|
||
|
||
// Per-frame scratch — reused across frames to avoid per-frame allocation.
|
||
private readonly Dictionary<GroupKey, InstanceGroup> _groups = new();
|
||
private readonly List<InstanceGroup> _opaqueDraws = new();
|
||
private readonly List<InstanceGroup> _translucentDraws = new();
|
||
|
||
// Per-entity-cull AABB radius. Conservative — covers most entities; large
|
||
// outliers (long banners, tall columns) are still landblock-culled.
|
||
private const float PerEntityCullRadius = 5.0f;
|
||
|
||
private bool _disposed;
|
||
|
||
// Diagnostic counters logged once per ~5s under ACDREAM_WB_DIAG=1.
|
||
private int _entitiesSeen;
|
||
private int _entitiesDrawn;
|
||
private int _meshesMissing;
|
||
private int _drawsIssued;
|
||
private int _instancesIssued;
|
||
private long _lastLogTick;
|
||
|
||
// CPU + GPU timing for [WB-DIAG] under ACDREAM_WB_DIAG=1.
|
||
private readonly System.Diagnostics.Stopwatch _cpuStopwatch = new();
|
||
private readonly long[] _cpuSamples = new long[256]; // microseconds
|
||
private int _cpuSampleCursor;
|
||
private uint _gpuQueryOpaque;
|
||
private uint _gpuQueryTransparent;
|
||
private readonly long[] _gpuSamples = new long[256]; // microseconds
|
||
private int _gpuSampleCursor;
|
||
private bool _gpuQueriesInitialized;
|
||
|
||
public WbDrawDispatcher(
|
||
GL gl,
|
||
Shader shader,
|
||
TextureCache textures,
|
||
WbMeshAdapter meshAdapter,
|
||
EntitySpawnAdapter entitySpawnAdapter,
|
||
BindlessSupport bindless)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(gl);
|
||
ArgumentNullException.ThrowIfNull(shader);
|
||
ArgumentNullException.ThrowIfNull(textures);
|
||
ArgumentNullException.ThrowIfNull(meshAdapter);
|
||
ArgumentNullException.ThrowIfNull(entitySpawnAdapter);
|
||
|
||
_gl = gl;
|
||
_shader = shader;
|
||
_textures = textures;
|
||
_meshAdapter = meshAdapter;
|
||
_entitySpawnAdapter = entitySpawnAdapter;
|
||
|
||
_bindless = bindless ?? throw new ArgumentNullException(nameof(bindless));
|
||
_instanceSsbo = _gl.GenBuffer();
|
||
_batchSsbo = _gl.GenBuffer();
|
||
_indirectBuffer = _gl.GenBuffer();
|
||
}
|
||
|
||
public static Matrix4x4 ComposePartWorldMatrix(
|
||
Matrix4x4 entityWorld,
|
||
Matrix4x4 animOverride,
|
||
Matrix4x4 restPose)
|
||
=> restPose * animOverride * entityWorld;
|
||
|
||
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,
|
||
HashSet<uint>? animatedEntityIds = null)
|
||
{
|
||
_shader.Use();
|
||
var vp = camera.View * camera.Projection;
|
||
_shader.SetMatrix4("uViewProjection", vp);
|
||
|
||
bool diag = string.Equals(Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", StringComparison.Ordinal);
|
||
|
||
if (diag && !_gpuQueriesInitialized)
|
||
{
|
||
_gpuQueryOpaque = _gl.GenQuery();
|
||
_gpuQueryTransparent = _gl.GenQuery();
|
||
_gpuQueriesInitialized = true;
|
||
}
|
||
|
||
// Always run the CPU stopwatch — cheap; only logged under diag.
|
||
_cpuStopwatch.Restart();
|
||
|
||
// Camera world-space position for front-to-back sort (perf #2). The view
|
||
// matrix is the inverse of the camera's world transform, so the world
|
||
// translation lives in the inverse's translation row.
|
||
Vector3 camPos = Vector3.Zero;
|
||
if (Matrix4x4.Invert(camera.View, out var invView))
|
||
camPos = invView.Translation;
|
||
|
||
// ── Phase 1: clear groups, walk entities, build groups ──────────────
|
||
foreach (var grp in _groups.Values) grp.Matrices.Clear();
|
||
|
||
var metaTable = _meshAdapter.MetadataTable;
|
||
uint anyVao = 0;
|
||
|
||
foreach (var entry in landblockEntries)
|
||
{
|
||
bool landblockVisible = frustum is null
|
||
|| entry.LandblockId == neverCullLandblockId
|
||
|| FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax);
|
||
|
||
if (!landblockVisible && (animatedEntityIds is null || animatedEntityIds.Count == 0))
|
||
continue;
|
||
|
||
foreach (var entity in entry.Entities)
|
||
{
|
||
if (entity.MeshRefs.Count == 0) continue;
|
||
|
||
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
|
||
if (!landblockVisible && !isAnimated) continue;
|
||
|
||
if (entity.ParentCellId.HasValue && visibleCellIds is not null
|
||
&& !visibleCellIds.Contains(entity.ParentCellId.Value))
|
||
continue;
|
||
|
||
// Per-entity AABB frustum cull (perf #3). Skips work for distant
|
||
// entities even when their landblock is visible. Animated
|
||
// entities bypass — they're tracked at landblock level + need
|
||
// per-frame work for animation regardless. Conservative 5m
|
||
// radius covers typical entity bounds.
|
||
if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
|
||
{
|
||
var p = entity.Position;
|
||
var aMin = new Vector3(p.X - PerEntityCullRadius, p.Y - PerEntityCullRadius, p.Z - PerEntityCullRadius);
|
||
var aMax = new Vector3(p.X + PerEntityCullRadius, p.Y + PerEntityCullRadius, p.Z + PerEntityCullRadius);
|
||
if (!FrustumCuller.IsAabbVisible(frustum.Value, aMin, aMax))
|
||
continue;
|
||
}
|
||
|
||
if (diag) _entitiesSeen++;
|
||
|
||
var entityWorld =
|
||
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
|
||
Matrix4x4.CreateTranslation(entity.Position);
|
||
|
||
// Compute palette-override hash ONCE per entity (perf #4).
|
||
// Reused across every (part, batch) lookup so the FNV-1a fold
|
||
// over SubPalettes runs once instead of N times. Zero when the
|
||
// entity has no palette override (trees, scenery).
|
||
ulong palHash = 0;
|
||
if (entity.PaletteOverride is not null)
|
||
palHash = TextureCache.HashPaletteOverride(entity.PaletteOverride);
|
||
|
||
bool drewAny = false;
|
||
for (int partIdx = 0; partIdx < entity.MeshRefs.Count; partIdx++)
|
||
{
|
||
// Note: GameWindow's spawn path already applies
|
||
// AnimPartChanges + GfxObjDegradeResolver (Issue #47 fix —
|
||
// close-detail mesh swap for humanoids) to MeshRefs. We
|
||
// trust MeshRefs as the source of truth here. AnimatedEntityState's
|
||
// overrides become relevant only for hot-swap (0xF625
|
||
// ObjDescEvent) which today rebuilds MeshRefs anyway.
|
||
var meshRef = entity.MeshRefs[partIdx];
|
||
ulong gfxObjId = meshRef.GfxObjId;
|
||
|
||
var renderData = _meshAdapter.TryGetRenderData(gfxObjId);
|
||
if (renderData is null)
|
||
{
|
||
if (diag) _meshesMissing++;
|
||
continue;
|
||
}
|
||
drewAny = true;
|
||
if (anyVao == 0) anyVao = renderData.VAO;
|
||
|
||
if (renderData.IsSetup && renderData.SetupParts.Count > 0)
|
||
{
|
||
foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)
|
||
{
|
||
var partData = _meshAdapter.TryGetRenderData(partGfxObjId);
|
||
if (partData is null) continue;
|
||
|
||
var model = ComposePartWorldMatrix(
|
||
entityWorld, meshRef.PartTransform, partTransform);
|
||
|
||
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
var model = meshRef.PartTransform * entityWorld;
|
||
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable);
|
||
}
|
||
}
|
||
|
||
if (diag && drewAny) _entitiesDrawn++;
|
||
}
|
||
}
|
||
|
||
// Nothing visible — skip the GL pass entirely.
|
||
if (anyVao == 0)
|
||
{
|
||
_cpuStopwatch.Stop();
|
||
if (diag) MaybeFlushDiag();
|
||
return;
|
||
}
|
||
|
||
// ── Phase 3: assign FirstInstance per group, lay matrices contiguously, sort opaque ──
|
||
int totalInstances = 0;
|
||
foreach (var grp in _groups.Values) totalInstances += grp.Matrices.Count;
|
||
if (totalInstances == 0)
|
||
{
|
||
_cpuStopwatch.Stop();
|
||
if (diag) MaybeFlushDiag();
|
||
return;
|
||
}
|
||
|
||
int needed = totalInstances * 16;
|
||
if (_instanceData.Length < needed)
|
||
_instanceData = new float[needed + 256 * 16];
|
||
|
||
_opaqueDraws.Clear();
|
||
_translucentDraws.Clear();
|
||
|
||
int cursor = 0;
|
||
foreach (var grp in _groups.Values)
|
||
{
|
||
if (grp.Matrices.Count == 0) continue;
|
||
|
||
grp.FirstInstance = cursor;
|
||
grp.InstanceCount = grp.Matrices.Count;
|
||
|
||
// Use the first instance's translation as the group's representative
|
||
// position for front-to-back sort (perf #2). Cheap heuristic; works
|
||
// well when instances of one group are spatially coherent
|
||
// (typical for trees in one landblock area, NPCs at one spawn).
|
||
var first = grp.Matrices[0];
|
||
var grpPos = new Vector3(first.M41, first.M42, first.M43);
|
||
grp.SortDistance = Vector3.DistanceSquared(camPos, grpPos);
|
||
|
||
for (int i = 0; i < grp.Matrices.Count; i++)
|
||
{
|
||
WriteMatrix(_instanceData, cursor * 16, grp.Matrices[i]);
|
||
cursor++;
|
||
}
|
||
|
||
if (IsOpaque(grp.Translucency))
|
||
_opaqueDraws.Add(grp);
|
||
else
|
||
_translucentDraws.Add(grp);
|
||
}
|
||
|
||
// Front-to-back sort for opaque pass: nearer groups draw first so the
|
||
// depth test rejects fragments hidden behind them, reducing fragment
|
||
// shader cost from overdraw on dense scenes (Holtburg courtyard,
|
||
// Foundry interior).
|
||
_opaqueDraws.Sort(static (a, b) => a.SortDistance.CompareTo(b.SortDistance));
|
||
|
||
// ── Phase 4: build IndirectGroupInput list (opaque sorted, then translucent),
|
||
// fill via BuildIndirectArrays ──────────────────────────────────
|
||
int totalDraws = _opaqueDraws.Count + _translucentDraws.Count;
|
||
if (_batchData.Length < totalDraws)
|
||
_batchData = new BatchData[totalDraws + 64];
|
||
if (_indirectCommands.Length < totalDraws)
|
||
_indirectCommands = new DrawElementsIndirectCommand[totalDraws + 64];
|
||
|
||
var groupInputs = new List<IndirectGroupInput>(totalDraws);
|
||
foreach (var g in _opaqueDraws) groupInputs.Add(ToInput(g));
|
||
foreach (var g in _translucentDraws) groupInputs.Add(ToInput(g));
|
||
|
||
// Cast _batchData (private BatchData) to public-mirror BatchDataPublic for BuildIndirectArrays.
|
||
// Layout is asserted at test time (BatchDataPublic_LayoutMatchesPrivateBatchData test).
|
||
var batchPublic = new BatchDataPublic[totalDraws];
|
||
var layout = BuildIndirectArrays(groupInputs, _indirectCommands, batchPublic);
|
||
|
||
// Copy back into _batchData
|
||
for (int i = 0; i < totalDraws; i++)
|
||
{
|
||
_batchData[i] = new BatchData
|
||
{
|
||
TextureHandle = batchPublic[i].TextureHandle,
|
||
TextureLayer = batchPublic[i].TextureLayer,
|
||
Flags = batchPublic[i].Flags,
|
||
};
|
||
}
|
||
_opaqueDrawCount = layout.OpaqueCount;
|
||
_transparentDrawCount = layout.TransparentCount;
|
||
_transparentByteOffset = layout.TransparentByteOffset;
|
||
|
||
// ── Phase 5: upload three buffers ───────────────────────────────────
|
||
fixed (float* ip = _instanceData)
|
||
UploadSsbo(_instanceSsbo, 0, ip, totalInstances * 16 * sizeof(float));
|
||
|
||
fixed (BatchData* bp = _batchData)
|
||
UploadSsbo(_batchSsbo, 1, bp, totalDraws * sizeof(BatchData));
|
||
|
||
fixed (DrawElementsIndirectCommand* cp = _indirectCommands)
|
||
{
|
||
_gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer);
|
||
_gl.BufferData(BufferTargetARB.DrawIndirectBuffer,
|
||
(nuint)(totalDraws * sizeof(DrawElementsIndirectCommand)), cp, BufferUsageARB.DynamicDraw);
|
||
}
|
||
|
||
// ── Phase 6: bind global VAO once ───────────────────────────────────
|
||
_gl.BindVertexArray(anyVao);
|
||
|
||
if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_NO_CULL"), "1", StringComparison.Ordinal))
|
||
_gl.Disable(EnableCap.CullFace);
|
||
|
||
// ── Phase 7: opaque pass ─────────────────────────────────────────────
|
||
if (_opaqueDrawCount > 0)
|
||
{
|
||
_gl.Disable(EnableCap.Blend);
|
||
_gl.DepthMask(true);
|
||
_shader.SetInt("uRenderPass", 0);
|
||
_gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer);
|
||
if (diag && _gpuQueriesInitialized) _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryOpaque);
|
||
_gl.MultiDrawElementsIndirect(
|
||
PrimitiveType.Triangles,
|
||
DrawElementsType.UnsignedShort,
|
||
(void*)0,
|
||
(uint)_opaqueDrawCount,
|
||
(uint)DrawCommandStride);
|
||
if (diag && _gpuQueriesInitialized) _gl.EndQuery(QueryTarget.TimeElapsed);
|
||
}
|
||
|
||
// ── Phase 8: transparent pass ────────────────────────────────────────
|
||
if (_transparentDrawCount > 0)
|
||
{
|
||
_gl.Enable(EnableCap.Blend);
|
||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||
_gl.DepthMask(false);
|
||
_shader.SetInt("uRenderPass", 1);
|
||
if (diag && _gpuQueriesInitialized) _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryTransparent);
|
||
_gl.MultiDrawElementsIndirect(
|
||
PrimitiveType.Triangles,
|
||
DrawElementsType.UnsignedShort,
|
||
(void*)_transparentByteOffset,
|
||
(uint)_transparentDrawCount,
|
||
(uint)DrawCommandStride);
|
||
if (diag && _gpuQueriesInitialized) _gl.EndQuery(QueryTarget.TimeElapsed);
|
||
_gl.DepthMask(true);
|
||
_gl.Disable(EnableCap.Blend);
|
||
}
|
||
|
||
_gl.Disable(EnableCap.CullFace);
|
||
_gl.BindVertexArray(0);
|
||
|
||
_cpuStopwatch.Stop();
|
||
|
||
if (diag)
|
||
{
|
||
long cpuUs = _cpuStopwatch.ElapsedTicks * 1_000_000L / System.Diagnostics.Stopwatch.Frequency;
|
||
_cpuSamples[_cpuSampleCursor] = cpuUs;
|
||
_cpuSampleCursor = (_cpuSampleCursor + 1) % _cpuSamples.Length;
|
||
|
||
// Read GPU samples non-blocking; the result for the previous frame's
|
||
// queries should be ready by now. If not, drop the sample (don't stall
|
||
// the CPU waiting for the GPU).
|
||
if (_gpuQueriesInitialized)
|
||
{
|
||
_gl.GetQueryObject(_gpuQueryOpaque, QueryObjectParameterName.ResultAvailable, out int avail);
|
||
if (avail != 0)
|
||
{
|
||
_gl.GetQueryObject(_gpuQueryOpaque, QueryObjectParameterName.Result, out ulong opaqueNs);
|
||
_gl.GetQueryObject(_gpuQueryTransparent, QueryObjectParameterName.Result, out ulong transNs);
|
||
long gpuUs = (long)((opaqueNs + transNs) / 1000UL);
|
||
_gpuSamples[_gpuSampleCursor] = gpuUs;
|
||
_gpuSampleCursor = (_gpuSampleCursor + 1) % _gpuSamples.Length;
|
||
}
|
||
}
|
||
|
||
_drawsIssued += _opaqueDrawCount + _transparentDrawCount;
|
||
_instancesIssued += totalInstances;
|
||
MaybeFlushDiag();
|
||
}
|
||
}
|
||
|
||
private static IndirectGroupInput ToInput(InstanceGroup g) => new(
|
||
IndexCount: g.IndexCount,
|
||
FirstIndex: g.FirstIndex,
|
||
BaseVertex: g.BaseVertex,
|
||
InstanceCount: g.InstanceCount,
|
||
FirstInstance: g.FirstInstance,
|
||
TextureHandle: g.BindlessTextureHandle,
|
||
TextureLayer: g.TextureLayer,
|
||
Translucency: g.Translucency);
|
||
|
||
private unsafe void UploadSsbo(uint ssbo, uint binding, void* data, int byteCount)
|
||
{
|
||
_gl.BindBuffer(BufferTargetARB.ShaderStorageBuffer, ssbo);
|
||
_gl.BufferData(BufferTargetARB.ShaderStorageBuffer, (nuint)byteCount, data, BufferUsageARB.DynamicDraw);
|
||
_gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, binding, ssbo);
|
||
}
|
||
|
||
private void MaybeFlushDiag()
|
||
{
|
||
long now = Environment.TickCount64;
|
||
if (now - _lastLogTick > 5000)
|
||
{
|
||
long cpuMed = MedianMicros(_cpuSamples);
|
||
long cpuP95 = Percentile95Micros(_cpuSamples);
|
||
long gpuMed = MedianMicros(_gpuSamples);
|
||
long gpuP95 = Percentile95Micros(_gpuSamples);
|
||
Console.WriteLine(
|
||
$"[WB-DIAG] entSeen={_entitiesSeen} entDrawn={_entitiesDrawn} meshMissing={_meshesMissing} drawsIssued={_drawsIssued} instances={_instancesIssued} groups={_groups.Count} " +
|
||
$"cpu_us={cpuMed}m/{cpuP95}p95 gpu_us={gpuMed}m/{gpuP95}p95");
|
||
_entitiesSeen = _entitiesDrawn = _meshesMissing = _drawsIssued = _instancesIssued = 0;
|
||
_lastLogTick = now;
|
||
// Don't reset the sample buffers — they're a moving window of the
|
||
// last 256 frames; clearing per 5s flush would lose recent history.
|
||
}
|
||
}
|
||
|
||
private static long MedianMicros(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 Percentile95Micros(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 ClassifyBatches(
|
||
ObjectRenderData renderData,
|
||
ulong gfxObjId,
|
||
Matrix4x4 model,
|
||
WorldEntity entity,
|
||
MeshRef meshRef,
|
||
ulong palHash,
|
||
AcSurfaceMetadataTable metaTable)
|
||
{
|
||
for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++)
|
||
{
|
||
var batch = renderData.Batches[batchIdx];
|
||
|
||
TranslucencyKind translucency;
|
||
if (metaTable.TryLookup(gfxObjId, batchIdx, out var meta))
|
||
{
|
||
translucency = meta.Translucency;
|
||
}
|
||
else
|
||
{
|
||
translucency = batch.IsAdditive ? TranslucencyKind.Additive
|
||
: batch.IsTransparent ? TranslucencyKind.AlphaBlend
|
||
: TranslucencyKind.Opaque;
|
||
}
|
||
|
||
ulong texHandle = ResolveTexture(entity, meshRef, batch, palHash);
|
||
if (texHandle == 0) continue;
|
||
|
||
// TextureLayer is always 0 for per-instance composites; non-zero when
|
||
// WB atlas is adopted in N.6+ and batches reference a shared atlas layer.
|
||
uint texLayer = 0;
|
||
|
||
var key = new GroupKey(
|
||
batch.IBO, batch.FirstIndex, (int)batch.BaseVertex,
|
||
batch.IndexCount, texHandle, texLayer, translucency);
|
||
|
||
if (!_groups.TryGetValue(key, out var grp))
|
||
{
|
||
grp = new InstanceGroup
|
||
{
|
||
Ibo = batch.IBO,
|
||
FirstIndex = batch.FirstIndex,
|
||
BaseVertex = (int)batch.BaseVertex,
|
||
IndexCount = batch.IndexCount,
|
||
BindlessTextureHandle = texHandle,
|
||
TextureLayer = texLayer,
|
||
Translucency = translucency,
|
||
};
|
||
_groups[key] = grp;
|
||
}
|
||
grp.Matrices.Add(model);
|
||
}
|
||
}
|
||
|
||
private ulong ResolveTexture(WorldEntity entity, MeshRef meshRef, ObjectRenderBatch batch, ulong palHash)
|
||
{
|
||
uint surfaceId = batch.Key.SurfaceId;
|
||
if (surfaceId == 0 || surfaceId == 0xFFFFFFFF) return 0;
|
||
|
||
uint overrideOrigTex = 0;
|
||
bool hasOrigTexOverride = meshRef.SurfaceOverrides is not null
|
||
&& meshRef.SurfaceOverrides.TryGetValue(surfaceId, out overrideOrigTex);
|
||
uint? origTexOverride = hasOrigTexOverride ? overrideOrigTex : (uint?)null;
|
||
|
||
if (entity.PaletteOverride is not null)
|
||
{
|
||
return _textures.GetOrUploadWithPaletteOverrideBindless(
|
||
surfaceId, origTexOverride, entity.PaletteOverride, palHash);
|
||
}
|
||
else if (hasOrigTexOverride)
|
||
{
|
||
return _textures.GetOrUploadWithOrigTextureOverrideBindless(surfaceId, overrideOrigTex);
|
||
}
|
||
else
|
||
{
|
||
return _textures.GetOrUploadBindless(surfaceId);
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
if (_disposed) return;
|
||
_disposed = true;
|
||
_gl.DeleteBuffer(_instanceSsbo);
|
||
_gl.DeleteBuffer(_batchSsbo);
|
||
_gl.DeleteBuffer(_indirectBuffer);
|
||
if (_gpuQueriesInitialized)
|
||
{
|
||
_gl.DeleteQuery(_gpuQueryOpaque);
|
||
_gl.DeleteQuery(_gpuQueryTransparent);
|
||
}
|
||
}
|
||
|
||
// ── Public types + helpers for BuildIndirectArrays (Task 9) ─────────────
|
||
//
|
||
// These are public so the pure-CPU unit tests in AcDream.Core.Tests can
|
||
// exercise BuildIndirectArrays without needing a GL context.
|
||
|
||
/// <summary>
|
||
/// Stride in bytes of <c>DrawElementsIndirectCommand</c> in the indirect buffer.
|
||
/// 5 × <c>uint</c> = 20 bytes. Tests and callers reference this symbolically
|
||
/// rather than hard-coding <c>20</c> so a layout change produces a compile error.
|
||
/// </summary>
|
||
public const int DrawCommandStride = 20; // sizeof(DrawElementsIndirectCommand): 5 × uint
|
||
|
||
/// <summary>
|
||
/// Public view of the per-group inputs to <see cref="BuildIndirectArrays"/> — used in tests.
|
||
/// </summary>
|
||
public readonly record struct IndirectGroupInput(
|
||
int IndexCount,
|
||
uint FirstIndex,
|
||
int BaseVertex,
|
||
int InstanceCount,
|
||
int FirstInstance,
|
||
ulong TextureHandle,
|
||
uint TextureLayer,
|
||
TranslucencyKind Translucency);
|
||
|
||
/// <summary>
|
||
/// Public mirror of the per-group <see cref="BatchData"/> uploaded to the SSBO.
|
||
/// Tests verify the layout. Same field shape as the private BatchData.
|
||
/// </summary>
|
||
[StructLayout(LayoutKind.Sequential, Pack = 8)]
|
||
public struct BatchDataPublic
|
||
{
|
||
public ulong TextureHandle;
|
||
public uint TextureLayer;
|
||
public uint Flags;
|
||
}
|
||
|
||
/// <summary>Result of <see cref="BuildIndirectArrays"/>.</summary>
|
||
public readonly record struct IndirectLayoutResult(
|
||
int OpaqueCount,
|
||
int TransparentCount,
|
||
int TransparentByteOffset);
|
||
|
||
/// <summary>
|
||
/// Lays out the indirect commands + parallel BatchData array contiguously:
|
||
/// opaque section first (caller sorts before calling), transparent section second.
|
||
/// Pure CPU, no GL state. Caller passes pre-sized scratch arrays.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Classification: Opaque + ClipMap → opaque pass (ClipMap uses discard, not
|
||
/// blending). Everything else (AlphaBlend, Additive, InvAlpha) → transparent pass.
|
||
/// </remarks>
|
||
public static IndirectLayoutResult BuildIndirectArrays(
|
||
IReadOnlyList<IndirectGroupInput> groups,
|
||
DrawElementsIndirectCommand[] indirectScratch,
|
||
BatchDataPublic[] batchScratch)
|
||
{
|
||
int opaqueCount = 0;
|
||
int transparentCount = 0;
|
||
|
||
foreach (var g in groups)
|
||
{
|
||
if (IsOpaque(g.Translucency)) opaqueCount++;
|
||
else transparentCount++;
|
||
}
|
||
|
||
int oi = 0; // opaque write cursor (fills [0..opaqueCount))
|
||
int ti = opaqueCount; // transparent write cursor (fills [opaqueCount..end))
|
||
|
||
foreach (var g in groups)
|
||
{
|
||
var dec = new DrawElementsIndirectCommand
|
||
{
|
||
Count = (uint)g.IndexCount,
|
||
InstanceCount = (uint)g.InstanceCount,
|
||
FirstIndex = g.FirstIndex,
|
||
BaseVertex = g.BaseVertex,
|
||
BaseInstance = (uint)g.FirstInstance,
|
||
};
|
||
var bd = new BatchDataPublic
|
||
{
|
||
TextureHandle = g.TextureHandle,
|
||
TextureLayer = g.TextureLayer,
|
||
Flags = 0,
|
||
};
|
||
|
||
if (IsOpaque(g.Translucency))
|
||
{
|
||
indirectScratch[oi] = dec;
|
||
batchScratch[oi] = bd;
|
||
oi++;
|
||
}
|
||
else
|
||
{
|
||
indirectScratch[ti] = dec;
|
||
batchScratch[ti] = bd;
|
||
ti++;
|
||
}
|
||
}
|
||
|
||
return new IndirectLayoutResult(opaqueCount, transparentCount, opaqueCount * DrawCommandStride);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Public test shim for <see cref="IsOpaque"/>. Locks in the N.5 Decision 2
|
||
/// translucency partition: Opaque + ClipMap → opaque indirect; AlphaBlend +
|
||
/// Additive + InvAlpha → transparent indirect.
|
||
/// </summary>
|
||
public static bool IsOpaquePublic(TranslucencyKind t) => IsOpaque(t);
|
||
|
||
private static bool IsOpaque(TranslucencyKind t)
|
||
=> t == TranslucencyKind.Opaque || t == TranslucencyKind.ClipMap;
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
|
||
private readonly record struct GroupKey(
|
||
uint Ibo,
|
||
uint FirstIndex,
|
||
int BaseVertex,
|
||
int IndexCount,
|
||
ulong BindlessTextureHandle,
|
||
uint TextureLayer,
|
||
TranslucencyKind Translucency);
|
||
|
||
private sealed class InstanceGroup
|
||
{
|
||
public uint Ibo;
|
||
public uint FirstIndex;
|
||
public int BaseVertex;
|
||
public int IndexCount;
|
||
public ulong BindlessTextureHandle; // 64-bit (was uint TextureHandle in N.4)
|
||
public uint TextureLayer; // 0 for per-instance composites; non-zero when WB atlas is adopted in N.6+
|
||
public TranslucencyKind Translucency;
|
||
public int FirstInstance; // offset into the shared instance VBO (in instances, not bytes)
|
||
public int InstanceCount;
|
||
public float SortDistance; // squared distance from camera to first instance, for opaque sort
|
||
public readonly List<Matrix4x4> Matrices = new();
|
||
}
|
||
}
|