feat(render): Phase A8 — indoor visibility + streaming fixes batch
Lands the working A8 indoor-rendering and streaming fixes accumulated this session. User has verified these visually to some degree (e.g. lifestone / translucent meshes confirmed fine under the FrontFace flip; bridge / wall / collision regressions confirmed fixed after travel); not every path has been exhaustively gated. The cellar-flap defect remains OPEN and will be solved the retail-faithful way via a dedicated brainstorm (see handoff docs). Rendering core (reviewed, high confidence): - EnvCellRenderer SSBO stride fix: upload packed Matrix4x4[] (64B) instead of the 80B CPU InstanceData struct the shader never expected — fixes the transform/texture "explosion" for any draw with >1 instance (cells that dedupe to a shared cellGeomId). Real root cause. - WB-style global FrontFace(CW) + per-batch CullMode carried through the MDI layout (GroupKey + BuildIndirectArrays + DrawIndirectRange split into same-cull runs with absolute uDrawIDOffset per run). - EntitySet partitioning (IndoorPass / OutdoorScenery / LiveDynamic) + WorldEntity.BuildingShellAnchorCellId so building shells scope to their dat-derived building cell instead of rendering everywhere. - RenderOutsideInAcdream (look into buildings from outside) + CollectVisiblePortalBuildings frustum cull of portal bounds. - Sky-when-inside-building + per-cell audit probe + GL-state probe. Streaming / perf (test-covered; not independently code-reviewed this session): - Near/far priority queues so near work wins over far; PromoteToNear carries full landblock + mesh data; LandblockEntriesWithoutAnimatedIndex avoids rebuilding the animated-lookup dict in the hot draw path. Fixes the bridge-not-appearing / missing-walls / broken-collision-after-travel regressions and improves post-transition FPS. Tooling + docs: - tools/A8CellAudit: offline dat cell/portal/building dumper (portals + buildings modes) — reproduces the cellar-flap investigation with no launch. - docs/research cellar-flap root-cause + option-2 handoff (the didInsideStencil double-duty finding + the WB-recursive design decision + brainstorm prompt), entity-taxonomy, replan, issue-78 visibility investigation. Diagnostics retained on purpose: ACDREAM_A8_DIAG_* gates, portal_stencil.vert provisional pos.w clamp, and the probe families are kept (env-var gated, zero cost when off) because the pending option-2 cellar-flap brainstorm needs them. Strip in the option-2 ship commit. Indoor branch stays behind ACDREAM_A8_INDOOR_BRANCH=1 (default off = pre-A8 visual). Build green; App tests + Core (streaming/dispatcher/loader) tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e415bb3863
commit
5dc4140c11
38 changed files with 3965 additions and 277 deletions
|
|
@ -225,6 +225,12 @@ public sealed class CellVisibility
|
|||
: System.Array.Empty<LoadedCell>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a currently loaded cell by full 32-bit cell id.
|
||||
/// </summary>
|
||||
public bool TryGetCell(uint cellId, out LoadedCell? cell)
|
||||
=> _cellLookup.TryGetValue(cellId, out cell);
|
||||
|
||||
/// <summary>
|
||||
/// Removes all cells belonging to <paramref name="lbId"/> (upper 16 bits of
|
||||
/// the landblock key, e.g. 0xA9B4 for landblock 0xA9B40000). Called when a
|
||||
|
|
|
|||
|
|
@ -171,6 +171,159 @@ public sealed class GameWindow : IDisposable
|
|||
// around each RenderBuildingStencilMask call.
|
||||
private AcDream.App.Rendering.IndoorCellStencilPipeline? _indoorStencilPipeline;
|
||||
|
||||
private void CollectVisiblePortalBuildings(
|
||||
System.Collections.Generic.List<AcDream.App.Rendering.Wb.Building> output,
|
||||
int centerLbX,
|
||||
int centerLbY,
|
||||
int radius)
|
||||
{
|
||||
output.Clear();
|
||||
|
||||
foreach (var (landblockId, reg) in _buildingRegistries)
|
||||
{
|
||||
int lbX = (int)((landblockId >> 24) & 0xFFu);
|
||||
int lbY = (int)((landblockId >> 16) & 0xFFu);
|
||||
if (System.Math.Abs(lbX - centerLbX) > radius ||
|
||||
System.Math.Abs(lbY - centerLbY) > radius)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var b in reg.All())
|
||||
{
|
||||
if (!b.HasPortalBounds)
|
||||
continue;
|
||||
|
||||
// WB PortalRenderManager.GetVisibleBuildingPortals frustum-culls
|
||||
// each building's portal AABB with ignoreNearPlane=true. That
|
||||
// prevents a doorway/window clipped by the camera near plane from
|
||||
// dropping out of the portal visibility list.
|
||||
if (_envCellFrustum is not null &&
|
||||
_envCellFrustum.TestBox(b.PortalBounds, ignoreNearPlane: true)
|
||||
== AcDream.App.Rendering.Wb.FrustumTestResult.Outside)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
output.Add(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderOutsideInAcdream(
|
||||
System.Numerics.Matrix4x4 viewProj,
|
||||
AcDream.App.Rendering.ICamera camera,
|
||||
AcDream.App.Rendering.FrustumPlanes? frustum,
|
||||
uint? playerLb,
|
||||
System.Collections.Generic.HashSet<uint>? animatedIds,
|
||||
System.Collections.Generic.IReadOnlyList<AcDream.App.Rendering.Wb.Building> visibleBuildings)
|
||||
{
|
||||
if (visibleBuildings.Count == 0)
|
||||
return;
|
||||
|
||||
var gl = _gl!;
|
||||
var visibleEnvCellIds = new System.Collections.Generic.HashSet<uint>();
|
||||
foreach (var b in visibleBuildings)
|
||||
{
|
||||
foreach (var id in b.EnvCellIds)
|
||||
visibleEnvCellIds.Add(id);
|
||||
}
|
||||
|
||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[outside-in] portals={visibleBuildings.Count} cells={visibleEnvCellIds.Count}");
|
||||
}
|
||||
|
||||
// WB VisibilityManager.RenderOutsideIn, but fed by the same
|
||||
// frustum-visible portal list prepared above instead of every loaded
|
||||
// building. Terrain/scenery are already drawn by the caller; this pass
|
||||
// opens portal silhouettes, repairs wall depth, then draws EnvCells
|
||||
// through those silhouettes. WB's outside-in EnvCell render passes a
|
||||
// null cell filter; the stencil/depth mask is the visibility gate.
|
||||
gl.Enable(EnableCap.StencilTest);
|
||||
gl.ClearStencil(0);
|
||||
gl.Clear(ClearBufferMask.StencilBufferBit);
|
||||
|
||||
// Step 1: mark visible building portals where the exterior depth test
|
||||
// says the portal surface is actually visible.
|
||||
gl.Disable(EnableCap.CullFace);
|
||||
gl.StencilFunc(StencilFunction.Always, 1, 0xFFu);
|
||||
gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace);
|
||||
gl.StencilMask(0xFFu);
|
||||
gl.ColorMask(false, false, false, false);
|
||||
gl.DepthMask(false);
|
||||
gl.Enable(EnableCap.DepthTest);
|
||||
gl.DepthFunc(DepthFunction.Less);
|
||||
foreach (var b in visibleBuildings)
|
||||
_indoorStencilPipeline!.RenderBuildingStencilMask(b, viewProj, writeFarDepth: false);
|
||||
|
||||
// Step 2: punch portal depth to the far plane.
|
||||
gl.StencilFunc(StencilFunction.Equal, 1, 0xFFu);
|
||||
gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep);
|
||||
gl.StencilMask(0x00u);
|
||||
gl.DepthMask(true);
|
||||
gl.DepthFunc(DepthFunction.Always);
|
||||
foreach (var b in visibleBuildings)
|
||||
_indoorStencilPipeline!.RenderBuildingStencilMask(b, viewProj, writeFarDepth: true);
|
||||
|
||||
// Step 3: depth-only repair for exterior walls that overlap portal
|
||||
// silhouettes, matching WB's staticObjectManager depth repair pass.
|
||||
// In acdream, the dispatcher can target just building shells here;
|
||||
// walking the full OutdoorScenery set would reprocess every tree and
|
||||
// outdoor static object only to write depth under portal masks.
|
||||
gl.Enable(EnableCap.CullFace);
|
||||
gl.FrontFace(FrontFaceDirection.CW);
|
||||
gl.DepthFunc(DepthFunction.Less);
|
||||
_meshShader!.Use();
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibleEnvCellIds,
|
||||
animatedEntityIds: null,
|
||||
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.BuildingShells);
|
||||
_a8PerfLastOutsideShellStats = _wbDrawDispatcher.LastDrawStats;
|
||||
|
||||
// Step 4/5: render EnvCells through the repaired stencil mask.
|
||||
//
|
||||
// WB EnvCellRenderManager owns BOTH cell geometry and EnvCell static
|
||||
// objects. A8 split that in acdream: EnvCellRenderer owns only the
|
||||
// CellStruct mesh, while static objects remain dispatcher WorldEntity
|
||||
// records with ParentCellId. Mirror WB's combined manager by drawing
|
||||
// the dispatcher IndoorPass through the same portal stencil and the
|
||||
// same WB-derived visible cell set used to prepare EnvCellRenderer.
|
||||
gl.StencilFunc(StencilFunction.Equal, 1, 0xFFu);
|
||||
gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep);
|
||||
gl.StencilMask(0x00u);
|
||||
gl.ColorMask(true, true, true, false);
|
||||
gl.DepthMask(true);
|
||||
gl.DepthFunc(DepthFunction.Less);
|
||||
gl.Disable(EnableCap.Blend);
|
||||
_meshShader!.Use();
|
||||
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Opaque, filter: null);
|
||||
|
||||
gl.Enable(EnableCap.Blend);
|
||||
gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||||
gl.DepthMask(false);
|
||||
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent, filter: null);
|
||||
gl.DepthMask(true);
|
||||
gl.Disable(EnableCap.Blend);
|
||||
|
||||
_meshShader!.Use();
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibleEnvCellIds,
|
||||
animatedEntityIds: null,
|
||||
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.IndoorPass);
|
||||
_a8PerfLastOutsideIndoorStats = _wbDrawDispatcher.LastDrawStats;
|
||||
|
||||
gl.Disable(EnableCap.StencilTest);
|
||||
gl.StencilMask(0xFFu);
|
||||
gl.ColorMask(true, true, true, true);
|
||||
gl.DepthMask(true);
|
||||
gl.DepthFunc(DepthFunction.Less);
|
||||
gl.FrontFace(FrontFaceDirection.CW);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.4: per-entity animation playback state for entities whose
|
||||
/// MotionTable resolved to a real cycle. The render loop ticks each
|
||||
|
|
@ -2281,23 +2434,27 @@ public sealed class GameWindow : IDisposable
|
|||
// its value is the live WorldEntity we need to dispose.
|
||||
RemoveLiveEntityByServerGuid(spawn.Guid, logDelete: false);
|
||||
|
||||
// Log every spawn that arrives so we can inventory what the server
|
||||
// When requested, log every spawn that arrives so we can inventory what the server
|
||||
// sends (including the ones we can't render yet). The Name field
|
||||
// is the critical one — we can grep the log for "Nullified Statue
|
||||
// of a Drudge" or similar to find a specific weenie by its
|
||||
// in-game name.
|
||||
string posStr = spawn.Position is { } sp
|
||||
? $"({sp.PositionX:F1},{sp.PositionY:F1},{sp.PositionZ:F1})@0x{sp.LandblockId:X8}"
|
||||
: "no-pos";
|
||||
string setupStr = spawn.SetupTableId is { } su ? $"0x{su:X8}" : "no-setup";
|
||||
string nameStr = spawn.Name is { Length: > 0 } n ? $"\"{n}\"" : "no-name";
|
||||
string itemTypeStr = spawn.ItemType is { } it ? $"0x{it:X8}" : "no-itemtype";
|
||||
int animPartCount = spawn.AnimPartChanges?.Count ?? 0;
|
||||
int texChangeCount = spawn.TextureChanges?.Count ?? 0;
|
||||
int subPalCount = spawn.SubPalettes?.Count ?? 0;
|
||||
Console.WriteLine(
|
||||
$"live: spawn guid=0x{spawn.Guid:X8} name={nameStr} setup={setupStr} pos={posStr} " +
|
||||
$"itemType={itemTypeStr} animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}");
|
||||
bool dumpLiveSpawns = _options.DumpLiveSpawns;
|
||||
if (dumpLiveSpawns)
|
||||
{
|
||||
string posStr = spawn.Position is { } sp
|
||||
? $"({sp.PositionX:F1},{sp.PositionY:F1},{sp.PositionZ:F1})@0x{sp.LandblockId:X8}"
|
||||
: "no-pos";
|
||||
string setupStr = spawn.SetupTableId is { } su ? $"0x{su:X8}" : "no-setup";
|
||||
string nameStr = spawn.Name is { Length: > 0 } n ? $"\"{n}\"" : "no-name";
|
||||
string itemTypeStr = spawn.ItemType is { } it ? $"0x{it:X8}" : "no-itemtype";
|
||||
int animPartCount = spawn.AnimPartChanges?.Count ?? 0;
|
||||
int texChangeCount = spawn.TextureChanges?.Count ?? 0;
|
||||
int subPalCount = spawn.SubPalettes?.Count ?? 0;
|
||||
Console.WriteLine(
|
||||
$"live: spawn guid=0x{spawn.Guid:X8} name={nameStr} setup={setupStr} pos={posStr} " +
|
||||
$"itemType={itemTypeStr} animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}");
|
||||
}
|
||||
|
||||
_liveEntityInfoByGuid[spawn.Guid] = new LiveEntityInfo(
|
||||
spawn.Name,
|
||||
|
|
@ -2308,7 +2465,9 @@ public sealed class GameWindow : IDisposable
|
|||
// Target the statue specifically for full diagnostic dump: Name match
|
||||
// is cheap and gives us exactly one entity's worth of log regardless
|
||||
// of arrival order.
|
||||
bool isStatue = spawn.Name is not null && spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase);
|
||||
bool isStatue = dumpLiveSpawns
|
||||
&& spawn.Name is not null
|
||||
&& spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase);
|
||||
if (isStatue)
|
||||
{
|
||||
Console.WriteLine($"live: [STATUE] objScale={spawn.ObjScale?.ToString("F3") ?? "null"}");
|
||||
|
|
@ -2388,8 +2547,9 @@ public sealed class GameWindow : IDisposable
|
|||
if (setup is null)
|
||||
{
|
||||
_liveDropReasonSetupDatMissing++;
|
||||
Console.WriteLine($"live: DROP setup dat 0x{spawn.SetupTableId.Value:X8} missing " +
|
||||
$"(guid=0x{spawn.Guid:X8})");
|
||||
if (dumpLiveSpawns)
|
||||
Console.WriteLine($"live: DROP setup dat 0x{spawn.SetupTableId.Value:X8} missing " +
|
||||
$"(guid=0x{spawn.Guid:X8})");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -2627,7 +2787,9 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
// Second pass: resolve each affected part's Surface chain and
|
||||
// build the Surface-id-keyed override map the renderer consumes.
|
||||
bool isStatueDiag = spawn.Name is not null && spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase);
|
||||
bool isStatueDiag = dumpLiveSpawns
|
||||
&& spawn.Name is not null
|
||||
&& spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase);
|
||||
resolvedOverridesByPart = new Dictionary<int, Dictionary<uint, uint>>();
|
||||
for (int pi = 0; pi < parts.Count; pi++)
|
||||
{
|
||||
|
|
@ -2720,8 +2882,9 @@ public sealed class GameWindow : IDisposable
|
|||
if (meshRefs.Count == 0)
|
||||
{
|
||||
_liveDropReasonNoMeshRefs++;
|
||||
Console.WriteLine($"live: DROP no mesh refs from setup 0x{spawn.SetupTableId.Value:X8} " +
|
||||
$"(guid=0x{spawn.Guid:X8})");
|
||||
if (dumpLiveSpawns)
|
||||
Console.WriteLine($"live: DROP no mesh refs from setup 0x{spawn.SetupTableId.Value:X8} " +
|
||||
$"(guid=0x{spawn.Guid:X8})");
|
||||
return;
|
||||
}
|
||||
if (dumpClothing)
|
||||
|
|
@ -3008,7 +3171,7 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
// Dump a summary periodically so we can see drop breakdowns without
|
||||
// waiting for a graceful shutdown.
|
||||
if (_liveSpawnReceived % 20 == 0)
|
||||
if (dumpLiveSpawns && _liveSpawnReceived % 20 == 0)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"live: animated={_animatedEntities.Count} " +
|
||||
|
|
@ -5173,6 +5336,7 @@ public sealed class GameWindow : IDisposable
|
|||
Rotation = e.Rotation,
|
||||
MeshRefs = meshRefs,
|
||||
IsBuildingShell = e.IsBuildingShell, // Phase A8: preserve dat-level tag
|
||||
BuildingShellAnchorCellId = e.BuildingShellAnchorCellId,
|
||||
};
|
||||
hydrated.Add(entity);
|
||||
}
|
||||
|
|
@ -6415,7 +6579,7 @@ public sealed class GameWindow : IDisposable
|
|||
lbNoneCount++;
|
||||
}
|
||||
}
|
||||
if (scTried > 0)
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled && scTried > 0)
|
||||
Console.WriteLine(
|
||||
$"lb 0x{lb.LandblockId:X8}: scenery tried={scTried} registered={scRegistered} " +
|
||||
$"noBounds={scNoBounds} tooThin={scTooThin} (outdoorNone={lbNoneCount})");
|
||||
|
|
@ -6439,7 +6603,7 @@ public sealed class GameWindow : IDisposable
|
|||
sampleMissing.Add(entity.SourceGfxObjOrSetupId);
|
||||
}
|
||||
}
|
||||
if (sceneryNoCache > 0)
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled && sceneryNoCache > 0)
|
||||
{
|
||||
string samples = string.Join(",", sampleMissing.Select(s => $"0x{s:X8}"));
|
||||
Console.WriteLine($" → {sceneryNoCache} scenery entities had no visual bounds cached. Samples: {samples}");
|
||||
|
|
@ -6965,8 +7129,56 @@ public sealed class GameWindow : IDisposable
|
|||
private double _perfAccum;
|
||||
private int _perfFrameCount;
|
||||
|
||||
private long _a8PerfLastLogTick;
|
||||
private long _a8PerfFrames;
|
||||
private long _a8PerfInsideFrames;
|
||||
private long _a8PerfOutsideInFrames;
|
||||
private long _a8PerfTickAnimTicks;
|
||||
private long _a8PerfCollectTicks;
|
||||
private long _a8PerfEnvPrepareTicks;
|
||||
private long _a8PerfTerrainTicks;
|
||||
private long _a8PerfStaticTicks;
|
||||
private long _a8PerfOutsideInTicks;
|
||||
private long _a8PerfLiveTicks;
|
||||
private long _a8PerfInsideOutTicks;
|
||||
private long _a8PerfInsideLiveTicks;
|
||||
private int _a8PerfLastPortalBuildings;
|
||||
private int _a8PerfMaxPortalBuildings;
|
||||
private int _a8PerfLastPortalCells;
|
||||
private int _a8PerfMaxPortalCells;
|
||||
private int _a8PerfLastVisibleLandblocks;
|
||||
private int _a8PerfLastTotalLandblocks;
|
||||
private readonly System.Collections.Generic.HashSet<uint> _a8PerfCellScratch = new();
|
||||
private const int A8PerfGpuRingDepth = 4;
|
||||
private const int A8PerfGpuPassCount = 6;
|
||||
private const int A8PerfGpuTerrain = 0;
|
||||
private const int A8PerfGpuStatic = 1;
|
||||
private const int A8PerfGpuOutsideIn = 2;
|
||||
private const int A8PerfGpuLive = 3;
|
||||
private const int A8PerfGpuInsideOut = 4;
|
||||
private const int A8PerfGpuInsideLive = 5;
|
||||
private readonly uint[] _a8PerfGpuQueries = new uint[A8PerfGpuRingDepth * A8PerfGpuPassCount];
|
||||
private bool _a8PerfGpuQueriesInitialized;
|
||||
private int _a8PerfGpuFrameIndex;
|
||||
private readonly bool[] _a8PerfGpuIssued = new bool[A8PerfGpuRingDepth * A8PerfGpuPassCount];
|
||||
private long _a8PerfTerrainGpuNs;
|
||||
private long _a8PerfStaticGpuNs;
|
||||
private long _a8PerfOutsideInGpuNs;
|
||||
private long _a8PerfLiveGpuNs;
|
||||
private long _a8PerfInsideOutGpuNs;
|
||||
private long _a8PerfInsideLiveGpuNs;
|
||||
private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastStaticStats;
|
||||
private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastLiveStats;
|
||||
private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastOutsideShellStats;
|
||||
private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastOutsideIndoorStats;
|
||||
private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastInsideStats;
|
||||
private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastInsideLiveStats;
|
||||
|
||||
private void OnRender(double deltaSeconds)
|
||||
{
|
||||
bool a8Perf = A8PerfEnabled();
|
||||
int a8GpuSlot = A8PerfBeginGpuFrame(a8Perf);
|
||||
|
||||
// Phase G.1: set the clear color from the current sky's fog
|
||||
// tint so the horizon band continues naturally past the
|
||||
// rendered geometry. Fog blends to this color at max distance
|
||||
|
|
@ -6990,6 +7202,13 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit | ClearBufferMask.StencilBufferBit);
|
||||
|
||||
// WB GameScene.cs:830-843 establishes CW as the frame-global
|
||||
// front-face convention; per-batch CullMode changes only the culled
|
||||
// side. A8 indoor/env-cell geometry and setup meshes share that
|
||||
// convention, so keep the GL state aligned before any scene pass.
|
||||
_gl.CullFace(TriangleFace.Back);
|
||||
_gl.FrontFace(FrontFaceDirection.CW);
|
||||
|
||||
// Phase N.6 slice 1: one-shot surface-format histogram dump under
|
||||
// ACDREAM_DUMP_SURFACES=1. Zero cost when off.
|
||||
_textureCache?.TickSurfaceHistogramDumpIfEnabled();
|
||||
|
|
@ -7008,8 +7227,10 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
// Phase 6.4: advance per-entity animation playback before drawing
|
||||
// so the renderer always sees the up-to-date per-part transforms.
|
||||
long a8PerfStart = A8PerfStart(a8Perf);
|
||||
if (_animatedEntities.Count > 0)
|
||||
TickAnimations((float)deltaSeconds);
|
||||
A8PerfStop(a8Perf, ref _a8PerfTickAnimTicks, a8PerfStart);
|
||||
|
||||
// Phase G.1: weather state machine — deterministic per-day roll
|
||||
// + transitions + lightning flash.
|
||||
|
|
@ -7126,6 +7347,9 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
var camBuildings = new System.Collections.Generic.List<AcDream.App.Rendering.Wb.Building>();
|
||||
var otherBuildings = new System.Collections.Generic.List<AcDream.App.Rendering.Wb.Building>();
|
||||
var visiblePortalBuildings = new System.Collections.Generic.List<AcDream.App.Rendering.Wb.Building>();
|
||||
System.Collections.Generic.HashSet<uint>? envCellPrepareFilter = null;
|
||||
int visiblePortalCellCount = 0;
|
||||
|
||||
if (cameraInsideBuilding)
|
||||
{
|
||||
|
|
@ -7135,12 +7359,6 @@ public sealed class GameWindow : IDisposable
|
|||
foreach (var b in reg.GetBuildingsContainingCell(visibility.CameraCell.CellId))
|
||||
camBuildings.Add(b);
|
||||
}
|
||||
|
||||
var camCellId = visibility!.CameraCell!.CellId;
|
||||
foreach (var rr in _buildingRegistries.Values)
|
||||
foreach (var b in rr.All())
|
||||
if (!b.EnvCellIds.Contains(camCellId))
|
||||
otherBuildings.Add(b);
|
||||
}
|
||||
|
||||
// SPIKE 2026-05-26: A8 transition investigation. Lights up the
|
||||
|
|
@ -7249,11 +7467,67 @@ public sealed class GameWindow : IDisposable
|
|||
playerLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF);
|
||||
}
|
||||
|
||||
int renderCenterLbX = _liveCenterX + (int)System.Math.Floor(camPos.X / 192f);
|
||||
int renderCenterLbY = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f);
|
||||
|
||||
// Phase A8: prepare EnvCellRenderer's per-frame visibility snapshot.
|
||||
// Always called — cheap when no cells loaded, cheap when frustum culls all.
|
||||
var envCellViewProj = camera.View * camera.Projection;
|
||||
_envCellFrustum?.Update(envCellViewProj);
|
||||
_envCellRenderer?.PrepareRenderBatches(envCellViewProj, camPos);
|
||||
if (a8IndoorBranchEnabled)
|
||||
{
|
||||
a8PerfStart = A8PerfStart(a8Perf);
|
||||
CollectVisiblePortalBuildings(
|
||||
visiblePortalBuildings,
|
||||
renderCenterLbX,
|
||||
renderCenterLbY,
|
||||
_nearRadius);
|
||||
|
||||
// WB VisibilityManager.PrepareVisibility builds the EnvCell
|
||||
// set before EnvCellRenderManager.PrepareRenderBatches, then
|
||||
// RenderOutsideIn calls Render(..., null) against that already-
|
||||
// narrowed snapshot. Keep that two-stage shape: the stencil is
|
||||
// the render gate, but the prepared workload remains limited
|
||||
// to camera-building cells, runtime portal-visible cells, plus
|
||||
// frustum-visible portal cells.
|
||||
envCellPrepareFilter = new System.Collections.Generic.HashSet<uint>();
|
||||
foreach (var b in camBuildings)
|
||||
foreach (var id in b.EnvCellIds)
|
||||
envCellPrepareFilter.Add(id);
|
||||
if (visibility?.VisibleCellIds is not null)
|
||||
foreach (var id in visibility.VisibleCellIds)
|
||||
envCellPrepareFilter.Add(id);
|
||||
foreach (var b in visiblePortalBuildings)
|
||||
foreach (var id in b.EnvCellIds)
|
||||
envCellPrepareFilter.Add(id);
|
||||
visiblePortalCellCount = envCellPrepareFilter.Count;
|
||||
|
||||
if (a8Perf)
|
||||
{
|
||||
_a8PerfCellScratch.Clear();
|
||||
foreach (var id in envCellPrepareFilter)
|
||||
_a8PerfCellScratch.Add(id);
|
||||
}
|
||||
A8PerfStop(a8Perf, ref _a8PerfCollectTicks, a8PerfStart);
|
||||
if (cameraInsideBuilding && visibility?.CameraCell is not null)
|
||||
{
|
||||
var camCellId = visibility.CameraCell.CellId;
|
||||
foreach (var b in visiblePortalBuildings)
|
||||
{
|
||||
if (!b.EnvCellIds.Contains(camCellId))
|
||||
otherBuildings.Add(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
a8PerfStart = A8PerfStart(a8Perf);
|
||||
_envCellRenderer?.PrepareRenderBatches(
|
||||
envCellViewProj,
|
||||
camPos,
|
||||
envCellPrepareFilter,
|
||||
centerLbX: renderCenterLbX,
|
||||
centerLbY: renderCenterLbY,
|
||||
renderRadius: _nearRadius);
|
||||
A8PerfStop(a8Perf, ref _a8PerfEnvPrepareTicks, a8PerfStart);
|
||||
|
||||
// Phase G.1: sky renderer — draws the far-plane-infinity
|
||||
// celestial meshes FIRST so the rest of the scene z-tests
|
||||
|
|
@ -7315,8 +7589,12 @@ public sealed class GameWindow : IDisposable
|
|||
// The proper fix is to NOT draw terrain here when indoor; Step 4
|
||||
// is the single, stencil-gated terrain pass.
|
||||
_terrainCpuStopwatch.Restart();
|
||||
a8PerfStart = A8PerfStart(a8Perf);
|
||||
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuTerrain);
|
||||
if (!cameraInsideBuilding)
|
||||
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
|
||||
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
|
||||
A8PerfStop(a8Perf, ref _a8PerfTerrainTicks, a8PerfStart);
|
||||
_terrainCpuStopwatch.Stop();
|
||||
// Multiply by 100 then divide by 100 in the diag print to keep
|
||||
// 0.01 µs precision in the long-typed sample buffer. Terrain Draw
|
||||
|
|
@ -7357,10 +7635,14 @@ public sealed class GameWindow : IDisposable
|
|||
// but aren't stencil-clipped.
|
||||
if (cameraInsideBuilding)
|
||||
{
|
||||
a8PerfStart = A8PerfStart(a8Perf);
|
||||
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuInsideOut);
|
||||
RenderInsideOutAcdream(envCellViewProj, camPos, visibility!.CameraCell!,
|
||||
camBuildings, otherBuildings,
|
||||
camera, frustum, playerLb, animatedIds,
|
||||
visibility?.VisibleCellIds);
|
||||
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
|
||||
A8PerfStop(a8Perf, ref _a8PerfInsideOutTicks, a8PerfStart);
|
||||
|
||||
// Phase A8 fix (2026-05-28 visual-gate-#2 follow-up): LiveDynamic
|
||||
// entities (player char, NPCs, dropped items, doors) were missing
|
||||
|
|
@ -7372,19 +7654,63 @@ public sealed class GameWindow : IDisposable
|
|||
// stencil + state restored to defaults at its cleanup block. Same
|
||||
// shape as the outdoor branch's Draw(All) for the LiveDynamic
|
||||
// subset only.
|
||||
a8PerfStart = A8PerfStart(a8Perf);
|
||||
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuInsideLive);
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibility?.VisibleCellIds,
|
||||
animatedEntityIds: animatedIds,
|
||||
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.LiveDynamic);
|
||||
_a8PerfLastInsideLiveStats = _wbDrawDispatcher.LastDrawStats;
|
||||
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
|
||||
A8PerfStop(a8Perf, ref _a8PerfInsideLiveTicks, a8PerfStart);
|
||||
}
|
||||
else
|
||||
{
|
||||
// N.5: WbDrawDispatcher is always non-null (modern path mandatory).
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibility?.VisibleCellIds,
|
||||
animatedEntityIds: animatedIds);
|
||||
if (a8IndoorBranchEnabled)
|
||||
{
|
||||
a8PerfStart = A8PerfStart(a8Perf);
|
||||
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuStatic);
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibility?.VisibleCellIds,
|
||||
animatedEntityIds: null,
|
||||
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.OutdoorScenery);
|
||||
_a8PerfLastStaticStats = _wbDrawDispatcher.LastDrawStats;
|
||||
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
|
||||
A8PerfStop(a8Perf, ref _a8PerfStaticTicks, a8PerfStart);
|
||||
|
||||
a8PerfStart = A8PerfStart(a8Perf);
|
||||
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuOutsideIn);
|
||||
RenderOutsideInAcdream(envCellViewProj, camera, frustum, playerLb,
|
||||
animatedIds, visiblePortalBuildings);
|
||||
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
|
||||
A8PerfStop(a8Perf, ref _a8PerfOutsideInTicks, a8PerfStart);
|
||||
|
||||
a8PerfStart = A8PerfStart(a8Perf);
|
||||
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuLive);
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibility?.VisibleCellIds,
|
||||
animatedEntityIds: animatedIds,
|
||||
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.LiveDynamic);
|
||||
_a8PerfLastLiveStats = _wbDrawDispatcher.LastDrawStats;
|
||||
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
|
||||
A8PerfStop(a8Perf, ref _a8PerfLiveTicks, a8PerfStart);
|
||||
}
|
||||
else
|
||||
{
|
||||
a8PerfStart = A8PerfStart(a8Perf);
|
||||
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuStatic);
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibility?.VisibleCellIds,
|
||||
animatedEntityIds: animatedIds);
|
||||
_a8PerfLastStaticStats = _wbDrawDispatcher.LastDrawStats;
|
||||
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
|
||||
A8PerfStop(a8Perf, ref _a8PerfStaticTicks, a8PerfStart);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase G.1 / E.3: draw all live particles after opaque
|
||||
|
|
@ -7488,12 +7814,19 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
|
||||
// Count visible vs total for the perf overlay.
|
||||
foreach (var entry in _worldState.LandblockEntries)
|
||||
foreach (var entry in _worldState.LandblockBounds)
|
||||
{
|
||||
totalLandblocks++;
|
||||
if (AcDream.App.Rendering.FrustumCuller.IsAabbVisible(frustum, entry.AabbMin, entry.AabbMax))
|
||||
visibleLandblocks++;
|
||||
}
|
||||
MaybeFlushA8Perf(
|
||||
a8Perf,
|
||||
cameraInsideBuilding,
|
||||
visiblePortalBuildings.Count,
|
||||
visiblePortalCellCount,
|
||||
visibleLandblocks,
|
||||
totalLandblocks);
|
||||
|
||||
// Phase I.2: refresh per-frame fields that DebugVM closures
|
||||
// can't compute lazily (frustum-derived counters + nearest-
|
||||
|
|
@ -7547,6 +7880,8 @@ public sealed class GameWindow : IDisposable
|
|||
// GL state it touches (blend, scissor, VAO, shader, texture); any
|
||||
// state not in its save-list (e.g. GL_FRAMEBUFFER_SRGB, unused
|
||||
// today) would need manual protection.
|
||||
A8PerfEndGpuFrame(a8Perf);
|
||||
|
||||
if (DevToolsEnabled && _imguiBootstrap is not null && _panelHost is not null)
|
||||
{
|
||||
// Phase I.3 — prefer the live command bus when a live session
|
||||
|
|
@ -10691,8 +11026,32 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
EmitBuildingsProbe(visibilityCellId: cameraCell.CellId, camBuildings, otherBuildings);
|
||||
|
||||
// Steps 1+2: stencil bit 1 + far-depth punch at camera-buildings' portals.
|
||||
if (camBuildings.Count > 0)
|
||||
bool diagDisableStep4Terrain = _options.A8DiagDisableInsideStep4Terrain;
|
||||
bool diagDisableStep4Outdoor = _options.A8DiagDisableInsideStep4Outdoor;
|
||||
bool diagDisableStep3EnvCellOpaque = _options.A8DiagDisableInsideStep3EnvCellOpaque;
|
||||
bool diagDisableStep3IndoorPass = _options.A8DiagDisableInsideStep3IndoorPass;
|
||||
bool diagDisableStep2Punch = _options.A8DiagDisableInsideStep2Punch;
|
||||
bool diagDisablePortalDepthClamp = _options.A8DiagDisablePortalDepthClamp;
|
||||
|
||||
var visiblePortalCells = new System.Collections.Generic.List<AcDream.App.Rendering.LoadedCell>();
|
||||
if (visibleCellIds is not null)
|
||||
{
|
||||
foreach (uint cellId in visibleCellIds)
|
||||
{
|
||||
if (_cellVisibility.TryGetCell(cellId, out var cell) && cell is not null)
|
||||
visiblePortalCells.Add(cell);
|
||||
}
|
||||
}
|
||||
|
||||
int insidePortalVertexCount = visiblePortalCells.Count > 0
|
||||
? _indoorStencilPipeline!.UploadPortalMesh(visiblePortalCells, camPos)
|
||||
: 0;
|
||||
|
||||
// Steps 1+2: stencil bit 1 + far-depth punch at portal-visible exits only.
|
||||
// WB builds its outside view from the current portal traversal; using every
|
||||
// exit on the camera building over-punches terrain through indoor openings
|
||||
// when an unrelated window/door portal overlaps them in screen space.
|
||||
if (insidePortalVertexCount > 0)
|
||||
{
|
||||
didInsideStencil = true;
|
||||
gl.Enable(EnableCap.StencilTest);
|
||||
|
|
@ -10711,26 +11070,30 @@ public sealed class GameWindow : IDisposable
|
|||
gl.DepthFunc(DepthFunction.Always);
|
||||
|
||||
EmitDrawOrderProbe(step: 1, sub: ' ');
|
||||
foreach (var b in camBuildings)
|
||||
{
|
||||
_indoorStencilPipeline!.RenderBuildingStencilMask(b, viewProj, writeFarDepth: false);
|
||||
EmitStencilProbe(op: "mark");
|
||||
}
|
||||
_indoorStencilPipeline!.DrawUploadedPortalMesh(
|
||||
viewProj,
|
||||
writeFarDepth: false,
|
||||
enableDepthClamp: !diagDisablePortalDepthClamp);
|
||||
EmitStencilProbe(op: "mark-visible");
|
||||
|
||||
// Step 2: punch depth at portals.
|
||||
// WB VisibilityManager.cs:99-104
|
||||
gl.DepthMask(true);
|
||||
gl.DepthFunc(DepthFunction.Always);
|
||||
|
||||
EmitDrawOrderProbe(step: 2, sub: ' ');
|
||||
foreach (var b in camBuildings)
|
||||
if (!diagDisableStep2Punch)
|
||||
{
|
||||
_indoorStencilPipeline!.RenderBuildingStencilMask(b, viewProj, writeFarDepth: true);
|
||||
EmitStencilProbe(op: "punch");
|
||||
// Step 2: punch depth at portals.
|
||||
// WB VisibilityManager.cs:99-104
|
||||
gl.DepthMask(true);
|
||||
gl.DepthFunc(DepthFunction.Always);
|
||||
|
||||
EmitDrawOrderProbe(step: 2, sub: ' ');
|
||||
_indoorStencilPipeline!.DrawUploadedPortalMesh(
|
||||
viewProj,
|
||||
writeFarDepth: true,
|
||||
enableDepthClamp: !diagDisablePortalDepthClamp);
|
||||
EmitStencilProbe(op: "punch-visible");
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: render camera-buildings' cells (stencil off, DepthFunc.Less).
|
||||
// Step 3: render the indoor cells visible from the camera cell
|
||||
// (stencil off, DepthFunc.Less).
|
||||
// WB VisibilityManager.cs:107-127
|
||||
gl.ColorMask(true, true, true, false);
|
||||
gl.DepthMask(true);
|
||||
|
|
@ -10744,12 +11107,27 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
foreach (var b in camBuildings)
|
||||
foreach (var id in b.EnvCellIds) currentEnvCellIds.Add(id);
|
||||
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Opaque, currentEnvCellIds);
|
||||
// A8 cellar-flap provenance (2026-05-28): disabling Step 4
|
||||
// terrain removes the green flap, proving terrain is the writer.
|
||||
// The leak happens because indoor-to-indoor portal cells reached
|
||||
// by the runtime visibility BFS can be outside the static
|
||||
// BuildingInfo cell set. Render them in Step 3 so their depth
|
||||
// blocks Step 4 terrain, while real exterior openings still show
|
||||
// terrain through the portal mask.
|
||||
if (visibleCellIds is not null)
|
||||
foreach (var id in visibleCellIds)
|
||||
currentEnvCellIds.Add(id);
|
||||
gl.Disable(EnableCap.Blend);
|
||||
if (!diagDisableStep3EnvCellOpaque)
|
||||
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Opaque, currentEnvCellIds);
|
||||
|
||||
// Transparency pass.
|
||||
gl.Enable(EnableCap.Blend);
|
||||
gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||||
gl.DepthMask(false);
|
||||
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent, currentEnvCellIds);
|
||||
gl.DepthMask(true);
|
||||
gl.Disable(EnableCap.Blend);
|
||||
}
|
||||
|
||||
// FIX 2026-05-28 (post-third-visual-gate): render IndoorPass entities.
|
||||
|
|
@ -10764,20 +11142,22 @@ public sealed class GameWindow : IDisposable
|
|||
// Result with the missing call: user reports "house missing lots of
|
||||
// walls" — the cottage's exterior wall slabs aren't drawn.
|
||||
//
|
||||
// Render IndoorPass between Step 3 and Step 4, with the
|
||||
// currentEnvCellIds filter narrowing cell stabs but NOT the building
|
||||
// shells (they have no ParentCellId and pass through). Depth-test
|
||||
// with DepthFunc.Less so cottage-A's near walls occlude cottage-B's
|
||||
// far walls. NO stencil — we want them rendered unconditionally
|
||||
// inside the camera-building.
|
||||
if (camBuildings.Count > 0)
|
||||
// Render IndoorPass between Step 3 and Step 4. The currentEnvCellIds
|
||||
// filter now narrows both cell stabs and building shells: shells have
|
||||
// no ParentCellId, but carry BuildingShellAnchorCellId from
|
||||
// LandBlockInfo.Buildings[]. Depth-test with DepthFunc.Less so the
|
||||
// current cottage shell occludes any farther geometry. NO stencil: we
|
||||
// want the active building shell rendered unconditionally inside the
|
||||
// camera-building.
|
||||
if (camBuildings.Count > 0 && !diagDisableStep3IndoorPass)
|
||||
{
|
||||
_meshShader!.Use();
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: currentEnvCellIds,
|
||||
animatedEntityIds: animatedIds,
|
||||
animatedEntityIds: null,
|
||||
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.IndoorPass);
|
||||
_a8PerfLastInsideStats = _wbDrawDispatcher.LastDrawStats;
|
||||
}
|
||||
|
||||
EmitEnvCellProbe(camBuildings.Count, otherBuildings.Count, currentEnvCellIds.Count);
|
||||
|
|
@ -10794,31 +11174,52 @@ public sealed class GameWindow : IDisposable
|
|||
gl.DepthMask(true);
|
||||
gl.Enable(EnableCap.CullFace);
|
||||
gl.DepthFunc(DepthFunction.Less);
|
||||
|
||||
EmitDrawOrderProbe(step: 4, sub: ' ');
|
||||
// Terrain (WB line 143).
|
||||
// acdream's retail/ACME terrain mesh is CCW from the visible top side
|
||||
// (see terrain_modern.vert's LandblockMesh order comment), while WB's
|
||||
// editor terrain uses the opposite vertex order under its global CW
|
||||
// convention. Step 4 enables culling before terrain, so temporarily
|
||||
// use terrain's own front-face convention or ground disappears through
|
||||
// indoor portal silhouettes.
|
||||
if (!diagDisableStep4Terrain)
|
||||
{
|
||||
gl.FrontFace(FrontFaceDirection.Ccw);
|
||||
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
|
||||
gl.FrontFace(FrontFaceDirection.CW);
|
||||
}
|
||||
else
|
||||
{
|
||||
gl.FrontFace(FrontFaceDirection.CW);
|
||||
}
|
||||
|
||||
_meshShader!.Use();
|
||||
// Scenery + static objects via dispatcher (WB lines 148-154).
|
||||
if (!diagDisableStep4Outdoor)
|
||||
{
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibleCellIds, // OK - outdoor cells outside the building
|
||||
animatedEntityIds: null,
|
||||
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.OutdoorScenery);
|
||||
_a8PerfLastStaticStats = _wbDrawDispatcher.LastDrawStats;
|
||||
}
|
||||
}
|
||||
|
||||
EmitDrawOrderProbe(step: 4, sub: ' ');
|
||||
// Terrain (WB line 143).
|
||||
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
|
||||
|
||||
_meshShader!.Use();
|
||||
// Scenery + static objects via dispatcher (WB lines 148-154).
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibleCellIds, // OK — outdoor cells outside the building
|
||||
animatedEntityIds: animatedIds,
|
||||
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.OutdoorScenery);
|
||||
|
||||
// Step 5: per-other-building 3-bit stencil pipeline.
|
||||
// WB VisibilityManager.cs:157-232
|
||||
//
|
||||
// FIX 2026-05-28 (post-first-visual-gate): Step 5 is GATED OFF BY DEFAULT.
|
||||
// First visual gate showed perf collapse + texture flicker indoors because
|
||||
// Step 5 iterates EVERY loaded other-building per frame (109 at Holtburg),
|
||||
// each doing 5 GL draws (mark/end-query/punch/render-opaque/render-trans/reset)
|
||||
// = ~545 extra draws/frame with no frustum culling. The driver hit TDR
|
||||
// limits. Set ACDREAM_A8_STEP5=1 to re-enable for cross-building visibility
|
||||
// testing once we add per-building frustum culling.
|
||||
bool step5Enabled = string.Equals(Environment.GetEnvironmentVariable("ACDREAM_A8_STEP5"), "1", StringComparison.Ordinal);
|
||||
// GATED OFF BY DEFAULT. Current acdream does not yet have WB's
|
||||
// portal-manager visibility/occlusion lifecycle; feeding this loop
|
||||
// directly from all loaded building registries causes unrelated
|
||||
// buildings' EnvCells to overwrite exterior walls through stale or
|
||||
// over-broad portal masks. Keep the apparatus, but require an explicit
|
||||
// opt-in until the portal list is proven equivalent to WB's
|
||||
// _visibleBuildingPortals.
|
||||
bool step5Enabled = string.Equals(
|
||||
Environment.GetEnvironmentVariable("ACDREAM_A8_STEP5"), "1",
|
||||
StringComparison.Ordinal);
|
||||
if (step5Enabled && didInsideStencil && otherBuildings.Count > 0)
|
||||
{
|
||||
gl.Enable(EnableCap.StencilTest);
|
||||
|
|
@ -10861,10 +11262,14 @@ public sealed class GameWindow : IDisposable
|
|||
gl.DepthFunc(DepthFunction.Less);
|
||||
gl.Enable(EnableCap.CullFace);
|
||||
_meshShader!.Use();
|
||||
gl.Disable(EnableCap.Blend);
|
||||
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Opaque, b.EnvCellIds);
|
||||
gl.Enable(EnableCap.Blend);
|
||||
gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||||
gl.DepthMask(false);
|
||||
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent, b.EnvCellIds);
|
||||
gl.DepthMask(true);
|
||||
gl.Disable(EnableCap.Blend);
|
||||
|
||||
// Step 5d: reset bit 2 (Ref=1, Mask=0x02) for next iteration. WB VisibilityManager.cs:222-228
|
||||
EmitDrawOrderProbe(step: 5, sub: 'd');
|
||||
|
|
@ -10896,6 +11301,13 @@ public sealed class GameWindow : IDisposable
|
|||
gl.DepthFunc(DepthFunction.Less);
|
||||
gl.Enable(EnableCap.CullFace);
|
||||
}
|
||||
|
||||
// If no visible exit portal was uploaded, Step 4 is skipped but Step 3
|
||||
// still leaves alpha writes disabled. Restore the outer-frame defaults.
|
||||
gl.ColorMask(true, true, true, true);
|
||||
gl.DepthMask(true);
|
||||
gl.DepthFunc(DepthFunction.Less);
|
||||
gl.Enable(EnableCap.CullFace);
|
||||
}
|
||||
|
||||
// ── Phase A8 Task 9 (2026-05-28): probe trail for RenderInsideOutAcdream ──
|
||||
|
|
@ -10915,7 +11327,8 @@ public sealed class GameWindow : IDisposable
|
|||
// - [buildings] camBldgs=[0x...] non-empty when inside a cottage
|
||||
// - [envcells] cells>=1 tris>=1 filterCnt>=1 for at least one indoor frame
|
||||
// - [stencil] op=mark verts>0 fires per camera-building
|
||||
// - [draworder] shows steps 1 → 2 → 3 → 4 → 5{a,b,c,d} per indoor frame
|
||||
// - [draworder] shows steps 1 → 2 → 3 → 4 per indoor frame
|
||||
// (and 5{a,b,c,d} only when ACDREAM_A8_STEP5=1)
|
||||
|
||||
private int _phaseA8DrawOrderFrame = 0;
|
||||
|
||||
|
|
@ -10942,6 +11355,7 @@ public sealed class GameWindow : IDisposable
|
|||
gl.GetBoolean(Silk.NET.OpenGL.GLEnum.DepthWritemask, out var depthMask);
|
||||
gl.GetInteger(Silk.NET.OpenGL.GLEnum.CullFace, out int cullEnabled);
|
||||
gl.GetInteger(Silk.NET.OpenGL.GLEnum.CullFaceMode, out int cullMode);
|
||||
gl.GetInteger(Silk.NET.OpenGL.GLEnum.FrontFace, out int frontFace);
|
||||
gl.GetInteger(Silk.NET.OpenGL.GLEnum.BlendSrc, out int blendSrc);
|
||||
gl.GetInteger(Silk.NET.OpenGL.GLEnum.BlendDst, out int blendDst);
|
||||
gl.GetInteger(Silk.NET.OpenGL.GLEnum.StencilFunc, out int sFunc);
|
||||
|
|
@ -10971,6 +11385,7 @@ public sealed class GameWindow : IDisposable
|
|||
$"[draworder] frame={_phaseA8DrawOrderFrame} step={step}{subStr} " +
|
||||
$"stencil={(stOn != 0 ? "on" : "off")} depthFn=0x{depthFn:X} depthMask={depthMask} " +
|
||||
$"cull={(cullEnabled != 0 ? "on" : "off")}({(cullMode == (int)Silk.NET.OpenGL.GLEnum.Front ? "front" : cullMode == (int)Silk.NET.OpenGL.GLEnum.Back ? "back" : "f+b")}) " +
|
||||
$"front={(frontFace == (int)Silk.NET.OpenGL.GLEnum.CW ? "cw" : "ccw")} " +
|
||||
$"blend=0x{blendSrc:X}/0x{blendDst:X} " +
|
||||
$"sFunc=0x{sFunc:X}:{sRef}:0x{sValMask:X} " +
|
||||
$"sOp=0x{sFail:X}/0x{sPdFail:X}/0x{sPdPass:X} sMask=0x{sWriteMask:X} " +
|
||||
|
|
@ -11035,6 +11450,152 @@ public sealed class GameWindow : IDisposable
|
|||
/// rolling 256-sample buffer of microseconds, median + p95 reported.
|
||||
/// Sample buffer is NOT cleared on flush — it's a moving window so the
|
||||
/// next 5s window already has 256 frames of recent history.</summary>
|
||||
private static bool A8PerfEnabled()
|
||||
=> string.Equals(Environment.GetEnvironmentVariable("ACDREAM_A8_PERF"), "1", StringComparison.Ordinal);
|
||||
|
||||
private static long A8PerfStart(bool enabled)
|
||||
=> enabled ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
|
||||
|
||||
private static void A8PerfStop(bool enabled, ref long bucket, long startTick)
|
||||
{
|
||||
if (!enabled || startTick == 0) return;
|
||||
bucket += System.Diagnostics.Stopwatch.GetTimestamp() - startTick;
|
||||
}
|
||||
|
||||
private int A8PerfBeginGpuFrame(bool enabled)
|
||||
{
|
||||
if (!enabled || _gl is null) return -1;
|
||||
|
||||
if (!_a8PerfGpuQueriesInitialized)
|
||||
{
|
||||
for (int i = 0; i < _a8PerfGpuQueries.Length; i++)
|
||||
_a8PerfGpuQueries[i] = _gl.GenQuery();
|
||||
_a8PerfGpuQueriesInitialized = true;
|
||||
}
|
||||
|
||||
int slot = _a8PerfGpuFrameIndex % A8PerfGpuRingDepth;
|
||||
if (_a8PerfGpuFrameIndex >= A8PerfGpuRingDepth)
|
||||
{
|
||||
for (int pass = 0; pass < A8PerfGpuPassCount; pass++)
|
||||
{
|
||||
int queryIndex = slot * A8PerfGpuPassCount + pass;
|
||||
if (!_a8PerfGpuIssued[queryIndex]) continue;
|
||||
uint query = _a8PerfGpuQueries[queryIndex];
|
||||
_gl.GetQueryObject(query, QueryObjectParameterName.ResultAvailable, out int available);
|
||||
if (available == 0) continue;
|
||||
_gl.GetQueryObject(query, QueryObjectParameterName.Result, out ulong elapsedNs);
|
||||
A8PerfAccumulateGpu(pass, elapsedNs);
|
||||
_a8PerfGpuIssued[queryIndex] = false;
|
||||
}
|
||||
}
|
||||
|
||||
return slot;
|
||||
}
|
||||
|
||||
private void A8PerfEndGpuFrame(bool enabled)
|
||||
{
|
||||
if (enabled && _a8PerfGpuQueriesInitialized)
|
||||
_a8PerfGpuFrameIndex++;
|
||||
}
|
||||
|
||||
private void A8PerfBeginGpuQuery(bool enabled, int slot, int pass)
|
||||
{
|
||||
if (!enabled || slot < 0 || _gl is null) return;
|
||||
int queryIndex = slot * A8PerfGpuPassCount + pass;
|
||||
_a8PerfGpuIssued[queryIndex] = true;
|
||||
uint query = _a8PerfGpuQueries[queryIndex];
|
||||
_gl.BeginQuery(QueryTarget.TimeElapsed, query);
|
||||
}
|
||||
|
||||
private void A8PerfEndGpuQuery(bool enabled, int slot)
|
||||
{
|
||||
if (!enabled || slot < 0 || _gl is null) return;
|
||||
_gl.EndQuery(QueryTarget.TimeElapsed);
|
||||
}
|
||||
|
||||
private void A8PerfAccumulateGpu(int pass, ulong elapsedNs)
|
||||
{
|
||||
long ns = elapsedNs > long.MaxValue ? long.MaxValue : (long)elapsedNs;
|
||||
switch (pass)
|
||||
{
|
||||
case A8PerfGpuTerrain: _a8PerfTerrainGpuNs += ns; break;
|
||||
case A8PerfGpuStatic: _a8PerfStaticGpuNs += ns; break;
|
||||
case A8PerfGpuOutsideIn: _a8PerfOutsideInGpuNs += ns; break;
|
||||
case A8PerfGpuLive: _a8PerfLiveGpuNs += ns; break;
|
||||
case A8PerfGpuInsideOut: _a8PerfInsideOutGpuNs += ns; break;
|
||||
case A8PerfGpuInsideLive: _a8PerfInsideLiveGpuNs += ns; break;
|
||||
}
|
||||
}
|
||||
|
||||
private static string A8DrawStats(AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats stats)
|
||||
=> $"{stats.Draws}d/{stats.CullRuns}r/{stats.Instances}i/{stats.Triangles}t";
|
||||
|
||||
private void MaybeFlushA8Perf(
|
||||
bool enabled,
|
||||
bool cameraInsideBuilding,
|
||||
int portalBuildings,
|
||||
int portalCells,
|
||||
int visibleLandblocks,
|
||||
int totalLandblocks)
|
||||
{
|
||||
if (!enabled) return;
|
||||
|
||||
_a8PerfFrames++;
|
||||
if (cameraInsideBuilding) _a8PerfInsideFrames++;
|
||||
if (portalBuildings > 0) _a8PerfOutsideInFrames++;
|
||||
_a8PerfLastPortalBuildings = portalBuildings;
|
||||
_a8PerfMaxPortalBuildings = System.Math.Max(_a8PerfMaxPortalBuildings, portalBuildings);
|
||||
_a8PerfLastPortalCells = portalCells;
|
||||
_a8PerfMaxPortalCells = System.Math.Max(_a8PerfMaxPortalCells, portalCells);
|
||||
_a8PerfLastVisibleLandblocks = visibleLandblocks;
|
||||
_a8PerfLastTotalLandblocks = totalLandblocks;
|
||||
|
||||
long now = Environment.TickCount64;
|
||||
if (now - _a8PerfLastLogTick <= 3000) return;
|
||||
|
||||
double Ms(long ticks) => _a8PerfFrames == 0
|
||||
? 0.0
|
||||
: ticks * 1000.0 / System.Diagnostics.Stopwatch.Frequency / _a8PerfFrames;
|
||||
double GpuMs(long ns) => _a8PerfFrames == 0 ? 0.0 : ns / 1_000_000.0 / _a8PerfFrames;
|
||||
|
||||
Console.WriteLine(string.Create(System.Globalization.CultureInfo.InvariantCulture,
|
||||
$"[A8-PERF] frames={_a8PerfFrames} inside={_a8PerfInsideFrames} outsideIn={_a8PerfOutsideInFrames} " +
|
||||
$"portals={_a8PerfLastPortalBuildings}/{_a8PerfMaxPortalBuildings} cells={_a8PerfLastPortalCells}/{_a8PerfMaxPortalCells} " +
|
||||
$"lb={_a8PerfLastVisibleLandblocks}/{_a8PerfLastTotalLandblocks} " +
|
||||
$"avg_ms anim={Ms(_a8PerfTickAnimTicks):F3} collect={Ms(_a8PerfCollectTicks):F3} " +
|
||||
$"envPrep={Ms(_a8PerfEnvPrepareTicks):F3} terrain={Ms(_a8PerfTerrainTicks):F3} " +
|
||||
$"static={Ms(_a8PerfStaticTicks):F3} outsideIn={Ms(_a8PerfOutsideInTicks):F3} " +
|
||||
$"live={Ms(_a8PerfLiveTicks):F3} insideOut={Ms(_a8PerfInsideOutTicks):F3} " +
|
||||
$"insideLive={Ms(_a8PerfInsideLiveTicks):F3} " +
|
||||
$"gpu_ms terrain={GpuMs(_a8PerfTerrainGpuNs):F3} static={GpuMs(_a8PerfStaticGpuNs):F3} " +
|
||||
$"outsideIn={GpuMs(_a8PerfOutsideInGpuNs):F3} live={GpuMs(_a8PerfLiveGpuNs):F3} " +
|
||||
$"insideOut={GpuMs(_a8PerfInsideOutGpuNs):F3} insideLive={GpuMs(_a8PerfInsideLiveGpuNs):F3} " +
|
||||
$"draws static={A8DrawStats(_a8PerfLastStaticStats)} live={A8DrawStats(_a8PerfLastLiveStats)} " +
|
||||
$"outsideShell={A8DrawStats(_a8PerfLastOutsideShellStats)} outsideIndoor={A8DrawStats(_a8PerfLastOutsideIndoorStats)}"));
|
||||
|
||||
_a8PerfFrames = 0;
|
||||
_a8PerfInsideFrames = 0;
|
||||
_a8PerfOutsideInFrames = 0;
|
||||
_a8PerfTickAnimTicks = 0;
|
||||
_a8PerfCollectTicks = 0;
|
||||
_a8PerfEnvPrepareTicks = 0;
|
||||
_a8PerfTerrainTicks = 0;
|
||||
_a8PerfStaticTicks = 0;
|
||||
_a8PerfOutsideInTicks = 0;
|
||||
_a8PerfLiveTicks = 0;
|
||||
_a8PerfInsideOutTicks = 0;
|
||||
_a8PerfInsideLiveTicks = 0;
|
||||
_a8PerfTerrainGpuNs = 0;
|
||||
_a8PerfStaticGpuNs = 0;
|
||||
_a8PerfOutsideInGpuNs = 0;
|
||||
_a8PerfLiveGpuNs = 0;
|
||||
_a8PerfInsideOutGpuNs = 0;
|
||||
_a8PerfInsideLiveGpuNs = 0;
|
||||
_a8PerfMaxPortalBuildings = portalBuildings;
|
||||
_a8PerfMaxPortalCells = portalCells;
|
||||
_a8PerfLastLogTick = now;
|
||||
}
|
||||
|
||||
private void MaybeFlushTerrainDiag()
|
||||
{
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", StringComparison.Ordinal))
|
||||
|
|
|
|||
|
|
@ -40,7 +40,9 @@ public static class PortalMeshBuilder
|
|||
/// — they don't open to outdoors, so stencil-marking them would let
|
||||
/// outdoor geometry bleed into adjacent rooms (incorrect).
|
||||
/// </summary>
|
||||
public static Vector3[] BuildTriangles(IReadOnlyCollection<LoadedCell> cells)
|
||||
public static Vector3[] BuildTriangles(
|
||||
IReadOnlyCollection<LoadedCell> cells,
|
||||
Vector3? cameraWorldPosition = null)
|
||||
{
|
||||
// Pre-count to size the output exactly.
|
||||
int triCount = 0;
|
||||
|
|
@ -49,6 +51,7 @@ public static class PortalMeshBuilder
|
|||
for (int p = 0; p < cell.Portals.Count; p++)
|
||||
{
|
||||
if (cell.Portals[p].OtherCellId != 0xFFFF) continue;
|
||||
if (!ExitPortalPassesCameraSide(cell, p, cameraWorldPosition)) continue;
|
||||
if (p >= cell.PortalPolygons.Count) continue;
|
||||
var poly = cell.PortalPolygons[p];
|
||||
if (poly.Length < 3) continue;
|
||||
|
|
@ -66,6 +69,7 @@ public static class PortalMeshBuilder
|
|||
for (int p = 0; p < cell.Portals.Count; p++)
|
||||
{
|
||||
if (cell.Portals[p].OtherCellId != 0xFFFF) continue;
|
||||
if (!ExitPortalPassesCameraSide(cell, p, cameraWorldPosition)) continue;
|
||||
if (p >= cell.PortalPolygons.Count) continue;
|
||||
var poly = cell.PortalPolygons[p];
|
||||
if (poly.Length < 3) continue;
|
||||
|
|
@ -83,6 +87,29 @@ public static class PortalMeshBuilder
|
|||
|
||||
return output;
|
||||
}
|
||||
|
||||
private static bool ExitPortalPassesCameraSide(
|
||||
LoadedCell cell,
|
||||
int portalIndex,
|
||||
Vector3? cameraWorldPosition)
|
||||
{
|
||||
if (cameraWorldPosition is not Vector3 camera)
|
||||
return true;
|
||||
if (portalIndex >= cell.ClipPlanes.Count)
|
||||
return true;
|
||||
|
||||
var plane = cell.ClipPlanes[portalIndex];
|
||||
if (plane.Normal.LengthSquared() < 1e-8f)
|
||||
return true;
|
||||
|
||||
var localCamera = Vector3.Transform(camera, cell.InverseWorldTransform);
|
||||
float dot = Vector3.Dot(plane.Normal, localCamera) + plane.D;
|
||||
|
||||
return plane.InsideSide == 0
|
||||
? dot >= -0.01f
|
||||
: dot <= 0.01f;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -120,9 +147,11 @@ public sealed unsafe class IndoorCellStencilPipeline : IDisposable
|
|||
/// and uploads it to <see cref="_vbo"/>. Returns the vertex count
|
||||
/// (0 means no exit portals — caller should skip stencil setup entirely).
|
||||
/// </summary>
|
||||
public int UploadPortalMesh(IReadOnlyCollection<LoadedCell> cells)
|
||||
public int UploadPortalMesh(
|
||||
IReadOnlyCollection<LoadedCell> cells,
|
||||
Vector3? cameraWorldPosition = null)
|
||||
{
|
||||
var verts = PortalMeshBuilder.BuildTriangles(cells);
|
||||
var verts = PortalMeshBuilder.BuildTriangles(cells, cameraWorldPosition);
|
||||
_lastVertexCount = verts.Length;
|
||||
if (_lastVertexCount == 0) return 0;
|
||||
|
||||
|
|
@ -142,6 +171,44 @@ public sealed unsafe class IndoorCellStencilPipeline : IDisposable
|
|||
return _lastVertexCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws the portal mesh most recently uploaded by <see cref="UploadPortalMesh"/>.
|
||||
/// The caller owns stencil/depth/color/cull state, matching
|
||||
/// <see cref="RenderBuildingStencilMask"/>.
|
||||
/// </summary>
|
||||
public void DrawUploadedPortalMesh(
|
||||
Matrix4x4 viewProjection,
|
||||
bool writeFarDepth,
|
||||
bool enableDepthClamp = true)
|
||||
{
|
||||
if (_lastVertexCount == 0)
|
||||
{
|
||||
LastStencilVertexCount = 0;
|
||||
LastStencilWasFarPunch = writeFarDepth;
|
||||
LastStencilBuildingId = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (enableDepthClamp)
|
||||
_gl.Enable(EnableCap.DepthClamp);
|
||||
|
||||
_shader.Use();
|
||||
var vp = viewProjection;
|
||||
_gl.UniformMatrix4(_uViewProjectionLoc, 1, false, (float*)&vp);
|
||||
_gl.Uniform1(_uWriteFarDepthLoc, writeFarDepth ? 1 : 0);
|
||||
|
||||
_gl.BindVertexArray(_vao);
|
||||
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_lastVertexCount);
|
||||
_gl.BindVertexArray(0);
|
||||
|
||||
if (enableDepthClamp)
|
||||
_gl.Disable(EnableCap.DepthClamp);
|
||||
|
||||
LastStencilVertexCount = _lastVertexCount;
|
||||
LastStencilWasFarPunch = writeFarDepth;
|
||||
LastStencilBuildingId = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Steps 1+2 of WB's RenderInsideOut: mark stencil ref=1 wherever
|
||||
/// portal polygons cover, then write gl_FragDepth=1.0 into those
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#version 430 core
|
||||
//
|
||||
// Phase A8 — portal stencil mark + far-depth punch.
|
||||
// Phase A8 - portal stencil mark + far-depth punch.
|
||||
//
|
||||
// Position is in WORLD space (pipeline transforms cell-local portal
|
||||
// polygon vertices through cell.WorldTransform on the CPU before
|
||||
|
|
@ -10,10 +10,16 @@ layout(location = 0) in vec3 aPosition;
|
|||
|
||||
uniform mat4 uViewProjection;
|
||||
|
||||
// Note: no pos.w clamp — coplanar-camera degenerate is accepted per spec.
|
||||
// If stencil artifacts appear when the camera straddles an exit portal plane,
|
||||
// re-introduce the clamp from WB's PortalStencil.vert.
|
||||
void main()
|
||||
{
|
||||
gl_Position = uViewProjection * vec4(aPosition, 1.0);
|
||||
vec4 pos = uViewProjection * vec4(aPosition, 1.0);
|
||||
|
||||
// Match WorldBuilder's PortalStencil.vert: keep portal polygons stable
|
||||
// when the chase camera straddles an exit portal plane. Without this,
|
||||
// near-zero clip W can explode the screen-space portal mask and let the
|
||||
// Step 4 terrain pass punch into indoor floor/wall pixels for a frame.
|
||||
if (abs(pos.w) < 0.001)
|
||||
pos.w = pos.w < 0.0 ? -0.001 : 0.001;
|
||||
|
||||
gl_Position = pos;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,9 @@ namespace AcDream.App.Rendering.Wb;
|
|||
/// <summary>
|
||||
/// Phase A8 (2026-05-26): a logical building — one or more EnvCells linked
|
||||
/// via the dat-level <c>LandBlockInfo.Buildings</c> entry. Building shells (cottage
|
||||
/// walls, inn walls — <c>IsBuildingShell=true</c> entities) render unconditionally
|
||||
/// when the camera is inside this building's cells. The exit portal polygons
|
||||
/// are stencil-marked so outdoor visibility leaks through portal silhouettes
|
||||
/// only.
|
||||
/// walls, inn walls — <c>IsBuildingShell=true</c> entities) are scoped to this
|
||||
/// building's cells via their dat-derived anchor. The exit portal polygons are
|
||||
/// stencil-marked so outdoor visibility leaks through portal silhouettes only.
|
||||
///
|
||||
/// <para>Step 5 (cross-building visibility via 3-stencil-bit pipeline) uses
|
||||
/// the occlusion-query state to skip rendering when the building's portals
|
||||
|
|
@ -39,6 +38,18 @@ public sealed class Building
|
|||
/// polygon vertices via <see cref="AcDream.App.Rendering.LoadedCell.WorldTransform"/>.</summary>
|
||||
public required IReadOnlyList<Vector3[]> ExitPortalPolygons { get; init; }
|
||||
|
||||
/// <summary>True when <see cref="PortalBounds"/> contains at least one
|
||||
/// exit-portal vertex. Mirrors WB's <c>BuildingPortalGPU.VertexCount > 0</c>
|
||||
/// filter before a building participates in outside-in / Step 5 stencil
|
||||
/// visibility.</summary>
|
||||
public bool HasPortalBounds { get; init; }
|
||||
|
||||
/// <summary>World-space AABB of all exit portal polygons. WB's
|
||||
/// <c>PortalRenderManager.GetVisibleBuildingPortals</c> frustum-culls this
|
||||
/// box with near-plane ignored before adding the building to the portal
|
||||
/// visibility list.</summary>
|
||||
public WbBoundingBox PortalBounds { get; init; }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Step 5 occlusion-query state (mutable, per-frame, RR9 scope).
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -117,6 +117,19 @@ public static class BuildingLoader
|
|||
}
|
||||
}
|
||||
|
||||
bool hasPortalBounds = false;
|
||||
var portalMin = new Vector3(float.MaxValue);
|
||||
var portalMax = new Vector3(float.MinValue);
|
||||
foreach (var poly in exitPortalPolys)
|
||||
{
|
||||
foreach (var v in poly)
|
||||
{
|
||||
hasPortalBounds = true;
|
||||
portalMin = Vector3.Min(portalMin, v);
|
||||
portalMax = Vector3.Max(portalMax, v);
|
||||
}
|
||||
}
|
||||
|
||||
// WB PortalService.cs:89: skip buildings with no interior cells.
|
||||
if (envCellIds.Count == 0) continue;
|
||||
|
||||
|
|
@ -125,6 +138,10 @@ public static class BuildingLoader
|
|||
BuildingId = nextId++,
|
||||
EnvCellIds = envCellIds,
|
||||
ExitPortalPolygons = exitPortalPolys,
|
||||
HasPortalBounds = hasPortalBounds,
|
||||
PortalBounds = hasPortalBounds
|
||||
? new WbBoundingBox(portalMin, portalMax)
|
||||
: default,
|
||||
};
|
||||
reg.Add(building);
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ using System.Collections.Concurrent;
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DatReaderWriter.Enums;
|
||||
|
|
@ -70,6 +69,10 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
private int _modernInstanceCapacity = 1024;
|
||||
private uint _modernBatchBuffer;
|
||||
private int _modernBatchCapacity = 1024;
|
||||
// mesh_modern.vert's SSBO InstanceData is only mat4 transform. The CPU
|
||||
// InstanceData below also carries CellId/Flags for filtering, so upload a
|
||||
// packed transform array instead of the 80-byte CPU struct.
|
||||
private Matrix4x4[] _gpuInstanceTransforms = Array.Empty<Matrix4x4>();
|
||||
|
||||
// Reusable scratch arrays — avoid per-frame allocation.
|
||||
// WB BaseObjectRenderManager.cs:58-59: private DrawElementsIndirectCommand[] _commands = Array.Empty<...>()
|
||||
|
|
@ -193,7 +196,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
_gl.GenBuffers(1, out _modernInstanceBuffer);
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(_modernInstanceCapacity * sizeof(InstanceData)), null, GLEnum.DynamicDraw);
|
||||
(nuint)(_modernInstanceCapacity * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw);
|
||||
|
||||
// Per-batch data SSBO (binding=1)
|
||||
_gl.GenBuffers(1, out _modernBatchBuffer);
|
||||
|
|
@ -464,11 +467,30 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
/// Call once per frame, before <see cref="Render"/>.
|
||||
/// Source: WB EnvCellRenderManager.cs:247-373 (verbatim).
|
||||
/// </summary>
|
||||
public void PrepareRenderBatches(Matrix4x4 viewProjection, Vector3 cameraPosition, HashSet<uint>? filter = null)
|
||||
public void PrepareRenderBatches(
|
||||
Matrix4x4 viewProjection,
|
||||
Vector3 cameraPosition,
|
||||
HashSet<uint>? filter = null,
|
||||
int? centerLbX = null,
|
||||
int? centerLbY = null,
|
||||
int? renderRadius = null)
|
||||
{
|
||||
// WB EnvCellRenderManager.cs:249-250:
|
||||
if (!_initialized || cameraPosition.Z > 4000) return;
|
||||
|
||||
if (filter is { Count: 0 })
|
||||
{
|
||||
lock (_renderLock)
|
||||
{
|
||||
_poolIndex = 0;
|
||||
_activeSnapshot = new EnvCellVisibilitySnapshot();
|
||||
_activeSnapshotGlobalGroups = new Dictionary<ulong, List<InstanceData>>();
|
||||
_activeSnapshotGlobalGfxObjIds = new List<ulong>();
|
||||
NeedsPrepare = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// WB EnvCellRenderManager.cs:251-253:
|
||||
lock (_renderLock) { _poolIndex = 0; }
|
||||
|
||||
|
|
@ -479,8 +501,19 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
// Filter loaded landblocks by GpuReady + Instances non-empty.
|
||||
var landblocks = new List<EnvCellLandblock>();
|
||||
foreach (var lb in _landblocks.Values)
|
||||
{
|
||||
if (centerLbX.HasValue && centerLbY.HasValue && renderRadius.HasValue)
|
||||
{
|
||||
if (Math.Abs(lb.GridX - centerLbX.Value) > renderRadius.Value ||
|
||||
Math.Abs(lb.GridY - centerLbY.Value) > renderRadius.Value)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (lb.GpuReady && lb.Instances.Count > 0)
|
||||
landblocks.Add(lb);
|
||||
}
|
||||
if (landblocks.Count == 0) return;
|
||||
|
||||
// WB EnvCellRenderManager.cs:265-267:
|
||||
|
|
@ -815,32 +848,13 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
_gl.BindVertexArray(0);
|
||||
_currentVao = 0;
|
||||
|
||||
// Phase A8 (2026-05-28 visual-gate-#4 follow-up): NO cull-restore
|
||||
// at exit. The Landblock→None override can leave cull DISABLED
|
||||
// if the last batch was Landblock — and that's intentional: the
|
||||
// subsequent `dispatcher.Draw(IndoorPass)` call in
|
||||
// RenderInsideOutAcdream's Step 3 wants cull-off too, because
|
||||
// AC's cottage-shell GfxObj parts (the wooden floor planks +
|
||||
// wall slabs that the player walks on / through) have winding
|
||||
// that gets back-face-culled by the dispatcher's default
|
||||
// FrontFace=CCW. Letting cull stay off through IndoorPass
|
||||
// renders both shell and cell mesh double-sided, so floors are
|
||||
// visible from above (and inverted-front-facing wall slabs are
|
||||
// visible from inside the room). Step 4's
|
||||
// `gl.Enable(EnableCap.CullFace)` (line ~10768) + the cleanup
|
||||
// block's enable (line ~10870) re-establish cull-back before
|
||||
// LiveDynamic chars / NPCs / doors render — so those still
|
||||
// look solid (no see-through head). The static `_currentVao`
|
||||
// is reset because the next Render call's batch loop needs to
|
||||
// re-issue BindVertexArray regardless; `_currentCullMode` is
|
||||
// intentionally left at None so the cache matches actual GL
|
||||
// state until the next Render call's per-batch SetCullMode
|
||||
// either confirms or re-sets it.
|
||||
//
|
||||
// The retail-faithful long-term move is matching WB's
|
||||
// glFrontFace(CW) globally (GameScene.cs:843) so cull-back
|
||||
// selects the correct side for AC's polygon winding without
|
||||
// double-sided rendering — deferred until a wider audit.
|
||||
// No cull restore at exit, matching WB's manager pattern: the
|
||||
// last SetCullMode call reflects actual GL state, and the next
|
||||
// Render call invalidates `_currentCullMode` before issuing its
|
||||
// own per-batch state. The Landblock->None override below can
|
||||
// intentionally leave cull disabled for the following IndoorPass,
|
||||
// preserving the shipped Gate #5 baseline while deeper evidence is
|
||||
// gathered.
|
||||
|
||||
// Update frame stats for probe emission at the call site.
|
||||
_lastFrameStats.CellsRendered = filter?.Count ?? snapshot.BatchedByCell.Count;
|
||||
|
|
@ -932,7 +946,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
_modernInstanceCapacity = Math.Max(_modernInstanceCapacity * 2, uniqueInstanceCount);
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(_modernInstanceCapacity * sizeof(InstanceData)), null, GLEnum.DynamicDraw);
|
||||
(nuint)(_modernInstanceCapacity * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw);
|
||||
}
|
||||
|
||||
// WB BaseObjectRenderManager.cs:761-762: grow scratch arrays.
|
||||
|
|
@ -977,12 +991,15 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer);
|
||||
_gl.BufferData(GLEnum.ShaderStorageBuffer,
|
||||
(nuint)(uniqueInstanceCount * sizeof(InstanceData)), null, GLEnum.DynamicDraw);
|
||||
var instancesSpan = CollectionsMarshal.AsSpan(allInstances);
|
||||
fixed (InstanceData* ptr = instancesSpan)
|
||||
(nuint)(uniqueInstanceCount * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw);
|
||||
if (_gpuInstanceTransforms.Length < uniqueInstanceCount)
|
||||
Array.Resize(ref _gpuInstanceTransforms, Math.Max(_gpuInstanceTransforms.Length * 2, uniqueInstanceCount));
|
||||
for (int i = 0; i < uniqueInstanceCount; i++)
|
||||
_gpuInstanceTransforms[i] = allInstances[i].Transform;
|
||||
fixed (Matrix4x4* ptr = _gpuInstanceTransforms)
|
||||
{
|
||||
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
|
||||
(nuint)(uniqueInstanceCount * sizeof(InstanceData)), ptr);
|
||||
(nuint)(uniqueInstanceCount * sizeof(Matrix4x4)), ptr);
|
||||
}
|
||||
|
||||
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernBatchBuffer);
|
||||
|
|
@ -1014,23 +1031,10 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
foreach (var group in batchesByCullMode)
|
||||
{
|
||||
var cullMode = (CullMode)(group.Key % 4);
|
||||
// Phase A8 fix (2026-05-28 visual-gate-#3 evidence): override
|
||||
// CullMode.Landblock to None for cell-mesh batches. WB sets
|
||||
// glFrontFace(CW) globally (GameScene.cs:843) so its CullMode
|
||||
// mapping (Landblock→Back) culls the correct side; we set
|
||||
// glFrontFace(CCW) in WbDrawDispatcher (line 1056) so the
|
||||
// mapping would cull the OPPOSITE side, hiding cell floors.
|
||||
// Cell-mesh polys with CullMode.Landblock represent the floor +
|
||||
// walls + ceiling of a single room — they face different
|
||||
// directions but share one CullMode value, so a single cull
|
||||
// setting can't be correct for all of them. The retail-faithful
|
||||
// approach is double-sided rendering for cell polys (cull off),
|
||||
// matching what the cull-disable A/B diagnostic empirically
|
||||
// confirmed (floor visible with cull off in visual-gate-#3).
|
||||
// CullMode.Landblock is only ever assigned in this codebase by
|
||||
// PrepareCellStructMeshData (cell polys) — terrain has its own
|
||||
// renderer that doesn't go through this code path — so this
|
||||
// override is scoped exactly right.
|
||||
// Phase A8 visual-gate evidence: cell meshes use CullMode.Landblock
|
||||
// uniformly, but the room surfaces need to be visible from inside
|
||||
// under acdream's current global winding state. Render cell polys
|
||||
// double-sided while the architectural cause is isolated.
|
||||
if (cullMode == CullMode.Landblock) cullMode = CullMode.None;
|
||||
if (_currentCullMode != cullMode)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using AcDream.Core.Meshing;
|
||||
using DatReaderWriter.Enums;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
|
|
@ -17,4 +18,5 @@ internal readonly record struct GroupKey(
|
|||
int IndexCount,
|
||||
ulong BindlessTextureHandle,
|
||||
uint TextureLayer,
|
||||
TranslucencyKind Translucency);
|
||||
TranslucencyKind Translucency,
|
||||
CullMode CullMode = CullMode.CounterClockwise);
|
||||
|
|
|
|||
|
|
@ -25,9 +25,12 @@ namespace AcDream.App.Rendering.Wb;
|
|||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Idempotency: a duplicate load for the same landblock is a no-op on
|
||||
/// ref-counting (the snapshot is already present). Defensive guard against
|
||||
/// streaming-controller bugs.
|
||||
/// Idempotency: repeated notifications for the same landblock only register
|
||||
/// newly-seen ids. This matters for two-tier streaming: a far-tier terrain
|
||||
/// load first snapshots an empty entity set, then a later Far-to-Near promotion
|
||||
/// supplies the actual stabs/buildings. Treating the second notification as a
|
||||
/// blanket no-op leaves the world-state entity list populated while the WB
|
||||
/// mesh cache never pins the promoted GfxObj ids.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
|
|
@ -53,18 +56,15 @@ public sealed class LandblockSpawnAdapter
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when a landblock finishes streaming in.
|
||||
/// Registers a ref-count increment with WB for each unique atlas-tier
|
||||
/// GfxObj id in the landblock. Duplicate loads for the same landblock id
|
||||
/// are silently ignored.
|
||||
/// Called when a landblock finishes streaming in or receives promoted
|
||||
/// atlas-tier entities. Registers a ref-count increment with WB for each
|
||||
/// unique atlas-tier GfxObj id that has not already been registered for
|
||||
/// this landblock.
|
||||
/// </summary>
|
||||
public void OnLandblockLoaded(LoadedLandblock landblock)
|
||||
{
|
||||
System.ArgumentNullException.ThrowIfNull(landblock);
|
||||
|
||||
// Idempotency: already-loaded landblock is a no-op.
|
||||
if (_idsByLandblock.ContainsKey(landblock.LandblockId)) return;
|
||||
|
||||
var unique = new HashSet<ulong>();
|
||||
foreach (var entity in landblock.Entities)
|
||||
{
|
||||
|
|
@ -76,8 +76,18 @@ public sealed class LandblockSpawnAdapter
|
|||
unique.Add((ulong)meshRef.GfxObjId);
|
||||
}
|
||||
|
||||
_idsByLandblock[landblock.LandblockId] = unique;
|
||||
foreach (var id in unique) _adapter.IncrementRefCount(id);
|
||||
if (!_idsByLandblock.TryGetValue(landblock.LandblockId, out var registered))
|
||||
{
|
||||
_idsByLandblock[landblock.LandblockId] = unique;
|
||||
foreach (var id in unique) _adapter.IncrementRefCount(id);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var id in unique)
|
||||
{
|
||||
if (registered.Add(id))
|
||||
_adapter.IncrementRefCount(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using AcDream.Core.Meshing;
|
|||
using AcDream.Core.Rendering;
|
||||
using AcDream.Core.Terrain;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.Enums;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
|
@ -76,18 +77,22 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
|
||||
/// <summary>Cell mesh + cell statics (<see cref="WorldEntity.ParentCellId"/>
|
||||
/// non-null) PLUS building shell stabs (<see cref="WorldEntity.IsBuildingShell"/>
|
||||
/// true, regardless of ParentCellId). These render unconditionally
|
||||
/// when the camera is inside their building — building shells ARE
|
||||
/// the indoor walls. Live-dynamic (<c>ServerGuid != 0</c>) is
|
||||
/// excluded; it flows through <see cref="LiveDynamic"/>.</summary>
|
||||
/// true) whose <see cref="WorldEntity.BuildingShellAnchorCellId"/>
|
||||
/// belongs to the active building cell set. Live-dynamic
|
||||
/// (<c>ServerGuid != 0</c>) is excluded; it flows through
|
||||
/// <see cref="LiveDynamic"/>.</summary>
|
||||
IndoorPass,
|
||||
|
||||
/// <summary>Outdoor scenery stabs (<c>ParentCellId == null</c>,
|
||||
/// <c>!IsBuildingShell</c>) plus procedurally-generated scenery.
|
||||
/// Drawn stencil-gated to portal silhouettes when the camera is
|
||||
/// inside. Live-dynamic excluded.</summary>
|
||||
/// <summary>Outdoor/top-level stabs (<c>ParentCellId == null</c>),
|
||||
/// including building shells. Drawn stencil-gated to portal
|
||||
/// silhouettes when the camera is inside. Live-dynamic excluded.</summary>
|
||||
OutdoorScenery,
|
||||
|
||||
/// <summary>Top-level building shell stabs only, optionally scoped by
|
||||
/// <see cref="WorldEntity.BuildingShellAnchorCellId"/>. Used for
|
||||
/// portal depth repair without walking the full outdoor scenery set.</summary>
|
||||
BuildingShells,
|
||||
|
||||
/// <summary>Server-spawned dynamic entities (<c>ServerGuid != 0</c>):
|
||||
/// player, NPCs, monsters, dropped items, animated and idle doors.
|
||||
/// Drawn last with stencil disabled so they're depth-tested against
|
||||
|
|
@ -103,6 +108,19 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
|
||||
private readonly BindlessSupport _bindless;
|
||||
|
||||
public readonly record struct DrawStats(
|
||||
EntitySet Set,
|
||||
int EntitiesWalked,
|
||||
int MeshRefs,
|
||||
int Instances,
|
||||
int Draws,
|
||||
int CullRuns,
|
||||
int OpaqueDraws,
|
||||
int TransparentDraws,
|
||||
long Triangles);
|
||||
|
||||
public DrawStats LastDrawStats { get; private set; }
|
||||
|
||||
// Tier 1 cache (#53): per-entity classification results for static
|
||||
// entities (those NOT in GameWindow._animatedEntities). Wired here in
|
||||
// Task 7 for plumbing only — Tasks 9-10 wire the per-entity
|
||||
|
|
@ -132,6 +150,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
private float[] _instanceData = new float[256 * 16]; // mat4 floats per instance
|
||||
private BatchData[] _batchData = new BatchData[256];
|
||||
private DrawElementsIndirectCommand[] _indirectCommands = new DrawElementsIndirectCommand[256];
|
||||
private CullMode[] _drawCullModes = new CullMode[256];
|
||||
|
||||
private int _opaqueDrawCount;
|
||||
private int _transparentDrawCount;
|
||||
|
|
@ -283,6 +302,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
public struct WalkResult
|
||||
{
|
||||
public int EntitiesWalked;
|
||||
public int BuildingShellAnchorPass;
|
||||
public int BuildingShellAnchorReject;
|
||||
public List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> ToDraw;
|
||||
}
|
||||
|
||||
|
|
@ -375,8 +396,15 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// Phase A8: EntitySet partition for indoor/outdoor split passes.
|
||||
if (!EntityMatchesSet(entity, set)) continue;
|
||||
if (entity.MeshRefs.Count == 0) continue;
|
||||
if (entity.ParentCellId.HasValue && visibleCellIds is not null
|
||||
&& !visibleCellIds.Contains(entity.ParentCellId.Value)) continue;
|
||||
bool shellScoped = IsShellScopedSet(set)
|
||||
&& entity.IsBuildingShell
|
||||
&& visibleCellIds is not null;
|
||||
if (!EntityPassesVisibleCellGate(entity, visibleCellIds, set))
|
||||
{
|
||||
if (shellScoped) result.BuildingShellAnchorReject++;
|
||||
continue;
|
||||
}
|
||||
if (shellScoped) result.BuildingShellAnchorPass++;
|
||||
result.EntitiesWalked++;
|
||||
for (int i = 0; i < entity.MeshRefs.Count; i++)
|
||||
scratch.Add((entity, i, entry.LandblockId));
|
||||
|
|
@ -397,11 +425,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
bool isCellEntity = indoorProbeState is not null
|
||||
&& RenderingDiagnostics.IsEnvCellId(cellProbeId);
|
||||
|
||||
bool cellInVis = !(entity.ParentCellId.HasValue
|
||||
&& visibleCellIds is not null
|
||||
&& !visibleCellIds.Contains(entity.ParentCellId.Value));
|
||||
bool shellScoped = IsShellScopedSet(set)
|
||||
&& entity.IsBuildingShell
|
||||
&& visibleCellIds is not null;
|
||||
bool cellInVis = EntityPassesVisibleCellGate(entity, visibleCellIds, set);
|
||||
if (!cellInVis)
|
||||
{
|
||||
if (shellScoped) result.BuildingShellAnchorReject++;
|
||||
if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled
|
||||
&& indoorProbeState!.ShouldEmit(cellProbeId))
|
||||
{
|
||||
|
|
@ -412,6 +442,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
}
|
||||
continue;
|
||||
}
|
||||
if (shellScoped) result.BuildingShellAnchorPass++;
|
||||
|
||||
// Per-entity AABB frustum cull (perf #3). Animated entities bypass —
|
||||
// they're tracked at landblock level + need per-frame work regardless.
|
||||
|
|
@ -545,6 +576,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
probeState,
|
||||
set);
|
||||
|
||||
if (set == EntitySet.IndoorPass && RenderingDiagnostics.ProbeVisibilityEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[indoor-shells] anchorPass={walkResult.BuildingShellAnchorPass} " +
|
||||
$"anchorReject={walkResult.BuildingShellAnchorReject} walked={walkResult.EntitiesWalked}");
|
||||
}
|
||||
|
||||
// Tier 1 cache (#53) flush-tracking locals. _walkScratch holds one tuple
|
||||
// per (entity, MeshRefIndex) and is in entity-order, so all MeshRefs of
|
||||
// a given entity are contiguous. We accumulate ALL of an entity's
|
||||
|
|
@ -855,6 +893,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// Nothing visible — skip the GL pass entirely.
|
||||
if (anyVao == 0)
|
||||
{
|
||||
LastDrawStats = new DrawStats(set, walkResult.EntitiesWalked, _walkScratch.Count, 0, 0, 0, 0, 0, 0);
|
||||
_cpuStopwatch.Stop();
|
||||
if (diag) MaybeFlushDiag();
|
||||
return;
|
||||
|
|
@ -865,6 +904,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
foreach (var grp in _groups.Values) totalInstances += grp.Matrices.Count;
|
||||
if (totalInstances == 0)
|
||||
{
|
||||
LastDrawStats = new DrawStats(set, walkResult.EntitiesWalked, _walkScratch.Count, 0, 0, 0, 0, 0, 0);
|
||||
_cpuStopwatch.Stop();
|
||||
if (diag) MaybeFlushDiag();
|
||||
return;
|
||||
|
|
@ -905,11 +945,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
_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));
|
||||
// Front-to-back sort within each cull mode. DrawIndirectRange must
|
||||
// split MDI calls whenever CullMode changes because GL state is not
|
||||
// part of an indirect command. Sorting by distance alone can turn a
|
||||
// stable 1k-draw live scene into hundreds of tiny MDI runs after a
|
||||
// landblock transition, which shows up as a GPU-command bottleneck
|
||||
// without a triangle-count spike.
|
||||
_opaqueDraws.Sort(CompareOpaqueSubmissionOrder);
|
||||
_translucentDraws.Sort(CompareTransparentSubmissionOrder);
|
||||
|
||||
// ── Phase 4: build IndirectGroupInput list (opaque sorted, then translucent),
|
||||
// fill via BuildIndirectArrays ──────────────────────────────────
|
||||
|
|
@ -918,6 +961,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
_batchData = new BatchData[totalDraws + 64];
|
||||
if (_indirectCommands.Length < totalDraws)
|
||||
_indirectCommands = new DrawElementsIndirectCommand[totalDraws + 64];
|
||||
if (_drawCullModes.Length < totalDraws)
|
||||
_drawCullModes = new CullMode[totalDraws + 64];
|
||||
|
||||
var groupInputs = new List<IndirectGroupInput>(totalDraws);
|
||||
foreach (var g in _opaqueDraws) groupInputs.Add(ToInput(g));
|
||||
|
|
@ -926,7 +971,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// 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);
|
||||
var layout = BuildIndirectArrays(groupInputs, _indirectCommands, batchPublic, _drawCullModes);
|
||||
long totalTriangles = 0;
|
||||
foreach (var input in groupInputs)
|
||||
totalTriangles += (long)(input.IndexCount / 3) * input.InstanceCount;
|
||||
int cullRuns =
|
||||
CountCullRuns(_drawCullModes, 0, layout.OpaqueCount) +
|
||||
CountCullRuns(_drawCullModes, layout.OpaqueCount, layout.TransparentCount);
|
||||
|
||||
// Copy back into _batchData
|
||||
for (int i = 0; i < totalDraws; i++)
|
||||
|
|
@ -941,6 +992,16 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
_opaqueDrawCount = layout.OpaqueCount;
|
||||
_transparentDrawCount = layout.TransparentCount;
|
||||
_transparentByteOffset = layout.TransparentByteOffset;
|
||||
LastDrawStats = new DrawStats(
|
||||
set,
|
||||
walkResult.EntitiesWalked,
|
||||
_walkScratch.Count,
|
||||
totalInstances,
|
||||
totalDraws,
|
||||
cullRuns,
|
||||
_opaqueDrawCount,
|
||||
_transparentDrawCount,
|
||||
totalTriangles);
|
||||
|
||||
// ── Phase 5: upload three buffers ───────────────────────────────────
|
||||
fixed (float* ip = _instanceData)
|
||||
|
|
@ -1007,12 +1068,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
_shader.SetInt("uDrawIDOffset", 0);
|
||||
_gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer);
|
||||
if (diag && _gpuQueriesInitialized) _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryOpaque[gpuQuerySlot]);
|
||||
_gl.MultiDrawElementsIndirect(
|
||||
PrimitiveType.Triangles,
|
||||
DrawElementsType.UnsignedShort,
|
||||
(void*)0,
|
||||
(uint)_opaqueDrawCount,
|
||||
(uint)DrawCommandStride);
|
||||
DrawIndirectRange(0, _opaqueDrawCount);
|
||||
if (diag && _gpuQueriesInitialized) _gl.EndQuery(QueryTarget.TimeElapsed);
|
||||
if (AlphaToCoverage) _gl.Disable(EnableCap.SampleAlphaToCoverage);
|
||||
}
|
||||
|
|
@ -1030,38 +1086,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// flickers to whatever opaque batch sorted first that frame. See
|
||||
// uDrawIDOffset comment in mesh_modern.vert.
|
||||
_shader.SetInt("uDrawIDOffset", _opaqueDrawCount);
|
||||
// Phase Post-A.5 (ISSUE #52, 2026-05-10): re-establish Phase 9.2's
|
||||
// back-face cull setup. The legacy StaticMeshRenderer had this
|
||||
// (commit 6f1971a, 2026-04-11) until the N.5 retirement amendment
|
||||
// (commit dcae2b6, 2026-05-08) deleted that renderer; the new
|
||||
// WbDrawDispatcher never inherited the cull-face state.
|
||||
//
|
||||
// Closed-shell translucent meshes — lifestone crystal, glow gems,
|
||||
// any convex blended mesh — NEED back-face culling in the
|
||||
// translucent pass. Without it, back faces composite OVER front
|
||||
// faces in arbitrary iteration order, because DepthMask(false)
|
||||
// means nothing records depth within the translucent set. The
|
||||
// result is the user-visible "one face missing, see into the
|
||||
// hollow interior" + frame-to-frame color flicker as rotation
|
||||
// shifts the triangle order.
|
||||
//
|
||||
// Our fan triangulation emits pos-side polygons as (0, i, i+1) —
|
||||
// CCW in standard OpenGL conventions — so GL_BACK + CCW-front is
|
||||
// the correct state. Matches WorldBuilder's per-batch CullMode
|
||||
// handling. Neg-side polygons (rare on translucent AC content)
|
||||
// use reversed winding and get culled here, matching the opaque
|
||||
// pass and the original Phase 9.2 fix's known limitation.
|
||||
_gl.Enable(EnableCap.CullFace);
|
||||
_gl.CullFace(TriangleFace.Back);
|
||||
_gl.FrontFace(FrontFaceDirection.Ccw);
|
||||
// Closed-shell translucent meshes still need culling, but the
|
||||
// cull side must come from each dat batch just like the opaque
|
||||
// section. BuildIndirectArrays preserves CullMode in _drawCullModes.
|
||||
_gl.FrontFace(FrontFaceDirection.CW);
|
||||
_shader.SetInt("uRenderPass", 1);
|
||||
if (diag && _gpuQueriesInitialized) _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryTransparent[gpuQuerySlot]);
|
||||
_gl.MultiDrawElementsIndirect(
|
||||
PrimitiveType.Triangles,
|
||||
DrawElementsType.UnsignedShort,
|
||||
(void*)_transparentByteOffset,
|
||||
(uint)_transparentDrawCount,
|
||||
(uint)DrawCommandStride);
|
||||
DrawIndirectRange(_opaqueDrawCount, _transparentDrawCount);
|
||||
if (diag && _gpuQueriesInitialized) _gl.EndQuery(QueryTarget.TimeElapsed);
|
||||
_gl.DepthMask(true);
|
||||
_gl.Disable(EnableCap.Blend);
|
||||
|
|
@ -1132,7 +1163,91 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
FirstInstance: g.FirstInstance,
|
||||
TextureHandle: g.BindlessTextureHandle,
|
||||
TextureLayer: g.TextureLayer,
|
||||
Translucency: g.Translucency);
|
||||
Translucency: g.Translucency,
|
||||
CullMode: g.CullMode);
|
||||
|
||||
private static int CompareOpaqueSubmissionOrder(InstanceGroup a, InstanceGroup b)
|
||||
{
|
||||
int cull = a.CullMode.CompareTo(b.CullMode);
|
||||
return cull != 0 ? cull : a.SortDistance.CompareTo(b.SortDistance);
|
||||
}
|
||||
|
||||
private static int CompareTransparentSubmissionOrder(InstanceGroup a, InstanceGroup b)
|
||||
{
|
||||
int cull = a.CullMode.CompareTo(b.CullMode);
|
||||
return cull != 0 ? cull : b.SortDistance.CompareTo(a.SortDistance);
|
||||
}
|
||||
|
||||
private static int CountCullRuns(CullMode[] modes, int startCommand, int commandCount)
|
||||
{
|
||||
if (commandCount <= 0) return 0;
|
||||
|
||||
int end = startCommand + commandCount;
|
||||
int runs = 1;
|
||||
var previous = modes[startCommand];
|
||||
for (int i = startCommand + 1; i < end; i++)
|
||||
{
|
||||
var current = modes[i];
|
||||
if (current == previous) continue;
|
||||
runs++;
|
||||
previous = current;
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
private unsafe void DrawIndirectRange(int startCommand, int commandCount)
|
||||
{
|
||||
int end = startCommand + commandCount;
|
||||
int command = startCommand;
|
||||
while (command < end)
|
||||
{
|
||||
var cullMode = _drawCullModes[command];
|
||||
ApplyCullMode(cullMode);
|
||||
|
||||
int runCount = 1;
|
||||
while (command + runCount < end && _drawCullModes[command + runCount] == cullMode)
|
||||
runCount++;
|
||||
|
||||
// Each glMultiDrawElementsIndirect call restarts gl_DrawID at 0.
|
||||
// Because this method splits one logical opaque/transparent pass
|
||||
// into CullMode runs, the shader must receive the absolute command
|
||||
// index for this run or it will read BatchData[0] again and bind
|
||||
// the wrong texture for later runs.
|
||||
_shader.SetInt("uDrawIDOffset", command);
|
||||
_gl.MultiDrawElementsIndirect(
|
||||
PrimitiveType.Triangles,
|
||||
DrawElementsType.UnsignedShort,
|
||||
(void*)(command * DrawCommandStride),
|
||||
(uint)runCount,
|
||||
(uint)DrawCommandStride);
|
||||
|
||||
command += runCount;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyCullMode(CullMode mode)
|
||||
{
|
||||
// WB BaseObjectRenderManager.cs:850-866 applies CullMode per MDI group.
|
||||
// WB GameScene.cs:843 sets FrontFace(CW) globally; SetCullMode then
|
||||
// only chooses front/back culling. Keep the same convention here so
|
||||
// splitting MDI commands by CullMode cannot resurrect stale CCW state.
|
||||
_gl.FrontFace(FrontFaceDirection.CW);
|
||||
switch (mode)
|
||||
{
|
||||
case CullMode.None:
|
||||
_gl.Disable(EnableCap.CullFace);
|
||||
break;
|
||||
case CullMode.Clockwise:
|
||||
_gl.Enable(EnableCap.CullFace);
|
||||
_gl.CullFace(TriangleFace.Front);
|
||||
break;
|
||||
case CullMode.CounterClockwise:
|
||||
case CullMode.Landblock:
|
||||
_gl.Enable(EnableCap.CullFace);
|
||||
_gl.CullFace(TriangleFace.Back);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe void UploadSsbo(uint ssbo, uint binding, void* data, int byteCount)
|
||||
{
|
||||
|
|
@ -1293,6 +1408,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
BindlessTextureHandle = key.BindlessTextureHandle,
|
||||
TextureLayer = key.TextureLayer,
|
||||
Translucency = key.Translucency,
|
||||
CullMode = key.CullMode,
|
||||
};
|
||||
_groups[key] = grp;
|
||||
}
|
||||
|
|
@ -1335,7 +1451,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
|
||||
var key = new GroupKey(
|
||||
batch.IBO, batch.FirstIndex, (int)batch.BaseVertex,
|
||||
batch.IndexCount, texHandle, texLayer, translucency);
|
||||
batch.IndexCount, texHandle, texLayer, translucency, batch.CullMode);
|
||||
|
||||
if (!_groups.TryGetValue(key, out var grp))
|
||||
{
|
||||
|
|
@ -1348,6 +1464,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
BindlessTextureHandle = texHandle,
|
||||
TextureLayer = texLayer,
|
||||
Translucency = translucency,
|
||||
CullMode = batch.CullMode,
|
||||
};
|
||||
_groups[key] = grp;
|
||||
}
|
||||
|
|
@ -1403,7 +1520,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
|
||||
bool isIndoor = entity.ParentCellId.HasValue || entity.IsBuildingShell;
|
||||
if (set == EntitySet.IndoorPass) return isIndoor;
|
||||
if (set == EntitySet.OutdoorScenery) return !isIndoor;
|
||||
if (set == EntitySet.OutdoorScenery) return !entity.ParentCellId.HasValue;
|
||||
if (set == EntitySet.BuildingShells) return entity.IsBuildingShell;
|
||||
|
||||
throw new InvalidOperationException($"Unhandled EntitySet value: {set}");
|
||||
}
|
||||
|
|
@ -1425,10 +1543,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
if (!EntityMatchesSet(entity, set)) continue;
|
||||
if (entity.MeshRefs.Count == 0) continue;
|
||||
|
||||
bool cellInVis = !(entity.ParentCellId.HasValue
|
||||
&& visibleCellIds is not null
|
||||
&& !visibleCellIds.Contains(entity.ParentCellId.Value));
|
||||
if (!cellInVis) continue;
|
||||
if (!EntityPassesVisibleCellGate(entity, visibleCellIds, set)) continue;
|
||||
|
||||
output.Add(entity.Id);
|
||||
}
|
||||
|
|
@ -1441,8 +1556,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
/// cells (instead of the visibility-derived set).
|
||||
///
|
||||
/// <para>Indoor entities (ParentCellId set) gated by membership in
|
||||
/// <paramref name="cellIds"/>. Building shells (IsBuildingShell) pass
|
||||
/// unconditionally when <paramref name="set"/> == IndoorPass. Outdoor
|
||||
/// <paramref name="cellIds"/>. Building shells are gated by
|
||||
/// BuildingShellAnchorCellId membership in the same cell set. Outdoor
|
||||
/// scenery is excluded by the EntitySet partition (no cell-list gate
|
||||
/// needed — EntityMatchesSet handles it).</para>
|
||||
/// </summary>
|
||||
|
|
@ -1458,11 +1573,40 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
if (entity.MeshRefs.Count == 0) continue;
|
||||
if (entity.ParentCellId.HasValue && !cellIds.Contains(entity.ParentCellId.Value))
|
||||
continue;
|
||||
if (IsShellScopedSet(set) && entity.IsBuildingShell)
|
||||
{
|
||||
if (entity.BuildingShellAnchorCellId is not uint anchorCellId ||
|
||||
!cellIds.Contains(anchorCellId))
|
||||
continue;
|
||||
}
|
||||
result.Add(entity.Id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool EntityPassesVisibleCellGate(
|
||||
WorldEntity entity,
|
||||
HashSet<uint>? visibleCellIds,
|
||||
EntitySet set)
|
||||
{
|
||||
if (visibleCellIds is null)
|
||||
return true;
|
||||
|
||||
if (entity.ParentCellId.HasValue)
|
||||
return visibleCellIds.Contains(entity.ParentCellId.Value);
|
||||
|
||||
if (IsShellScopedSet(set) && entity.IsBuildingShell)
|
||||
{
|
||||
return entity.BuildingShellAnchorCellId is uint anchorCellId
|
||||
&& visibleCellIds.Contains(anchorCellId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsShellScopedSet(EntitySet set) =>
|
||||
set == EntitySet.IndoorPass || set == EntitySet.BuildingShells;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
|
@ -1503,7 +1647,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
int FirstInstance,
|
||||
ulong TextureHandle,
|
||||
uint TextureLayer,
|
||||
TranslucencyKind Translucency);
|
||||
TranslucencyKind Translucency,
|
||||
CullMode CullMode = CullMode.CounterClockwise);
|
||||
|
||||
/// <summary>
|
||||
/// Public mirror of the per-group <see cref="BatchData"/> uploaded to the SSBO.
|
||||
|
|
@ -1535,7 +1680,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
public static IndirectLayoutResult BuildIndirectArrays(
|
||||
IReadOnlyList<IndirectGroupInput> groups,
|
||||
DrawElementsIndirectCommand[] indirectScratch,
|
||||
BatchDataPublic[] batchScratch)
|
||||
BatchDataPublic[] batchScratch,
|
||||
CullMode[]? cullScratch = null)
|
||||
{
|
||||
int opaqueCount = 0;
|
||||
int transparentCount = 0;
|
||||
|
|
@ -1570,12 +1716,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
{
|
||||
indirectScratch[oi] = dec;
|
||||
batchScratch[oi] = bd;
|
||||
if (cullScratch is not null) cullScratch[oi] = g.CullMode;
|
||||
oi++;
|
||||
}
|
||||
else
|
||||
{
|
||||
indirectScratch[ti] = dec;
|
||||
batchScratch[ti] = bd;
|
||||
if (cullScratch is not null) cullScratch[ti] = g.CullMode;
|
||||
ti++;
|
||||
}
|
||||
}
|
||||
|
|
@ -1639,6 +1787,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
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 CullMode CullMode;
|
||||
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
|
||||
|
|
|
|||
|
|
@ -38,6 +38,13 @@ public sealed record RuntimeOptions(
|
|||
int HidePartIndex,
|
||||
bool RetailCloseDegrades,
|
||||
bool DumpSceneryZ,
|
||||
bool DumpLiveSpawns,
|
||||
bool A8DiagDisableInsideStep4Terrain,
|
||||
bool A8DiagDisableInsideStep4Outdoor,
|
||||
bool A8DiagDisableInsideStep3EnvCellOpaque,
|
||||
bool A8DiagDisableInsideStep3IndoorPass,
|
||||
bool A8DiagDisableInsideStep2Punch,
|
||||
bool A8DiagDisablePortalDepthClamp,
|
||||
int? LegacyStreamRadius)
|
||||
{
|
||||
/// <summary>
|
||||
|
|
@ -77,6 +84,19 @@ public sealed record RuntimeOptions(
|
|||
// only for before/after diagnostic comparisons.
|
||||
RetailCloseDegrades: !string.Equals(env("ACDREAM_RETAIL_CLOSE_DEGRADES"), "0", StringComparison.Ordinal),
|
||||
DumpSceneryZ: IsExactlyOne(env("ACDREAM_DUMP_SCENERY_Z")),
|
||||
DumpLiveSpawns: IsExactlyOne(env("ACDREAM_DUMP_LIVE_SPAWNS")),
|
||||
A8DiagDisableInsideStep4Terrain:
|
||||
IsExactlyOne(env("ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP4_TERRAIN")),
|
||||
A8DiagDisableInsideStep4Outdoor:
|
||||
IsExactlyOne(env("ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP4_OUTDOOR")),
|
||||
A8DiagDisableInsideStep3EnvCellOpaque:
|
||||
IsExactlyOne(env("ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP3_ENVCELL_OPAQUE")),
|
||||
A8DiagDisableInsideStep3IndoorPass:
|
||||
IsExactlyOne(env("ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP3_INDOORPASS")),
|
||||
A8DiagDisableInsideStep2Punch:
|
||||
IsExactlyOne(env("ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP2_PUNCH")),
|
||||
A8DiagDisablePortalDepthClamp:
|
||||
IsExactlyOne(env("ACDREAM_A8_DIAG_DISABLE_PORTAL_DEPTH_CLAMP")),
|
||||
// Legacy override for ACDREAM_STREAM_RADIUS. Caller applies it on
|
||||
// top of the quality preset's radii. Null when unset or invalid.
|
||||
LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")));
|
||||
|
|
|
|||
|
|
@ -137,21 +137,55 @@ public sealed class GpuWorldState
|
|||
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
||||
IReadOnlyList<WorldEntity> Entities,
|
||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries
|
||||
=> EnumerateLandblockEntries(includeAnimatedIndex: true);
|
||||
|
||||
/// <summary>
|
||||
/// Per-landblock render entries without the animated lookup dictionary.
|
||||
/// Static render passes use this to avoid rebuilding an index they cannot
|
||||
/// consume.
|
||||
/// </summary>
|
||||
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
||||
IReadOnlyList<WorldEntity> Entities,
|
||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntriesWithoutAnimatedIndex
|
||||
=> EnumerateLandblockEntries(includeAnimatedIndex: false);
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight bounds-only enumeration for overlays and diagnostics.
|
||||
/// Does not walk entity lists.
|
||||
/// </summary>
|
||||
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds
|
||||
{
|
||||
get
|
||||
{
|
||||
foreach (var kvp in _loaded)
|
||||
{
|
||||
// Build AnimatedById on the fly — cheap (~132 entities/LB max).
|
||||
var byId = new Dictionary<uint, WorldEntity>(kvp.Value.Entities.Count);
|
||||
if (_aabbs.TryGetValue(kvp.Key, out var aabb))
|
||||
yield return (kvp.Key, aabb.Min, aabb.Max);
|
||||
else
|
||||
yield return (kvp.Key, Vector3.Zero, Vector3.Zero);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
||||
IReadOnlyList<WorldEntity> Entities,
|
||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> EnumerateLandblockEntries(
|
||||
bool includeAnimatedIndex)
|
||||
{
|
||||
foreach (var kvp in _loaded)
|
||||
{
|
||||
Dictionary<uint, WorldEntity>? byId = null;
|
||||
if (includeAnimatedIndex)
|
||||
{
|
||||
byId = new Dictionary<uint, WorldEntity>(kvp.Value.Entities.Count);
|
||||
foreach (var e in kvp.Value.Entities)
|
||||
byId[e.Id] = e;
|
||||
|
||||
if (_aabbs.TryGetValue(kvp.Key, out var aabb))
|
||||
yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities, byId);
|
||||
else
|
||||
yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities, byId);
|
||||
}
|
||||
|
||||
if (_aabbs.TryGetValue(kvp.Key, out var aabb))
|
||||
yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities, byId);
|
||||
else
|
||||
yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities, byId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,15 +37,21 @@ public abstract record LandblockStreamResult(uint LandblockId)
|
|||
) : LandblockStreamResult(LandblockId);
|
||||
|
||||
/// <summary>
|
||||
/// A previously-Far-resident landblock was promoted to Near. Terrain
|
||||
/// mesh is already on the GPU; the result carries the entity layer
|
||||
/// (stabs, buildings, scenery) to merge into the existing GpuWorldState
|
||||
/// entry.
|
||||
/// A previously-Far-resident landblock was promoted to Near. The result
|
||||
/// carries the full near landblock plus mesh data so the render thread can
|
||||
/// run the same near-tier side effects as a fresh LoadNear: cell visibility,
|
||||
/// building registries, EnvCell finalization, lighting, and static collision.
|
||||
/// GpuWorldState still merges only the entity layer so live entities already
|
||||
/// attached to the landblock are preserved.
|
||||
/// </summary>
|
||||
public sealed record Promoted(
|
||||
uint LandblockId,
|
||||
IReadOnlyList<WorldEntity> Entities
|
||||
) : LandblockStreamResult(LandblockId);
|
||||
LoadedLandblock Landblock,
|
||||
LandblockMeshData MeshData
|
||||
) : LandblockStreamResult(LandblockId)
|
||||
{
|
||||
public IReadOnlyList<WorldEntity> Entities => Landblock.Entities;
|
||||
}
|
||||
|
||||
public sealed record Failed(uint LandblockId, string Error) : LandblockStreamResult(LandblockId);
|
||||
public sealed record Unloaded(uint LandblockId) : LandblockStreamResult(LandblockId);
|
||||
|
|
|
|||
|
|
@ -159,6 +159,9 @@ public sealed class LandblockStreamer : IDisposable
|
|||
|
||||
private void WorkerLoop()
|
||||
{
|
||||
var highPriority = new Queue<LandblockStreamJob>();
|
||||
var lowPriority = new Queue<LandblockStreamJob>();
|
||||
|
||||
try
|
||||
{
|
||||
// Safe to block: this is a dedicated worker thread with no
|
||||
|
|
@ -169,14 +172,24 @@ public sealed class LandblockStreamer : IDisposable
|
|||
// simple thread-start shape.
|
||||
while (!_cancel.Token.IsCancellationRequested)
|
||||
{
|
||||
if (!_inbox.Reader.WaitToReadAsync(_cancel.Token).AsTask().GetAwaiter().GetResult())
|
||||
if (highPriority.Count == 0 &&
|
||||
lowPriority.Count == 0 &&
|
||||
!_inbox.Reader.WaitToReadAsync(_cancel.Token).AsTask().GetAwaiter().GetResult())
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
while (_inbox.Reader.TryRead(out var job))
|
||||
{
|
||||
if (_cancel.Token.IsCancellationRequested) return;
|
||||
HandleJob(job);
|
||||
}
|
||||
EnqueuePrioritized(job, highPriority, lowPriority);
|
||||
|
||||
if (highPriority.Count == 0 && lowPriority.Count == 0)
|
||||
continue;
|
||||
|
||||
if (_cancel.Token.IsCancellationRequested) return;
|
||||
var next = highPriority.Count > 0
|
||||
? highPriority.Dequeue()
|
||||
: lowPriority.Dequeue();
|
||||
HandleJob(next);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { /* graceful shutdown */ }
|
||||
|
|
@ -192,6 +205,55 @@ public sealed class LandblockStreamer : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private static void EnqueuePrioritized(
|
||||
LandblockStreamJob job,
|
||||
Queue<LandblockStreamJob> highPriority,
|
||||
Queue<LandblockStreamJob> lowPriority)
|
||||
{
|
||||
if (job is LandblockStreamJob.Load
|
||||
{
|
||||
Kind: LandblockStreamJobKind.LoadNear or LandblockStreamJobKind.PromoteToNear
|
||||
} high)
|
||||
{
|
||||
// Near-tier jobs are visible-content critical. They supersede an
|
||||
// older queued LoadFar for the same landblock: LoadNear obviously
|
||||
// loads everything, and PromoteToNear now carries mesh data so the
|
||||
// render thread can run the full near-tier apply side effects. If a
|
||||
// LoadFar is already being processed, the single worker naturally
|
||||
// finishes it before the promotion is dequeued.
|
||||
RemoveLowPriorityJobsForLandblock(
|
||||
lowPriority,
|
||||
high.LandblockId,
|
||||
removeLoadFar: true,
|
||||
removeUnload: true);
|
||||
highPriority.Enqueue(job);
|
||||
return;
|
||||
}
|
||||
|
||||
lowPriority.Enqueue(job);
|
||||
}
|
||||
|
||||
private static void RemoveLowPriorityJobsForLandblock(
|
||||
Queue<LandblockStreamJob> queue,
|
||||
uint landblockId,
|
||||
bool removeLoadFar,
|
||||
bool removeUnload)
|
||||
{
|
||||
int count = queue.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var job = queue.Dequeue();
|
||||
bool remove = job.LandblockId == landblockId && job switch
|
||||
{
|
||||
LandblockStreamJob.Load { Kind: LandblockStreamJobKind.LoadFar } => removeLoadFar,
|
||||
LandblockStreamJob.Unload => removeUnload,
|
||||
_ => false
|
||||
};
|
||||
if (!remove)
|
||||
queue.Enqueue(job);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleJob(LandblockStreamJob job)
|
||||
{
|
||||
switch (job)
|
||||
|
|
@ -212,6 +274,19 @@ public sealed class LandblockStreamer : IDisposable
|
|||
load.LandblockId, "LandblockLoader.Load returned null"));
|
||||
break;
|
||||
}
|
||||
if (load.Kind == LandblockStreamJobKind.PromoteToNear)
|
||||
{
|
||||
var promotedMesh = _buildMeshOrNull(load.LandblockId, lb);
|
||||
if (promotedMesh is null)
|
||||
{
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
||||
load.LandblockId, "buildMeshOrNull returned null"));
|
||||
break;
|
||||
}
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Promoted(
|
||||
load.LandblockId, lb, promotedMesh));
|
||||
break;
|
||||
}
|
||||
var mesh = _buildMeshOrNull(load.LandblockId, lb);
|
||||
if (mesh is null)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -103,16 +103,16 @@ public sealed class StreamingController
|
|||
{
|
||||
_region = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
|
||||
var bootstrap = _region.ComputeFirstTickDiff();
|
||||
foreach (var id in bootstrap.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||||
foreach (var id in bootstrap.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
|
||||
foreach (var id in bootstrap.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||||
_region.MarkResidentFromBootstrap();
|
||||
}
|
||||
else if (_region.CenterX != observerCx || _region.CenterY != observerCy)
|
||||
{
|
||||
var diff = _region.RecenterTo(observerCx, observerCy);
|
||||
foreach (var id in diff.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||||
foreach (var id in diff.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
|
||||
foreach (var id in diff.ToPromote) _enqueueLoad(id, LandblockStreamJobKind.PromoteToNear);
|
||||
foreach (var id in diff.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
|
||||
foreach (var id in diff.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||||
foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id);
|
||||
foreach (var id in diff.ToUnload) _enqueueUnload(id);
|
||||
}
|
||||
|
|
@ -129,6 +129,7 @@ public sealed class StreamingController
|
|||
_state.AddLandblock(loaded.Landblock);
|
||||
break;
|
||||
case LandblockStreamResult.Promoted promoted:
|
||||
_applyTerrain(promoted.Landblock, promoted.MeshData);
|
||||
_state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities);
|
||||
break;
|
||||
case LandblockStreamResult.Unloaded unloaded:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.World;
|
||||
|
||||
|
|
@ -83,6 +84,7 @@ public static class LandblockLoader
|
|||
Rotation = building.Frame.Orientation,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
IsBuildingShell = true, // Phase A8: tag at source array boundary
|
||||
BuildingShellAnchorCellId = FirstBuildingAnchorCellId(building, landblockId),
|
||||
};
|
||||
buildingEntity.RefreshAabb(); // A.5 T18: populate cached AABB at construction
|
||||
result.Add(buildingEntity);
|
||||
|
|
@ -96,4 +98,20 @@ public static class LandblockLoader
|
|||
var type = id & TypeMask;
|
||||
return type == GfxObjMask || type == SetupMask;
|
||||
}
|
||||
|
||||
private static uint? FirstBuildingAnchorCellId(BuildingInfo building, uint landblockId)
|
||||
{
|
||||
if (landblockId == 0)
|
||||
return null;
|
||||
|
||||
uint lbPrefix = landblockId & 0xFFFF0000u;
|
||||
foreach (var portal in building.Portals)
|
||||
{
|
||||
if (portal.OtherCellId == 0xFFFF)
|
||||
continue;
|
||||
return lbPrefix | (uint)portal.OtherCellId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,13 +56,24 @@ public sealed class WorldEntity
|
|||
///
|
||||
/// <para>
|
||||
/// Read at draw time by <c>WbDrawDispatcher</c>'s <c>IndoorPass</c>
|
||||
/// partition so building shells render unconditionally when the camera
|
||||
/// is inside their building (they ARE the indoor walls), not stencil-gated
|
||||
/// as outdoor scenery would be.
|
||||
/// partition so building shells can render when the camera is inside their
|
||||
/// own building (they ARE the indoor walls), not stencil-gated as outdoor
|
||||
/// scenery would be.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public bool IsBuildingShell { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Dat-derived EnvCell anchor for a building shell. Building shells are
|
||||
/// top-level landblock stabs, so they do not have a real ParentCellId, but
|
||||
/// the LandBlockInfo.Buildings[] portal list names cells owned by the same
|
||||
/// building. The indoor renderer uses this anchor only for draw scoping:
|
||||
/// a shell renders in IndoorPass when its anchor belongs to the camera
|
||||
/// building's EnvCell set. Collision still treats the shell as an outdoor
|
||||
/// stab unless ParentCellId is explicitly set.
|
||||
/// </summary>
|
||||
public uint? BuildingShellAnchorCellId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Uniform scale applied to this entity's mesh by the scenery pipeline.
|
||||
/// For scenery objects this is spawn.Scale (typically 0.8–1.3). For stabs
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue