acdream/src/AcDream.App/Streaming/LandblockRenderPublisher.cs
Erik 9aaf97e785 Revert "Campaign V slice V4a" - it lost world multisampling
This reverts ceec3bc4. Two independent reasons, either sufficient.

The rendering regression. The slice deleted TextRenderGlStateScope, which
saved GL_MULTISAMPLE and GL_SAMPLE_ALPHA_TO_COVERAGE on entry, disabled them
for the text pass, and restored them on exit (TextRenderGlStateScope.cs:111-112
and 153-154 at the parent commit). Its replacement bakes that state into the
text pipeline but nothing restores it, and GlGpuPassEncoder.Dispose does not
either. Every world renderer is still raw GL at this point in the campaign, so
from the first UI frame onward the world drew with multisampling disabled.

The offline pixel gate caught it: 1,791 of 563,200 compared pixels differed,
0.318% against a 0.001 threshold. The commit message attributed this to
wall-clock-driven ambient animation shifting phase, and committed through the
failure. That explanation does not survive its own control: capturing twice at
the reverted-to commit differs by 19 pixels and twice at the slice's own commit
by 8, while base-versus-head differs by 1,791 - a 224x gap that no shared-noise
source explains. An amplified difference image settles it visually: the changed
pixels are the silhouette edges of every tree, building and rock, with terrain
interiors, water and the entire UI untouched. That is the signature of losing
edge antialiasing, not of animated sprites.

This is the exact failure mode two existing memory notes already warn about -
a mid-frame renderer must set every GL state it uses rather than inherit it,
and issue #52's lesson that a rendering migration must audit per-pass GL state
before declaring itself done.

The scope. The brief was three small leaf renderers plus additive frame-
lifecycle wiring, roughly ten files. The commit changed 334 files with 3,665
insertions and 3,845 deletions, including 323 public-to-internal visibility
conversions across the App assembly, 55 test files, two retired conformance
tests, and a self-described temporary escape hatch for bridging raw-GL viewport
textures. Even without the regression, that is not separable into the part
worth keeping and the part worth dropping.

Reverting rather than patching because the good work here - the RHI frame
lifecycle wiring and a genuine render-state-cache staleness fix - is small
enough to redo cleanly against a tightened spec, while untangling it from 300+
files of unrelated churn is not.

Post-revert: Release build clean, App suite back to 3,843 passed / 3 skipped,
offline pixel gate passing at 19 differing pixels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 18:29:28 +02:00

456 lines
16 KiB
C#

using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Terrain;
namespace AcDream.App.Streaming;
/// <summary>
/// Immutable receipt for the render-owned prefix of one accepted landblock
/// publication. Physics and static presentation consume the same captured
/// origin between <see cref="LandblockRenderPublisher.BeginPublication"/> and
/// <see cref="LandblockRenderPublisher.CompletePublication"/>.
/// </summary>
public sealed class LandblockRenderPublication
{
private readonly IReadOnlyDictionary<uint, LoadedCell> _visibilityCells;
internal LandblockRenderPublication(
object owner,
LandblockBuild build,
LandblockMeshData meshData,
Vector3 origin,
Dictionary<uint, LoadedCell> visibilityCells,
Vector3 aabbMin,
Vector3 aabbMax,
BuildingRegistryPublication? buildingPublication,
EnvCellLandblockPublication? envCellPublication)
{
Owner = owner;
Build = build;
MeshData = meshData;
Origin = origin;
_visibilityCells = new ReadOnlyDictionary<uint, LoadedCell>(visibilityCells);
AabbMin = aabbMin;
AabbMax = aabbMax;
BuildingPublication = buildingPublication;
EnvCellPublication = envCellPublication;
}
internal object Owner { get; }
internal LandblockBuild Build { get; }
internal LandblockMeshData MeshData { get; }
internal Vector3 AabbMin { get; }
internal Vector3 AabbMax { get; }
internal BuildingRegistryPublication? BuildingPublication { get; }
internal EnvCellLandblockPublication? EnvCellPublication { get; }
internal bool TerrainCommitted { get; set; }
internal bool VisibilityCommitted { get; set; }
internal bool AabbCommitted { get; set; }
internal bool BeginCommitted { get; set; }
internal bool BuildingRegistryCommitted { get; set; }
internal bool EnvCellsCommitted { get; set; }
internal bool CompletionCommitted { get; set; }
public uint LandblockId => Build.Landblock.LandblockId;
public Vector3 Origin { get; }
public IReadOnlyDictionary<uint, LoadedCell> VisibilityCells => _visibilityCells;
}
/// <summary>
/// Typed cumulative diagnostics owned by the render publication boundary.
/// Times use <see cref="Stopwatch"/> ticks so callers can aggregate without
/// losing sub-millisecond precision.
/// </summary>
public readonly record struct LandblockRenderPublisherDiagnostics(
long BeginCount,
long CompleteCount,
long TerrainPublishTicks,
long BeginPublishTicks,
long CompletePublishTicks,
long EnvCellPreparationCount,
long TerrainRemovalCount,
long CellVisibilityRemovalCount,
long BuildingRegistryRemovalCount,
long EnvCellRemovalCount);
/// <summary>
/// Update-thread owner of landblock render publication. It publishes terrain,
/// the complete visibility-cell transaction, frustum bounds, building
/// registries, and EnvCell renderer state without reading DATs or deciding
/// residency. Render-ID acquisition remains in <see cref="GpuWorldState"/>;
/// schema-aware EnvCell preparation is replayed only after that acquisition.
/// </summary>
/// <remarks>
/// The ordering preserves retail <c>LScape::grab_visible_cells</c>
/// (0x00504EC0), <c>CLandBlock::init_buildings</c> (0x0052FD80),
/// <c>CLandBlock::grab_visible_cells</c> (0x0052F460), and the extracted
/// WorldBuilder one-job/one-EnvCell-transaction seam documented in
/// <c>docs/architecture/worldbuilder-inventory.md</c>.
/// </remarks>
public sealed class LandblockRenderPublisher
{
private readonly object _receiptOwner = new();
private readonly Action<uint, LandblockMeshData, Vector3> _publishTerrain;
private readonly Action<uint> _removeTerrain;
private readonly CellVisibility _cellVisibility;
private readonly GpuWorldState _worldState;
private readonly Action<EnvCellLandblockBuild>? _commitEnvCells;
private readonly IEnvCellLandblockPublisher? _envCellPublisher;
private readonly Action<EnvCellLandblockBuild>? _prepareEnvCells;
private readonly Action<uint>? _removeEnvCells;
private readonly Dictionary<uint, BuildingRegistry> _buildingRegistries = new();
private long _beginCount;
private long _completeCount;
private long _terrainPublishTicks;
private long _beginPublishTicks;
private long _completePublishTicks;
private long _envCellPreparationCount;
private long _terrainRemovalCount;
private long _cellVisibilityRemovalCount;
private long _buildingRegistryRemovalCount;
private long _envCellRemovalCount;
public LandblockRenderPublisher(
Action<uint, LandblockMeshData, Vector3> publishTerrain,
Action<uint> removeTerrain,
CellVisibility cellVisibility,
GpuWorldState worldState,
Action<EnvCellLandblockBuild>? commitEnvCells = null,
Action<EnvCellLandblockBuild>? prepareEnvCells = null,
Action<uint>? removeEnvCells = null,
IEnvCellLandblockPublisher? envCellPublisher = null)
{
ArgumentNullException.ThrowIfNull(publishTerrain);
ArgumentNullException.ThrowIfNull(removeTerrain);
ArgumentNullException.ThrowIfNull(cellVisibility);
ArgumentNullException.ThrowIfNull(worldState);
_publishTerrain = publishTerrain;
_removeTerrain = removeTerrain;
_cellVisibility = cellVisibility;
_worldState = worldState;
_commitEnvCells = commitEnvCells;
_envCellPublisher = envCellPublisher;
if (_commitEnvCells is not null && _envCellPublisher is not null)
{
throw new ArgumentException(
"Supply either the retained EnvCell publisher or the compatibility callback, not both.",
nameof(envCellPublisher));
}
_prepareEnvCells = prepareEnvCells;
_removeEnvCells = removeEnvCells;
}
public IReadOnlyCollection<BuildingRegistry> BuildingRegistries =>
_buildingRegistries.Values;
public LandblockRenderPublisherDiagnostics Diagnostics => new(
_beginCount,
_completeCount,
_terrainPublishTicks,
_beginPublishTicks,
_completePublishTicks,
_envCellPreparationCount,
_terrainRemovalCount,
_cellVisibilityRemovalCount,
_buildingRegistryRemovalCount,
_envCellRemovalCount);
internal bool MatchesState(GpuWorldState state) =>
ReferenceEquals(_worldState, state);
/// <summary>
/// Publishes the prefix that retail establishes before physics/static
/// activation: terrain, complete visibility cells, and spatial bounds.
/// </summary>
public LandblockRenderPublication BeginPublication(
LandblockBuild build,
LandblockMeshData meshData)
{
LandblockRenderPublication publication = PreparePublication(
build,
meshData);
BeginPublication(publication);
return publication;
}
/// <summary>
/// Validates and captures the immutable render transaction before its
/// first external mutation. A coordinator retains this receipt when an
/// exact-replacement callback fails, then resumes the same publication.
/// </summary>
public LandblockRenderPublication PreparePublication(
LandblockBuild build,
LandblockMeshData meshData)
{
ArgumentNullException.ThrowIfNull(build);
ArgumentNullException.ThrowIfNull(meshData);
if (!build.Origin.IsSpecified)
throw new ArgumentException(
"Render publication requires the build's captured origin.",
nameof(build));
uint landblockId = build.Landblock.LandblockId;
if (build.EnvCells is { } candidateEnvCells &&
candidateEnvCells.LandblockId != landblockId)
{
throw new InvalidOperationException(
$"EnvCell transaction 0x{candidateEnvCells.LandblockId:X8} was attached to " +
$"landblock 0x{landblockId:X8}.");
}
Vector3 origin = ComputeOrigin(landblockId, build.Origin);
var visibilityCells = new Dictionary<uint, LoadedCell>();
if (build.EnvCells is { } envCells)
{
foreach (LoadedCell cell in envCells.VisibilityCells)
visibilityCells[cell.CellId] = cell;
}
(Vector3 aabbMin, Vector3 aabbMax) = ComputeAabb(meshData, origin);
BuildingRegistryPublication? buildingPublication =
build.Landblock.PhysicsDats?.Info is { } info
? BuildingLoader.PreparePublication(
info,
landblockId,
visibilityCells)
: null;
EnvCellLandblockPublication? envCellPublication =
build.EnvCells is { } retainedEnvCells
? _envCellPublisher?.PreparePublication(retainedEnvCells)
: null;
return new LandblockRenderPublication(
_receiptOwner,
build,
meshData,
origin,
visibilityCells,
aabbMin,
aabbMax,
buildingPublication,
envCellPublication);
}
/// <summary>
/// Advances the render prefix from a retained receipt. Every operation is
/// exact replacement, so a callback failure can safely retry this stage.
/// </summary>
public void BeginPublication(LandblockRenderPublication publication)
{
ValidateReceipt(publication);
while (!AdvanceBeginOne(publication))
{
}
}
/// <summary>
/// Advances one exact render-prefix mutation from the retained receipt.
/// Terrain, visibility, and AABB publication remain ordered and retries do
/// not replay an already committed prefix.
/// </summary>
internal bool AdvanceBeginOne(LandblockRenderPublication publication)
{
ValidateReceipt(publication);
if (publication.BeginCommitted)
return true;
long started = Stopwatch.GetTimestamp();
LandblockBuild build = publication.Build;
uint landblockId = publication.LandblockId;
if (!publication.TerrainCommitted)
{
long terrainStarted = Stopwatch.GetTimestamp();
_publishTerrain(landblockId, publication.MeshData, publication.Origin);
_terrainPublishTicks += Stopwatch.GetTimestamp() - terrainStarted;
publication.TerrainCommitted = true;
}
else if (!publication.VisibilityCommitted)
{
if (build.EnvCells is { } envCells)
_cellVisibility.CommitLandblock(landblockId, envCells.VisibilityCells);
publication.VisibilityCommitted = true;
}
else if (!publication.AabbCommitted)
{
_worldState.SetLandblockAabb(
landblockId,
publication.AabbMin,
publication.AabbMax);
publication.AabbCommitted = true;
}
else
{
publication.BeginCommitted = true;
_beginCount++;
}
_beginPublishTicks += Stopwatch.GetTimestamp() - started;
return publication.BeginCommitted;
}
/// <summary>
/// Completes the render-owned suffix after physics has consumed the same
/// receipt: building membership is stamped before the EnvCell renderer
/// receives the complete immutable transaction.
/// </summary>
public void CompletePublication(LandblockRenderPublication publication)
{
ValidateReceipt(publication);
if (!publication.BeginCommitted)
throw new InvalidOperationException(
"Render publication cannot complete before its prefix commits.");
while (!AdvanceCompleteOne(publication))
{
}
}
/// <summary>
/// Advances one exact render-suffix mutation. The building registry is
/// visible before the EnvCell renderer snapshot, matching the existing
/// retail-rooted ordering.
/// </summary>
internal bool AdvanceCompleteOne(LandblockRenderPublication publication)
{
ValidateReceipt(publication);
if (!publication.BeginCommitted)
throw new InvalidOperationException(
"Render publication cannot complete before its prefix commits.");
if (publication.CompletionCommitted)
return true;
long started = Stopwatch.GetTimestamp();
LandblockBuild build = publication.Build;
uint landblockId = publication.LandblockId;
if (publication.BuildingPublication is { PreparationCommitted: false }
buildingPublication)
{
BuildingLoader.AdvancePreparationOne(buildingPublication);
}
else if (!publication.BuildingRegistryCommitted)
{
if (publication.BuildingPublication is { } completedBuildings)
{
BuildingLoader.CommitPublication(completedBuildings);
uint registryKey = landblockId & 0xFFFF0000u;
_buildingRegistries[registryKey] =
completedBuildings.Registry;
}
publication.BuildingRegistryCommitted = true;
}
else if (publication.EnvCellPublication is { } envCellPublication
&& !envCellPublication.PreparationCommitted)
{
_envCellPublisher?.AdvancePreparationOne(envCellPublication);
}
else if (!publication.EnvCellsCommitted)
{
if (publication.EnvCellPublication is { } retained)
{
(_envCellPublisher
?? throw new InvalidOperationException(
"A retained EnvCell receipt has no owning publisher."))
.CommitPublication(retained);
}
else if (build.EnvCells is { } envCells)
{
_commitEnvCells?.Invoke(envCells);
}
publication.EnvCellsCommitted = true;
}
else
{
publication.CompletionCommitted = true;
_completeCount++;
}
_completePublishTicks += Stopwatch.GetTimestamp() - started;
return publication.CompletionCommitted;
}
/// <summary>
/// Replays EnvCell schema preparation after <see cref="GpuWorldState"/>
/// has acquired every synthetic render identifier.
/// </summary>
public void PrepareAfterRenderPins(EnvCellLandblockBuild build)
{
ArgumentNullException.ThrowIfNull(build);
_prepareEnvCells?.Invoke(build);
_envCellPreparationCount++;
}
public void RemoveTerrain(uint landblockId)
{
_removeTerrain(landblockId);
_terrainRemovalCount++;
}
public void RemoveCellVisibility(uint landblockId)
{
_cellVisibility.RemoveLandblock((landblockId >> 16) & 0xFFFFu);
_cellVisibilityRemovalCount++;
}
public void RemoveBuildingRegistry(uint landblockId)
{
_buildingRegistries.Remove(landblockId & 0xFFFF0000u);
_buildingRegistryRemovalCount++;
}
public void RemoveEnvironmentCells(uint landblockId)
{
_removeEnvCells?.Invoke(landblockId);
_envCellRemovalCount++;
}
private static Vector3 ComputeOrigin(
uint landblockId,
LandblockBuildOrigin capturedOrigin)
{
int landblockX = (int)((landblockId >> 24) & 0xFFu);
int landblockY = (int)((landblockId >> 16) & 0xFFu);
return new Vector3(
(landblockX - capturedOrigin.CenterX) * 192f,
(landblockY - capturedOrigin.CenterY) * 192f,
0f);
}
private static (Vector3 Min, Vector3 Max) ComputeAabb(
LandblockMeshData meshData,
Vector3 origin)
{
float zMin = float.MaxValue;
float zMax = float.MinValue;
foreach (TerrainVertex vertex in meshData.Vertices)
{
float z = vertex.Position.Z;
if (z < zMin) zMin = z;
if (z > zMax) zMax = z;
}
if (zMin == float.MaxValue)
{
zMin = 0f;
zMax = 0f;
}
zMax += 50f;
zMin -= 10f;
return (
new Vector3(origin.X, origin.Y, zMin),
new Vector3(origin.X + 192f, origin.Y + 192f, zMax));
}
private void ValidateReceipt(LandblockRenderPublication publication)
{
ArgumentNullException.ThrowIfNull(publication);
if (!ReferenceEquals(publication.Owner, _receiptOwner))
{
throw new ArgumentException(
"The render publication receipt belongs to another publisher.",
nameof(publication));
}
}
}