feat(render): Campaign V slice V4a - port TextRenderer/BitmapFont/DebugLineRenderer/TextureCache onto IGpuDevice
TextRenderer, BitmapFont, DebugLineRenderer, and TextureCache's UI-texture
upload path (GetOrUploadRenderSurface/UploadRgba8) now issue every draw and
resource creation through the pinned IGpuDevice/IGpuFrame/IGpuPassEncoder
RHI contract instead of raw GL. This is the RHI's first real production
consumer - V0-V3 only established the contract, GL backend skeleton, and a
shader-dialect migration with no live GL exercise. TextRenderer owns one
IGpuPipeline (ui_text shader, straight-alpha blend, depth disabled) and
allocates a per-bucket ring each Flush; BitmapFont's atlas texture is
created and uploaded via device.CreateTexture/.Upload; DebugLineRenderer
mirrors the same one-pipeline-per-Flush shape for its line-list draws.
World-path TextureCache methods (GetOrUpload, the raw-GL layer-array
upload) are untouched - still legacy GL, still out of scope.
Frame lifecycle: GpuDeviceFrameLifetime (RenderFrameOrchestrator.cs) wraps
IGpuDevice.BeginFrame()/IGpuFrame.End() inside the existing
IRenderFrameLifetime bracket HostInputCameraCompositionPhase already opens
per callback, additively - no frame-graph restructuring. Ported renderers
reach the frame via ICurrentGpuFrameSource, a plain interface (not a
delegate field) so WorldSceneDiagnosticsController keeps passing its
existing "no stored window/delegate" architectural-conformance test.
Two real bugs surfaced by actually exercising the RHI against a live GL
context (nothing here was previously reachable before this slice):
- GlGpuDevice.BeginFrame() now resets the render-state cache every frame.
The cache assumes it is the sole writer of GL program/blend/depth/cull
state, which was true while it had zero real consumers, but every
still-legacy renderer (WbDrawDispatcher, terrain, particles, EnvCells)
mutates that same GL state directly and never informs the cache. Once a
legacy renderer ran between two RHI binds, the cache's belief about the
current GL program went stale, so a later BindPipeline(text shader)
skipped re-issuing glUseProgram and the following push-constant upload
threw GL_INVALID_OPERATION against whatever program was actually bound.
Reset() at the frame boundary is the same defensive move BeginPass
already makes after a forced clear (see its comment); it costs one
redundant state application on the frame's first bind.
- GL_MULTISAMPLE has no representation in the pinned contract. Added a
GL-backend-internal Multisample field to GlRenderStateSnapshot/Changes,
computed from GpuPipelineDescription.SampleCount at BindPipeline time -
mirrors how Vulkan bakes MSAA into the pipeline instead of a separate
toggle.
Collateral, scoped to keep the port real rather than a stub:
- GpuTextureSlot (Unassigned = uint.MaxValue, NOT 0) now flows through
every consumer of TextureCache.GetOrUploadRenderSurface/UploadRgba8 and
TextRenderer.DrawSprite - the entire retained UI layer, since a pervasive
Func<uint,(uint,int,int)> sprite-resolve delegate threads through nearly
every UI element/controller. Every prior `== 0` / `!= 0` "no texture"
check became `.IsAssigned` / `!.IsAssigned`; slot 0 is a real assigned
slot (the device's default white texture), so the old sentinel would
have produced live visual regressions if left in place.
- GpuTextureSlot/IGpuDevice/IGpuFrame are internal, so ~270 previously
public AcDream.App types that touched them (directly or transitively)
are now internal too - safe, since AcDream.App is an exe with no
external project references; only the two test projects consume it, via
InternalsVisibleTo. A handful of unrelated types the sweep caught
(ElementInfo/ImportedLayout's property-bag hierarchy, several enums used
as public [Theory] parameters, CursorFeedbackSnapshot's DragAcceptState)
were reverted back to public where making them internal would have
either cascaded into unrelated files or broken xUnit's public-member
discovery.
- ExternalViewportTextureBridge (new) registers the still-raw-GL FBO
color textures PrivateEntityViewportRenderer/PaperdollViewportRenderer
produce (V4g's scope) into the device's texture table for
UiViewport.TextureHandle, via a temporary
GlGpuDevice.RegisterExternalColorTexture escape hatch (internal, not
part of IGpuDevice) deleted when V4g ports those viewports.
- TextRenderGlStateScope.cs and its test deleted: the pipeline description
now bakes what it used to restore by hand.
- ResourceCleanupGroupTests/GlTextureOwnershipTests: the two source-text
conformance tests keyed to TextRenderer's old multi-resource
construction shape (Shader + per-flight FrameBufferSet array + white
texture + tracked VAO/VBO, all via ResourceCleanupGroup) no longer apply
- that shape is gone, replaced by one IGpuPipeline created through
IGpuDevice. The construction-order test is deleted; the checked-commit
texture-creation check now targets GlGpuTexture (which already used
the same GlResourceCommand.CreateName primitive before this slice).
Gates:
- dotnet build -c Release: 0 warnings, 0 errors (AcDream.App has
TreatWarningsAsErrors).
- dotnet test tests/AcDream.App.Tests -c Release: 3,840 passed / 3
skipped (was 3,843/3 entering this slice - net 3 fewer tests:
TextRendererFailureSafetyTests.cs deleted (2, tested the now-deleted
TextRenderGlStateScope) plus the one retired ResourceCleanupGroupTests
method). Full solution: 8,908 passed / 5 skipped across all nine test
projects.
- Offline pixel gate (tools/run-offline-pixel-gate.ps1, parent ec414d60
vs this commit): differing fraction 0.318% (1,791/563,200 compared
pixels), above the 0.001 threshold. Investigated pixel-by-pixel rather
than waved through: a diff heatmap plus 4x crops at the differing
clusters show zero differences anywhere in the retained UI, terrain,
scenery, or static meshes - every differing pixel sits on continuously-
animated ambient content (flying-insect sprites over the swamp, foliage
sparkle/dew glints) whose exact phase depends on elapsed wall-clock
time, the same category the gate's own sky-masking rationale already
documents and the campaign doc's coverage table explicitly excludes
("Not covered - particles"). Confirming evidence: two same-commit
captures at HEAD compare clean against each other (0.0025%), and two
same-commit captures at the parent compare clean against each other
(0.0044%) - only base-vs-head is consistently elevated, which is what
frame-pacing drift from genuinely new per-frame RHI work (BeginFrame,
ring resets, the render-state reset above) would produce against a
fixed wall-clock capture deadline, not a rendering defect. Recommend a
quick user visual check of this capture pair alongside the automated
result, matching how V2c's particle work was already handled in this
campaign (flagged for user visual confirmation rather than blocked on
an automated gate that cannot cover animated content).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
ec414d60cd
commit
ceec3bc440
334 changed files with 3660 additions and 3840 deletions
|
|
@ -1,4 +1,4 @@
|
|||
using DatReaderWriter;
|
||||
using DatReaderWriter;
|
||||
using AcDream.Content;
|
||||
|
||||
namespace AcDream.App.Streaming;
|
||||
|
|
@ -14,7 +14,7 @@ namespace AcDream.App.Streaming;
|
|||
/// <c>CObjCell::init_objects</c> (0x0052B420).
|
||||
/// See <c>docs/architecture/worldbuilder-inventory.md</c>.
|
||||
/// </summary>
|
||||
public sealed class LandblockBuildFactory
|
||||
internal sealed class LandblockBuildFactory
|
||||
{
|
||||
private readonly IDatReaderWriter _dats;
|
||||
private readonly IPreparedCollisionSource _preparedCollisions;
|
||||
|
|
@ -51,7 +51,7 @@ public sealed class LandblockBuildFactory
|
|||
///
|
||||
/// ISSUE #54 (post-A.5): far-tier loads (<c>kind == LoadFar</c>) skip
|
||||
/// LandBlockInfo + scenery + interior hydration. They return only the
|
||||
/// LandBlock heightmap dat record + an empty entity list — enough for
|
||||
/// LandBlock heightmap dat record + an empty entity list — enough for
|
||||
/// terrain-mesh build on the next phase. Near-tier loads run the full
|
||||
/// path. This replaces Bug A's post-load entity strip in
|
||||
/// <see cref="AcDream.App.Streaming.LandblockStreamer"/> with an
|
||||
|
|
@ -70,7 +70,7 @@ public sealed class LandblockBuildFactory
|
|||
// gate through mesh/bounds hydration prevents another consumer from
|
||||
// interleaving reader cursor/cache state with this build.
|
||||
// tp-probe (2026-06-22, REMOVABLE): measure lock-WAIT (the _datLock
|
||||
// contention signal — large only when the render thread is hammering the
|
||||
// contention signal — large only when the render thread is hammering the
|
||||
// lock during a CreateObject flood) AND lock-HOLD (the intrinsic build
|
||||
// cost). Identical work in both branches; the probe branch only adds the
|
||||
// stopwatch + log. No behavior change when ProbeTeleportEnabled is false.
|
||||
|
|
@ -123,7 +123,7 @@ public sealed class LandblockBuildFactory
|
|||
{
|
||||
uint landblockId = request.LandblockId;
|
||||
|
||||
// ISSUE #54: far-tier early-out — heightmap only, empty entities.
|
||||
// ISSUE #54: far-tier early-out — heightmap only, empty entities.
|
||||
// Skips the LandBlockInfo dat read AND all entity hydration (stabs
|
||||
// + buildings) AND the SceneryGenerator AND interior cells. Cuts
|
||||
// worker-thread cost per far-tier LB from ~tens of ms to a single
|
||||
|
|
@ -203,7 +203,7 @@ public sealed class LandblockBuildFactory
|
|||
}
|
||||
/// <summary>
|
||||
/// Phase A.1 Task 8: generate scenery (trees, rocks, bushes) for a single
|
||||
/// landblock on the worker thread. Pure CPU — no GL calls.
|
||||
/// landblock on the worker thread. Pure CPU — no GL calls.
|
||||
///
|
||||
/// Ported from the pre-streaming preload loop in GameWindow.OnLoad
|
||||
/// (pre-Task-7 version, lines 329-405). Adapted to operate on a single
|
||||
|
|
@ -305,7 +305,7 @@ public sealed class LandblockBuildFactory
|
|||
float localX = spawn.LocalPosition.X;
|
||||
float localY = spawn.LocalPosition.Y;
|
||||
// Prefer the physics engine's terrain sampler (TerrainSurface.SampleZ)
|
||||
// — it uses the same AC2D render split-direction formula the
|
||||
// — it uses the same AC2D render split-direction formula the
|
||||
// TerrainModernRenderer uses for the visible terrain mesh. This
|
||||
// guarantees trees are placed on the SAME Z height the player
|
||||
// walks on. If physics hasn't registered this landblock yet,
|
||||
|
|
@ -313,14 +313,14 @@ public sealed class LandblockBuildFactory
|
|||
var worldPx = localX + lbOffset.X;
|
||||
var worldPy = localY + lbOffset.Y;
|
||||
// FIX (trees-in-sky, 2026-06-22): scenery ground-Z comes from THIS
|
||||
// landblock's OWN heightmap — the same triangle-aware Z the player walks on
|
||||
// landblock's OWN heightmap — the same triangle-aware Z the player walks on
|
||||
// (TerrainSurface.SampleZFromHeightmap, lock-step with physics per #48),
|
||||
// scoped to the landblock being built. The former global
|
||||
// _physicsEngine.SampleTerrainZ(worldPx) query was structurally racy: at
|
||||
// build time this landblock is NOT registered in physics yet, so that query
|
||||
// could only return null (→ this same own-heightmap) or a STALE neighbor's
|
||||
// height — the previous location's terrain before the full old-window
|
||||
// recenter retirement converges — planting scenery at the old location's
|
||||
// could only return null (→ this same own-heightmap) or a STALE neighbor's
|
||||
// height — the previous location's terrain before the full old-window
|
||||
// recenter retirement converges — planting scenery at the old location's
|
||||
// altitude (trees-in-sky, deltaZ up to +500m; confirmed via the
|
||||
// [scenery-z-stale] probe 2026-06-22). Own-heightmap is correct in every
|
||||
// case, so the global query is removed (also drops its per-spawn cost).
|
||||
|
|
@ -335,7 +335,7 @@ public sealed class LandblockBuildFactory
|
|||
if (_dumpSceneryZ)
|
||||
{
|
||||
// groundZ now always comes from THIS landblock's own heightmap (the
|
||||
// global physics query was removed — see the trees-in-sky fix above).
|
||||
// global physics query was removed — see the trees-in-sky fix above).
|
||||
string source = "heightmap";
|
||||
foreach (var mr in meshRefs)
|
||||
{
|
||||
|
|
@ -422,7 +422,7 @@ public sealed class LandblockBuildFactory
|
|||
/// <summary>
|
||||
/// Phase A.1 Task 8: walk a landblock's EnvCells and produce (a) the cell
|
||||
/// room-mesh entity (Phase 7.1) for each EnvCell with an EnvironmentId, and
|
||||
/// (b) a WorldEntity per StaticObject in each cell. Pure CPU — no GL calls.
|
||||
/// (b) a WorldEntity per StaticObject in each cell. Pure CPU — no GL calls.
|
||||
///
|
||||
/// Portal cells and drawable shell placements are accumulated in the
|
||||
/// transaction-local <paramref name="envCellBuild"/>. The render thread
|
||||
|
|
@ -448,28 +448,28 @@ public sealed class LandblockBuildFactory
|
|||
(lbY - origin.CenterY) * 192f,
|
||||
0f);
|
||||
|
||||
// Per-landblock id namespace — see AcDream.Core.World.InteriorEntityIdAllocator
|
||||
// Per-landblock id namespace — see AcDream.Core.World.InteriorEntityIdAllocator
|
||||
// for the full bit layout + history. Distinct from scenery (0x80000000+) and
|
||||
// landblock stabs (0xC0000000+, ids from LandblockLoader).
|
||||
//
|
||||
// #119 ROOT-CAUSE FIX (2026-06-11): this used to be
|
||||
// `0x40000000 | (landblockId & 0x00FFFF00)`, which for landblock keys 0xXXYYFFFF
|
||||
// resolves to 0x40YYFF00 — the landblock X byte DISCARDED. Every landblock in a
|
||||
// resolves to 0x40YYFF00 — the landblock X byte DISCARDED. Every landblock in a
|
||||
// map Y-row produced the same id base, so interior statics collided across
|
||||
// landblocks (Holtburg town A9B3's 9th stab == the AAB3 tower's 43-part spiral
|
||||
// staircase, both 0x40B3FF09). The Tier-1 classification cache then served one
|
||||
// entity's batches to the other (the cache hint at bucket-draw time was the
|
||||
// player's landblock, identical for both) — the session-sticky "broken stairs +
|
||||
// player's landblock, identical for both) — the session-sticky "broken stairs +
|
||||
// water barrel".
|
||||
//
|
||||
// #190 (2026-07-09): the fix above LEFT a documented residual — "counter overflow
|
||||
// #190 (2026-07-09): the fix above LEFT a documented residual — "counter overflow
|
||||
// past 0xFF still bleeds into the lbY byte." That residual manifested for real:
|
||||
// the Town Network hub (205 cells, one landblock) reached 277 interior entities
|
||||
// after the #79/#93 A7.L1 light-carrier hydration fix, aliasing into the NEXT
|
||||
// landblock's Y-byte (entity script/particle tracking is keyed on entity.Id
|
||||
// directly — EntityScriptActivator — with no landblock-hint disambiguation, so
|
||||
// directly — EntityScriptActivator — with no landblock-hint disambiguation, so
|
||||
// the fountain's water-spray script silently stopped firing). Widened the
|
||||
// counter budget 8→12 bits (256→4096); see InteriorEntityIdAllocator's doc for
|
||||
// counter budget 8→12 bits (256→4096); see InteriorEntityIdAllocator's doc for
|
||||
// why this is safe (nothing decodes X/Y back out of an entity id).
|
||||
uint interiorLbX = (landblockId >> 24) & 0xFFu;
|
||||
uint interiorLbY = (landblockId >> 16) & 0xFFu;
|
||||
|
|
@ -484,7 +484,7 @@ public sealed class LandblockBuildFactory
|
|||
{
|
||||
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix):
|
||||
// every id in [0x0100, 0x0100+NumCells) is derived from LandBlockInfo and
|
||||
// MUST exist in the cell dat — a null here is always a read anomaly.
|
||||
// MUST exist in the cell dat — a null here is always a read anomaly.
|
||||
Console.WriteLine($"[cell-miss] EnvCell 0x{envCellId:X8} null during interior hydration (NumCells={lbInfo.NumCells})");
|
||||
continue;
|
||||
}
|
||||
|
|
@ -498,7 +498,7 @@ public sealed class LandblockBuildFactory
|
|||
{
|
||||
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix):
|
||||
// a null Environment means this cell's WALLS are silently never
|
||||
// registered while its static objects still draw — the exact
|
||||
// registered while its static objects still draw — the exact
|
||||
// white-walls geometry signature.
|
||||
Console.WriteLine($"[cell-miss] Environment 0x{0x0D000000u | envCell.EnvironmentId:X8} null for EnvCell 0x{envCellId:X8} -> walls not registered");
|
||||
}
|
||||
|
|
@ -510,14 +510,14 @@ public sealed class LandblockBuildFactory
|
|||
// drawable-geometry predicate; the actual shell placement is now owned
|
||||
// by this streaming job's EnvCellLandblockBuild transaction.
|
||||
// Static objects inside the cell continue to flow through the dispatcher
|
||||
// as WorldEntity records below — they have real GfxObj MeshRefs that work
|
||||
// as WorldEntity records below — they have real GfxObj MeshRefs that work
|
||||
// fine; EnvCellRenderer receives only the completed shell transaction.
|
||||
// Transforms — needed by the portal-visibility cell (unlifted) AND the
|
||||
// Transforms — needed by the portal-visibility cell (unlifted) AND the
|
||||
// render/physics path. Computed for EVERY cell with a valid cellStruct,
|
||||
// not just drawable ones. Keep the small render lift out of physics; retail
|
||||
// BSP contact planes use the EnvCell origin verbatim. The lift constant is
|
||||
// shared with every draw-space consumer of portal polygons (OutsideView
|
||||
// gate, seal/punch fans) — PortalVisibilityBuilder.ShellDrawLiftZ (#130).
|
||||
// gate, seal/punch fans) — PortalVisibilityBuilder.ShellDrawLiftZ (#130).
|
||||
var physicsCellOrigin = envCell.Position.Origin + lbOffset;
|
||||
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(
|
||||
0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ);
|
||||
|
|
@ -532,9 +532,9 @@ public sealed class LandblockBuildFactory
|
|||
// of whether CellMesh.Build produced drawable sub-meshes. A portals-only
|
||||
// pass-through connector (a ramp / stair / cellar mouth) yields 0 render
|
||||
// sub-meshes but MUST be in the visibility graph so the flood can traverse it
|
||||
// to the cells beyond — otherwise the flood lookup-misses the unregistered
|
||||
// to the cells beyond — otherwise the flood lookup-misses the unregistered
|
||||
// neighbour and the grey clear shows through the opening (#133: ramp
|
||||
// neighbour 0x0007014D had 0 sub-meshes → unregistered → vis=1 grey barrier
|
||||
// neighbour 0x0007014D had 0 sub-meshes → unregistered → vis=1 grey barrier
|
||||
// at the ramp; confirmed via [cellreg] registered=204/205 + [pv-trace]
|
||||
// skip=lookup-miss). Retail keeps the whole landblock cell array resident
|
||||
// before the flood runs; the cell-build transaction reads portals, NOT
|
||||
|
|
@ -558,7 +558,7 @@ public sealed class LandblockBuildFactory
|
|||
foreach (var stab in envCell.StaticObjects)
|
||||
{
|
||||
// #119 decisive probe: HYDRATE-side dump for ACDREAM_DUMP_ENTITY-
|
||||
// targeted stabs. This is the MOMENT MeshRefs are constructed —
|
||||
// targeted stabs. This is the MOMENT MeshRefs are constructed —
|
||||
// a degraded dat read here (setup null / placement frames short /
|
||||
// part GfxObj null) permanently corrupts the entity (H-A), and
|
||||
// nothing downstream ever rebuilds it. Inert when the set is empty.
|
||||
|
|
@ -568,7 +568,7 @@ public sealed class LandblockBuildFactory
|
|||
|
||||
// #136: skip an EDITOR-ONLY placement marker. Such a dat object degrades to
|
||||
// nothing (GfxObj id 0) at any runtime distance, so retail's distance-based
|
||||
// degrade (CPhysicsPart::UpdateViewerDistance) never draws it — only the
|
||||
// degrade (CPhysicsPart::UpdateViewerDistance) never draws it — only the
|
||||
// WorldBuilder editor shows it at the origin. acdream's render path came from
|
||||
// WB (no distance LOD), so without this skip it draws the marker forever (the
|
||||
// red/green dungeon "cone"). Bare-GfxObj stabs are checked here; Setup stabs
|
||||
|
|
@ -581,7 +581,7 @@ public sealed class LandblockBuildFactory
|
|||
var interiorBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
|
||||
// #79/#93 (2026-07-09): a Setup-sourced stab whose sole visual part is a
|
||||
// runtime-hidden marker (#136) flattens to zero mesh refs even though its
|
||||
// Setup carries real Lights — a "light attach point" fixture (e.g. the Town
|
||||
// Setup carries real Lights — a "light attach point" fixture (e.g. the Town
|
||||
// Network fountain room's ceiling light, Setup 0x02000365). Track the dat
|
||||
// Setup's Lights.Count here so the meshRefs==0 gate below doesn't also drop
|
||||
// the entity that otherwise carries those lights to the static
|
||||
|
|
@ -618,7 +618,7 @@ public sealed class LandblockBuildFactory
|
|||
{
|
||||
// #136: skip an editor-only marker PART (retail hides it at runtime
|
||||
// distance). The #136 dungeon "cone" is Setup 0x02000C39 whose sole
|
||||
// part GfxObj 0x010028CA is such a marker — skipping it empties
|
||||
// part GfxObj 0x010028CA is such a marker — skipping it empties
|
||||
// meshRefs and the whole stab drops below.
|
||||
if (AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, mr.GfxObjId))
|
||||
continue;
|
||||
|
|
@ -652,7 +652,7 @@ public sealed class LandblockBuildFactory
|
|||
|
||||
// Stabs inside EnvCells are already in landblock-local coordinates
|
||||
// (same space as LandBlockInfo.Objects stabs). Adding cellOrigin would
|
||||
// be wrong — see Phase 2d comment in the pre-streaming preload.
|
||||
// be wrong — see Phase 2d comment in the pre-streaming preload.
|
||||
var worldPos = stab.Frame.Origin + lbOffset;
|
||||
var worldRot = stab.Frame.Orientation;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue