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 System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Scene;
|
||||
|
|
@ -11,7 +11,7 @@ namespace AcDream.App.Rendering;
|
|||
/// SmartBox::RenderNormalMode -> RenderDeviceD3D::DrawInside ->
|
||||
/// PView::DrawInside -> ConstructView -> DrawCells.
|
||||
/// </summary>
|
||||
public sealed class RetailPViewRenderer
|
||||
internal sealed class RetailPViewRenderer
|
||||
{
|
||||
private readonly InteriorEntityPartition.IObserver? _partitionObserver;
|
||||
private readonly ICurrentRenderPViewObserver? _candidateObserver;
|
||||
|
|
@ -44,7 +44,7 @@ public sealed class RetailPViewRenderer
|
|||
private readonly PortalVisibilityFrame _outdoorBuildingFrameScratch = new();
|
||||
|
||||
// #124: per-building look-in frames under an INTERIOR root, drawn as a
|
||||
// landscape-stage sub-pass (DrawBuildingLookIns) — never merged into the
|
||||
// landscape-stage sub-pass (DrawBuildingLookIns) — never merged into the
|
||||
// main frame (see DrawInside). Rebuilt each interior-root frame.
|
||||
private readonly List<PortalVisibilityFrame> _lookInFrames = new();
|
||||
private readonly Stack<PortalVisibilityFrame> _lookInFramePool = new();
|
||||
|
|
@ -59,7 +59,7 @@ public sealed class RetailPViewRenderer
|
|||
// MP-Alloc (2026-07-05): the frame's entity partition (ByCell/OutdoorStatic/
|
||||
// Dynamics), reused across frames instead of `new`ing a Result (a Dictionary
|
||||
// + 2 Lists, plus one List<WorldEntity> per visible cell) every DrawInside
|
||||
// call. See InteriorEntityPartition.Partition(Result, ...) — clears in
|
||||
// call. See InteriorEntityPartition.Partition(Result, ...) — clears in
|
||||
// place and reuses each cell's list across frames when the cell stays
|
||||
// visible.
|
||||
// Slice G4: this is now a diagnostic/fallback oracle only. Normal
|
||||
|
|
@ -88,7 +88,7 @@ public sealed class RetailPViewRenderer
|
|||
}
|
||||
|
||||
// T2 (BR-4): retail has NO distance constant on the flood-admission chain
|
||||
// (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test +
|
||||
// (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test +
|
||||
// GetClip + GetVisible only). The old 48 m seed cap is replaced by the
|
||||
// caller's per-building frustum pre-gate on aperture bounds (GameWindow's
|
||||
// gather); seeds themselves are unbounded.
|
||||
|
|
@ -113,28 +113,28 @@ public sealed class RetailPViewRenderer
|
|||
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ,
|
||||
reuseFrame: _mainPortalFrameScratch);
|
||||
|
||||
// R-A2: outdoor root — flood each nearby building SEPARATELY from its own entrance and merge
|
||||
// R-A2: outdoor root — flood each nearby building SEPARATELY from its own entrance and merge
|
||||
// the small (~2-cell) per-building views into the frame. Retail reaches building interiors via
|
||||
// the terrain BSP -> DrawPortal -> ConstructView(CBldPortal) (decomp:326881/433895/433827); the
|
||||
// land root itself has no portals (it floods nothing into buildings). Per-building seeding is
|
||||
// robust to the eye's ~36 µm rest jitter where the pre-R-A2 single reverse-portal flood
|
||||
// robust to the eye's ~36 µm rest jitter where the pre-R-A2 single reverse-portal flood
|
||||
// oscillated as the chase eye grazed a doorway (the indoor flap).
|
||||
if (ctx.RootCell.IsOutdoorNode && ctx.NearbyBuildingCells is not null)
|
||||
MergeNearbyBuildingFloods(ctx, pvFrame);
|
||||
|
||||
// #124: interior-root building look-ins. Retail runs the look-in INSIDE
|
||||
// the landscape stage for ANY root — LScape::draw is the FIRST call of
|
||||
// the landscape stage for ANY root — LScape::draw is the FIRST call of
|
||||
// DrawCells' outside-view branch (pc:432719), strictly BEFORE the depth
|
||||
// clear (pc:432732) and the exit-portal seals (pc:432785); a far
|
||||
// building seen through our doorway floods clipped to the INSTALLED
|
||||
// outside view (GetClip vs current view, ConstructView(CBldPortal)
|
||||
// 0x005a59a0). These frames therefore draw in DrawBuildingLookIns
|
||||
// (inside the landscape stage), NEVER merged into the main frame — a
|
||||
// (inside the landscape stage), NEVER merged into the main frame — a
|
||||
// merged cell would draw post-clear and z-fail against the root's seal
|
||||
// (its geometry is beyond the door plane). The eye-side seed test
|
||||
// self-excludes the root's own building (the eye is on its interior
|
||||
// side). Outdoor roots keep the MergeNearbyBuildingFloods path above
|
||||
// (no depth clear under outdoor roots — the merged form is equivalent
|
||||
// (no depth clear under outdoor roots — the merged form is equivalent
|
||||
// there).
|
||||
if (!ctx.RootCell.IsOutdoorNode
|
||||
&& ctx.NearbyBuildingCells is not null
|
||||
|
|
@ -149,7 +149,7 @@ public sealed class RetailPViewRenderer
|
|||
|
||||
// R1: draw EVERY visible cell (retail cell_draw_list), not only the cells the
|
||||
// assembler handed a clip-slot. This feeds the Prepare filter + entity partition,
|
||||
// so every visible cell's shell has a prepared batch and seals — killing the grey
|
||||
// so every visible cell's shell has a prepared batch and seals — killing the grey
|
||||
// (the old clipAssembly.CellIdToSlot.Keys filter silently dropped slot-less cells).
|
||||
// Per-slice trim still applies in DrawEnvCellShells (Task 4 makes it self-contained).
|
||||
_drawableCellsScratch.Clear();
|
||||
|
|
@ -158,7 +158,7 @@ public sealed class RetailPViewRenderer
|
|||
passes.UseIndoorMembershipOnlyRouting();
|
||||
|
||||
// #124: look-in cells need prepared shell batches + their statics routed
|
||||
// into partition.ByCell (consumed ONLY by DrawBuildingLookIns — the main
|
||||
// into partition.ByCell (consumed ONLY by DrawBuildingLookIns — the main
|
||||
// cell-object pass iterates pvFrame.OrderedVisibleCells, which never
|
||||
// contains them). drawableCells itself stays the MAIN flood: it feeds the
|
||||
// seals, the outside-stage predicate, and the frame result.
|
||||
|
|
@ -174,22 +174,22 @@ public sealed class RetailPViewRenderer
|
|||
}
|
||||
|
||||
// (#176 correction, 2026-07-06: the flood-scoped light-pool rebuild that ran
|
||||
// here was the seam-floor flicker mechanism — retail's visible_cell_table is
|
||||
// the RESIDENT-cell registry, not the frame flood — and is deleted. The pool
|
||||
// here was the seam-floor flicker mechanism — retail's visible_cell_table is
|
||||
// the RESIDENT-cell registry, not the frame flood — and is deleted. The pool
|
||||
// is built once per frame in GameWindow, player-anchored.)
|
||||
|
||||
passes.PrepareCellBatches(ctx, prepareCells);
|
||||
|
||||
// T1 (fused BR-2/3): retail's frame order — static world, then the
|
||||
// aperture depth writes, then interior cells WHOLE far→near, then
|
||||
// T1 (fused BR-2/3): retail's frame order — static world, then the
|
||||
// aperture depth writes, then interior cells WHOLE far→near, then
|
||||
// per-cell statics, then ALL dynamics last (retail draws objects after
|
||||
// cells: PView::DrawCells Ghidra 0x005a4840; DrawBuilding 0x0059f2a0).
|
||||
// The geometric shell chop (gl_ClipDistance crop, 927fd8f/9ce335e) is
|
||||
// DELETED — retail never clips cell geometry; aperture exactness comes
|
||||
// DELETED — retail never clips cell geometry; aperture exactness comes
|
||||
// from the punch/seal depth writes + the z-buffer, and the dynamics-
|
||||
// last order is what makes the punch safe (the first BR-2 attempt
|
||||
// punched after dynamics and erased the player, reverted 88be519).
|
||||
// T3 (BR-5): retail viewconeCheck — meshes are sphere-CULLED per view,
|
||||
// T3 (BR-5): retail viewconeCheck — meshes are sphere-CULLED per view,
|
||||
// never clipped (Ghidra 0x0054c250). Built once per frame from the
|
||||
// assembled slices + this frame's view-projection.
|
||||
var viewcone = ViewconeCuller.Build(
|
||||
|
|
@ -256,19 +256,19 @@ public sealed class RetailPViewRenderer
|
|||
passes.EmitDiagnostics(ctx, result);
|
||||
|
||||
// #118: stage assignment for dynamics under an INTERIOR root. Retail
|
||||
// draws the OUTSIDE world's objects inside the landscape stage —
|
||||
// draws the OUTSIDE world's objects inside the landscape stage —
|
||||
// PView::DrawCells runs LScape::draw FIRST (pc:432719), then the gated
|
||||
// full depth clear (pc:432731-432732) and the exit-portal SEALS
|
||||
// (pc:432785-432786); DrawBlock draws every landcell's objects via
|
||||
// DrawSortCell (0x005a17c0, pc:430124). A dynamic deferred to our
|
||||
// single last pass instead z-fails against the seal's true-depth stamp
|
||||
// the moment it stands beyond the door plane — the house-exit
|
||||
// the moment it stands beyond the door plane — the house-exit
|
||||
// clip+vanish (pinned by HouseExitWalkReplayTests). So under an
|
||||
// interior root: outdoor-classified dynamics draw in the outside
|
||||
// stage; an indoor dynamic whose sphere STRADDLES an exit portal
|
||||
// draws in BOTH stages (retail's per-overlapped-cell shadow-part
|
||||
// draw, DrawBlock pc:430056-430064) so neither body half clips at the
|
||||
// plane. Outdoor roots keep ALL dynamics in the last pass — our
|
||||
// plane. Outdoor roots keep ALL dynamics in the last pass — our
|
||||
// z-buffered equivalent of retail's painter-ordered outdoor pass (the
|
||||
// BR-2 punch-after-dynamics lesson, reverted 88be519).
|
||||
_outsideStageDynamics.Clear();
|
||||
|
|
@ -362,11 +362,11 @@ public sealed class RetailPViewRenderer
|
|||
// on the cell (Render::copy_view appends + view_count++, Ghidra 0x0054dfc0;
|
||||
// a cell visible through two apertures holds two views, all consumed
|
||||
// downstream). The old first-wins (`ContainsKey -> continue`) dropped the
|
||||
// second building flood's views whenever a cell was already in the frame —
|
||||
// second building flood's views whenever a cell was already in the frame —
|
||||
// the multiview-loss-first-wins divergence (a named #109 suspect: per-frame
|
||||
// winner flips between apertures). CellView.Add dedups exact/collinear
|
||||
// re-emissions (the dac8f6a CanonicalKey), so unioning is convergent.
|
||||
// OutsideView is NOT merged — the outdoor root already seeds full-screen
|
||||
// OutsideView is NOT merged — the outdoor root already seeds full-screen
|
||||
// terrain, and ConstructViewBuilding (BuildFromExterior) leaves OutsideView
|
||||
// empty (it stops at exit portals once inside the building).
|
||||
private static void MergeBuildingFrame(PortalVisibilityFrame target, PortalVisibilityFrame src)
|
||||
|
|
@ -393,8 +393,8 @@ public sealed class RetailPViewRenderer
|
|||
}
|
||||
|
||||
// #124: per-building look-in floods for an INTERIOR root, seeded clipped
|
||||
// against the OutsideView (retail: GetClip runs under the INSTALLED view —
|
||||
// the accumulated doorway region — so a far building floods only within the
|
||||
// against the OutsideView (retail: GetClip runs under the INSTALLED view —
|
||||
// the accumulated doorway region — so a far building floods only within the
|
||||
// doorway, ConstructView(CBldPortal) 0x005a59a0 via PView::GetClip
|
||||
// 0x005a4320). Same grouping as MergeNearbyBuildingFloods; the root's own
|
||||
// building self-excludes via the seed eye-side test.
|
||||
|
|
@ -445,15 +445,15 @@ public sealed class RetailPViewRenderer
|
|||
private void ResetBuildingGroups()
|
||||
=> _buildingGroups.Reset();
|
||||
|
||||
// #124: draw the interior-root look-ins INSIDE the landscape stage —
|
||||
// retail's placement (LScape::draw → DrawBlock → DrawSortCell →
|
||||
// #124: draw the interior-root look-ins INSIDE the landscape stage —
|
||||
// retail's placement (LScape::draw → DrawBlock → DrawSortCell →
|
||||
// DrawBuilding runs as the FIRST call of DrawCells' outside-view branch,
|
||||
// pc:432719, before the depth clear + seals). Per building: punch ALL
|
||||
// apertures first (retail finishes build_draw_portals_only pass 1 — the
|
||||
// far-Z maxZ1 punch — across the whole building BSP before pass 2 floods),
|
||||
// then draw the flooded cells' shells + statics far→near (the nested
|
||||
// apertures first (retail finishes build_draw_portals_only pass 1 — the
|
||||
// far-Z maxZ1 punch — across the whole building BSP before pass 2 floods),
|
||||
// then draw the flooded cells' shells + statics far→near (the nested
|
||||
// DrawCells' DrawEnvCell + DrawObjCellForDummies; its outside_view is
|
||||
// empty by construction — PView ctor draw_landscape=0 — so no recursive
|
||||
// empty by construction — PView ctor draw_landscape=0 — so no recursive
|
||||
// landscape/clear/seal). Anything rasterized outside an aperture is
|
||||
// repainted by the root's own shells after the depth clear, so over-draw
|
||||
// here is color-safe; statics draw whole (the main viewcone has no entry
|
||||
|
|
@ -491,12 +491,12 @@ public sealed class RetailPViewRenderer
|
|||
}
|
||||
}
|
||||
|
||||
// Pass 2: shells + statics, far→near.
|
||||
// Pass 2: shells + statics, far→near.
|
||||
passes.UseIndoorMembershipOnlyRouting();
|
||||
|
||||
// Opaque shells batched per building into ONE Render (this building's
|
||||
// aperture punches above already ran; z-buffer handles order and
|
||||
// lighting is per-instance CellId-keyed) — was one heavy per-frame
|
||||
// lighting is per-instance CellId-keyed) — was one heavy per-frame
|
||||
// Render per cell. Per-cell entity/particle work stays in the loop.
|
||||
_shellBatch.Clear();
|
||||
foreach (uint cid in frame.OrderedVisibleCells)
|
||||
|
|
@ -509,7 +509,7 @@ public sealed class RetailPViewRenderer
|
|||
uint cellId = frame.OrderedVisibleCells[i];
|
||||
_oneCell.Clear();
|
||||
_oneCell.Add(cellId);
|
||||
// Opaque shell batched above. Transparent stays per-cell (far→near)
|
||||
// Opaque shell batched above. Transparent stays per-cell (far→near)
|
||||
// for correct compositing; skipped for opaque-only cells.
|
||||
if (passes.CellHasTransparentShell(cellId))
|
||||
passes.DrawTransparentCellShells(_oneCell);
|
||||
|
|
@ -523,7 +523,7 @@ public sealed class RetailPViewRenderer
|
|||
|
||||
// #131 ROOT CAUSE: DYNAMICS living in a look-in cell (the
|
||||
// Holtburg hall-porch PORTAL, pCell 0xA9B4017A) draw NOWHERE
|
||||
// under an interior root — DrawDynamicsLast viewcone-culls
|
||||
// under an interior root — DrawDynamicsLast viewcone-culls
|
||||
// them (the main cone has no entries for look-in cells), and
|
||||
// post-clear they would z-fail against the root's seal anyway
|
||||
// (the #118 lesson). Retail draws a look-in cell's objects
|
||||
|
|
@ -576,7 +576,7 @@ public sealed class RetailPViewRenderer
|
|||
_cellStaticScratch,
|
||||
_oneCell);
|
||||
|
||||
// The cell-particles pass for look-in cells — retail's
|
||||
// The cell-particles pass for look-in cells — retail's
|
||||
// nested DrawCells draws objects WITH their emitters.
|
||||
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
|
||||
passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext(
|
||||
|
|
@ -600,7 +600,7 @@ public sealed class RetailPViewRenderer
|
|||
|
||||
// #131/#132 (the FlushAlphaList deferral): retail collects ALL alpha
|
||||
// draws of the landscape stage and flushes them ONCE after LScape::draw
|
||||
// (D3DPolyRender::FlushAlphaList, DrawCells pc:432722) — so translucent
|
||||
// (D3DPolyRender::FlushAlphaList, DrawCells pc:432722) — so translucent
|
||||
// landscape content (portal swirl meshes, flame particles) composites
|
||||
// AFTER the building look-ins. Our dispatcher draws translucency inside
|
||||
// each Draw call, so the stage is split in TWO phases instead: EARLY =
|
||||
|
|
@ -609,12 +609,12 @@ public sealed class RetailPViewRenderer
|
|||
// LATE = outside-stage dynamics' meshes + ALL scene particles +
|
||||
// weather. Content drawn early and overlapped by a look-in aperture
|
||||
// was otherwise overpainted by the far interior (translucents write no
|
||||
// depth to protect themselves) — the portal-swirl/candle-flame class.
|
||||
// depth to protect themselves) — the portal-swirl/candle-flame class.
|
||||
int probeSliceIndex = 0;
|
||||
foreach (var slice in clipAssembly.OutsideViewSlices)
|
||||
{
|
||||
passes.SetTerrainClip(slice.Planes);
|
||||
// T3 (BR-5): entities are never hard-clipped — retail viewcone-
|
||||
// T3 (BR-5): entities are never hard-clipped — retail viewcone-
|
||||
// CHECKS each mesh's sphere against the view (Ghidra 0x0054c250)
|
||||
// and draws it whole. The old per-slice entity clip routing
|
||||
// (gl_ClipDistance via SetClipRouting) is replaced by the sphere
|
||||
|
|
@ -663,7 +663,7 @@ public sealed class RetailPViewRenderer
|
|||
});
|
||||
}
|
||||
|
||||
// #124: far-building look-ins draw HERE — still inside the landscape
|
||||
// #124: far-building look-ins draw HERE — still inside the landscape
|
||||
// stage (their punches mark against the terrain/exterior depth just
|
||||
// drawn), strictly BEFORE the depth clear + seals below, matching
|
||||
// retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785).
|
||||
|
|
@ -675,11 +675,11 @@ public sealed class RetailPViewRenderer
|
|||
frameEntityPasses,
|
||||
in frameView);
|
||||
|
||||
// LATE phase (per slice): outside-stage dynamics' meshes (#118 — drawn
|
||||
// LATE phase (per slice): outside-stage dynamics' meshes (#118 — drawn
|
||||
// pre-clear so the seal protects their aperture pixels; AFTER the
|
||||
// look-ins so a translucent portal mesh blends over a far interior
|
||||
// instead of being overpainted) + the scene-particle owners (statics +
|
||||
// dynamics cone survivors — flames ride here for the same reason).
|
||||
// dynamics cone survivors — flames ride here for the same reason).
|
||||
probeSliceIndex = 0;
|
||||
foreach (var slice in clipAssembly.OutsideViewSlices)
|
||||
{
|
||||
|
|
@ -700,7 +700,7 @@ public sealed class RetailPViewRenderer
|
|||
if (ownerPass)
|
||||
_lateParticleOwnerScratch.Add(e.Id);
|
||||
// #131 owner watchlist (throwaway): ACDREAM_DUMP_ENTITY ids
|
||||
// double as an ENTITY-id watchlist here — one line per watched
|
||||
// double as an ENTITY-id watchlist here — one line per watched
|
||||
// outdoor-static owner per CHANGE of its cone verdict.
|
||||
passes.EmitOutStageOwner(
|
||||
e,
|
||||
|
|
@ -764,11 +764,11 @@ public sealed class RetailPViewRenderer
|
|||
});
|
||||
}
|
||||
|
||||
// #131: UNATTACHED emitters (AttachedObjectId == 0 — portal swirls,
|
||||
// #131: UNATTACHED emitters (AttachedObjectId == 0 — portal swirls,
|
||||
// campfires, ground effects anchored at a position) have no owner id
|
||||
// to ride any of the id-filtered particle passes. The outdoor root
|
||||
// has the dedicated T3 pass for them; an INTERIOR root had NO pass
|
||||
// at all. Draw them ONCE per frame (not per slice — alpha particles
|
||||
// at all. Draw them ONCE per frame (not per slice — alpha particles
|
||||
// must not double-draw, the #121 lesson), at the END of the landscape
|
||||
// stage: after the clear they would z-fail against the doorway seal.
|
||||
if (!ctx.RootCell.IsOutdoorNode)
|
||||
|
|
@ -780,7 +780,7 @@ public sealed class RetailPViewRenderer
|
|||
passes.FlushLandscapeAlpha();
|
||||
|
||||
// T1: retail clears the FULL depth buffer ONCE between the outside
|
||||
// stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 —
|
||||
// stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 —
|
||||
// Clear gated on portalsDrawnCount; exact gate semantics is a plan
|
||||
// open question, staged as "any outside slice drawn"), then re-stamps
|
||||
// every outside-leading portal's TRUE depth (the seals,
|
||||
|
|
@ -819,20 +819,20 @@ public sealed class RetailPViewRenderer
|
|||
IRetailPViewPassExecutor passes,
|
||||
PortalVisibilityFrame pvFrame)
|
||||
{
|
||||
// T1 (fused BR-2/3): retail DrawCells Loop 2 — every visible cell's
|
||||
// shell drawn WHOLE, reverse cell_draw_list (far→near), drawn once.
|
||||
// T1 (fused BR-2/3): retail DrawCells Loop 2 — every visible cell's
|
||||
// shell drawn WHOLE, reverse cell_draw_list (far→near), drawn once.
|
||||
// Retail NEVER clips cell geometry: the production path is the
|
||||
// prebuilt mesh (DrawEnvCell use_built_mesh, pc:427905; the
|
||||
// planeMask=0xffffffff legacy submit means skip-all-edges), and
|
||||
// aperture exactness comes from the punch/seal depth writes + the
|
||||
// z-buffer + this order. The former gl_ClipDistance chop
|
||||
// (927fd8f/9ce335e, #114) is deleted with this rewrite.
|
||||
// Per-cell opaque+transparent keeps the far→near transparent
|
||||
// Per-cell opaque+transparent keeps the far→near transparent
|
||||
// compositing the per-cell loop already provided.
|
||||
passes.UseIndoorMembershipOnlyRouting();
|
||||
|
||||
// Opaque: ONE batched Render for all shell cells (was one heavy per-frame
|
||||
// Render call PER cell — the dense-town FPS sink, ~94 calls/24.75ms at
|
||||
// Render call PER cell — the dense-town FPS sink, ~94 calls/24.75ms at
|
||||
// Arwic). Opaque needs no draw order (z-buffer), and lighting is
|
||||
// per-instance (CellId-keyed light SSBO in EnvCellRenderer.RenderModernMDI-
|
||||
// Internal), so cross-cell batching is visually identical. The filtered
|
||||
|
|
@ -856,16 +856,16 @@ public sealed class RetailPViewRenderer
|
|||
passes.DrawTransparentCellShellsOrdered(_orderedTransparentShellCells);
|
||||
}
|
||||
|
||||
// T1: the frame's single LAST entity pass — ALL server-spawned dynamics
|
||||
// T1: the frame's single LAST entity pass — ALL server-spawned dynamics
|
||||
// (player, NPCs, doors, items), indoor or out, drawn after the static
|
||||
// world + punches + interior cells. Depth-tested, never hard-clipped
|
||||
// (retail draws objects per cell AFTER cells and viewcone-culls them —
|
||||
// (retail draws objects per cell AFTER cells and viewcone-culls them —
|
||||
// PView::DrawCells epilogue Ghidra 0x005a4840; the sphere-vs-view cull is
|
||||
// T3). Drawing dynamics last is what makes the aperture punch safe.
|
||||
// T3 (BR-5): each dynamic is viewcone-culled like retail — sphere vs its
|
||||
// T3 (BR-5): each dynamic is viewcone-culled like retail — sphere vs its
|
||||
// cell's views; outdoor/unresolved vs the outside views (pass-all under
|
||||
// the outdoor root's full-screen outside view). A dynamic in a NON-flooded
|
||||
// room culls HERE — retail never reaches an object whose cell is not in
|
||||
// room culls HERE — retail never reaches an object whose cell is not in
|
||||
// the draw list; the partition keeps routing it so the CULL (not the
|
||||
// visibility set) drops it, exactly retail's shape.
|
||||
private void DrawDynamicsLast(
|
||||
|
|
@ -928,12 +928,12 @@ public sealed class RetailPViewRenderer
|
|||
&& AcDream.App.Streaming.EntityVanishProbe.PlayerGuid != 0
|
||||
&& e.ServerGuid == AcDream.App.Streaming.EntityVanishProbe.PlayerGuid;
|
||||
// #118: under an interior root, outdoor-classified dynamics drew in
|
||||
// the outside stage (pre-clear, seal-protected) — retail draws them
|
||||
// the outside stage (pre-clear, seal-protected) — retail draws them
|
||||
// via LScape::draw's per-landcell DrawSortCell, never in the
|
||||
// post-seal cell-object epilogue (PView::DrawCells pc:432719 vs
|
||||
// pc:432878). Drawing them here instead z-fails them against the
|
||||
// seal. Indoor dynamics (incl. exit-portal straddlers, which drew
|
||||
// in BOTH stages) stay — this pass is retail's loop C.
|
||||
// in BOTH stages) stay — this pass is retail's loop C.
|
||||
if (!rootIsOutdoor && !indoor)
|
||||
{
|
||||
if (isProbePlayer)
|
||||
|
|
@ -976,12 +976,12 @@ public sealed class RetailPViewRenderer
|
|||
visibleCellIds: null);
|
||||
|
||||
// #121: dynamics' attached emitters (portal swirls, creature effects)
|
||||
// gate through the SAME cone-surviving owner set as their meshes —
|
||||
// gate through the SAME cone-surviving owner set as their meshes —
|
||||
// retail draws emitters with the owner object. Before this callback,
|
||||
// dynamics' emitters fell through EVERY particle filter under the pview
|
||||
// path (the landscape slice carries outdoor statics + #118 outside-
|
||||
// stage dynamics; the cell callback carries cell statics; T4 deleted
|
||||
// the old clipRoot==null global pass from normal frames) — all world
|
||||
// the old clipRoot==null global pass from normal frames) — all world
|
||||
// portals went invisible. Outside-stage dynamics are excluded here:
|
||||
// their emitters already drew in the landscape slice (alpha-blended
|
||||
// particles must not double-draw, unlike the depth-idempotent meshes).
|
||||
|
|
@ -1049,29 +1049,29 @@ public sealed class RetailPViewRenderer
|
|||
return;
|
||||
}
|
||||
|
||||
// T1: per-cell STATIC object lists only (dat-baked 0x40 statics) —
|
||||
// dynamics moved to DrawDynamicsLast. Far→near with the cells, after
|
||||
// the shells (retail DrawCells epilogue: PortalList = cell's views →
|
||||
// T1: per-cell STATIC object lists only (dat-baked 0x40 statics) —
|
||||
// dynamics moved to DrawDynamicsLast. Far→near with the cells, after
|
||||
// the shells (retail DrawCells epilogue: PortalList = cell's views →
|
||||
// DrawObjCell, Ghidra 0x005a4840). T3 (BR-5): each static's sphere is
|
||||
// tested against ITS CELL's views (retail viewconeCheck) — the
|
||||
// tested against ITS CELL's views (retail viewconeCheck) — the
|
||||
// statics-through-walls fix: a static whose sphere is outside every
|
||||
// view of its cell no longer paints through the wall (the cottage
|
||||
// phantom staircase's draw path).
|
||||
// Dense-town FPS iteration-1 (spec 2026-06-23-cellobject-draw-batching):
|
||||
// the per-cell DrawEntityBucket calls below were the top CPU sink at Arwic
|
||||
// (cellobjects ~3.5 ms/frame; each WbDrawDispatcher.Draw orphans 6 SSBOs +
|
||||
// full state setup). Collapse them into ONE cross-cell batched draw — the
|
||||
// full state setup). Collapse them into ONE cross-cell batched draw — the
|
||||
// shipped cells-shell batching pattern applied to cell OBJECTS. Two loops
|
||||
// preserve the statics-before-particles depth order: loop 1 culls +
|
||||
// accumulates every cell's survivors and draws them once; loop 2 runs the
|
||||
// per-cell particle passes AFTER the statics own the depth buffer (particles
|
||||
// depth-test but write no depth). The dispatcher sorts opaque front-to-back
|
||||
// and transparent back-to-front by group distance (WbDrawDispatcher.cs:
|
||||
// 1469-1470), so cross-cell batching composites correctly — equal-or-better
|
||||
// 1469-1470), so cross-cell batching composites correctly — equal-or-better
|
||||
// than the old per-cell-bucketed order. visibleCellIds = the union of cells,
|
||||
// so the dispatcher admits exactly the same survivor set.
|
||||
|
||||
// Loop 1: per-cell viewcone cull → accumulate survivors + the union of cells.
|
||||
// Loop 1: per-cell viewcone cull → accumulate survivors + the union of cells.
|
||||
_allCellStatics.Clear();
|
||||
_cellObjCells.Clear();
|
||||
for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--)
|
||||
|
|
@ -1099,7 +1099,7 @@ public sealed class RetailPViewRenderer
|
|||
}
|
||||
|
||||
// ONE batched static-object draw for every visible cell (was N per-cell
|
||||
// WbDrawDispatcher.Draw calls). T1: per-cell STATIC lists only — dynamics
|
||||
// WbDrawDispatcher.Draw calls). T1: per-cell STATIC lists only — dynamics
|
||||
// draw in DrawDynamicsLast. T3 (BR-5): each static was sphere-tested against
|
||||
// ITS cell's views above (the statics-through-walls fix is preserved by the
|
||||
// cull; only the draw is batched).
|
||||
|
|
@ -1124,20 +1124,20 @@ public sealed class RetailPViewRenderer
|
|||
_cellObjCells);
|
||||
}
|
||||
|
||||
// Cell-particle pass — consolidated across ALL visible cells into ONE
|
||||
// Cell-particle pass — consolidated across ALL visible cells into ONE
|
||||
// draw. Was per-cell, and each call re-walked the ENTIRE live particle set
|
||||
// (RetailPViewPassExecutor.DrawCellParticles → ParticleRenderer.Draw enumerates every
|
||||
// live emitter), i.e. O(cells × particles) — the dense-town cellobjects
|
||||
// (RetailPViewPassExecutor.DrawCellParticles → ParticleRenderer.Draw enumerates every
|
||||
// live emitter), i.e. O(cells × particles) — the dense-town cellobjects
|
||||
// sink (~5 ms at Arwic). Static owners are disjoint per cell, so the UNION
|
||||
// (= _allCellStatics, already accumulated above for the batched draw) draws
|
||||
// EXACTLY the same emitters: the callback gates on owner id (the cone-
|
||||
// surviving set), the renderer sorts globally back-to-front, and the per-
|
||||
// cell slice was never used for clipping (the scissor gate was deleted in
|
||||
// T3 — RetailPViewPassExecutor.DrawCellParticles disables clip distances). Runs after
|
||||
// T3 — RetailPViewPassExecutor.DrawCellParticles disables clip distances). Runs after
|
||||
// the batched static draw so emitters depth-test against the statics now in
|
||||
// the buffer (the statics-before-particles order). cellId/slice are unused
|
||||
// by the particle pass — pass NoClipSlice + the union owner list. This also
|
||||
// drops the per-cell BuildDrawList allocations (N → 1).
|
||||
// by the particle pass — pass NoClipSlice + the union owner list. This also
|
||||
// drops the per-cell BuildDrawList allocations (N → 1).
|
||||
if (frameEntityPasses is not null
|
||||
|| _allCellStatics.Count > 0)
|
||||
{
|
||||
|
|
@ -1199,7 +1199,7 @@ public sealed class RetailPViewRenderer
|
|||
private readonly List<WorldEntity> _cellStaticScratch = new();
|
||||
private readonly List<WorldEntity> _dynamicsScratch = new();
|
||||
// #118: dynamics assigned to the OUTSIDE stage this frame (interior roots
|
||||
// only) — outdoor-classified + exit-portal straddlers. Cleared per frame.
|
||||
// only) — outdoor-classified + exit-portal straddlers. Cleared per frame.
|
||||
private readonly List<WorldEntity> _outsideStageDynamics = new();
|
||||
// Dense-town FPS iteration-1 (cellobject batching): all visible cells'
|
||||
// viewcone-surviving statics accumulated for ONE batched DrawEntityBucket,
|
||||
|
|
@ -1274,17 +1274,17 @@ public sealed class RetailPViewRenderer
|
|||
|
||||
/// <summary>
|
||||
/// #118 stage assignment for a dynamic under an INTERIOR root: does it draw
|
||||
/// in the OUTSIDE (landscape) stage — before the gated depth clear and the
|
||||
/// exit-portal seals — like retail's per-landcell object draw
|
||||
/// (LScape::draw → DrawBlock 0x005a17c0 → DrawSortCell pc:430124, run at
|
||||
/// in the OUTSIDE (landscape) stage — before the gated depth clear and the
|
||||
/// exit-portal seals — like retail's per-landcell object draw
|
||||
/// (LScape::draw → DrawBlock 0x005a17c0 → DrawSortCell pc:430124, run at
|
||||
/// the top of PView::DrawCells pc:432719)?
|
||||
///
|
||||
/// True for outdoor-classified dynamics (their fragments lie beyond the
|
||||
/// door plane and would z-fail the seal in the last pass), and for INDOOR
|
||||
/// dynamics whose sphere straddles an exit-portal plane of their flood-
|
||||
/// visible cell — retail draws an object once per overlapped shadow cell
|
||||
/// visible cell — retail draws an object once per overlapped shadow cell
|
||||
/// (DrawBlock pc:430056-430064), so a threshold-straddling body draws in
|
||||
/// both stages and neither half clips at the plane. Pure — also driven
|
||||
/// both stages and neither half clips at the plane. Pure — also driven
|
||||
/// headlessly by HouseExitWalkReplayTests as the ordering contract.
|
||||
/// </summary>
|
||||
public static bool DynamicDrawsInOutsideStage(
|
||||
|
|
@ -1299,7 +1299,7 @@ public sealed class RetailPViewRenderer
|
|||
|
||||
uint cellId = parentCellId!.Value;
|
||||
if (!drawableCells.Contains(cellId))
|
||||
return false; // not in the flood — the last-pass cone cull owns it
|
||||
return false; // not in the flood — the last-pass cone cull owns it
|
||||
var cell = cells.Find(cellId);
|
||||
if (cell is null)
|
||||
return false;
|
||||
|
|
@ -1320,7 +1320,7 @@ public sealed class RetailPViewRenderer
|
|||
return false;
|
||||
}
|
||||
|
||||
// Conservative bounding sphere from the entity's cached AABB — the same
|
||||
// Conservative bounding sphere from the entity's cached AABB — the same
|
||||
// bounds source the dispatcher's frustum cull uses.
|
||||
private static void EntitySphere(WorldEntity e, out Vector3 center, out float radius)
|
||||
{
|
||||
|
|
@ -1343,7 +1343,7 @@ public sealed class RetailPViewRenderer
|
|||
|
||||
}
|
||||
|
||||
public interface IRetailPViewCellSource
|
||||
internal interface IRetailPViewCellSource
|
||||
{
|
||||
LoadedCell? Find(uint cellId);
|
||||
}
|
||||
|
|
@ -1354,7 +1354,7 @@ public interface IRetailPViewCellSource
|
|||
/// pass only; visibility construction and draw ordering remain renderer-owned.
|
||||
/// All frame inputs and results are borrowed for the duration of the call.
|
||||
/// </summary>
|
||||
public interface IRetailPViewPassExecutor
|
||||
internal interface IRetailPViewPassExecutor
|
||||
{
|
||||
void AbortFrame();
|
||||
void BeginFrame();
|
||||
|
|
@ -1565,7 +1565,7 @@ internal sealed class BuildingGroupScratch
|
|||
}
|
||||
}
|
||||
|
||||
public sealed class RetailPViewFrameInput
|
||||
internal sealed class RetailPViewFrameInput
|
||||
{
|
||||
public LoadedCell RootCell { get; private set; } = null!;
|
||||
|
||||
|
|
@ -1669,7 +1669,7 @@ public sealed class RetailPViewFrameInput
|
|||
/// frame objects are deliberately reused to keep the render loop allocation
|
||||
/// free; consumers must copy any state they need to retain asynchronously.
|
||||
/// </summary>
|
||||
public sealed class RetailPViewFrameResult
|
||||
internal sealed class RetailPViewFrameResult
|
||||
{
|
||||
public PortalVisibilityFrame PortalFrame { get; private set; } = null!;
|
||||
public ClipFrameAssembly ClipAssembly { get; private set; } = null!;
|
||||
|
|
@ -1712,17 +1712,17 @@ public sealed class RetailPViewFrameResult
|
|||
diagnosticPartition);
|
||||
}
|
||||
|
||||
public readonly record struct RetailPViewLandscapeSliceContext(
|
||||
internal readonly record struct RetailPViewLandscapeSliceContext(
|
||||
ClipViewSlice Slice,
|
||||
IReadOnlyList<WorldEntity> OutdoorEntities)
|
||||
{
|
||||
internal RenderFrameEntityDrawRequest? EntityDraw { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>#131/#132: the late landscape phase's per-slice payload —
|
||||
/// <summary>#131/#132: the late landscape phase's per-slice payload —
|
||||
/// outside-stage dynamics to mesh-draw, plus the full scene-particle owner
|
||||
/// set (statics + dynamics cone survivors) the attached-emitter filter keys on.</summary>
|
||||
public readonly record struct RetailPViewLandscapeLateSliceContext(
|
||||
internal readonly record struct RetailPViewLandscapeLateSliceContext(
|
||||
ClipViewSlice Slice,
|
||||
IReadOnlyList<WorldEntity> Dynamics,
|
||||
IReadOnlySet<uint> ParticleOwnerIds)
|
||||
|
|
@ -1730,7 +1730,7 @@ public readonly record struct RetailPViewLandscapeLateSliceContext(
|
|||
internal RenderFrameEntityDrawRequest? EntityDraw { get; init; }
|
||||
}
|
||||
|
||||
public readonly record struct RetailPViewCellSliceContext(
|
||||
internal readonly record struct RetailPViewCellSliceContext(
|
||||
uint CellId,
|
||||
ClipViewSlice Slice,
|
||||
IReadOnlySet<uint> ParticleOwnerIds);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue