fix(render): S3 chunk 4 round 2 — draw-gate condition pinned, scissor and terrain-clip stacks deleted, one outside-view slice constructor

Round-1 three-lens FAIL at d60ca4ea0 found: the K2 fix was right in
production but nothing pinned the draw side (restoring the pre-fix gate
left every lane green); a stale "still ends an active scissor" comment
asserted a mechanism the same round had already deleted; the sky.vert
comment claimed retail clips the sky when it draws unclipped; the K6
pin ran through ClipFrameAssembler.Assemble, which has zero production
callers, so it proved nothing about the producer that actually runs.
This round closes L1-L9.

L1 (BLOCKING) — draw-side weather-gate pin. Added
RetailPViewPassExecutorTests.DrawLandscapeDynamicsPhase_GatesDrawWeatherOnceOnWalkDriverWeatherTurnFired:
reads DrawLandscapeDynamicsPhase's compiled call graph and asserts (a)
the call immediately before DrawWeatherOnce is
WalkFrameDriver.get_WeatherTurnFired and (b) exactly one
brfalse/brfalse.s branch sits between that call and the draw, jumping
forward past it. Three mutations, each shown failing:

  M1 (restore `if (clipAssembly.OutsideViewSlices.Length != 0)`):
    Assert.Equal() Failure: Values differ
    Expected: typeof(AcDream.App.Rendering.Walk.WalkFrameDriver)
    Actual:   typeof(AcDream.App.Rendering.ClipFrameAssembly)

  M2 (drop the gate, unconditional call):
    Assert.Equal() Failure: Values differ
    Expected: typeof(AcDream.App.Rendering.Walk.WalkFrameDriver)
    Actual:   typeof(AcDream.App.Rendering.RetailPViewPassExecutor)

  M3 (invert to `if (!walkDriver.WeatherTurnFired)`):
    Assert.Single() Failure: The collection did not contain any matching items
    Expected:   (predicate expression)
    Collection: [CompiledBranch { Offset = 7, OpCode = brfalse.s, TargetOffset = 17 }, CompiledBranch { Offset = 24, OpCode = brtrue.s, TargetOffset = 33 }]

Corrected WalkFrameDriverTranscriptTests.cs's doc comment (the
Collect_WeatherTurnFiredMatchesThePrintedOcLineExactly block): it pins
the print half and the flag only, and now names the real draw-side pin
instead of falsely claiming to double as one.

L2 (MAJOR) — scissor stack deleted, K4 comment corrected. Deleted
IWorldPassSurface.BeginScissor/EndScissor and their RhiWorldPassSurface
bodies, the scissor call inside ClearInteriorDepth, NdcScissorRect.cs +
NdcScissorRectTests.cs (BeginScissor had zero remaining callers, and
RhiWorldPassSurface.BeginScissor was NdcScissorRect.ToPixels' only
production caller). This left RhiWorldPassSurface's 4th constructor
parameter (IRetailPViewFramebufferSource) and RetailPViewPassExecutor.cs's
RetailPViewFramebufferSize/IRetailPViewFramebufferSource/
SilkRetailPViewFramebufferSource types entirely dead (framebuffer size
was needed only for the scissor's NDC-to-pixel conversion) — removed
them and their one call site in FrameRootComposition.cs. Rewrote
RetailPViewPassExecutor.cs's K4 comment: it no longer asserts a live
scissor mechanism; it states that VulkanGpuPassEncoder's constructor
sets the full-attachment scissor once, at pass begin
(VulkanGpuPassEncoder.cs:87), and nothing narrows it after that. KEPT
per the round-1 verdict: ScissorFallbacks, OutsidePlaneCount,
OutsideViewNdcAabb, HasOutsideView, OutdoorVisible, OutdoorSlot,
ClipViewSlice.NdcAabb (the >8-edge zero-plane slice case the punch fans
still consume) and the VulkanViewportMapping.ScissorToVulkan pass-begin
path (IGpuPassEncoder.SetScissor stays). Also deleted
ClipFrameAssembly.TerrainMode/TerrainScissorNdcAabb and their writers
in ClipFrameAssembler.cs (both Assemble and ReassembleOutsideViewFromWalk);
OutsidePlaneCount's formula reduces to `outsideHasScissorFallback ? 0 :
outsideMaxPlaneCount` without needing the deleted TerrainClipMode
comparison (a scissor-fallback slice can only exist when outdoorVisible
is already true, so the three original cases collapse identically).
Deleted the two "terrain=" / "outMode=" diagnostic reads in
WorldRenderDiagnostics.cs (:345, :409) and the now-vestigial
EmitClipRouteProbe "ubo: n=..." segment that read ClipFrame's deleted
terrain bytes.

DEVIATION from L2's literal text: kept the TerrainClipMode enum type
itself — WorldSceneRenderer.cs's flat-world safety path (explicitly
"out of this chunk's scope" per K4's own round-1 comment) still uses it
for an unrelated "did the flat terrain draw" diagnostic flag, with its
own WorldSceneDiagnosticsController/WorldRenderDiagnostics/test
consumers. Deleting the type would require rewriting files outside
every round's declared file list. Reworded its doc comment to state
this plainly.

L3 (MAJOR, completes K3) — dead TerrainClip UBO deleted from the sky
and terrain shaders. Deleted the block, both gl_ClipDistance loops, and
the gl_PerVertex redeclaration (nothing else needs it) from sky.vert,
terrain_modern.vert, terrain_atmospheric.vert. Recompiled via
tools/compile-shaders.ps1 (glslc via the Vulkan SDK, managed shaderc
fallback also runs) — 24/24 pairs compiled; only sky.vert.spv,
terrain_atmospheric.vert.spv and terrain_modern.vert.spv changed.
Re-pinned VulkanShaderManifestTests.cs's frozen retail-oracle hashes
for sky.vert.spv (7d67a9e3624d198b370d402b5c12e4ce925bf9b8e646ef5123636a86d5985ab5)
and terrain_modern.vert.spv (8a73d89ef0e51e550327b9ff8c24857e309103b1d491030cf0d4d8594b45068c)
with dated comments, matching the existing re-pin convention.

Deleted WorldFrameSectionBinding.BindTerrainClip and
WorldFrameSections.TerrainClip (+ its Reset) with its two callers
(SkyRenderer.Rhi.cs:258, TerrainModernRenderer.Rhi.cs:259). DEVIATION
from L3's literal text: kept the shared Zeroed(...) helper in
WorldPassScope.cs — it is also called by BindSceneLighting and
BindClipRegions, both of which stay; deleting it would have broken
those two live bindings. Deleted IWorldPassSurface.BindTerrainClip
(no-op interface method + RhiWorldPassSurface body) and
WorldScenePassExecutor.cs's two calls to it (:121, :235).

Deleted ClipFrame's _terrainBytes field, TerrainBytes/TerrainBytesForTest
properties, Reset's Array.Clear(_terrainBytes), and the stale
header/K3 comment paragraphs. DEVIATION from L3's literal text: kept
ClipFrame.TerrainUboBytes and ClipFrame.TerrainClipUboBinding —
PortalDepthMaskRenderer.Rhi.cs (:140, :187) is a live production
consumer of both constants for the KEPT exit-seal/punch-fan clip block,
which was never in scope for deletion (portal_depth.vert's own
TerrainClip UBO declaration is the KEEP block, untouched). Reworded
both constants' doc comments (ClipFrame.cs, VulkanPipelineLayouts.cs's
UniformTerrainClip) to say only the portal-depth clip block uses
binding 2 now, and corrected the two GpuBindingModel.cs comments that
explained why terrain-tiling/sky-params bindings are 3/4 by naming "the
terrain clip block". Updated VulkanShaderDescriptorContractTests.cs's
TerrainVertexShaderDeclaresItsClipBlockInTheUniformSet (renamed
TerrainVertexShaderDeclaresOnlySceneLightingInTheUniformSet):
terrain_modern.vert's uniform set is now {SceneLighting} only. Deleted
ClipFrameLayoutTests.NoClip_TerrainBytes_Count0_AllZeros and its K3
comment; corrected the class doc comment and LayoutConstants_MatchShaderStruct's
"terrain UBO"/binding-contract comments to describe the portal-depth
consumer instead.

REQUIRED L3 fact (declared-but-never-bound dynamic uniform binding):
VulkanFrameBindings's constructor seeds EVERY declared uniform binding
(0..UniformBindingCount-1, including binding 2) with the shared dummy
buffer's range before any renderer runs (VulkanFrameBindings.cs:128-130,
`_arena.SeedUniform(binding, dummy.Handle.Handle, dummyUniformRange)`
in a loop over every binding) — "Every binding is always bound, whether
a renderer uses it or not... unused ones point at a shared dummy range"
(VulkanFrameBindings.cs:27-31). So after this round, when the sky or
terrain pipeline draws, set 1's shared descriptor layout still declares
binding 2 (portal_depth.vert's own declaration keeps
IsDeclaredUniformBinding(2) true), and its descriptor still points at a
valid (dummy) range from that seed — vkCmdBindDescriptorSets stays
legal even though neither shader statically uses binding 2 any more.
No SPIR-V-side change was needed to keep this legal.

L4 (MAJOR) — one outside-view slice constructor. Extracted
ClipFrameAssembler.AppendOutsideSlice (frame, ViewPolygon,
outsideSlicesList, ref maxPlaneCount, ref hasScissorFallback, ref
scissorFallbacks) — the exact ClipPlaneSet.From/AppendSlot/
ClipViewSlice-construction body — and call it from both Assemble's
outside_view loop and ReassembleOutsideViewFromWalk's outside_view
loop. Rewrote ClipFrameLayoutTests's K6 pin
(ClipViewSlicePlanes_PunchFanPath_EqualsCpuViewPolygonEdgePlanes_ForASyntheticView):
builds a synthetic WalkPortalView from pixel-space points (the
WalkCopyViewTests pattern) via a trivial IWalkRayCaster, runs
BeginWalkFrame(frame, outdoorRoot: false) then
ReassembleOutsideViewFromWalk(assembly, walkView, 640, 480), and reads
assembly.OutsideViewSlices[0].Planes — the exact production pair
RetailPViewPassExecutor.cs's BeginWalkFrame call and
RetailPViewRenderer.cs's ReassembleOutsideViewFromWalk call make.
Mutation (perturbed planes[0].W inside the shared helper, right after
`planes = cps.PlaneArray;`):

  edge 0 midpoint (-0.3,0.10000001) should lie ~on its OWN GPU plane; the closest plane was only 0.5251073 away

The pin's own source contains no `Assemble(` call — grep-checked by
extracting the method body and searching it for the literal text; no
match.

L5 — shader comments corrected. sky.vert/terrain_modern.vert/
terrain_atmospheric.vert now state retail draws the sky once
(LScape::draw @0x00506330 -> GameSky::Draw(sky,0) @0x0050633c, before
draw_check_blocks) and the landscape is view-culled per cell, never
GPU-clipped (RenderDeviceD3D::DrawBlock @0x005a17c0). The "Phase W
Stage 4"/"U.3 default" clip narratives are deleted along with the
blocks they described.

L6 — grep sweep. `grep -rn "<token>" src tests docs/architecture`
returns nothing for SetTerrainClip, BeginDoorwayScissor,
BindTerrainClip, TerrainBytes, "active scissor", and NdcScissorRect —
confirmed after this commit (docs/plans and docs/research keep the
historical record, untouched). TerrainClipMode is the one deliberate
exception (see the L2 deviation note above); every one of its 9
remaining hits is a live, non-stale reference (the enum declaration,
WorldSceneRenderer's flat-path local, or their diagnostic/test
plumbing), not a stale mention of a deleted mechanism.

L7 — Issue130DoorwayStripTests. Deleted AnySliceAdmitsScissor,
worstScissorGapPx and its PIN 1 assertion, the header's scissor
sentences, and the scissorGap half of MeasureTopEdgeGap (dropped the
fbW parameter it alone needed). Rewrote the header: the scissor
mechanism is retired (the sky and landscape draw unclipped; aperture
exactness comes from the depth clear, the exit seals and the interior
repaint). The remaining plane-gap half (the canary PIN,
`worstPlaneGapPx <= 1.2f`) still pins something production reads:
AnySliceAdmitsPlanes walks slice.Planes from the SAME ProjectToClip ->
ClipToRegion -> ClipPlaneSet.From pipeline
RetailPViewPassExecutor.DrawWalkPunchFan reads through
clipAssembly.OutsideViewSlices[activeViewIndex].Planes — so the test
was kept, not deleted.

L8 — DrawWalkSky loop-shape pin. Added
DrawWalkSky_RenderSkyCallSiteHasNoEnclosingBackwardBranch (same
backward-branch-span shape as K1's DrawWeatherOnce pin), plus a note in
both pins that CompiledCallGraph.ReadBranches does not decode a
compiled switch jump table, but no C# loop construct compiles to one.
Mutation (wrapped the RenderSky call in `for (int i = 0; i < 2; i++)`):

  Assert.DoesNotContain() Failure: Filter matched in collection
  Collection: [..., CompiledBranch { Offset = 20, OpCode = brtrue.s, TargetOffset = 25 }, CompiledBranch { Offset = 23, OpCode = br.s, TargetOffset = 66 }, CompiledBranch { Offset = 72, OpCode = blt.s, TargetOffset = 13 }, CompiledBranch { Offset = 80, OpCode = brfalse.s, TargetOffset = 116 }, CompiledBranch { Offset = 88, OpCode = brfalse.s, TargetOffset = 116 }]

(the Offset=72 -> TargetOffset=13 entry is the injected loop's backward
branch spanning the call).

L9 — this message.

Gates: dotnet build 0 warnings / 0 errors. Hermetic filter
(Lane!=InstalledDat&...&Status!=KnownFailure): 6827 passed, 0 failed.
InstalledDat lane against a real DAT directory: 244 passed, 4 failed —
exactly the four known failures (LayoutImporterMediaBearingChildSweepTests
+ LayoutImporterInvisibleSweepTests, both #383; TowerAscentReplayTests,
KnownFailure; WalkTraceConformanceTests.Oh_doorway_still_first_frame_diff,
#458 KnownFailure). No register row added (every deletion here removes
an acdream-only rule; the KEEP items already had their equivalence
pins from round 1). No new flag/probe.

OWED: the lead's ACDREAM_DEVTOOLS=1 four-pose visual self-gate against
the running client is not run by this agent — CLAUDE.md and this
task's own instructions forbid launching the graphical client from
here; that visual/validation-layer pass remains the user's to run
before this round is accepted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-03 15:11:21 +02:00
parent ff607a1e04
commit 7df0b94c9f
32 changed files with 832 additions and 991 deletions

View file

@ -368,15 +368,12 @@ internal sealed class FrameRootCompositionPhase
d.RenderDiagnosticLog); d.RenderDiagnosticLog);
IRenderFrameGlState worldFrameGlState = NullRenderFrameGlState.Instance; IRenderFrameGlState worldFrameGlState = NullRenderFrameGlState.Instance;
IWorldPassScope? worldPassScope = d.Graphics.WorldPassScope; IWorldPassScope? worldPassScope = d.Graphics.WorldPassScope;
var worldFramebufferSource =
new SilkRetailPViewFramebufferSource(d.Window);
IWorldPassSurface worldPassSurface = new RhiWorldPassSurface( IWorldPassSurface worldPassSurface = new RhiWorldPassSurface(
worldPassScope worldPassScope
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"The graphics backend must publish a world pass scope."), "The graphics backend must publish a world pass scope."),
host.GpuFrameLifetime, host.GpuFrameLifetime,
live.ClipFrame, live.ClipFrame);
worldFramebufferSource);
var worldFrameEnvironment = var worldFrameEnvironment =
new RuntimeWorldFrameEnvironmentPreparation( new RuntimeWorldFrameEnvironmentPreparation(
d.Options, d.Options,

View file

@ -1,39 +1,38 @@
// ClipFrame.cs // ClipFrame.cs
// //
// Phase U.3: the per-frame container for the SHARED per-frame clip data // Phase U.3: the per-frame container for the mesh SSBO clip-region table
// consumed by mesh_modern.vert (SSBO binding=2) and terrain_modern.vert (UBO // (binding=2) that mesh_modern.vert reads. The per-instance slot index
// binding=2). This is the "shared" half of the U.3 clip mechanism; the // buffer (SSBO binding=3) is PER-RENDERER and owned by each renderer
// per-instance slot index buffer (SSBO binding=3) is PER-RENDERER and owned by // (WbDrawDispatcher / EnvCellRenderer), parallel to its instance buffer —
// each renderer (WbDrawDispatcher / EnvCellRenderer), parallel to its instance // it is NOT here.
// buffer — it is NOT here.
// //
// === The contract (both shader sides obey) =================================== // === The contract (the shader side obeys) =====================================
// binding=2 mesh SSBO holds an array of CellClip, one per "slot": // binding=2 mesh SSBO holds an array of CellClip, one per "slot":
// struct CellClip { uint count; uint _p0; uint _p1; uint _p2; vec4 planes[8]; }; // struct CellClip { uint count; uint _p0; uint _p1; uint _p2; vec4 planes[8]; };
// std430 layout: count at byte 0, three pad uints at 4/8/12, planes[8] at 16 // std430 layout: count at byte 0, three pad uints at 4/8/12, planes[8] at 16
// (vec4 stride 16) → 144 bytes per slot. Slot 0 is RESERVED = no-clip (count 0). // (vec4 stride 16) → 144 bytes per slot. Slot 0 is RESERVED = no-clip (count 0).
// binding=2 terrain UBO holds the single OutsideView region:
// layout(std140) { int uTerrainClipCount; vec4 uTerrainClipPlanes[8]; };
// std140 layout: count at byte 0 (padded to 16), planes[8] at 16 → 144 bytes.
// //
// In U.3 a ClipFrame is built via NoClip(): one slot (slot 0, count 0) and a // In U.3 a ClipFrame is built via NoClip(): one slot (slot 0, count 0).
// terrain count of 0. Everything renders exactly as before. U.4 populates real // Everything renders exactly as before. U.4 populates real slots from a
// slots from a PortalVisibilityFrame (one CellClip per visible cell) and sets the // PortalVisibilityFrame (one CellClip per visible cell), then points each
// terrain OutsideView planes, then points each renderer's per-instance slot // renderer's per-instance slot buffer at the right slots.
// buffer at the right slots. //
// The one CONSUMER outside the mesh path that still reads a slot's packed
// bytes directly is PortalDepthMaskRenderer's exit-seal/punch-fan draw
// (S3 §8/§10 KEEP): it shares the SAME std140 144-byte layout
// (TerrainUboBytes) and binding (TerrainClipUboBinding = 2) the walk's own
// screen-space clip gate used to occupy before S3 chunk 4 fix round 2 (L3)
// deleted that gate outright — the terrain and sky shaders no longer declare
// any block at that binding.
// //
// Pure CPU byte-packing. The GL upload machinery this file used to carry // Pure CPU byte-packing. The GL upload machinery this file used to carry
// alongside the packing (a per-flight-slot region SSBO + terrain UBO arena, // alongside the packing (a per-flight-slot region SSBO arena,
// reservation/upload-once bookkeeping, and their disposal) was deleted at // reservation/upload-once bookkeeping, and their disposal) was deleted at
// Campaign V slice V11: the RHI arm (see RhiWorldPassSurface.PrepareClipFrame // Campaign V slice V11: the RHI arm (see RhiWorldPassSurface.PrepareClipFrame
// in WorldPassSurface.cs) reads RegionBytes below and copies it into a frame // in WorldPassSurface.cs) reads RegionBytes below and copies it into a frame
// ring allocation instead, so nothing here owns a GPU resource anymore. S3 // ring allocation instead, so nothing here owns a GPU resource anymore. The
// chunk 4 fix round 1 (K3) deleted SetTerrainClip (its only writer) — the // byte layout is asserted by ClipFrameLayoutTests so a silent std430 drift
// terrain UBO half of PrepareClipFrame's publication is gone with it; // can't reach the GPU.
// WorldFrameSectionBinding.BindTerrainClip's existing zeroed-ring fallback
// binds TerrainBytes's permanent all-zero state instead. The byte layout is
// asserted by ClipFrameLayoutTests so a silent std430/std140 drift can't
// reach the GPU.
using System; using System;
using System.Numerics; using System.Numerics;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
@ -41,15 +40,15 @@ using System.Runtime.InteropServices;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
/// <summary> /// <summary>
/// Per-frame container for the SHARED clip data: the binding=2 mesh SSBO (one /// Per-frame container for the mesh SSBO clip-region table (binding=2, one
/// <c>CellClip</c> per slot, slot 0 reserved no-clip) and the binding=2 terrain /// <c>CellClip</c> per slot, slot 0 reserved no-clip). See the file header for
/// UBO (the single OutsideView region). See the file header for the exact /// the exact std430 byte layout and for <see cref="TerrainUboBytes"/>/
/// std430 / std140 byte layout. Per-instance slot buffers (binding=3) are owned by /// <see cref="TerrainClipUboBinding"/>'s separate remaining purpose.
/// each renderer, not here. /// Per-instance slot buffers (binding=3) are owned by each renderer, not here.
/// </summary> /// </summary>
public sealed class ClipFrame : IDisposable public sealed class ClipFrame : IDisposable
{ {
// ---- Layout constants (mirror mesh_modern.vert + terrain_modern.vert) ---- // ---- Layout constants (mirror mesh_modern.vert) ----
/// <summary>Max planes per clip region — matches the shader's <c>planes[8]</c> /// <summary>Max planes per clip region — matches the shader's <c>planes[8]</c>
/// and GL's guaranteed <c>GL_MAX_CLIP_DISTANCES &gt;= 8</c>.</summary> /// and GL's guaranteed <c>GL_MAX_CLIP_DISTANCES &gt;= 8</c>.</summary>
@ -63,17 +62,24 @@ public sealed class ClipFrame : IDisposable
/// count + 3 pad uints).</summary> /// count + 3 pad uints).</summary>
public const int CellClipPlanesOffset = 16; public const int CellClipPlanesOffset = 16;
/// <summary>std140 size of the terrain UBO block: int count padded to 16, then /// <summary>std140 size of <c>PortalDepthMaskRenderer</c>'s exit-seal/punch-fan
/// 8 × 16 (vec4 planes) = 144 bytes. Same number as the SSBO stride by /// clip block: int count padded to 16, then 8 × 16 (vec4 planes) = 144
/// coincidence of the 16-byte vec4 rule, but a DIFFERENT layout family.</summary> /// bytes. Same number as the mesh SSBO stride by coincidence of the
/// 16-byte vec4 rule, but a DIFFERENT layout family. S3 chunk 4 fix round 2
/// (L3) retired this constant's other former use — the walk's own
/// screen-space clip gate, which terrain_modern.vert and sky.vert used to
/// declare at the same binding before the gate itself was deleted.</summary>
public const int TerrainUboBytes = 16 + MaxPlanes * 16; // 144 public const int TerrainUboBytes = 16 + MaxPlanes * 16; // 144
/// <summary>UBO binding index for the terrain OutsideView clip region /// <summary>UBO binding index for <c>PortalDepthMaskRenderer</c>'s
/// (terrain_modern.vert binding=2). Read directly by both the RHI world-pass /// exit-seal/punch-fan clip block (<c>portal_depth.vert</c> binding=2) — the
/// section binder (<c>WorldFrameSectionBinding.BindTerrainClip</c>) and /// one production consumer left at this binding after S3 chunk 4 fix
/// <c>PortalDepthMaskRenderer</c>, so unlike the mesh SSBO binding (which the /// round 2 (L3) deleted the walk's own screen-space clip gate (which used
/// RHI arm addresses through its own <c>GpuBindingModel.StorageClipRegions</c> /// to share this same binding number in terrain_modern.vert and
/// instead) this one is still genuinely shared.</summary> /// sky.vert). Unlike the mesh SSBO binding (which the RHI arm addresses
/// through its own <c>GpuBindingModel.StorageClipRegions</c> instead) this
/// one stays a restated raw constant — see
/// <c>VulkanPipelineLayouts.UniformTerrainClip</c>'s matching doc.</summary>
public const uint TerrainClipUboBinding = 2; public const uint TerrainClipUboBinding = 2;
// ---- CPU-side state ------------------------------------------------------ // ---- CPU-side state ------------------------------------------------------
@ -82,9 +88,6 @@ public sealed class ClipFrame : IDisposable
private byte[] _regionBytes; private byte[] _regionBytes;
private int _slotCount; private int _slotCount;
// Packed std140 bytes for the terrain UBO (always TerrainUboBytes long).
private readonly byte[] _terrainBytes = new byte[TerrainUboBytes];
/// <summary> /// <summary>
/// The GL arm's per-flight-slot region/terrain buffer rings this used to /// The GL arm's per-flight-slot region/terrain buffer rings this used to
/// report on were deleted at Campaign V slice V11: every publication now /// report on were deleted at Campaign V slice V11: every publication now
@ -98,14 +101,12 @@ public sealed class ClipFrame : IDisposable
{ {
_regionBytes = regionBytes; _regionBytes = regionBytes;
_slotCount = slotCount; _slotCount = slotCount;
// Terrain defaults to count 0 (ungated). _terrainBytes is already all
// zeros, which encodes count=0 + zeroed (unused) planes.
} }
/// <summary> /// <summary>
/// The U.3 default frame: exactly slot 0 (no-clip, count 0) and a terrain /// The U.3 default frame: exactly slot 0 (no-clip, count 0). The whole
/// count of 0. The whole scene renders ungated — identical to pre-U.3. U.4 /// scene renders ungated — identical to pre-U.3. U.4 replaces this with a
/// replaces this with a frame built from real portal visibility. /// frame built from real portal visibility.
/// </summary> /// </summary>
public static ClipFrame NoClip() public static ClipFrame NoClip()
{ {
@ -120,10 +121,10 @@ public sealed class ClipFrame : IDisposable
/// <summary> /// <summary>
/// Phase U.4: reset this frame back to the NoClip state — exactly slot 0 /// Phase U.4: reset this frame back to the NoClip state — exactly slot 0
/// (no-clip, count 0) and a terrain count of 0 — WITHOUT allocating a new /// (no-clip, count 0) — WITHOUT allocating a new frame. The single
/// frame. The single long-lived <c>_clipFrame</c> in GameWindow is reset + /// long-lived <c>_clipFrame</c> in GameWindow is reset + re-packed every
/// re-packed every frame by <see cref="ClipFrameAssembler"/>, then published /// frame by <see cref="ClipFrameAssembler"/>, then published through one
/// through one frame-ring allocation per section (see /// frame-ring allocation per section (see
/// <c>RhiWorldPassSurface.PrepareClipFrame</c>). /// <c>RhiWorldPassSurface.PrepareClipFrame</c>).
/// </summary> /// </summary>
public void Reset() public void Reset()
@ -136,9 +137,6 @@ public sealed class ClipFrame : IDisposable
EnsureRegionCapacity(CellClipStrideBytes); EnsureRegionCapacity(CellClipStrideBytes);
Array.Clear(_regionBytes, 0, CellClipStrideBytes); Array.Clear(_regionBytes, 0, CellClipStrideBytes);
_slotCount = 1; _slotCount = 1;
// Terrain back to count 0 (ungated) until SetTerrainClip is called again.
Array.Clear(_terrainBytes);
} }
/// <summary> /// <summary>
@ -230,18 +228,13 @@ public sealed class ClipFrame : IDisposable
count * sizeof(float) * 4)); count * sizeof(float) * 4));
} }
// S3 chunk 4 fix round 1 (K3): SetTerrainClip (the terrain OutsideView // S3 chunk 4 fix round 2 (L3): the walk's screen-space clip gate is gone
// writer) is deleted — no production caller has written a non-empty // — terrain_modern.vert and sky.vert no longer declare a block at this
// terrain clip since S3 chunk 4's original round deleted the walk's // binding, and the last CPU-side publisher of one was already removed a
// per-outside-view-slice sky/terrain-clip loop, and fix round 1's K4 // round earlier. Every KEEP clip (exit seals, punch fans) reads its
// deletes the sky loop's own now-dead SetTerrainClip wrapper too. Every // planes through GetSlotPlanes above / ClipViewSlice.Planes directly,
// KEEP clip (exit seals, punch fans) already read their planes through // never through a terrain-specific slot — this class carries no terrain
// GetSlotPlanes above / ClipViewSlice.Planes directly, never through // state at all any more.
// this terrain-specific slot. _terrainBytes therefore stays at its
// NoClip/Reset default (count 0, all zero) for the life of a frame —
// see WorldPassSurface.cs's PrepareClipFrame for how the TerrainClip
// UBO section still gets bound as that all-zero disabled block even
// with nothing left to publish it.
/// <summary> /// <summary>
/// No-op: this container owns no GPU resource of its own on the RHI arm — /// No-op: this container owns no GPU resource of its own on the RHI arm —
@ -308,15 +301,9 @@ public sealed class ClipFrame : IDisposable
internal ReadOnlySpan<byte> RegionBytes => internal ReadOnlySpan<byte> RegionBytes =>
_regionBytes.AsSpan(0, _slotCount * CellClipStrideBytes); _regionBytes.AsSpan(0, _slotCount * CellClipStrideBytes);
/// <summary>The packed std140 terrain-clip block. See <see cref="RegionBytes"/>.</summary>
internal ReadOnlySpan<byte> TerrainBytes => _terrainBytes;
// ---- Test seams ---------------------------------------------------------- // ---- Test seams ----------------------------------------------------------
/// <summary>Test seam: the packed std430 region bytes (slot 0..SlotCount-1). /// <summary>Test seam: the packed std430 region bytes (slot 0..SlotCount-1).
/// Read-only snapshot used by ClipFrameLayoutTests to assert the byte layout.</summary> /// Read-only snapshot used by ClipFrameLayoutTests to assert the byte layout.</summary>
internal ReadOnlySpan<byte> RegionBytesForTest => RegionBytes; internal ReadOnlySpan<byte> RegionBytesForTest => RegionBytes;
/// <summary>Test seam: the packed std140 terrain UBO bytes.</summary>
internal ReadOnlySpan<byte> TerrainBytesForTest => TerrainBytes;
} }

View file

@ -19,7 +19,15 @@ using System.Numerics;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
/// <summary> /// <summary>
/// How the landscape-through-outside_view pass should be interpreted. /// S3 chunk 4 fix round 2 (L2): retired from the walk's own clip assembly —
/// <see cref="ClipFrameAssembly"/> no longer tracks a terrain clip mode at
/// all, because the walk draws the sky, terrain and weather unclipped, one
/// call each, matching retail (<c>LScape::draw</c> never installs a view
/// before any of the three). The type stays only because
/// <see cref="WorldSceneRenderer"/>'s separate flat-world safety path still
/// uses it for its own unrelated "did the flat terrain draw this frame"
/// diagnostic flag (<c>Planes</c> = drew, <c>Skip</c> = the PView walk ran
/// instead; that path never produces <c>Scissor</c>).
/// </summary> /// </summary>
public enum TerrainClipMode public enum TerrainClipMode
{ {
@ -61,8 +69,6 @@ public sealed class ClipFrameAssembly
public int OutdoorSlot { get; internal set; } public int OutdoorSlot { get; internal set; }
public bool OutdoorVisible { get; internal set; } public bool OutdoorVisible { get; internal set; }
public TerrainClipMode TerrainMode { get; internal set; }
public Vector4 TerrainScissorNdcAabb { get; internal set; }
public bool HasOutsideView { get; internal set; } public bool HasOutsideView { get; internal set; }
public Vector4 OutsideViewNdcAabb { get; internal set; } public Vector4 OutsideViewNdcAabb { get; internal set; }
@ -279,8 +285,6 @@ public static class ClipFrameAssembler
assembly.SetOutsideViewSlices(assembly.CopySlices(slices)); assembly.SetOutsideViewSlices(assembly.CopySlices(slices));
assembly.OutdoorSlot = 0; assembly.OutdoorSlot = 0;
assembly.OutdoorVisible = true; assembly.OutdoorVisible = true;
assembly.TerrainMode = TerrainClipMode.Scissor;
assembly.TerrainScissorNdcAabb = fullScreen;
assembly.HasOutsideView = true; assembly.HasOutsideView = true;
assembly.OutsideViewNdcAabb = fullScreen; assembly.OutsideViewNdcAabb = fullScreen;
assembly.OutsidePlaneCount = 0; assembly.OutsidePlaneCount = 0;
@ -290,8 +294,6 @@ public static class ClipFrameAssembler
{ {
assembly.OutdoorSlot = 0; assembly.OutdoorSlot = 0;
assembly.OutdoorVisible = false; assembly.OutdoorVisible = false;
assembly.TerrainMode = TerrainClipMode.Skip;
assembly.TerrainScissorNdcAabb = Vector4.Zero;
assembly.HasOutsideView = false; assembly.HasOutsideView = false;
assembly.OutsideViewNdcAabb = Vector4.Zero; assembly.OutsideViewNdcAabb = Vector4.Zero;
assembly.OutsidePlaneCount = 0; assembly.OutsidePlaneCount = 0;
@ -370,53 +372,36 @@ public static class ClipFrameAssembler
foreach (var poly in pvFrame.OutsideView.Polygons) foreach (var poly in pvFrame.OutsideView.Polygons)
{ {
var cps = ClipPlaneSet.From(poly); AppendOutsideSlice(
if (cps.IsNothingVisible) frame,
continue; poly,
outsideSlicesList,
int slot; ref outsideMaxPlaneCount,
Vector4[] planes; ref outsideHasScissorFallback,
if (cps.Count > 0) ref scissorFallbacks);
{
planes = cps.PlaneArray;
slot = frame.AppendSlot(planes);
if (cps.Count > outsideMaxPlaneCount)
outsideMaxPlaneCount = cps.Count;
}
else
{
planes = System.Array.Empty<Vector4>();
slot = 0;
outsideHasScissorFallback = true;
scissorFallbacks++;
}
outsideSlicesList.Add(new ClipViewSlice(slot, AabbOf(poly), planes));
} }
ClipViewSlice[] outsideViewSlices = assembly.CopySlices(outsideSlicesList); ClipViewSlice[] outsideViewSlices = assembly.CopySlices(outsideSlicesList);
bool outdoorVisible = outsideViewSlices.Length > 0; bool outdoorVisible = outsideViewSlices.Length > 0;
int outdoorSlot = outdoorVisible ? outsideViewSlices[0].Slot : 0; int outdoorSlot = outdoorVisible ? outsideViewSlices[0].Slot : 0;
TerrainClipMode terrainMode = !outdoorVisible
? TerrainClipMode.Skip
: (outsideHasScissorFallback ? TerrainClipMode.Scissor : TerrainClipMode.Planes);
Vector4 outsideViewNdcAabb = outdoorVisible Vector4 outsideViewNdcAabb = outdoorVisible
? new Vector4(pvFrame.OutsideView.MinX, pvFrame.OutsideView.MinY, ? new Vector4(pvFrame.OutsideView.MinX, pvFrame.OutsideView.MinY,
pvFrame.OutsideView.MaxX, pvFrame.OutsideView.MaxY) pvFrame.OutsideView.MaxX, pvFrame.OutsideView.MaxY)
: Vector4.Zero; : Vector4.Zero;
Vector4 terrainScissor = terrainMode == TerrainClipMode.Scissor
? outsideViewNdcAabb
: Vector4.Zero;
assembly.SetOutsideViewSlices(outsideViewSlices); assembly.SetOutsideViewSlices(outsideViewSlices);
assembly.OutdoorSlot = outdoorSlot; assembly.OutdoorSlot = outdoorSlot;
assembly.OutdoorVisible = outdoorVisible; assembly.OutdoorVisible = outdoorVisible;
assembly.TerrainMode = terrainMode;
assembly.TerrainScissorNdcAabb = terrainScissor;
assembly.HasOutsideView = outdoorVisible; assembly.HasOutsideView = outdoorVisible;
assembly.OutsideViewNdcAabb = outsideViewNdcAabb; assembly.OutsideViewNdcAabb = outsideViewNdcAabb;
assembly.OutsidePlaneCount = terrainMode == TerrainClipMode.Planes ? outsideMaxPlaneCount : 0; // S3 chunk 4 fix round 2 (L2): equivalent to the deleted
// terrainMode == TerrainClipMode.Planes gate without needing
// TerrainClipMode at all — a scissor-fallback slice can only exist
// when outdoorVisible is already true (both branches above append a
// slice), so this reduces to the same three cases (no slices -> 0;
// any scissor fallback -> 0; all-planes -> outsideMaxPlaneCount).
assembly.OutsidePlaneCount = outsideHasScissorFallback ? 0 : outsideMaxPlaneCount;
assembly.ScissorFallbacks = scissorFallbacks; assembly.ScissorFallbacks = scissorFallbacks;
return assembly; return assembly;
} }
@ -509,55 +494,82 @@ public static class ClipFrameAssembler
if (poly.MaxY > unionMaxY) unionMaxY = poly.MaxY; if (poly.MaxY > unionMaxY) unionMaxY = poly.MaxY;
} }
var cps = ClipPlaneSet.From(poly); AppendOutsideSlice(
if (cps.IsNothingVisible) frame,
continue; poly,
outsideSlicesList,
int slot; ref outsideMaxPlaneCount,
Vector4[] planes; ref outsideHasScissorFallback,
if (cps.Count > 0) ref scissorFallbacks);
{
planes = cps.PlaneArray;
slot = frame.AppendSlot(planes);
if (cps.Count > outsideMaxPlaneCount)
outsideMaxPlaneCount = cps.Count;
}
else
{
planes = System.Array.Empty<Vector4>();
slot = 0;
outsideHasScissorFallback = true;
scissorFallbacks++;
}
outsideSlicesList.Add(new ClipViewSlice(slot, AabbOf(poly), planes));
} }
ClipViewSlice[] outsideViewSlices = assembly.CopySlices(outsideSlicesList); ClipViewSlice[] outsideViewSlices = assembly.CopySlices(outsideSlicesList);
bool outdoorVisible = outsideViewSlices.Length > 0; bool outdoorVisible = outsideViewSlices.Length > 0;
int outdoorSlot = outdoorVisible ? outsideViewSlices[0].Slot : 0; int outdoorSlot = outdoorVisible ? outsideViewSlices[0].Slot : 0;
TerrainClipMode terrainMode = !outdoorVisible
? TerrainClipMode.Skip
: (outsideHasScissorFallback ? TerrainClipMode.Scissor : TerrainClipMode.Planes);
Vector4 outsideViewNdcAabb = outdoorVisible Vector4 outsideViewNdcAabb = outdoorVisible
? new Vector4(unionMinX, unionMinY, unionMaxX, unionMaxY) ? new Vector4(unionMinX, unionMinY, unionMaxX, unionMaxY)
: Vector4.Zero; : Vector4.Zero;
Vector4 terrainScissor = terrainMode == TerrainClipMode.Scissor
? outsideViewNdcAabb
: Vector4.Zero;
assembly.SetOutsideViewSlices(outsideViewSlices); assembly.SetOutsideViewSlices(outsideViewSlices);
assembly.OutdoorSlot = outdoorSlot; assembly.OutdoorSlot = outdoorSlot;
assembly.OutdoorVisible = outdoorVisible; assembly.OutdoorVisible = outdoorVisible;
assembly.TerrainMode = terrainMode;
assembly.TerrainScissorNdcAabb = terrainScissor;
assembly.HasOutsideView = outdoorVisible; assembly.HasOutsideView = outdoorVisible;
assembly.OutsideViewNdcAabb = outsideViewNdcAabb; assembly.OutsideViewNdcAabb = outsideViewNdcAabb;
assembly.OutsidePlaneCount = terrainMode == TerrainClipMode.Planes ? outsideMaxPlaneCount : 0; // S3 chunk 4 fix round 2 (L2): see Assemble's matching comment — the
// same reduction applies here.
assembly.OutsidePlaneCount = outsideHasScissorFallback ? 0 : outsideMaxPlaneCount;
assembly.ScissorFallbacks = scissorFallbacks; assembly.ScissorFallbacks = scissorFallbacks;
} }
/// <summary>
/// S3 chunk 4 fix round 2 (L4): the ONE place a single outside_view
/// <see cref="ViewPolygon"/> becomes an appended clip slot plus its
/// <see cref="ClipViewSlice"/> — both <see cref="Assemble"/>'s
/// outside_view loop and <see cref="ReassembleOutsideViewFromWalk"/>'s
/// outside_view loop call this instead of each carrying its own copy of
/// the plane/scissor-fallback bookkeeping (a prior round's three-lens
/// review found the duplication let a CPU/GPU equivalence pin exercise
/// one copy while production ran the other — see <c>ClipFrameLayoutTests</c>'
/// punch-fan pin). A polygon entirely outside every plane
/// (<see cref="ClipPlaneSet.IsNothingVisible"/>) appends nothing and
/// returns false; the caller's own loop simply moves to the next
/// polygon either way, so the return value only matters to a caller that
/// needs to know.
/// </summary>
private static bool AppendOutsideSlice(
ClipFrame frame,
in ViewPolygon poly,
List<ClipViewSlice> outsideSlicesList,
ref int maxPlaneCount,
ref bool hasScissorFallback,
ref int scissorFallbacks)
{
var cps = ClipPlaneSet.From(poly);
if (cps.IsNothingVisible)
return false;
int slot;
Vector4[] planes;
if (cps.Count > 0)
{
planes = cps.PlaneArray;
slot = frame.AppendSlot(planes);
if (cps.Count > maxPlaneCount)
maxPlaneCount = cps.Count;
}
else
{
planes = System.Array.Empty<Vector4>();
slot = 0;
hasScissorFallback = true;
scissorFallbacks++;
}
outsideSlicesList.Add(new ClipViewSlice(slot, AabbOf(poly), planes));
return true;
}
private static Vector4 AabbOf(ViewPolygon poly) => private static Vector4 AabbOf(ViewPolygon poly) =>
new(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY); new(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY);

View file

@ -93,7 +93,8 @@ internal static class GpuBindingModel
/// is 144 bytes: too large for the 96-byte push-constant block (and for /// is 144 bytes: too large for the 96-byte push-constant block (and for
/// Vulkan's guaranteed 128-byte ceiling), and there is no RHI verb for setting /// Vulkan's guaranteed 128-byte ceiling), and there is no RHI verb for setting
/// a uniform array. A small uniform buffer is the Vulkan-legal home. Binding 2 /// a uniform array. A small uniform buffer is the Vulkan-legal home. Binding 2
/// is taken by the terrain clip block, so this is 3. /// is taken by <c>PortalDepthMaskRenderer</c>'s exit-seal/punch-fan clip block
/// (<see cref="VulkanPipelineLayouts.UniformTerrainClip"/>), so this is 3.
/// </summary> /// </summary>
public const uint UniformTerrainTiling = 3; public const uint UniformTerrainTiling = 3;
@ -107,8 +108,8 @@ internal static class GpuBindingModel
/// block, so a loose <c>uniform mat4 uSkyView;</c> is unspellable, and this /// block, so a loose <c>uniform mat4 uSkyView;</c> is unspellable, and this
/// set is 256 bytes in std140 — three matrices alone are twice the entire /// set is 256 bytes in std140 — three matrices alone are twice the entire
/// 96-byte push-constant block. A uniform buffer is the only legal home. /// 96-byte push-constant block. A uniform buffer is the only legal home.
/// Bindings 1, 2 and 3 are taken by SceneLighting, the terrain clip block /// Bindings 1, 2 and 3 are taken by SceneLighting, PortalDepthMaskRenderer's
/// and terrain tiling, so this is 4. /// clip block and terrain tiling, so this is 4.
/// </summary> /// </summary>
public const uint UniformSkyParams = 4; public const uint UniformSkyParams = 4;

View file

@ -371,13 +371,17 @@ internal static unsafe class VulkanPipelineLayouts
} }
/// <summary> /// <summary>
/// The terrain screen-space clip block's uniform binding. /// <c>PortalDepthMaskRenderer</c>'s exit-seal/punch-fan clip block's uniform
/// binding (<c>portal_depth.vert</c>).
/// ///
/// <para><see cref="GpuBindingModel"/> does not name this number — it only /// <para><see cref="GpuBindingModel"/> does not name this number — it only
/// records, twice, that "binding 2 is taken by the terrain clip block" while /// records that binding 2 is taken here while explaining why terrain tiling
/// explaining why terrain tiling is 3 and sky params are 4. The number itself /// is 3 and sky params are 4. Pinned since Phase U.3; S3 chunk 4 fix round 2
/// has been pinned by <c>terrain_modern.vert</c> and <c>sky.vert</c> since /// (L3) deleted the walk's OWN screen-space clip gate, which used to share
/// Phase U.3. Restating it here rather than promoting it into the frozen /// this exact binding number in <c>terrain_modern.vert</c> and
/// <c>sky.vert</c> — neither shader declares a block at this binding any
/// more, leaving <c>portal_depth.vert</c> the one production consumer.
/// Restating the number here rather than promoting it into the frozen
/// binding model keeps slice V6i-2 out of the pinned contract; a later slice /// binding model keeps slice V6i-2 out of the pinned contract; a later slice
/// entitled to change §3.3 should move it.</para> /// entitled to change §3.3 should move it.</para>
/// </summary> /// </summary>

View file

@ -38,11 +38,13 @@ namespace AcDream.App.Rendering.Gpu.Vk;
/// <para><b>Scissor does NOT flip with the viewport.</b> The V3 audit called /// <para><b>Scissor does NOT flip with the viewport.</b> The V3 audit called
/// this out as a concrete acceptance item (plan §4.10, item 1): /// this out as a concrete acceptance item (plan §4.10, item 1):
/// <c>vkCmdSetScissor</c> is always top-left-origin regardless of viewport sign, /// <c>vkCmdSetScissor</c> is always top-left-origin regardless of viewport sign,
/// and <c>NdcScissorRect.ToPixels</c> emits GL bottom-left rectangles. So the /// while every caller of <see cref="ScissorToVulkan"/> passes a GL-convention
/// scissor rectangle needs an explicit Y flip against the attachment height, /// bottom-left rectangle (the pass encoder's own full-attachment default,
/// while the viewport needs none. Getting this wrong shows up as a doorway /// set once at pass begin — S3 chunk 4 fix round 2 deleted the one caller that
/// aperture clipped from the wrong edge — visible, but only in a scene that has /// used to pass anything narrower). So the scissor rectangle needs an explicit
/// one.</para> /// Y flip against the attachment height, while the viewport needs none.
/// Getting this wrong shows up as a doorway aperture clipped from the wrong
/// edge — visible, but only in a scene that has one.</para>
/// ///
/// <para>Clip space itself needs nothing. acdream's cameras already build /// <para>Clip space itself needs nothing. acdream's cameras already build
/// projections with <c>Matrix4x4.CreatePerspectiveFieldOfView</c>, which is the /// projections with <c>Matrix4x4.CreatePerspectiveFieldOfView</c>, which is the

View file

@ -1,45 +0,0 @@
// NdcScissorRect.cs
//
// NDC AABB → framebuffer-pixel scissor box, CONSERVATIVE (outer bound).
// The scissor that brackets a landscape/doorway slice is a fallback BOUND on
// the slice's view region (AD-17 in the divergence register): it must CONTAIN
// every fragment the per-fragment plane clip would keep. Under-inclusion is
// the bug class — the #130 doorway top-edge background strip was this box
// computed as Floor(origin) + Ceiling(size), whose far edge
// floor(min)+ceil(maxmin) lands up to one pixel SHORT of the true max edge
// at unlucky fractional alignments, scissoring away the aperture's top/right
// pixel row for the whole slice (sky, terrain, statics, weather) while the
// seal still stamps it — a strip of clear color no later pass can fill.
//
// Correct outer bound: floor both mins, ceil both maxes, width = difference.
// A fragment at pixel (i,j) rasterizes iff its CENTER (i+0.5, j+0.5) lies in
// the region ⊆ the NDC box [X0,X1]×[Y0,Y1] (pixel units). Center-inside ⇒
// i ≥ X00.5 ⇒ i ≥ floor(X0) and i ≤ X10.5 ⇒ i < ceil(X1). So
// [floor(X0), ceil(X1)) admits every center-inside pixel, over-including by
// at most one pixel per edge — safe per AD-17's doctrine (the wall shell /
// plane clip repaints or kills the surplus).
using System;
using System.Numerics;
namespace AcDream.App.Rendering;
public static class NdcScissorRect
{
/// <summary>Convert an NDC AABB (minX, minY, maxX, maxY in [-1,1]) to a
/// framebuffer-pixel scissor box that CONTAINS it. Inputs are clamped to
/// the screen so a region extending past an edge still yields a valid box.
/// Width/height are at least 1.</summary>
public static (int X, int Y, int Width, int Height) ToPixels(
Vector4 ndcAabb, int fbWidth, int fbHeight)
{
float nx0 = Math.Clamp(ndcAabb.X, -1f, 1f);
float ny0 = Math.Clamp(ndcAabb.Y, -1f, 1f);
float nx1 = Math.Clamp(ndcAabb.Z, -1f, 1f);
float ny1 = Math.Clamp(ndcAabb.W, -1f, 1f);
int px0 = (int)MathF.Floor((nx0 * 0.5f + 0.5f) * fbWidth);
int py0 = (int)MathF.Floor((ny0 * 0.5f + 0.5f) * fbHeight);
int px1 = (int)MathF.Ceiling((nx1 * 0.5f + 0.5f) * fbWidth);
int py1 = (int)MathF.Ceiling((ny1 * 0.5f + 0.5f) * fbHeight);
return (px0, py0, Math.Max(1, px1 - px0), Math.Max(1, py1 - py0));
}
}

View file

@ -6,29 +6,14 @@ using AcDream.App.Rendering.Wb;
using AcDream.Core.Rendering; using AcDream.Core.Rendering;
using AcDream.Core.Vfx; using AcDream.Core.Vfx;
using AcDream.Core.World; using AcDream.Core.World;
using Silk.NET.Windowing;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
internal readonly record struct RetailPViewFramebufferSize(int Width, int Height); // S3 chunk 4 fix round 2 (L2): RetailPViewFramebufferSize / IRetailPViewFramebufferSource
// / SilkRetailPViewFramebufferSource are deleted — their only consumer was
internal interface IRetailPViewFramebufferSource // RhiWorldPassSurface.BeginScissor's NDC-to-pixel conversion, and BeginScissor
{ // itself is deleted in the same round (no producer of a narrowed scissor
RetailPViewFramebufferSize Capture(); // rectangle remains anywhere in the walk).
}
internal sealed class SilkRetailPViewFramebufferSource(IWindow window) :
IRetailPViewFramebufferSource
{
private readonly IWindow _window = window
?? throw new ArgumentNullException(nameof(window));
public RetailPViewFramebufferSize Capture()
{
var size = _window.FramebufferSize;
return new RetailPViewFramebufferSize(size.X, size.Y);
}
}
internal sealed class RetailPViewCellSource : IRetailPViewCellSource internal sealed class RetailPViewCellSource : IRetailPViewCellSource
{ {
@ -184,9 +169,10 @@ public RetailPViewPassExecutor(
/// <c>arg2==1</c> runs AFTER <c>LScape::draw</c>'s landblock loop /// <c>arg2==1</c> runs AFTER <c>LScape::draw</c>'s landblock loop
/// finishes (@0x00506396), not once per active landscape view. This call /// finishes (@0x00506396), not once per active landscape view. This call
/// is UNCLIPPED and sets no scissor — S3 chunk 4 (§10.2) deleted the /// is UNCLIPPED and sets no scissor — S3 chunk 4 (§10.2) deleted the
/// former per-outside-view-slice loop (<c>SetTerrainClip</c> + /// former per-outside-view-slice loop (the walk's own screen-space
/// <c>ClearClipRouting</c> + the old <c>DrawLandscapeSliceLate</c> leaf, /// terrain-clip writer, <c>ClearClipRouting</c>, and the old
/// one call per active landscape view) that used to run before this /// <c>DrawLandscapeSliceLate</c> leaf, one call per active landscape
/// view) that used to run before this
/// call and re-submit the rain mesh once per doorway aperture; this is /// call and re-submit the rain mesh once per doorway aperture; this is
/// now the ONLY weather call site, matching retail's ONE unclipped /// now the ONLY weather call site, matching retail's ONE unclipped
/// <c>GameSky::Draw(sky,1)</c>. /// <c>GameSky::Draw(sky,1)</c>.
@ -447,16 +433,28 @@ public RetailPViewPassExecutor(
return submitted; return submitted;
} }
// S3 chunk 4 fix round 1 (K4): BeginDoorwayScissor and the // S3 chunk 4 fix round 1 (K4): the sky's own doorway-scissor bracket and
// EnableClipDistances wrapper are deleted — their only caller was // the EnableClipDistances wrapper around it are deleted — their only
// DrawWalkSky's per-outside-view-slice loop (RetailPViewPassExecutor. // caller was DrawWalkSky's per-outside-view-slice loop
// WalkLeaf.cs), itself deleted by the same fix (retail draws the sky // (RetailPViewPassExecutor.WalkLeaf.cs), itself deleted by the same fix
// ONCE, unclipped, exactly like the terrain and the weather — see // (retail draws the sky ONCE, unclipped, exactly like the terrain and
// DrawWalkSky's own doc comment). _surface.BeginScissor/EndScissor and // the weather — see DrawWalkSky's own doc comment).
// IWorldPassSurface.EnableClipDistances stay: RhiWorldPassSurface. //
// ClearInteriorDepth still ends an active scissor, and // S3 chunk 4 fix round 2 (L2): round 1's own comment here asserted that
// WorldScenePassExecutor (the separate flat-world path, out of this // the interior depth clear still had a live scissor to end at that
// chunk's scope) still calls EnableClipDistances directly. // point — a mechanism that no longer existed ANYWHERE by then (K4 had
// already deleted the sky's own scissor bracket, the last production
// producer of a narrowed rectangle). IWorldPassSurface.BeginScissor/
// EndScissor and their RhiWorldPassSurface bodies are deleted outright
// in this round; the interior depth clear no longer ends anything
// because nothing narrows a rectangle any more — the pass encoder sets
// the full-attachment scissor once, at pass begin
// (VulkanGpuPassEncoder.cs:87), and it stays that way for the life of
// the pass. IWorldPassSurface.EnableClipDistances/DisableClipDistances
// stay: WorldScenePassExecutor (the separate flat-world path, out of
// this chunk's scope) still calls EnableClipDistances directly, and the
// KEEP clips (exit seals, punch fans) bracket their own draws with
// Enable/DisableClipDistances below.
private void DisableClipDistances() => _surface.DisableClipDistances(); private void DisableClipDistances() => _surface.DisableClipDistances();
} }

View file

@ -639,8 +639,9 @@ internal sealed class RetailPViewRenderer
} }
// S3 chunk 4 (§10.2): the former per-outside-view-slice loop // S3 chunk 4 (§10.2): the former per-outside-view-slice loop
// (SetTerrainClip + ClearClipRouting + DrawLandscapeSliceLate, one // (the walk's own screen-space terrain-clip writer + ClearClipRouting
// call per active landscape view) is DELETED — retail draws the // + DrawLandscapeSliceLate, one call per active landscape view) is
// DELETED — retail draws the
// weather mesh and its rain particles ONCE, unclipped, after // weather mesh and its rain particles ONCE, unclipped, after
// LScape::draw's whole landblock loop (GameSky::Draw(sky,1) // LScape::draw's whole landblock loop (GameSky::Draw(sky,1)
// @0x00506ff0), never once per doorway aperture. DrawWeatherOnce // @0x00506ff0), never once per doorway aperture. DrawWeatherOnce

View file

@ -85,32 +85,18 @@ layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting {
vec4 uCameraAndTime; vec4 uCameraAndTime;
}; };
// === Phase W Stage 4: sky/weather portal clip (the OutsideView region) ======== // Retail draws the sky ONCE per frame, UNCLIPPED (LScape::draw @0x00506330
// The sky + weather (rain cylinder) meshes are "the outside seen through a // -> GameSky::Draw(sky,0) @0x0050633c, before draw_check_blocks — no view is
// doorway" — retail draws them as part of LScape, clipped to the exit-portal // ever installed for it, and the landscape itself is view-CULLED per cell,
// region (PView::DrawCells @ 0x005a4840). acdream gates them with the SAME // never GPU-clipped, RenderDeviceD3D::DrawBlock @0x005a17c0). S3 chunk 4 fix
// binding=2 TerrainClip UBO the terrain shader reads (ClipFrame.SetTerrainClip → // round 2 (L3/L5) deletes the screen-space clip UBO and gl_ClipDistance
// the OutsideView convex planes). The planes are SCREEN-SPACE (NDC) half-spaces // writes this shader used to declare at binding=2 to gate the sky/weather to
// encoded as clip-space planes (nx, ny, 0, dw) with the test // a doorway aperture — that mechanism's only CPU-side writer was already
// dot(plane, gl_Position) >= 0. After the perspective divide that reduces to // deleted at fix round 1's K3, and its own doorway scissor bracket at fix
// nx*ndcX + ny*ndcY + dw >= 0 — INDEPENDENT of the projection matrix. So the same // round 1's K4; an interior root's aperture exactness comes from the depth
// plane set clips the sky correctly even though the sky uses its OWN dome // clear, the exit seals, and the interior repaint instead (WalkFrameDriver's
// projection (uSkyProjection / uSkyView, translation-zeroed) rather than the // interior turn draws the real cell geometry back over whatever the
// camera view-proj. uTerrainClipCount == 0 (outdoor / no exit portal visible) // unclipped sky/terrain painted through the doorway).
// ungates the sky entirely (the second loop sets all 8 distances to +1.0 ⇒
// full-screen sky, bit-identical to pre-Stage-4). Host enables GL_CLIP_DISTANCE0..7
// only around the sky/weather draws.
layout(std140, ACDREAM_UBO_SET binding = 2) uniform TerrainClip {
int uTerrainClipCount;
vec4 uTerrainClipPlanes[8];
};
// Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal
// (mirrors terrain_modern.vert). Sized 8 to match GL_MAX_CLIP_DISTANCES >= 8.
out gl_PerVertex {
vec4 gl_Position;
float gl_ClipDistance[8];
};
out vec2 vTex; out vec2 vTex;
out vec3 vTint; out vec3 vTint;
@ -165,17 +151,4 @@ void main() {
float fogEnd = uFogParams.y; float fogEnd = uFogParams.y;
float span = max(fogEnd - fogStart, 1e-3); float span = max(fogEnd - fogStart, 1e-3);
vFogFactor = clamp((fogEnd - dist) / span, 0.0, 1.0); vFogFactor = clamp((fogEnd - dist) / span, 0.0, 1.0);
// Phase W Stage 4: clip the sky/weather to the OutsideView (doorway) region.
// With uTerrainClipCount == 0 (outdoor / no exit portal in view) the first loop
// is skipped and the second sets all 8 distances to +1.0 ⇒ no clipping ⇒
// full-screen sky. Indoors with an exit portal visible, the OutsideView planes
// confine the sky to the doorway opening — exactly, per-fragment, matching the
// terrain (no scissor approximation). plane.z is 0 (a screen-space slab), so the
// sky's depth / dome radius is irrelevant. gl_Position here is the sky's own
// dome-projected clip position; the NDC-plane test is projection-independent.
for (int i = 0; i < uTerrainClipCount; ++i)
gl_ClipDistance[i] = dot(uTerrainClipPlanes[i], gl_Position);
for (int i = uTerrainClipCount; i < 8; ++i)
gl_ClipDistance[i] = 1.0;
} }

View file

@ -311,7 +311,7 @@
"stages": [ "stages": [
{ {
"stage": "vert", "stage": "vert",
"sourceSha256": "9102b156bd4fd667831353640b2feee2ddde66e88579a4e425566c9b67304b64", "sourceSha256": "51b42232202cd005439e95c50fd325a3849359b3357ab7550af9a4bb404279e4",
"compiled": true "compiled": true
}, },
{ {
@ -327,7 +327,7 @@
"stages": [ "stages": [
{ {
"stage": "vert", "stage": "vert",
"sourceSha256": "882979f63c858760e977ecafb708416ab01f7adabe4bb38c0630020c087f1c28", "sourceSha256": "42d1dbd0dbd950862e8672f79a3f429f771bae55fd2b5721c3e551a0ea9563b6",
"compiled": true "compiled": true
}, },
{ {
@ -343,7 +343,7 @@
"stages": [ "stages": [
{ {
"stage": "vert", "stage": "vert",
"sourceSha256": "a3f8592d482793622f7a69c08f8ba828a4e071f2fb5e1763f865be84797bceb7", "sourceSha256": "faf6855222cb2b09da6697c93a93f35cbc57c27bbc124055a6d235c72ef1a23a",
"compiled": true "compiled": true
}, },
{ {

View file

@ -40,33 +40,15 @@ layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting {
vec4 uCameraAndTime; vec4 uCameraAndTime;
}; };
// === Phase U.3: terrain screen-space clip gate (OutsideView region) =========== // Retail never view-clips terrain — LScape::draw draws whole landblocks, and
// Terrain is a single global region (the OutsideView), so it needs one set of // the walk's own CellInView admission already decided which cells reach here
// clip planes, not a per-instance slot table like the mesh shader. A std140 UBO // (RenderDeviceD3D::DrawBlock @0x005a17c0; S3 chunk 3's own findings). S3
// at binding=2 carries it. The UBO binding namespace is distinct from the SSBO // chunk 4 fix round 2 (L3/L5) deletes the Phase U.3 terrain screen-space clip
// binding namespace, so this does NOT collide with the mesh shader's SSBO // gate this shader used to declare at binding=2 along with its
// binding=2 — and within THIS shader binding=1 (SceneLighting) is the only other // gl_ClipDistance writes — that mechanism's only CPU-side writer was already
// UBO, leaving binding=2 free. uTerrainClipCount == 0 (the U.3 default) ungates // deleted at fix round 1's K3; an interior root's aperture exactness comes
// terrain entirely (the second loop sets all 8 distances to +1.0). Uploaded by // from the depth clear, the exit seals, and the interior repaint instead
// ClipFrame.UploadShared each frame; TerrainModernRenderer binds it before draw. // (WalkFrameDriver's interior turn).
//
// Campaign V slice V6i-2: ACDREAM_UBO_SET is what puts this in set 1 under the
// Vulkan dialect and expands to nothing under GL. Omitting it left the block at
// set 0 binding 2, which the storage layout declares as a STORAGE buffer — see
// plan §5.5.12 finding 2, measured on the committed SPIR-V rather than inferred.
// sky.vert declares the SAME block correctly and is the precedent.
layout(std140, ACDREAM_UBO_SET binding = 2) uniform TerrainClip {
int uTerrainClipCount;
vec4 uTerrainClipPlanes[8];
};
// Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal.
// Sized 8 to match GL_MAX_CLIP_DISTANCES >= 8. Host enables GL_CLIP_DISTANCE0..7
// once at startup; unused planes are set to +1.0 below so they pass everything.
out gl_PerVertex {
vec4 gl_Position;
float gl_ClipDistance[8];
};
out vec2 vBaseUV; out vec2 vBaseUV;
out vec3 vWorldNormal; out vec3 vWorldNormal;
@ -199,12 +181,4 @@ void main() {
// Closes issue #100; supersedes the hiddenTerrainCells cell-collapse hack. // Closes issue #100; supersedes the hiddenTerrainCells cell-collapse hack.
vec3 terrainPos = vec3(aPos.xy, aPos.z - 0.01); vec3 terrainPos = vec3(aPos.xy, aPos.z - 0.01);
gl_Position = uViewProjection * vec4(terrainPos, 1.0); gl_Position = uViewProjection * vec4(terrainPos, 1.0);
// Phase U.3: terrain clip gate against the single OutsideView region. With
// uTerrainClipCount == 0 (U.3 default) the first loop is skipped and the
// second sets all 8 distances to +1.0 ⇒ no clipping ⇒ identical terrain.
for (int i = 0; i < uTerrainClipCount; ++i)
gl_ClipDistance[i] = dot(uTerrainClipPlanes[i], gl_Position);
for (int i = uTerrainClipCount; i < 8; ++i)
gl_ClipDistance[i] = 1.0;
} }

View file

@ -38,33 +38,15 @@ layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting {
vec4 uCameraAndTime; vec4 uCameraAndTime;
}; };
// === Phase U.3: terrain screen-space clip gate (OutsideView region) =========== // Retail never view-clips terrain — LScape::draw draws whole landblocks, and
// Terrain is a single global region (the OutsideView), so it needs one set of // the walk's own CellInView admission already decided which cells reach here
// clip planes, not a per-instance slot table like the mesh shader. A std140 UBO // (RenderDeviceD3D::DrawBlock @0x005a17c0; S3 chunk 3's own findings). S3
// at binding=2 carries it. The UBO binding namespace is distinct from the SSBO // chunk 4 fix round 2 (L3/L5) deletes the Phase U.3 terrain screen-space clip
// binding namespace, so this does NOT collide with the mesh shader's SSBO // gate this shader used to declare at binding=2 along with its
// binding=2 — and within THIS shader binding=1 (SceneLighting) is the only other // gl_ClipDistance writes — that mechanism's only CPU-side writer was already
// UBO, leaving binding=2 free. uTerrainClipCount == 0 (the U.3 default) ungates // deleted at fix round 1's K3; an interior root's aperture exactness comes
// terrain entirely (the second loop sets all 8 distances to +1.0). Uploaded by // from the depth clear, the exit seals, and the interior repaint instead
// ClipFrame.UploadShared each frame; TerrainModernRenderer binds it before draw. // (WalkFrameDriver's interior turn).
//
// Campaign V slice V6i-2: ACDREAM_UBO_SET is what puts this in set 1 under the
// Vulkan dialect and expands to nothing under GL. Omitting it left the block at
// set 0 binding 2, which the storage layout declares as a STORAGE buffer — see
// plan §5.5.12 finding 2, measured on the committed SPIR-V rather than inferred.
// sky.vert declares the SAME block correctly and is the precedent.
layout(std140, ACDREAM_UBO_SET binding = 2) uniform TerrainClip {
int uTerrainClipCount;
vec4 uTerrainClipPlanes[8];
};
// Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal.
// Sized 8 to match GL_MAX_CLIP_DISTANCES >= 8. Host enables GL_CLIP_DISTANCE0..7
// once at startup; unused planes are set to +1.0 below so they pass everything.
out gl_PerVertex {
vec4 gl_Position;
float gl_ClipDistance[8];
};
out vec2 vBaseUV; out vec2 vBaseUV;
out vec3 vWorldNormal; out vec3 vWorldNormal;
@ -180,12 +162,4 @@ void main() {
// Closes issue #100; supersedes the hiddenTerrainCells cell-collapse hack. // Closes issue #100; supersedes the hiddenTerrainCells cell-collapse hack.
vec3 terrainPos = vec3(aPos.xy, aPos.z - 0.01); vec3 terrainPos = vec3(aPos.xy, aPos.z - 0.01);
gl_Position = uViewProjection * vec4(terrainPos, 1.0); gl_Position = uViewProjection * vec4(terrainPos, 1.0);
// Phase U.3: terrain clip gate against the single OutsideView region. With
// uTerrainClipCount == 0 (U.3 default) the first loop is skipped and the
// second sets all 8 distances to +1.0 ⇒ no clipping ⇒ identical terrain.
for (int i = 0; i < uTerrainClipCount; ++i)
gl_ClipDistance[i] = dot(uTerrainClipPlanes[i], gl_Position);
for (int i = uTerrainClipCount; i < 8; ++i)
gl_ClipDistance[i] = 1.0;
} }

View file

@ -255,7 +255,6 @@ public sealed unsafe partial class SkyRenderer
SkyParams.SizeInBytes); SkyParams.SizeInBytes);
WorldFrameSectionBinding.BindSceneLighting(encoder, scope.Sections, frame); WorldFrameSectionBinding.BindSceneLighting(encoder, scope.Sections, frame);
WorldFrameSectionBinding.BindTerrainClip(encoder, scope.Sections, frame);
encoder.DrawIndexed((uint)sub.IndexCount, 1, 0, 0, 0); encoder.DrawIndexed((uint)sub.IndexCount, 1, 0, 0, 0);
} }

View file

@ -256,7 +256,6 @@ public sealed unsafe partial class TerrainModernRenderer
encoder.BindIndexBuffer(RequireIndexStore(), 0, GpuIndexType.UInt32); encoder.BindIndexBuffer(RequireIndexStore(), 0, GpuIndexType.UInt32);
BindTilingTable(encoder); BindTilingTable(encoder);
WorldFrameSectionBinding.BindSceneLighting(encoder, scope.Sections, frame); WorldFrameSectionBinding.BindSceneLighting(encoder, scope.Sections, frame);
WorldFrameSectionBinding.BindTerrainClip(encoder, scope.Sections, frame);
// Campaign VM VM6 review fix round 4 (item 4): bind on BINDABLE, // Campaign VM VM6 review fix round 4 (item 4): bind on BINDABLE,
// not Enabled — same rule as WbDrawDispatcher.BindDirectionalShadowReceiver. // not Enabled — same rule as WbDrawDispatcher.BindDirectionalShadowReceiver.
// TryGetCurrentFrameBinding now returns true for a disabled-content // TryGetCurrentFrameBinding now returns true for a disabled-content

View file

@ -21,16 +21,20 @@ internal readonly record struct GpuBufferSection(
} }
/// <summary> /// <summary>
/// Campaign V slice V6j: the three sections GL binds frame-globally and Vulkan /// Campaign V slice V6j: the sections GL binds frame-globally and Vulkan
/// cannot. /// cannot.
/// ///
/// <para>On GL the SceneLighting UBO (set 1 binding 1), the per-cell clip regions /// <para>On GL the SceneLighting UBO (set 1 binding 1) and the per-cell clip
/// (set 0 binding 2) and the terrain clip block (set 1 binding 2) are each bound /// regions (set 0 binding 2) are each bound once, to a global binding point,
/// once, to a global binding point, and every consumer inherits them. Vulkan has /// and every consumer inherits them. Vulkan has no global binding points: a
/// no global binding points: a descriptor set is bound per draw, and a renderer /// descriptor set is bound per draw, and a renderer that binds its own
/// that binds its own buffers selects the descriptor scope those sections have to /// buffers selects the descriptor scope those sections have to land in (plan
/// land in (plan §5.5.14 item 2). So the writers PUBLISH here and each renderer /// §5.5.14 item 2). So the writers PUBLISH here and each renderer binds them
/// binds them inside the pass, after its own binds.</para> /// inside the pass, after its own binds. S3 chunk 4 fix round 2 (L3) deleted
/// the third section this class used to carry (the terrain screen-space clip
/// block, set 1 binding 2) — no shader declares it any more, and the
/// unrelated KEEP clip block that still uses binding 2 (the exit-seal/punch
/// fan clip) is fed directly by its own renderer, not through here.</para>
/// ///
/// <para>Borrowed for the frame that publishes it — the sections are ring slices /// <para>Borrowed for the frame that publishes it — the sections are ring slices
/// and die when the frame retires.</para> /// and die when the frame retires.</para>
@ -43,14 +47,10 @@ internal sealed class WorldFrameSections
/// <summary>Set 0 binding 2 — the per-cell <c>CellClip</c> table.</summary> /// <summary>Set 0 binding 2 — the per-cell <c>CellClip</c> table.</summary>
public GpuBufferSection ClipRegions { get; set; } public GpuBufferSection ClipRegions { get; set; }
/// <summary>Set 1 binding 2 — <c>TerrainClip</c>.</summary>
public GpuBufferSection TerrainClip { get; set; }
public void Reset() public void Reset()
{ {
SceneLighting = default; SceneLighting = default;
ClipRegions = default; ClipRegions = default;
TerrainClip = default;
} }
} }
@ -135,7 +135,10 @@ internal interface IWorldPassScope
/// <para>Each helper falls back to a zeroed ring slice when nothing published the /// <para>Each helper falls back to a zeroed ring slice when nothing published the
/// section. That is the same rule the GL arm already states for its own bindings /// section. That is the same rule the GL arm already states for its own bindings
/// — bind at least one element so the shader never reads an unbound buffer — /// — bind at least one element so the shader never reads an unbound buffer —
/// applied to the three sections whose publisher runs outside the renderer.</para> /// applied to the two sections whose publisher runs outside the renderer. S3
/// chunk 4 fix round 2 (L3) deleted the third helper this used to carry —
/// the terrain screen-space clip block's binder — with its last two callers;
/// no shader declares that block any more.</para>
/// </summary> /// </summary>
internal static class WorldFrameSectionBinding internal static class WorldFrameSectionBinding
{ {
@ -182,27 +185,6 @@ internal static class WorldFrameSectionBinding
section.SizeBytes); section.SizeBytes);
} }
internal static void BindTerrainClip(
IGpuPassEncoder encoder,
WorldFrameSections sections,
IGpuFrame frame)
{
GpuBufferSection section = sections.TerrainClip;
if (!section.IsValid)
{
section = Zeroed(
frame,
ClipFrame.TerrainUboBytes,
GpuRingUsage.Uniform);
}
encoder.BindUniformBuffer(
ClipFrame.TerrainClipUboBinding,
section.Buffer!,
section.OffsetBytes,
section.SizeBytes);
}
private static GpuBufferSection Zeroed( private static GpuBufferSection Zeroed(
IGpuFrame frame, IGpuFrame frame,
int byteCount, int byteCount,

View file

@ -1,4 +1,3 @@
using System.Numerics;
using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Gpu;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
@ -39,30 +38,16 @@ internal interface IWorldPassSurface
/// RHI arm ignores it: a ring allocation is distinct memory by construction /// RHI arm ignores it: a ring allocation is distinct memory by construction
/// and lives until the frame retires.</para> /// and lives until the frame retires.</para>
/// ///
/// <para>S3 chunk 4 fix round 1 (K3): this no longer also publishes the /// <para>S3 chunk 4 fix round 1 (K3) deleted the walk's per-frame terrain
/// terrain clip block — <c>ClipFrame.SetTerrainClip</c>, its only /// screen-space clip publish this used to also carry — its only writer
/// writer, is deleted, so <see cref="ClipFrame.TerrainBytes"/> stays at /// was retired along with the per-outside-view-slice terrain/sky/weather
/// its permanent NoClip/Reset default (count 0, all zero) for the life of /// clip loop — and fix round 2 (L3) deleted the terrain/sky shaders' own
/// a frame. The terrain/sky shaders still declare the <c>TerrainClip</c> /// declaration of that clip block along with the section binder that used
/// UBO (binding=2), so <see cref="WorldFrameSectionBinding.BindTerrainClip"/>'s /// to re-assert it, so this contract now publishes exactly one section,
/// existing zeroed-ring fallback (used whenever nothing published the /// the per-cell clip-region table.</para>
/// section) now binds that all-zero disabled block on every frame instead
/// of only when something raced the publish — the observable result is
/// identical either way, since a zeroed publish and a zeroed fallback are
/// the same bytes.</para>
/// </summary> /// </summary>
void PrepareClipFrame(int terrainUploadCount); void PrepareClipFrame(int terrainUploadCount);
/// <summary>
/// Re-asserts the terrain clip block at its binding.
///
/// <para>GL needs this because binding points are global and the sky and mesh
/// shaders read the same uniform binding between two terrain slices. On the
/// RHI arm every consumer binds the published section inside the pass, so
/// there is no ambient binding to re-assert and this is a no-op.</para>
/// </summary>
void BindTerrainClip();
/// <summary> /// <summary>
/// Enables every <c>gl_ClipDistance</c> slot. /// Enables every <c>gl_ClipDistance</c> slot.
/// ///
@ -77,15 +62,6 @@ internal interface IWorldPassSurface
/// <summary>Disables every <c>gl_ClipDistance</c> slot. See <see cref="EnableClipDistances"/>.</summary> /// <summary>Disables every <c>gl_ClipDistance</c> slot. See <see cref="EnableClipDistances"/>.</summary>
void DisableClipDistances(); void DisableClipDistances();
/// <summary>
/// Scissors to a doorway slice's screen-space bounding box. Returns whether a
/// scissor is now active, so the caller can pair it with <see cref="EndScissor"/>.
/// </summary>
bool BeginScissor(Vector4 ndcAabb);
/// <summary>Restores the full drawable rectangle.</summary>
void EndScissor();
/// <summary> /// <summary>
/// Retail's interior depth clear, between the landscape slice and the /// Retail's interior depth clear, between the landscape slice and the
/// interior cells (<c>PView::DrawCells @ 0x005A4840</c>). Retail gates it /// interior cells (<c>PView::DrawCells @ 0x005A4840</c>). Retail gates it
@ -108,18 +84,15 @@ internal sealed class RhiWorldPassSurface : IWorldPassSurface
private readonly IWorldPassScope _scope; private readonly IWorldPassScope _scope;
private readonly ICurrentGpuFrameSource _frames; private readonly ICurrentGpuFrameSource _frames;
private readonly ClipFrame _clipFrame; private readonly ClipFrame _clipFrame;
private readonly IRetailPViewFramebufferSource _framebuffer;
public RhiWorldPassSurface( public RhiWorldPassSurface(
IWorldPassScope scope, IWorldPassScope scope,
ICurrentGpuFrameSource frames, ICurrentGpuFrameSource frames,
ClipFrame clipFrame, ClipFrame clipFrame)
IRetailPViewFramebufferSource framebuffer)
{ {
_scope = scope ?? throw new ArgumentNullException(nameof(scope)); _scope = scope ?? throw new ArgumentNullException(nameof(scope));
_frames = frames ?? throw new ArgumentNullException(nameof(frames)); _frames = frames ?? throw new ArgumentNullException(nameof(frames));
_clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame)); _clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame));
_framebuffer = framebuffer ?? throw new ArgumentNullException(nameof(framebuffer));
} }
public void PrepareClipFrame(int terrainUploadCount) public void PrepareClipFrame(int terrainUploadCount)
@ -131,17 +104,6 @@ internal sealed class RhiWorldPassSurface : IWorldPassSurface
_scope.Sections.ClipRegions = Publish( _scope.Sections.ClipRegions = Publish(
_clipFrame.RegionBytes, _clipFrame.RegionBytes,
GpuRingUsage.Storage); GpuRingUsage.Storage);
// S3 chunk 4 fix round 1 (K3): the terrain-clip publish half is
// deleted — SetTerrainClip (its only writer) is gone, so publishing
// ClipFrame.TerrainBytes here would only ever republish permanent
// zero bytes. WorldFrameSectionBinding.BindTerrainClip's existing
// zeroed-ring fallback (for when nothing published the section)
// binds that same all-zero disabled block instead.
}
/// <summary>No-op: there is no ambient binding to re-assert. See the interface.</summary>
public void BindTerrainClip()
{
} }
/// <summary>No-op: Vulkan activates every declared clip distance. See the interface.</summary> /// <summary>No-op: Vulkan activates every declared clip distance. See the interface.</summary>
@ -154,33 +116,17 @@ internal sealed class RhiWorldPassSurface : IWorldPassSurface
{ {
} }
public bool BeginScissor(Vector4 ndcAabb)
{
RetailPViewFramebufferSize framebuffer = _framebuffer.Capture();
var box = NdcScissorRect.ToPixels(
ndcAabb,
framebuffer.Width,
framebuffer.Height);
// GL convention on the way in; the backend performs its own Y flip.
_scope.RequireEncoder().SetScissor(box.X, box.Y, box.Width, box.Height);
return true;
}
public void EndScissor() =>
_scope.RequireEncoder().SetScissor(
0,
0,
_scope.AttachmentWidth,
_scope.AttachmentHeight);
public void ClearInteriorDepth() public void ClearInteriorDepth()
{ {
// The GL arm drops the scissor before clearing so the clear covers the // S3 chunk 4 fix round 2 (L2): BeginScissor/EndScissor are deleted —
// whole target; vkCmdClearAttachments takes its own rectangle and is not // no producer of a narrowed scissor rectangle remains anywhere in the
// scissored, so the same coverage comes for free — but the scissor still // walk (K4 deleted the doorway scissor that used to bracket the sky;
// has to come off, because the interior cells drawn after it are not // fix round 1 already deleted the terrain/weather per-slice scissor
// confined to the doorway slice that was active. // loop). VulkanGpuPassEncoder's constructor sets the full-attachment
EndScissor(); // scissor exactly once, at pass begin (VulkanGpuPassEncoder.cs:87),
// and nothing narrows it after that any more, so
// vkCmdClearAttachments's own unscissored clear and every draw after
// it already cover the whole target with no bracket needed here.
_scope.ClearInteriorDepth(); _scope.ClearInteriorDepth();
} }

View file

@ -243,14 +243,10 @@ internal sealed class WorldRenderDiagnostics
$" ssbo[{slice.Slot}]: OUT-OF-RANGE len={regionBytes.Length}")); $" ssbo[{slice.Slot}]: OUT-OF-RANGE len={regionBytes.Length}"));
} }
ReadOnlySpan<byte> terrainBytes = clipFrame.TerrainBytesForTest; // S3 chunk 4 fix round 2 (L3): the "ubo: n=... p0=..." segment this
int terrainCount = BitConverter.ToInt32(terrainBytes[..4]); // used to append read ClipFrame's own screen-space clip gate bytes,
float p0 = BitConverter.ToSingle(terrainBytes.Slice(16, 4)); // deleted along with that gate — no shader declares the block any
float p1 = BitConverter.ToSingle(terrainBytes.Slice(20, 4)); // more, so there is nothing left here to read.
float p2 = BitConverter.ToSingle(terrainBytes.Slice(24, 4));
float p3 = BitConverter.ToSingle(terrainBytes.Slice(28, 4));
text.Append(FormattableString.Invariant(
$" ubo: n={terrainCount} p0=({p0:F3},{p1:F3},{p2:F3},{p3:F3})"));
string signature = text.ToString(); string signature = text.ToString();
_clipRouteSequence++; _clipRouteSequence++;
@ -342,7 +338,11 @@ internal sealed class WorldRenderDiagnostics
+ $"playerInRoot={(playerInRoot ? "Y" : "n")} " + $"playerInRoot={(playerInRoot ? "Y" : "n")} "
+ $"eye=({cameraPosition.X:F2},{cameraPosition.Y:F2},{cameraPosition.Z:F2}) " + $"eye=({cameraPosition.X:F2},{cameraPosition.Y:F2},{cameraPosition.Z:F2}) "
+ $"player=({playerPosition.X:F2},{playerPosition.Y:F2},{playerPosition.Z:F2}) " + $"player=({playerPosition.X:F2},{playerPosition.Y:F2},{playerPosition.Z:F2}) "
+ $"terrain={result.ClipAssembly.TerrainMode} " // S3 chunk 4 fix round 2 (L2): the "terrain=" field read
// ClipFrameAssembly.TerrainMode, deleted along with the
// walk's own terrain-clip-mode tracking (the walk draws
// terrain unclipped, one call, matching retail — there is no
// longer a mode to report here).
+ $"outVisible={result.ClipAssembly.OutdoorVisible}"); + $"outVisible={result.ClipAssembly.OutdoorVisible}");
} }
} }
@ -402,15 +402,17 @@ internal sealed class WorldRenderDiagnostics
text.Append(" skyFrame=").Append(drawSkyThisFrame ? 'Y' : 'n'); text.Append(" skyFrame=").Append(drawSkyThisFrame ? 'Y' : 'n');
text.Append(" zclear=").Append(depthClear ? 'Y' : 'n'); text.Append(" zclear=").Append(depthClear ? 'Y' : 'n');
text.Append(" sceneParticles=").Append(sceneParticles); text.Append(" sceneParticles=").Append(sceneParticles);
// S3 chunk 4 fix round 2 (L2): "outMode=" read
// ClipFrameAssembly.TerrainMode, deleted with the walk's own
// terrain-clip-mode tracking.
if (clipAssembly is not null) if (clipAssembly is not null)
{ {
text.Append(" outSlices=").Append(clipAssembly.OutsideViewSlices.Length); text.Append(" outSlices=").Append(clipAssembly.OutsideViewSlices.Length);
text.Append(" outPolys=").Append(clipAssembly.OutsideViewSlices.Length); text.Append(" outPolys=").Append(clipAssembly.OutsideViewSlices.Length);
text.Append(" outMode=").Append(clipAssembly.TerrainMode);
} }
else else
{ {
text.Append(" outSlices=0 outPolys=0 outMode=none"); text.Append(" outSlices=0 outPolys=0");
} }
text.Append(" ids=").Append(FormatIds(visibleCells, false)); text.Append(" ids=").Append(FormatIds(visibleCells, false));

View file

@ -1,240 +1,238 @@
using AcDream.App.Rendering.Sky; using AcDream.App.Rendering.Sky;
using AcDream.App.Rendering.Wb; using AcDream.App.Rendering.Wb;
using AcDream.Core.Rendering; using AcDream.Core.Rendering;
using AcDream.Core.Vfx; using AcDream.Core.Vfx;
using AcDream.Core.World; using AcDream.Core.World;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
internal interface IWorldScenePassExecutor internal interface IWorldScenePassExecutor
{ {
HashSet<uint>? TerrainVisibleCellIds { get; } HashSet<uint>? TerrainVisibleCellIds { get; }
void BeginFrame(); void BeginFrame();
void PrepareFlatWorldClip(); void PrepareFlatWorldClip();
void DrawFlatSky( void DrawFlatSky(
in WorldCameraFrame camera, in WorldCameraFrame camera,
in RenderFrameFoundation foundation, in RenderFrameFoundation foundation,
DayGroupData? activeDayGroup, DayGroupData? activeDayGroup,
float dayFraction); float dayFraction);
void DrawFlatTerrain(in WorldCameraFrame camera, uint? playerLandblockId); void DrawFlatTerrain(in WorldCameraFrame camera, uint? playerLandblockId);
void DrawFlatEntities( void DrawFlatEntities(
in WorldCameraFrame camera, in WorldCameraFrame camera,
IEnumerable<(uint LandblockId, System.Numerics.Vector3 AabbMin, IEnumerable<(uint LandblockId, System.Numerics.Vector3 AabbMin,
System.Numerics.Vector3 AabbMax, System.Numerics.Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities, IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> entries, IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> entries,
uint? playerLandblockId, uint? playerLandblockId,
HashSet<uint> animatedEntityIds); HashSet<uint> animatedEntityIds);
string DrawPostWorldParticles( string DrawPostWorldParticles(
LoadedCell? clipRoot, LoadedCell? clipRoot,
ClipFrameAssembly? clipAssembly, ClipFrameAssembly? clipAssembly,
in WorldCameraFrame camera, in WorldCameraFrame camera,
string currentSignature); string currentSignature);
void DrawFlatWeather( void DrawFlatWeather(
in WorldCameraFrame camera, in WorldCameraFrame camera,
in RenderFrameFoundation foundation, in RenderFrameFoundation foundation,
DayGroupData? activeDayGroup, DayGroupData? activeDayGroup,
float dayFraction); float dayFraction);
void DisableClipDistances(); void DisableClipDistances();
void AbortFrame(); void AbortFrame();
} }
/// <summary> /// <summary>
/// Concrete leaf for the flat-world safety path and the post-world particle /// Concrete leaf for the flat-world safety path and the post-world particle
/// and weather passes. Retail PView frames remain owned by /// and weather passes. Retail PView frames remain owned by
/// <see cref="RetailPViewRenderer"/> and <see cref="RetailPViewPassExecutor"/>. /// <see cref="RetailPViewRenderer"/> and <see cref="RetailPViewPassExecutor"/>.
/// ///
/// <para>Campaign V slice V6j: backend-neutral. Everything it does is either /// <para>Campaign V slice V6j: backend-neutral. Everything it does is either
/// delegation to a renderer or one of the four concerns /// delegation to a renderer or one of the four concerns
/// <see cref="IWorldPassSurface"/> owns, so one implementation serves both /// <see cref="IWorldPassSurface"/> owns, so one implementation serves both
/// backends and the retail ordering is written once.</para> /// backends and the retail ordering is written once.</para>
/// </summary> /// </summary>
internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor
{ {
private readonly IWorldPassSurface _surface; private readonly IWorldPassSurface _surface;
private readonly IRenderFrameGlState _frameGlState; private readonly IRenderFrameGlState _frameGlState;
private readonly ClipFrame _clipFrame; private readonly ClipFrame _clipFrame;
private readonly WbDrawDispatcher _entities; private readonly WbDrawDispatcher _entities;
private readonly EnvCellRenderer _environmentCells; private readonly EnvCellRenderer _environmentCells;
private readonly TerrainModernRenderer? _terrain; private readonly TerrainModernRenderer? _terrain;
private readonly TerrainDrawDiagnosticsController _terrainDiagnostics; private readonly TerrainDrawDiagnosticsController _terrainDiagnostics;
private readonly SkyRenderer? _sky; private readonly SkyRenderer? _sky;
private readonly ParticleSystem? _particles; private readonly ParticleSystem? _particles;
private readonly ParticleRenderer? _particleRenderer; private readonly ParticleRenderer? _particleRenderer;
private readonly HashSet<uint> _visibleParticleOwners = []; private readonly HashSet<uint> _visibleParticleOwners = [];
private readonly HashSet<uint> _noExcludedParticleOwners = []; private readonly HashSet<uint> _noExcludedParticleOwners = [];
public WorldScenePassExecutor( public WorldScenePassExecutor(
IWorldPassSurface surface, IWorldPassSurface surface,
IRenderFrameGlState frameGlState, IRenderFrameGlState frameGlState,
ClipFrame clipFrame, ClipFrame clipFrame,
WbDrawDispatcher entities, WbDrawDispatcher entities,
EnvCellRenderer environmentCells, EnvCellRenderer environmentCells,
TerrainModernRenderer? terrain, TerrainModernRenderer? terrain,
TerrainDrawDiagnosticsController terrainDiagnostics, TerrainDrawDiagnosticsController terrainDiagnostics,
SkyRenderer? sky, SkyRenderer? sky,
ParticleSystem? particles, ParticleSystem? particles,
ParticleRenderer? particleRenderer) ParticleRenderer? particleRenderer)
{ {
_surface = surface ?? throw new ArgumentNullException(nameof(surface)); _surface = surface ?? throw new ArgumentNullException(nameof(surface));
_frameGlState = frameGlState _frameGlState = frameGlState
?? throw new ArgumentNullException(nameof(frameGlState)); ?? throw new ArgumentNullException(nameof(frameGlState));
_clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame)); _clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame));
_entities = entities ?? throw new ArgumentNullException(nameof(entities)); _entities = entities ?? throw new ArgumentNullException(nameof(entities));
_environmentCells = environmentCells _environmentCells = environmentCells
?? throw new ArgumentNullException(nameof(environmentCells)); ?? throw new ArgumentNullException(nameof(environmentCells));
_terrain = terrain; _terrain = terrain;
_terrainDiagnostics = terrainDiagnostics _terrainDiagnostics = terrainDiagnostics
?? throw new ArgumentNullException(nameof(terrainDiagnostics)); ?? throw new ArgumentNullException(nameof(terrainDiagnostics));
_sky = sky; _sky = sky;
_particles = particles; _particles = particles;
_particleRenderer = particleRenderer; _particleRenderer = particleRenderer;
} }
public HashSet<uint>? TerrainVisibleCellIds => _terrain?.VisibleCellIds; public HashSet<uint>? TerrainVisibleCellIds => _terrain?.VisibleCellIds;
public void BeginFrame() public void BeginFrame()
{ {
_visibleParticleOwners.Clear(); _visibleParticleOwners.Clear();
_clipFrame.Reset(); _clipFrame.Reset();
_entities.ClearClipRouting(); _entities.ClearClipRouting();
_environmentCells.SetClipRouting(null); _environmentCells.SetClipRouting(null);
} }
public void PrepareFlatWorldClip() => _surface.PrepareClipFrame(1); public void PrepareFlatWorldClip() => _surface.PrepareClipFrame(1);
public void DrawFlatSky( public void DrawFlatSky(
in WorldCameraFrame camera, in WorldCameraFrame camera,
in RenderFrameFoundation foundation, in RenderFrameFoundation foundation,
DayGroupData? activeDayGroup, DayGroupData? activeDayGroup,
float dayFraction) float dayFraction)
{ {
_surface.BindTerrainClip(); _surface.EnableClipDistances();
_surface.EnableClipDistances(); Exception? drawFailure = null;
Exception? drawFailure = null; try
try {
{ _sky?.RenderSky(
_sky?.RenderSky( camera.Camera,
camera.Camera, camera.Position,
camera.Position, dayFraction,
dayFraction, activeDayGroup,
activeDayGroup, foundation.Sky,
foundation.Sky, foundation.EnvironOverrideActive);
foundation.EnvironOverrideActive); }
} catch (Exception error)
catch (Exception error) {
{ drawFailure = error;
drawFailure = error; throw;
throw; }
} finally
finally {
{ try
try {
{ DisableClipDistances();
DisableClipDistances(); }
} catch (Exception closeFailure) when (drawFailure is not null)
catch (Exception closeFailure) when (drawFailure is not null) {
{ throw new AggregateException(
throw new AggregateException( "Sky drawing failed and its clip-distance bracket could not be closed.",
"Sky drawing failed and its clip-distance bracket could not be closed.", drawFailure,
drawFailure, closeFailure);
closeFailure); }
} }
}
if (_particles is not null && _particleRenderer is not null)
if (_particles is not null && _particleRenderer is not null) {
{ _particleRenderer.Draw(
_particleRenderer.Draw( camera.Camera,
camera.Camera, camera.Position,
camera.Position, ParticleRenderPass.SkyPreScene);
ParticleRenderPass.SkyPreScene); }
} }
}
public void DrawFlatTerrain(
public void DrawFlatTerrain( in WorldCameraFrame camera,
in WorldCameraFrame camera, uint? playerLandblockId)
uint? playerLandblockId) {
{ _surface.EnableClipDistances();
_surface.EnableClipDistances(); _terrainDiagnostics.Begin();
_terrainDiagnostics.Begin(); _terrain?.Draw(
_terrain?.Draw( camera.Camera,
camera.Camera, camera.Frustum,
camera.Frustum, neverCullLandblockId: playerLandblockId);
neverCullLandblockId: playerLandblockId); _terrainDiagnostics.Complete();
_terrainDiagnostics.Complete(); }
}
public void DrawFlatEntities(
public void DrawFlatEntities( in WorldCameraFrame camera,
in WorldCameraFrame camera, IEnumerable<(uint LandblockId, System.Numerics.Vector3 AabbMin,
IEnumerable<(uint LandblockId, System.Numerics.Vector3 AabbMin, System.Numerics.Vector3 AabbMax,
System.Numerics.Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities,
IReadOnlyList<WorldEntity> Entities, IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> entries,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> entries, uint? playerLandblockId,
uint? playerLandblockId, HashSet<uint> animatedEntityIds) =>
HashSet<uint> animatedEntityIds) => _entities.Draw(
_entities.Draw( camera.Camera,
camera.Camera, entries,
entries, camera.Frustum,
camera.Frustum, neverCullLandblockId: playerLandblockId,
neverCullLandblockId: playerLandblockId, visibleCellIds: null,
visibleCellIds: null, animatedEntityIds: animatedEntityIds);
animatedEntityIds: animatedEntityIds);
public string DrawPostWorldParticles(
public string DrawPostWorldParticles( LoadedCell? clipRoot,
LoadedCell? clipRoot, ClipFrameAssembly? clipAssembly,
ClipFrameAssembly? clipAssembly, in WorldCameraFrame camera,
in WorldCameraFrame camera, string currentSignature)
string currentSignature) {
{ if (_particles is null || _particleRenderer is null)
if (_particles is null || _particleRenderer is null) return currentSignature;
return currentSignature;
if (clipRoot is null)
if (clipRoot is null) {
{ if (clipAssembly is not null)
if (clipAssembly is not null) {
{ _particleRenderer.DrawForOwners(
_particleRenderer.DrawForOwners( camera.Camera,
camera.Camera, camera.Position,
camera.Position, ParticleRenderPass.Scene,
ParticleRenderPass.Scene, _visibleParticleOwners,
_visibleParticleOwners, includeUnattached: true,
includeUnattached: true, excludedAttachedOwnerIds: _noExcludedParticleOwners);
excludedAttachedOwnerIds: _noExcludedParticleOwners); return AppendSignature(currentSignature, "filtered");
return AppendSignature(currentSignature, "filtered"); }
}
_particleRenderer.Draw(
_particleRenderer.Draw( camera.Camera,
camera.Camera, camera.Position,
camera.Position, ParticleRenderPass.Scene);
ParticleRenderPass.Scene); return AppendSignature(currentSignature, "global");
return AppendSignature(currentSignature, "global"); }
}
// Every PView root, including the outdoor sentinel, now submits scene
// Every PView root, including the outdoor sentinel, now submits scene // particles inside LScape::draw. Replaying them here is both a duplicate
// particles inside LScape::draw. Replaying them here is both a duplicate // and too late: it occurs after nested building cells, allowing exterior
// and too late: it occurs after nested building cells, allowing exterior // waterfall/foliage alpha to repaint an indoor/outdoor transition.
// waterfall/foliage alpha to repaint an indoor/outdoor transition. return currentSignature;
return currentSignature; }
}
public void DrawFlatWeather(
public void DrawFlatWeather( in WorldCameraFrame camera,
in WorldCameraFrame camera, in RenderFrameFoundation foundation,
in RenderFrameFoundation foundation, DayGroupData? activeDayGroup,
DayGroupData? activeDayGroup, float dayFraction)
float dayFraction) {
{ _surface.EnableClipDistances();
_surface.BindTerrainClip(); Exception? drawFailure = null;
_surface.EnableClipDistances();
Exception? drawFailure = null;
try try
{ {
_sky?.RenderWeather( _sky?.RenderWeather(
@ -244,63 +242,63 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor
activeDayGroup, activeDayGroup,
foundation.Sky, foundation.Sky,
foundation.EnvironOverrideActive); foundation.EnvironOverrideActive);
} }
catch (Exception error) catch (Exception error)
{ {
drawFailure = error; drawFailure = error;
throw; throw;
} }
finally finally
{ {
try try
{ {
DisableClipDistances(); DisableClipDistances();
} }
catch (Exception closeFailure) when (drawFailure is not null) catch (Exception closeFailure) when (drawFailure is not null)
{ {
throw new AggregateException( throw new AggregateException(
"Weather drawing failed and its clip-distance bracket could not be closed.", "Weather drawing failed and its clip-distance bracket could not be closed.",
drawFailure, drawFailure,
closeFailure); closeFailure);
} }
} }
if (_particles is not null && _particleRenderer is not null) if (_particles is not null && _particleRenderer is not null)
{ {
_particleRenderer.Draw( _particleRenderer.Draw(
camera.Camera, camera.Camera,
camera.Position, camera.Position,
ParticleRenderPass.SkyPostScene); ParticleRenderPass.SkyPostScene);
} }
} }
public void DisableClipDistances() => _surface.DisableClipDistances(); public void DisableClipDistances() => _surface.DisableClipDistances();
public void AbortFrame() public void AbortFrame()
{ {
List<Exception>? failures = null; List<Exception>? failures = null;
TryAbort(_frameGlState.RestoreFrameDefaults); TryAbort(_frameGlState.RestoreFrameDefaults);
TryAbort(_clipFrame.Reset); TryAbort(_clipFrame.Reset);
TryAbort(_entities.ClearClipRouting); TryAbort(_entities.ClearClipRouting);
TryAbort(_entities.AbortCurrentRenderSceneObserverFrame); TryAbort(_entities.AbortCurrentRenderSceneObserverFrame);
TryAbort(() => _environmentCells.SetClipRouting(null)); TryAbort(() => _environmentCells.SetClipRouting(null));
_visibleParticleOwners.Clear(); _visibleParticleOwners.Clear();
if (failures is { Count: > 0 }) if (failures is { Count: > 0 })
throw new AggregateException("World scene pass abort failed.", failures); throw new AggregateException("World scene pass abort failed.", failures);
void TryAbort(Action operation) void TryAbort(Action operation)
{ {
try try
{ {
operation(); operation();
} }
catch (Exception error) catch (Exception error)
{ {
(failures ??= []).Add(error); (failures ??= []).Add(error);
} }
} }
} }
private static string AppendSignature(string current, string value) => private static string AppendSignature(string current, string value) =>
current == "none" ? value : current + "+" + value; current == "none" ? value : current + "+" + value;
} }

View file

@ -38,7 +38,6 @@ public class ClipFrameAssemblerTests
Assert.Equal(new Vector4(-1f, -1f, 1f, 1f), slice.NdcAabb); Assert.Equal(new Vector4(-1f, -1f, 1f, 1f), slice.NdcAabb);
Assert.True(assembly.OutdoorVisible); Assert.True(assembly.OutdoorVisible);
Assert.True(assembly.HasOutsideView); Assert.True(assembly.HasOutsideView);
Assert.Equal(TerrainClipMode.Scissor, assembly.TerrainMode);
Assert.Equal(1, frame.SlotCount); Assert.Equal(1, frame.SlotCount);
} }
@ -59,7 +58,6 @@ public class ClipFrameAssemblerTests
Assert.Empty(assembly.OutsideViewSlices); Assert.Empty(assembly.OutsideViewSlices);
Assert.False(assembly.OutdoorVisible); Assert.False(assembly.OutdoorVisible);
Assert.False(assembly.HasOutsideView); Assert.False(assembly.HasOutsideView);
Assert.Equal(TerrainClipMode.Skip, assembly.TerrainMode);
Assert.Equal(Vector4.Zero, assembly.OutsideViewNdcAabb); Assert.Equal(Vector4.Zero, assembly.OutsideViewNdcAabb);
Assert.Equal(0, assembly.ScissorFallbacks); Assert.Equal(0, assembly.ScissorFallbacks);
Assert.Equal(1, frame.SlotCount); Assert.Equal(1, frame.SlotCount);
@ -93,7 +91,6 @@ public class ClipFrameAssemblerTests
Assert.NotEqual(0, asm.OutdoorSlot); Assert.NotEqual(0, asm.OutdoorSlot);
Assert.Single(asm.OutsideViewSlices); Assert.Single(asm.OutsideViewSlices);
Assert.Equal(asm.OutdoorSlot, asm.OutsideViewSlices[0].Slot); Assert.Equal(asm.OutdoorSlot, asm.OutsideViewSlices[0].Slot);
Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
Assert.Equal(4, asm.OutsidePlaneCount); Assert.Equal(4, asm.OutsidePlaneCount);
Assert.Equal(0, asm.ScissorFallbacks); Assert.Equal(0, asm.ScissorFallbacks);
} }
@ -117,7 +114,6 @@ public class ClipFrameAssemblerTests
Assert.DoesNotContain(cellB, asm.CellIdToSlot.Keys); Assert.DoesNotContain(cellB, asm.CellIdToSlot.Keys);
Assert.False(asm.OutdoorVisible); Assert.False(asm.OutdoorVisible);
Assert.Empty(asm.OutsideViewSlices); Assert.Empty(asm.OutsideViewSlices);
Assert.Equal(TerrainClipMode.Skip, asm.TerrainMode);
Assert.Equal(0, asm.OutsidePlaneCount); Assert.Equal(0, asm.OutsidePlaneCount);
} }
@ -138,11 +134,9 @@ public class ClipFrameAssemblerTests
Assert.NotEqual(0, asm.OutdoorSlot); Assert.NotEqual(0, asm.OutdoorSlot);
Assert.Equal(2, asm.OutsideViewSlices.Length); Assert.Equal(2, asm.OutsideViewSlices.Length);
Assert.NotEqual(asm.OutsideViewSlices[0].Slot, asm.OutsideViewSlices[1].Slot); Assert.NotEqual(asm.OutsideViewSlices[0].Slot, asm.OutsideViewSlices[1].Slot);
Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
Assert.Equal(4, asm.OutsidePlaneCount); Assert.Equal(4, asm.OutsidePlaneCount);
Assert.Equal(0, asm.ScissorFallbacks); Assert.Equal(0, asm.ScissorFallbacks);
Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + cell + two outside slices Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + cell + two outside slices
Assert.Equal(Vector4.Zero, asm.TerrainScissorNdcAabb);
} }
[Fact] [Fact]
@ -167,7 +161,6 @@ public class ClipFrameAssemblerTests
Assert.Single(asm.OutsideViewSlices); Assert.Single(asm.OutsideViewSlices);
Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + two cell slices + outside slice Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + two cell slices + outside slice
Assert.Equal(0, asm.ScissorFallbacks); Assert.Equal(0, asm.ScissorFallbacks);
Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
} }
[Fact] [Fact]
@ -179,7 +172,6 @@ public class ClipFrameAssemblerTests
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
Assert.True(asm.HasOutsideView); Assert.True(asm.HasOutsideView);
Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
Assert.Single(asm.OutsideViewSlices); Assert.Single(asm.OutsideViewSlices);
var expected = new Vector4( var expected = new Vector4(
@ -198,14 +190,12 @@ public class ClipFrameAssemblerTests
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
Assert.True(asm.HasOutsideView); Assert.True(asm.HasOutsideView);
Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
Assert.Equal(2, asm.OutsideViewSlices.Length); Assert.Equal(2, asm.OutsideViewSlices.Length);
var expected = new Vector4( var expected = new Vector4(
pv.OutsideView.MinX, pv.OutsideView.MinY, pv.OutsideView.MinX, pv.OutsideView.MinY,
pv.OutsideView.MaxX, pv.OutsideView.MaxY); pv.OutsideView.MaxX, pv.OutsideView.MaxY);
Assert.Equal(expected, asm.OutsideViewNdcAabb); Assert.Equal(expected, asm.OutsideViewNdcAabb);
Assert.Equal(Vector4.Zero, asm.TerrainScissorNdcAabb);
} }
[Fact] [Fact]
@ -219,7 +209,6 @@ public class ClipFrameAssemblerTests
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv); var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
Assert.False(asm.HasOutsideView); Assert.False(asm.HasOutsideView);
Assert.Equal(TerrainClipMode.Skip, asm.TerrainMode);
Assert.Equal(Vector4.Zero, asm.OutsideViewNdcAabb); Assert.Equal(Vector4.Zero, asm.OutsideViewNdcAabb);
} }
@ -246,7 +235,6 @@ public class ClipFrameAssemblerTests
Assert.Contains(0xA9B40200, asm2.CellIdToSlot.Keys); Assert.Contains(0xA9B40200, asm2.CellIdToSlot.Keys);
Assert.DoesNotContain(0xA9B40100, asm2.CellIdToSlot.Keys); Assert.DoesNotContain(0xA9B40100, asm2.CellIdToSlot.Keys);
Assert.False(asm2.OutdoorVisible); Assert.False(asm2.OutdoorVisible);
Assert.Equal(TerrainClipMode.Skip, asm2.TerrainMode);
} }
[Fact] [Fact]
@ -295,7 +283,6 @@ public class ClipFrameAssemblerTests
Assert.Empty(cleared.CellIdToViewSlices); Assert.Empty(cleared.CellIdToViewSlices);
Assert.Empty(cleared.PerCellPlaneCounts); Assert.Empty(cleared.PerCellPlaneCounts);
Assert.Empty(cleared.OutsideViewSlices); Assert.Empty(cleared.OutsideViewSlices);
Assert.Equal(TerrainClipMode.Skip, cleared.TerrainMode);
} }
[Fact] [Fact]

View file

@ -1,15 +1,16 @@
using System.Numerics; using System.Numerics;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Walk;
using Xunit; using Xunit;
namespace AcDream.App.Tests.Rendering; namespace AcDream.App.Tests.Rendering;
/// <summary> /// <summary>
/// Phase U.3: CPU-side proof that <see cref="ClipFrame"/> packs the shared clip /// Phase U.3: CPU-side proof that <see cref="ClipFrame"/> packs the mesh SSBO
/// data in the EXACT std430 (mesh SSBO) / std140 (terrain UBO) byte layout the /// clip-region table in the EXACT std430 byte layout mesh_modern.vert reads. A
/// shaders read. A silent layout drift here would mis-clip at U.4 with no build /// silent layout drift here would mis-clip at U.4 with no build error — these
/// error — these tests are the gate that catches it. /// tests are the gate that catches it.
/// ///
/// Layout under test (mesh CellClip, std430): /// Layout under test (mesh CellClip, std430):
/// offset 0 : uint count /// offset 0 : uint count
@ -20,7 +21,11 @@ namespace AcDream.App.Tests.Rendering;
/// ... /// ...
/// offset 16 + i*16 : vec4 planes[i] /// offset 16 + i*16 : vec4 planes[i]
/// stride 144 bytes per slot. /// stride 144 bytes per slot.
/// Terrain UBO (std140): int count at 0 (padded to 16), vec4 planes[8] at 16. ///
/// <see cref="ClipFrame.TerrainUboBytes"/>/<see cref="ClipFrame.TerrainClipUboBinding"/>
/// share this file only because they happen to reuse the identical std140
/// 144-byte shape for a DIFFERENT, still-live consumer — see their own doc
/// comments (S3 chunk 4 fix round 2, L3).
/// </summary> /// </summary>
public class ClipFrameLayoutTests public class ClipFrameLayoutTests
{ {
@ -41,11 +46,12 @@ public class ClipFrameLayoutTests
Assert.Equal(16, ClipFrame.CellClipPlanesOffset); Assert.Equal(16, ClipFrame.CellClipPlanesOffset);
Assert.Equal(8, ClipFrame.MaxPlanes); Assert.Equal(8, ClipFrame.MaxPlanes);
Assert.Equal(144, ClipFrame.TerrainUboBytes); Assert.Equal(144, ClipFrame.TerrainUboBytes);
// Binding contract: mesh clip regions on SSBO binding=2, terrain on UBO binding=2. // Binding contract: mesh clip regions on SSBO binding=2, PortalDepthMaskRenderer's
// The mesh side's binding index moved off ClipFrame at Campaign V slice // exit-seal/punch-fan clip block on UBO binding=2. The mesh side's binding
// V11 — the RHI arm addresses it through GpuBindingModel.StorageClipRegions // index moved off ClipFrame at Campaign V slice V11 — the RHI arm addresses
// instead of a raw GL binding constant (see ClipFrame's BeginFrame doc // it through GpuBindingModel.StorageClipRegions instead of a raw GL binding
// comment); the terrain UBO binding is still genuinely shared, so it stays. // constant (see ClipFrame's BeginFrame doc comment); the portal-depth UBO
// binding is still genuinely shared, so it stays.
Assert.Equal(2u, GpuBindingModel.StorageClipRegions); Assert.Equal(2u, GpuBindingModel.StorageClipRegions);
Assert.Equal(2u, ClipFrame.TerrainClipUboBinding); Assert.Equal(2u, ClipFrame.TerrainClipUboBinding);
} }
@ -66,17 +72,6 @@ public class ClipFrameLayoutTests
Assert.Equal(0, b); Assert.Equal(0, b);
} }
[Fact]
public void NoClip_TerrainBytes_Count0_AllZeros()
{
var frame = ClipFrame.NoClip();
var t = frame.TerrainBytesForTest;
Assert.Equal(ClipFrame.TerrainUboBytes, t.Length);
Assert.Equal(0, ReadInt(t, 0)); // count 0 ⇒ terrain ungated
foreach (var b in t)
Assert.Equal(0, b);
}
[Fact] [Fact]
public void AppendSlot_WritesCountAndPlanes_AtStd430Offsets() public void AppendSlot_WritesCountAndPlanes_AtStd430Offsets()
{ {
@ -180,11 +175,9 @@ public class ClipFrameLayoutTests
AssertPlaneAt(bytes, baseOff + ClipFrame.CellClipPlanesOffset + i * 16, cps.Planes[i]); AssertPlaneAt(bytes, baseOff + ClipFrame.CellClipPlanesOffset + i * 16, cps.Planes[i]);
} }
// S3 chunk 4 fix round 1 (K3): SetTerrainClip_WritesCountAndPlanes_AtStd140Offsets // S3 chunk 4 fix round 2 (L3): the walk's screen-space clip gate and its
// is deleted along with ClipFrame.SetTerrainClip itself (no production // own std140 layout pin are deleted outright — no shader declares that
// writer remains). NoClip_TerrainBytes_Count0_AllZeros above still pins // block any more, so there is nothing left to pack or pin.
// the permanent all-zero state SetTerrainClip used to be the only way
// to move off of.
private static void AssertPlaneAt(System.ReadOnlySpan<byte> bytes, int offset, Vector4 expected) private static void AssertPlaneAt(System.ReadOnlySpan<byte> bytes, int offset, Vector4 expected)
{ {
@ -246,39 +239,62 @@ public class ClipFrameLayoutTests
/// KEEP item 2 — punch fans: <c>RetailPViewPassExecutor.DrawWalkPunchFan</c> /// KEEP item 2 — punch fans: <c>RetailPViewPassExecutor.DrawWalkPunchFan</c>
/// reads its clip planes through <c>clipAssembly.OutsideViewSlices /// reads its clip planes through <c>clipAssembly.OutsideViewSlices
/// [activeViewIndex].Planes</c> — <see cref="ClipViewSlice"/>'s /// [activeViewIndex].Planes</c> — <see cref="ClipViewSlice"/>'s
/// <c>Planes</c> field. S3 chunk 4 fix round 1 (K6): this pin now builds /// <c>Planes</c> field. S3 chunk 4 fix round 2 (L4): a prior round's pin
/// that slice through the REAL production assembly — /// here built the slice through <c>ClipFrameAssembler.Assemble</c> — but
/// <c>ClipFrameAssembler.Assemble</c>'s own /// <c>Assemble</c> has ZERO production callers (it exists only for
/// <c>outsideSlicesList.Add(new ClipViewSlice(slot, AabbOf(poly), /// isolated research/replay tests, per this file's own class header);
/// planes))</c> line, the exact construction /// production builds an interior root's outside-view slices through
/// <c>ReassembleOutsideViewFromWalk</c> (the walk's real interior-root /// <c>BeginWalkFrame</c> + <c>ReassembleOutsideViewFromWalk</c>
/// producer) shares — instead of hand-constructing a /// (<c>RetailPViewPassExecutor.cs</c>'s <c>BeginWalkFrame</c> call,
/// <see cref="ClipViewSlice"/> directly from <see /// <c>RetailPViewRenderer.cs</c>'s <c>ReassembleOutsideViewFromWalk</c>
/// cref="ClipPlaneSet.From(CellView)"/>'s raw output: a hand-built slice /// call), so a pin through <c>Assemble</c> proves nothing about the code
/// could pass even if Assemble's own packing/array-construction diverged /// that actually runs. This pin now drives that EXACT pair: a synthetic
/// from that raw output, which is exactly the gap a prior round's /// <see cref="WalkPortalView"/> holding one pixel-space polygon (built
/// three-lens review found (a hand-built <c>ClipViewSlice</c> is not /// the way <c>WalkCopyViewTests</c> builds views), fed through
/// proof the production path builds the same one). Same synthetic-view /// <c>BeginWalkFrame(frame, outdoorRoot: false)</c> then
/// helper as the exit-seal pin, a different (non-axis-aligned) synthetic /// <c>ReassembleOutsideViewFromWalk</c> — both of which now call the
/// polygon so the two pins are not testing the identical input. /// SAME shared <c>ClipFrameAssembler.AppendOutsideSlice</c> helper
/// <c>Assemble</c> would have (L4's own de-duplication), so this pin
/// exercises the helper through the producer that actually runs.
/// MUTATION: perturbing <c>planes[0].W</c> INSIDE that shared helper (the
/// one place both producers pack a plane) breaks this pin — see the
/// commit body for the recorded failing assertion text. The pin's own
/// source contains no <c>Assemble(</c> call (grep-checked in the commit
/// body). Same non-axis-aligned synthetic polygon as before, kept
/// distinct from the exit-seal pin's input.
/// </summary> /// </summary>
[Fact] [Fact]
public void ClipViewSlicePlanes_PunchFanPath_EqualsCpuViewPolygonEdgePlanes_ForASyntheticView() public void ClipViewSlicePlanes_PunchFanPath_EqualsCpuViewPolygonEdgePlanes_ForASyntheticView()
{ {
const float ViewportWidth = 640f, ViewportHeight = 480f;
Vector2[] verts = Vector2[] verts =
[ [
new(0f, 0.6f), new(-0.6f, -0.4f), new(0.5f, -0.5f), new(0.7f, 0.2f), new(0f, 0.6f), new(-0.6f, -0.4f), new(0.5f, -0.5f), new(0.7f, 0.2f),
]; ];
// The EXACT production assembly path: ClipFrameAssembler.Assemble // Pixel-space points (origin top-left, +Y down) that ReassembleOutsideViewFromWalk's
// packs the outside_view polygon into a slot and constructs the // own inverse transform (px = (ndc+1)*W/2, py = (1-ndc)*H/2) maps back to `verts`.
// ClipViewSlice DrawWalkPunchFan reads back through var pixelPoints = new WalkScreenPoint[verts.Length];
// clipAssembly.OutsideViewSlices[activeViewIndex].Planes. for (int i = 0; i < verts.Length; i++)
var pvFrame = new PortalVisibilityFrame(); {
pvFrame.OutsideView.Add(new ViewPolygon(verts)); float px = (verts[i].X + 1f) * ViewportWidth / 2f;
float py = (1f - verts[i].Y) * ViewportHeight / 2f;
pixelPoints[i] = new WalkScreenPoint(px, py, 0f, 1f);
}
var frame = ClipFrame.NoClip(); var walkView = new WalkPortalView();
ClipFrameAssembly assembly = ClipFrameAssembler.Assemble(frame, pvFrame); Assert.True(WalkCopyView.Append(
walkView, pixelPoints, new SyntheticRayCaster(), Vector3.Zero));
// The EXACT production pair: BeginWalkFrame seeds an interior root's
// empty outside-view assembly; ReassembleOutsideViewFromWalk (the
// walk's real producer) fills it from the walk's own pixel-space
// view — the same two calls RetailPViewPassExecutor.cs and
// RetailPViewRenderer.cs make.
ClipFrameAssembly assembly = ClipFrameAssembler.BeginWalkFrame(
ClipFrame.NoClip(), outdoorRoot: false);
ClipFrameAssembler.ReassembleOutsideViewFromWalk(
assembly, walkView, ViewportWidth, ViewportHeight);
ClipViewSlice slice = Assert.Single(assembly.OutsideViewSlices); ClipViewSlice slice = Assert.Single(assembly.OutsideViewSlices);
Assert.True(slice.Planes.Length >= 3); Assert.True(slice.Planes.Length >= 3);
@ -286,6 +302,16 @@ public class ClipFrameLayoutTests
AssertEveryEdgeMidpointLiesOnSomeGpuPlane(verts, slice.Planes); AssertEveryEdgeMidpointLiesOnSomeGpuPlane(verts, slice.Planes);
} }
/// <summary>Trivial ray caster: only <c>WalkPortalView</c>'s stored pixel
/// points feed <see cref="ClipFrameAssembler.ReassembleOutsideViewFromWalk"/>
/// (via NDC conversion); the per-vertex plane a ray caster would seed is
/// unused by that path, so any non-degenerate direction works.</summary>
private sealed class SyntheticRayCaster : IWalkRayCaster
{
public Vector3 RayThrough(float screenX, float screenY) =>
Vector3.Normalize(new Vector3(screenX, screenY, 1000f));
}
/// <summary> /// <summary>
/// CPU/GPU equivalence: a point on a convex polygon's edge must sit /// CPU/GPU equivalence: a point on a convex polygon's edge must sit
/// (a) non-negative under EVERY plane (still inside-or-on the region — /// (a) non-negative under EVERY plane (still inside-or-on the region —

View file

@ -133,12 +133,15 @@ public sealed class VulkanShaderDescriptorContractTests
|| module.StartsWith("terrain_atmospheric.", StringComparison.Ordinal); || module.StartsWith("terrain_atmospheric.", StringComparison.Ordinal);
/// <summary> /// <summary>
/// The regression itself, named. <c>TerrainClip</c> is the only uniform block /// The regression itself, named — corrected for S3 chunk 4 fix round 2 (L3).
/// <c>terrain_modern.vert</c> declares besides <c>SceneLighting</c>, so /// <c>terrain_modern.vert</c> used to also declare a screen-space clip block
/// asserting the module's uniform bindings as a set pins it exactly. /// at set 1 binding 2; that gate is deleted outright (no shader writer, no
/// GPU reader) and <c>SceneLighting</c> is now the ONLY uniform block the
/// module declares, so asserting the module's uniform bindings as a set
/// pins that directly.
/// </summary> /// </summary>
[Fact] [Fact]
public void TerrainVertexShaderDeclaresItsClipBlockInTheUniformSet() public void TerrainVertexShaderDeclaresOnlySceneLightingInTheUniformSet()
{ {
uint[] uniformBindings = uint[] uniformBindings =
[ [
@ -152,9 +155,7 @@ public sealed class VulkanShaderDescriptorContractTests
.Order(), .Order(),
]; ];
Assert.Equal( Assert.Equal([GpuBindingModel.UniformSceneLighting], uniformBindings);
[GpuBindingModel.UniformSceneLighting, VulkanPipelineLayouts.UniformTerrainClip],
uniformBindings);
} }
/// <summary> /// <summary>

View file

@ -62,9 +62,19 @@ public sealed class VulkanShaderManifestTests
// SkyFogRuleTests). A deliberate default-path change, reviewed // SkyFogRuleTests). A deliberate default-path change, reviewed
// with the world-fog-range fix in the same commit. // with the world-fog-range fix in the same commit.
["sky.frag.spv"] = "1c4ae77056837cbdc188f8cfcc4b0e8851647cdfaf398f25d8c8ff489ef84d57", ["sky.frag.spv"] = "1c4ae77056837cbdc188f8cfcc4b0e8851647cdfaf398f25d8c8ff489ef84d57",
["sky.vert.spv"] = "3b51945fa4ff1be1604144df92866bdd47aade22f9dd90267591ef36adb28cde", // sky.vert re-pinned 2026-09-03 (Campaign OVERHAUL S3 chunk 4 fix
// round 2, L3): the screen-space TerrainClip UBO (binding=2) and
// its gl_ClipDistance writes are deleted outright — retail draws
// the sky ONCE, unclipped (GameSky::Draw(sky,0) @0x0050633c), and
// that mechanism's only CPU-side writer was already retired at
// fix round 1's K3, leaving the shader-side declaration dead.
["sky.vert.spv"] = "7d67a9e3624d198b370d402b5c12e4ce925bf9b8e646ef5123636a86d5985ab5",
["terrain_modern.frag.spv"] = "7b3cdb01b837ed77ee20559a81c1ce5c9d5395300efcc072560ab0be3c5a1af9", ["terrain_modern.frag.spv"] = "7b3cdb01b837ed77ee20559a81c1ce5c9d5395300efcc072560ab0be3c5a1af9",
["terrain_modern.vert.spv"] = "9f4cb221ea6aed94a8d23af6cb8e3f3ed96c3cce6e50d135a72d3b55667b1557", // terrain_modern.vert re-pinned 2026-09-03 (Campaign OVERHAUL S3
// chunk 4 fix round 2, L3): same deletion as sky.vert above —
// retail never view-clips terrain (LScape::draw draws whole
// landblocks; RenderDeviceD3D::DrawBlock @0x005a17c0).
["terrain_modern.vert.spv"] = "8a73d89ef0e51e550327b9ff8c24857e309103b1d491030cf0d4d8594b45068c",
["ui_text.frag.spv"] = "37a281bf80441cb425eaa3ad8e0b3a43cfa21b74b60973ed4201718b9dc102df", ["ui_text.frag.spv"] = "37a281bf80441cb425eaa3ad8e0b3a43cfa21b74b60973ed4201718b9dc102df",
["ui_text.vert.spv"] = "018ac64477cf7d4c3fc0c5878951b148c7bfeb6ee3a7eebb02381d7904877798", ["ui_text.vert.spv"] = "018ac64477cf7d4c3fc0c5878951b148c7bfeb6ee3a7eebb02381d7904877798",
["vk_probe.frag.spv"] = "c2dedbcc6dcc89744707b4b47138f1c31b38ef9088e584f1da07dd6953586c42", ["vk_probe.frag.spv"] = "c2dedbcc6dcc89744707b4b47138f1c31b38ef9088e584f1da07dd6953586c42",

View file

@ -18,7 +18,7 @@ namespace AcDream.App.Tests.Rendering.Gpu.Vk;
/// ///
/// <para>The scissor is the trap. It does NOT flip with the viewport: /// <para>The scissor is the trap. It does NOT flip with the viewport:
/// <c>vkCmdSetScissor</c> is always top-left-origin regardless of viewport sign, /// <c>vkCmdSetScissor</c> is always top-left-origin regardless of viewport sign,
/// while <c>NdcScissorRect.ToPixels</c> emits GL bottom-left rectangles. The V3 /// while every caller passes a GL-convention bottom-left rectangle. The V3
/// audit flagged this explicitly as a V6 acceptance item, and getting it wrong /// audit flagged this explicitly as a V6 acceptance item, and getting it wrong
/// shows up as a doorway aperture clipped from the wrong edge — which only a /// shows up as a doorway aperture clipped from the wrong edge — which only a
/// scene containing one would reveal.</para> /// scene containing one would reveal.</para>

View file

@ -14,29 +14,44 @@ namespace AcDream.App.Tests.Rendering;
/// looking out from inside. Mechanism model (2026-06-12 evidence sweep): for /// looking out from inside. Mechanism model (2026-06-12 evidence sweep): for
/// an interior root the SEAL stamps the FULL raw dat portal polygon at true /// an interior root the SEAL stamps the FULL raw dat portal polygon at true
/// depth (PortalDepthMaskRenderer, root-cell slice = full screen), while /// depth (PortalDepthMaskRenderer, root-cell slice = full screen), while
/// terrain/sky COLOR is gated per fragment by the OutsideView region — the /// terrain/sky COLOR used to be gated per fragment by the OutsideView region —
/// same dat polygon run through ProjectToClip → ClipToRegion (1-px /// the same dat polygon run through ProjectToClip → ClipToRegion (1-px
/// MergeSubPixelVertices) → ClipPlaneSet.From (0.5° collinear merge) → planes, /// MergeSubPixelVertices) → ClipPlaneSet.From (0.5° collinear merge) → planes,
/// with a Floor/Ceil pixel scissor (BeginDoorwayScissor) on the slice AABB on /// with a Floor/Ceil pixel scissor (the sky's own doorway scissor bracket)
/// top. Every one of those passes can only SHRINK the gate, so any shave shows /// on the slice AABB on top. Every one of those passes could only SHRINK the gate, so any
/// as a strip of clear color between the gate's top edge and the aperture's /// shave showed as a strip of clear color between the gate's top edge and the
/// rasterized top edge (the shell wall starts above it; the seal z-kills /// aperture's rasterized top edge.
/// everything beyond; nothing re-covers).
/// ///
/// This harness measures that gap headlessly at the real Holtburg corner /// RETIRED MECHANISM (S3 chunk 4 fix round 2, L7): the scissor half of this
/// building exit door (A9B4 0x0170, the HouseExitWalkReplay door): project the /// story no longer applies. Retail draws the sky and the landscape unclipped
/// aperture, run the production flood + assembler, then walk sample points /// (<c>LScape::draw</c> never installs a view before either — S3 chunk 3/4's
/// just inside the aperture's top edge downward until the gate admits them. /// own findings), and acdream now matches that: the sky's own doorway
/// Plane-gap and scissor-gap are measured separately (mechanism attribution). /// scissor bracket, <c>IWorldPassSurface.BeginScissor/EndScissor</c>, and
/// every per-slice terrain/sky/weather clip they used to bracket are deleted
/// outright. An
/// interior root's aperture exactness comes instead from the depth clear, the
/// exit seals, and the interior repaint (<c>WalkFrameDriver</c>'s interior
/// turn draws the real cell geometry back over whatever the unclipped
/// landscape painted through the doorway) — see the S3 chunk 4 plan section
/// for the retail citations.
/// ///
/// VERDICT (2026-06-12, 147 eye/gaze combos): the CPU polygon pipeline is /// What remains below still pins something production reads: the outside-view
/// sub-pixel exact (worst 0.54 px) — the W=0 clip port 987313a and both merge /// polygon pipeline (<c>ProjectToClip</c> → <c>ClipToRegion</c> →
/// passes are EXONERATED. The strip was the scissor box: the old /// <c>ClipPlaneSet.From</c>) that the KEPT punch-fan clip still consumes
/// Floor(origin)+Ceiling(size) form cut up to 1 px off the TOP/RIGHT edges at /// (<c>RetailPViewPassExecutor.DrawWalkPunchFan</c> reads
/// unlucky fractional alignments (captured live by this harness: top edge /// <c>clipAssembly.OutsideViewSlices[activeViewIndex].Planes</c> — see
/// y=0.7938 at 1080p → row 968 cut; right edge x=0.3503 at 1920 → column 1296 /// <c>ClipFrameLayoutTests</c>'s punch-fan equivalence pin for the synthetic-
/// cut). Fixed by the conservative NdcScissorRect bound; the assertions below /// view version of this same proof). This harness measures the PLANE gap
/// pin both properties. /// headlessly at the real Holtburg corner building exit door (A9B4 0x0170,
/// the HouseExitWalkReplay door): project the aperture, run the production
/// flood + assembler, then walk sample points just inside the aperture's top
/// edge downward until the plane gate admits them.
///
/// VERDICT (2026-06-12, 147 eye/gaze combos, plane half only — the original
/// sweep also measured a scissor gap, since retired): the CPU polygon
/// pipeline is sub-pixel exact (worst 0.54 px) — the W=0 clip port 987313a
/// and both merge passes are EXONERATED. PIN 2 below still asserts that
/// bound.
/// </summary> /// </summary>
[Trait("Lane", "InstalledDat")] [Trait("Lane", "InstalledDat")]
public class Issue130DoorwayStripTests public class Issue130DoorwayStripTests
@ -108,7 +123,7 @@ public class Issue130DoorwayStripTests
_out.WriteLine(FormattableString.Invariant( _out.WriteLine(FormattableString.Invariant(
$" poly[{i}] world=({worldPoly[i].X:F3},{worldPoly[i].Y:F3},{worldPoly[i].Z:F3})")); $" poly[{i}] world=({worldPoly[i].X:F3},{worldPoly[i].Y:F3},{worldPoly[i].Z:F3})"));
float worstPlaneGapPx = 0f, worstScissorGapPx = 0f; float worstPlaneGapPx = 0f;
string worstDesc = "(none)"; string worstDesc = "(none)";
// Eye sweep: back off the doorway along the inward normal at several // Eye sweep: back off the doorway along the inward normal at several
@ -155,21 +170,20 @@ public class Issue130DoorwayStripTests
} }
evaluated++; evaluated++;
(float planeGapPx, float scissorGapPx, float atX) = (float planeGapPx, float atX) =
MeasureTopEdgeGap(aperture, asm.OutsideViewSlices, 1920, 1080); MeasureTopEdgeGap(aperture, asm.OutsideViewSlices, 1080);
if (planeGapPx > worstPlaneGapPx || scissorGapPx > worstScissorGapPx) if (planeGapPx > worstPlaneGapPx)
{ {
worstDesc = FormattableString.Invariant( worstDesc = FormattableString.Invariant(
$"d={d} h={h} lat={lat} gz={gz} minW={minW:F2} atX={atX:F3} slices={asm.OutsideViewSlices.Length} mode={asm.TerrainMode} outVerts={DescribePolys(pv.OutsideView)} apVerts={aperture.Length}"); $"d={d} h={h} lat={lat} gz={gz} minW={minW:F2} atX={atX:F3} slices={asm.OutsideViewSlices.Length} outVerts={DescribePolys(pv.OutsideView)} apVerts={aperture.Length}");
worstPlaneGapPx = MathF.Max(worstPlaneGapPx, planeGapPx); worstPlaneGapPx = planeGapPx;
worstScissorGapPx = MathF.Max(worstScissorGapPx, scissorGapPx);
} }
if (planeGapPx > 0.55f || scissorGapPx > 0.55f) if (planeGapPx > 0.55f)
{ {
_out.WriteLine(FormattableString.Invariant( _out.WriteLine(FormattableString.Invariant(
$"GAP d={d} h={h} lat={lat} gz={gz}: planeGap={planeGapPx:F2}px scissorGap={scissorGapPx:F2}px atX={atX:F3} mode={asm.TerrainMode} outVerts={DescribePolys(pv.OutsideView)}")); $"GAP d={d} h={h} lat={lat} gz={gz}: planeGap={planeGapPx:F2}px atX={atX:F3} outVerts={DescribePolys(pv.OutsideView)}"));
float apTop = TopBoundaryY(aperture, atX); float apTop = TopBoundaryY(aperture, atX);
foreach (var slice in asm.OutsideViewSlices) foreach (var slice in asm.OutsideViewSlices)
_out.WriteLine(FormattableString.Invariant( _out.WriteLine(FormattableString.Invariant(
@ -185,15 +199,10 @@ public class Issue130DoorwayStripTests
} }
_out.WriteLine(FormattableString.Invariant( _out.WriteLine(FormattableString.Invariant(
$"evaluated={evaluated} worstPlaneGapPx={worstPlaneGapPx:F2} worstScissorGapPx={worstScissorGapPx:F2} @ {worstDesc}")); $"evaluated={evaluated} worstPlaneGapPx={worstPlaneGapPx:F2} @ {worstDesc}"));
Assert.True(evaluated > 100, $"sweep degenerated: only {evaluated} eye/gaze combos evaluated"); Assert.True(evaluated > 100, $"sweep degenerated: only {evaluated} eye/gaze combos evaluated");
// PIN 1 (#130): the scissor box never cuts a fragment the plane gate // PIN (canary): the CPU polygon pipeline (ProjectToClip → ClipToRegion
// admits — conservative containment (AD-17's over-include doctrine).
// One probe step is ~0.11 px; anything beyond it is a real cut row.
Assert.True(worstScissorGapPx <= 0.15f, FormattableString.Invariant(
$"scissor under-covers the plane-admitted region by {worstScissorGapPx:F2}px @ {worstDesc}"));
// PIN 2 (canary): the CPU polygon pipeline (ProjectToClip → ClipToRegion
// merges → ClipPlaneSet planes) stays sub-pixel exact against the raw // merges → ClipPlaneSet planes) stays sub-pixel exact against the raw
// aperture projection. Observed 0.54 px worst (2026-06-12); the // aperture projection. Observed 0.54 px worst (2026-06-12); the
// production vertex-merge floor is ~1 px — beyond 1.2 px means a new // production vertex-merge floor is ~1 px — beyond 1.2 px means a new
@ -211,12 +220,14 @@ public class Issue130DoorwayStripTests
/// <summary> /// <summary>
/// For sample x positions across the aperture's projected top edge, find the /// For sample x positions across the aperture's projected top edge, find the
/// aperture boundary's top y, then walk downward until the gate admits the /// aperture boundary's top y, then walk downward until the plane gate
/// point. Returns the worst gaps in 1080p pixels (plane gate and modeled /// admits the point. Returns the worst gap in 1080p pixels, and the x of
/// scissor gate measured independently), and the x of the worst plane gap. /// the worst gap. S3 chunk 4 fix round 2 (L7): the scissor-gap half this
/// used to measure alongside the plane gap is deleted — the scissor
/// mechanism it modeled no longer exists in production.
/// </summary> /// </summary>
private static (float planeGapPx, float scissorGapPx, float atX) MeasureTopEdgeGap( private static (float planeGapPx, float atX) MeasureTopEdgeGap(
Vector2[] aperture, ClipViewSlice[] slices, int fbW, int fbH, Vector2[] aperture, ClipViewSlice[] slices, int fbH,
ITestOutputHelper? debug = null) ITestOutputHelper? debug = null)
{ {
const float Inset = 1e-4f; // dodge exact-boundary ambiguity const float Inset = 1e-4f; // dodge exact-boundary ambiguity
@ -226,9 +237,9 @@ public class Issue130DoorwayStripTests
float minX = float.MaxValue, maxX = float.MinValue; float minX = float.MaxValue, maxX = float.MinValue;
foreach (var v in aperture) { minX = MathF.Min(minX, v.X); maxX = MathF.Max(maxX, v.X); } foreach (var v in aperture) { minX = MathF.Min(minX, v.X); maxX = MathF.Max(maxX, v.X); }
float span = maxX - minX; float span = maxX - minX;
if (span <= 0.01f) return (0, 0, 0); if (span <= 0.01f) return (0, 0);
float worstPlane = 0, worstScissor = 0, atX = 0; float worstPlane = 0, atX = 0;
const int Samples = 160; const int Samples = 160;
for (int s = 0; s <= Samples; s++) for (int s = 0; s <= Samples; s++)
{ {
@ -240,22 +251,15 @@ public class Issue130DoorwayStripTests
var p = new Vector2(x, topY - Inset); var p = new Vector2(x, topY - Inset);
float planeGap = GapBelow(p, q => AnySliceAdmitsPlanes(slices, q), StepY, CapY); float planeGap = GapBelow(p, q => AnySliceAdmitsPlanes(slices, q), StepY, CapY);
// The scissor question is "does the box cut pixels the PLANES would
// draw" — measure it from the planes-admitted top, not the aperture
// top (at slanted corners the aperture top can sit legitimately
// outside the gate polygon's column).
var pPlanes = new Vector2(p.X, p.Y - planeGap - Inset);
float scissorGap = GapBelow(pPlanes, q => AnySliceAdmitsScissor(slices, q, fbW, fbH), StepY, CapY);
if (debug is not null && scissorGap > 0.005f) if (debug is not null && planeGap > 0.005f)
debug.WriteLine(FormattableString.Invariant( debug.WriteLine(FormattableString.Invariant(
$" sample x={x:F4} apTop={topY:F4} planeGap={planeGap * fbH / 2f:F2}px pPlanes=({pPlanes.X:F4},{pPlanes.Y:F4}) scissorGap={scissorGap * fbH / 2f:F2}px")); $" sample x={x:F4} apTop={topY:F4} planeGap={planeGap * fbH / 2f:F2}px"));
if (planeGap > worstPlane) { worstPlane = planeGap; atX = x; } if (planeGap > worstPlane) { worstPlane = planeGap; atX = x; }
worstScissor = MathF.Max(worstScissor, scissorGap);
} }
// NDC y → pixels at the given framebuffer height. // NDC y → pixels at the given framebuffer height.
return (worstPlane * fbH / 2f, worstScissor * fbH / 2f, atX); return (worstPlane * fbH / 2f, atX);
} }
private static float GapBelow(Vector2 start, Func<Vector2, bool> admitted, float step, float cap) private static float GapBelow(Vector2 start, Func<Vector2, bool> admitted, float step, float cap)
@ -272,7 +276,7 @@ public class Issue130DoorwayStripTests
// Production semantics: each OutsideView polygon is one slice; the union of // Production semantics: each OutsideView polygon is one slice; the union of
// slices is drawn. A slice with planes gates per fragment via // slices is drawn. A slice with planes gates per fragment via
// gl_ClipDistance (dot((nx,ny,0,d),(x,y,z,1)) >= 0 for an NDC point); // gl_ClipDistance (dot((nx,ny,0,d),(x,y,z,1)) >= 0 for an NDC point);
// a planeless slice (scissor fallback) admits its whole NDC AABB. // a planeless slice (>8-edge zero-plane fallback, ClipFrameAssembler.cs) admits its whole NDC AABB.
private static bool AnySliceAdmitsPlanes(ClipViewSlice[] slices, Vector2 p) private static bool AnySliceAdmitsPlanes(ClipViewSlice[] slices, Vector2 p)
{ {
foreach (var slice in slices) foreach (var slice in slices)
@ -294,22 +298,6 @@ public class Issue130DoorwayStripTests
return false; return false;
} }
// Production scissor (BeginDoorwayScissor → NdcScissorRect.ToPixels): a
// point is admitted when its pixel falls inside some slice's scissor box.
private static bool AnySliceAdmitsScissor(ClipViewSlice[] slices, Vector2 p, int fbW, int fbH)
{
int pixX = (int)MathF.Floor((p.X * 0.5f + 0.5f) * fbW);
int pixY = (int)MathF.Floor((p.Y * 0.5f + 0.5f) * fbH);
foreach (var slice in slices)
{
var box = NdcScissorRect.ToPixels(slice.NdcAabb, fbW, fbH);
if (pixX >= box.X && pixX < box.X + box.Width
&& pixY >= box.Y && pixY < box.Y + box.Height)
return true;
}
return false;
}
/// <summary>Highest boundary y of the polygon at vertical line x (NaN when /// <summary>Highest boundary y of the polygon at vertical line x (NaN when
/// the line misses the polygon).</summary> /// the line misses the polygon).</summary>
private static float TopBoundaryY(Vector2[] poly, float x) private static float TopBoundaryY(Vector2[] poly, float x)

View file

@ -1,80 +0,0 @@
using System;
using System.Numerics;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #130: the doorway-slice scissor must be a CONSERVATIVE outer bound of its
/// NDC AABB (AD-17: over-inclusion safe, under-inclusion is the bug class).
/// The old Floor(origin)+Ceiling(size) form put the far edge at
/// floor(min)+ceil(maxmin), up to one pixel short of the true max edge —
/// the doorway top-edge background strip.
/// </summary>
public class NdcScissorRectTests
{
/// <summary>Containment property: every pixel whose CENTER lies inside the
/// NDC box is inside the scissor box, across a dense grid of fractional
/// alignments at two framebuffer sizes.</summary>
[Theory]
[InlineData(1920, 1080)]
[InlineData(2560, 1440)]
public void EveryCenterInsidePixel_IsInsideTheBox(int fbW, int fbH)
{
for (int i = 0; i < 251; i++)
{
// Sweep fractional alignments of all four edges.
float f = i / 251f;
float minX = -0.83f + f * 0.0031f;
float minY = -0.71f + f * 0.0047f;
float maxX = 0.339f + f * 0.0043f;
float maxY = 0.7938f + f * 0.0029f;
var box = NdcScissorRect.ToPixels(new Vector4(minX, minY, maxX, maxY), fbW, fbH);
// Pixel-space extremes of center-inside pixels.
float x0 = (minX * 0.5f + 0.5f) * fbW, x1 = (maxX * 0.5f + 0.5f) * fbW;
float y0 = (minY * 0.5f + 0.5f) * fbH, y1 = (maxY * 0.5f + 0.5f) * fbH;
int loX = (int)MathF.Ceiling(x0 - 0.5f), hiX = (int)MathF.Floor(x1 - 0.5f);
int loY = (int)MathF.Ceiling(y0 - 0.5f), hiY = (int)MathF.Floor(y1 - 0.5f);
Assert.True(box.X <= loX, $"left cut: box.X={box.X} > loX={loX} (minX={minX})");
Assert.True(box.Y <= loY, $"bottom cut: box.Y={box.Y} > loY={loY} (minY={minY})");
Assert.True(box.X + box.Width > hiX, $"right cut: box ends {box.X + box.Width} <= hiX={hiX} (maxX={maxX})");
Assert.True(box.Y + box.Height > hiY, $"top cut: box ends {box.Y + box.Height} <= hiY={hiY} (maxY={maxY})");
// Over-inclusion stays bounded (≤1 px per edge).
Assert.True(box.X >= loX - 1 && box.Y >= loY - 1);
Assert.True(box.X + box.Width <= hiX + 2 && box.Y + box.Height <= hiY + 2);
}
}
[Fact]
public void CapturedRegression_TopEdgeRow968_At1080p()
{
// Issue130DoorwayStripTests live capture: aperture top y=0.7938 →
// pixel row 968 (center 968.5 < 968.65). The old formula ended the box
// at row 967 — the visible strip.
var box = NdcScissorRect.ToPixels(new Vector4(-0.339f, -0.743f, 0.339f, 0.7938f), 1920, 1080);
Assert.True(box.Y + box.Height > 968, $"top row 968 cut: box ends at {box.Y + box.Height}");
}
[Fact]
public void CapturedRegression_RightColumn1296_At1920()
{
// Issue130DoorwayStripTests live capture: gate right edge x=0.3507 →
// pixel column 1296 admitted by the plane gate; the old formula ended
// the box at column 1295.
var box = NdcScissorRect.ToPixels(new Vector4(-0.2845f, -1.0f, 0.3507f, 0.2630f), 1920, 1080);
Assert.True(box.X + box.Width > 1296, $"right column 1296 cut: box ends at {box.X + box.Width}");
}
[Fact]
public void DegenerateAndOffscreenBoxes_StayValid()
{
// Past-the-edge regions clamp to the screen and keep min 1 px size.
var box = NdcScissorRect.ToPixels(new Vector4(0.999f, 0.999f, 1.5f, 1.5f), 1920, 1080);
Assert.True(box.Width >= 1 && box.Height >= 1);
var inverted = NdcScissorRect.ToPixels(new Vector4(1f, 1f, -1f, -1f), 1920, 1080);
Assert.True(inverted.Width >= 1 && inverted.Height >= 1);
}
}

View file

@ -1,4 +1,5 @@
using System.Reflection; using System.Reflection;
using System.Reflection.Emit;
using AcDream.App.Composition; using AcDream.App.Composition;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Rendering.Sky; using AcDream.App.Rendering.Sky;
@ -120,9 +121,10 @@ public sealed class RetailPViewPassExecutorTests
/// <c>WalkFrameDriverTranscriptTests</c>: <c>Collect_OutdoorRoot_...</c> /// <c>WalkFrameDriverTranscriptTests</c>: <c>Collect_OutdoorRoot_...</c>
/// and <c>Collect_InteriorRoot_...</c>). This method now draws the /// and <c>Collect_InteriorRoot_...</c>). This method now draws the
/// weather MESH and the rain PARTICLES only — the former per-outside- /// weather MESH and the rain PARTICLES only — the former per-outside-
/// view-slice loop that used to run before this call (<c>SetTerrainClip</c> /// view-slice loop that used to run before this call (the walk's own
/// + <c>ClearClipRouting</c> + the old <c>DrawLandscapeSliceLate</c> leaf) /// screen-space terrain-clip writer + <c>ClearClipRouting</c> + the old
/// is deleted outright (§10.2): retail draws the weather mesh and its /// <c>DrawLandscapeSliceLate</c> leaf) is deleted outright (§10.2): retail
/// draws the weather mesh and its
/// rain particles ONCE, unclipped, never once per doorway aperture. /// rain particles ONCE, unclipped, never once per doorway aperture.
/// MUTATION: re-inlining a /// MUTATION: re-inlining a
/// <c>WalkTranscriptDump.PrintObjectCellTurn</c> call back into this /// <c>WalkTranscriptDump.PrintObjectCellTurn</c> call back into this
@ -153,9 +155,9 @@ public sealed class RetailPViewPassExecutorTests
/// <summary> /// <summary>
/// S3 chunk 4 (§10.2): the former per-outside-view-slice loop /// S3 chunk 4 (§10.2): the former per-outside-view-slice loop
/// (<c>SetTerrainClip</c> + <c>ClearClipRouting</c> + the old /// (the walk's own screen-space terrain-clip writer +
/// <c>DrawLandscapeSliceLate</c> leaf, one call per active landscape /// <c>ClearClipRouting</c> + the old <c>DrawLandscapeSliceLate</c> leaf,
/// view) is deleted — <c>DrawLandscapeDynamicsPhase</c> now calls /// one call per active landscape view) is deleted — <c>DrawLandscapeDynamicsPhase</c> now calls
/// <see cref="RetailPViewPassExecutor.DrawWeatherOnce"/> exactly once, /// <see cref="RetailPViewPassExecutor.DrawWeatherOnce"/> exactly once,
/// unconditionally, with no loop of any kind around it. This /// unconditionally, with no loop of any kind around it. This
/// <c>Assert.Single</c> alone proved insufficient at fix round 1 (K1): /// <c>Assert.Single</c> alone proved insufficient at fix round 1 (K1):
@ -222,6 +224,103 @@ public sealed class RetailPViewPassExecutorTests
&& callOffset < branch.Offset); && callOffset < branch.Offset);
} }
/// <summary>
/// S3 chunk 4 fix round 2 (L1, BLOCKING). Round 1's three-lens review
/// found that neither existing pin above actually looks at the
/// production CONDITION gating <c>DrawWeatherOnce</c>: restoring the
/// pre-fix gate <c>if (clipAssembly.OutsideViewSlices.Length != 0)</c> —
/// the exact regression K2 was supposed to close — leaves both green,
/// because both only ask "is the call site shaped correctly", never
/// "does the call site read <c>WalkFrameDriver.WeatherTurnFired</c>".
/// This pin reads the compiled condition directly: with <c>c</c> the
/// index of the <c>DrawWeatherOnce</c> call, (a) <c>calls[c-1]</c> must
/// be the <c>WeatherTurnFired</c> getter — the LAST call before the draw
/// — and (b) exactly one branch must sit strictly between that getter
/// call and the draw call, be a <c>brfalse</c>/<c>brfalse.s</c>, and
/// jump FORWARD past the draw call — the compiled shape of
/// <c>if (walkDriver.WeatherTurnFired) passes.DrawWeatherOnce(ctx);</c>
/// and nothing else (an inverted test, an unconditional call, or a
/// different condition entirely all fail one of the two checks).
/// MUTATION M1 (restores the pre-fix regression): change the gate back
/// to <c>if (clipAssembly.OutsideViewSlices.Length != 0)</c> — check (a)
/// fails because <c>calls[c-1]</c> is no longer the
/// <c>WeatherTurnFired</c> getter. MUTATION M2 (drops the gate
/// entirely): make the call unconditional — check (b) fails because no
/// branch sits between the getter call and the draw call (in fact the
/// getter call itself disappears with the gate, so check (a) fails
/// first). MUTATION M3 (inverts the condition): change the gate to
/// <c>if (!walkDriver.WeatherTurnFired)</c> — <c>calls[c-1]</c> is still
/// the getter (check (a) passes), but the compiler emits a
/// <c>brtrue</c>/<c>brtrue.s</c> to skip the draw instead of a
/// <c>brfalse</c>/<c>brfalse.s</c>, so check (b)'s opcode filter finds
/// nothing and <c>Assert.Single</c> fails on zero matches.
/// </summary>
[Fact]
public void DrawLandscapeDynamicsPhase_GatesDrawWeatherOnceOnWalkDriverWeatherTurnFired()
{
MethodInfo method = typeof(RetailPViewRenderer).GetMethod(
"DrawLandscapeDynamicsPhase",
BindingFlags.Instance | BindingFlags.NonPublic)!;
IReadOnlyList<CompiledCall> calls = CompiledCallGraph.Read(method);
int callIndex = RequiredCallIndex(
calls,
typeof(RetailPViewPassExecutor),
nameof(RetailPViewPassExecutor.DrawWeatherOnce));
Assert.True(callIndex > 0, "Expected a call before DrawWeatherOnce — the gate condition.");
CompiledCall condition = calls[callIndex - 1];
Assert.Equal(typeof(AcDream.App.Rendering.Walk.WalkFrameDriver), condition.Target.DeclaringType);
Assert.Equal("get_WeatherTurnFired", condition.Target.Name);
int conditionOffset = condition.Offset;
int drawOffset = calls[callIndex].Offset;
IReadOnlyList<CompiledBranch> branches = CompiledCallGraph.ReadBranches(method);
Assert.Single(
branches,
branch => branch.Offset > conditionOffset
&& branch.Offset < drawOffset
&& (branch.OpCode == OpCodes.Brfalse || branch.OpCode == OpCodes.Brfalse_S)
&& branch.TargetOffset > drawOffset);
}
/// <summary>
/// S3 chunk 4 fix round 2 (L8): the identical loop-shape question K1
/// asked of <see cref="RetailPViewPassExecutor.DrawWeatherOnce"/>'s call
/// site, applied to <see cref="RetailPViewPassExecutor.DrawWalkSky"/>'s
/// own <see cref="SkyRenderer.RenderSky"/> call — retail draws the sky
/// dome exactly once per frame too (K4's own doc comment on
/// <c>DrawWalkSky</c>), so nothing may wrap this call in a loop either.
/// Note for reviewers: <see cref="CompiledCallGraph.ReadBranches"/> reads
/// only <c>br</c>/<c>brtrue</c>/<c>brfalse</c>-family single-target
/// branches — it does not decode a compiled <c>switch</c> jump table,
/// but no C# loop construct (<c>for</c>/<c>foreach</c>/<c>while</c>/
/// <c>do</c>) ever compiles to one, so this pin's blind spot is not a
/// loop shape it could miss. MUTATION: wrapping the call in
/// <c>for (int i = 0; i &lt; 2; i++) { _sky?.RenderSky(...); }</c> makes
/// this fail; restoring the single unconditional call makes it pass
/// again.
/// </summary>
[Fact]
public void DrawWalkSky_RenderSkyCallSiteHasNoEnclosingBackwardBranch()
{
MethodInfo method = typeof(RetailPViewPassExecutor).GetMethod(
"DrawWalkSky",
BindingFlags.Instance | BindingFlags.NonPublic)!;
IReadOnlyList<CompiledCall> calls = CompiledCallGraph.Read(method);
int callIndex = RequiredCallIndex(
calls,
typeof(SkyRenderer),
nameof(SkyRenderer.RenderSky));
int callOffset = calls[callIndex].Offset;
IReadOnlyList<CompiledBranch> branches = CompiledCallGraph.ReadBranches(method);
Assert.DoesNotContain(
branches,
branch => branch.TargetOffset < branch.Offset
&& branch.TargetOffset <= callOffset
&& callOffset < branch.Offset);
}
/// <summary> /// <summary>
/// S3 chunk 1 fix round 2 (§11.6 H1): <see /// S3 chunk 1 fix round 2 (§11.6 H1): <see
/// cref="RetailPViewPassExecutor.ShouldDrawWeatherOnce"/> is /// cref="RetailPViewPassExecutor.ShouldDrawWeatherOnce"/> is

View file

@ -723,13 +723,22 @@ public sealed partial class WalkFrameDriverTests
/// separately at Replay — on an interior root whose landscape turn ran /// separately at Replay — on an interior root whose landscape turn ran
/// with the gate open but whose reassembled outside-view slices ended /// with the gate open but whose reassembled outside-view slices ended
/// up empty, the transcript could report a weather turn the frame never /// up empty, the transcript could report a weather turn the frame never
/// actually drew. <see cref="WalkFrameDriver.WeatherTurnFired"/> is exactly the flag /// actually drew. <see cref="WalkFrameDriver.WeatherTurnFired"/> is
/// <c>RetailPViewRenderer.DrawLandscapeDynamicsPhase</c> now gates /// exactly the flag <c>RetailPViewRenderer.DrawLandscapeDynamicsPhase</c>
/// <c>DrawWeatherOnce</c> on, so this test's flag assertion doubles as /// gates <c>DrawWeatherOnce</c> on: gate closed -&gt; the flag stays
/// the draw-side pin: gate closed -&gt; the flag stays false (so /// false; gate open -&gt; the flag becomes true AND exactly one "OC"
/// <c>DrawWeatherOnce</c> would not fire either) AND no "OC" line /// line prints. CORRECTION (S3 chunk 4 fix round 2, L1): this test's
/// prints; gate open -&gt; the flag becomes true (so /// flag assertion does NOT double as the draw-side pin — it builds a
/// <c>DrawWeatherOnce</c> would fire) AND exactly one "OC" line prints. /// bare <see cref="WalkFrameDriver"/> and never touches
/// <c>DrawLandscapeDynamicsPhase</c>, so it cannot see whether that
/// method actually reads this flag (round 1 shipped with the pre-fix
/// gate <c>clipAssembly.OutsideViewSlices.Length != 0</c> silently
/// restored, and every test in this file stayed green). The real
/// draw-side pin is
/// <c>RetailPViewPassExecutorTests.DrawLandscapeDynamicsPhase_GatesDrawWeatherOnceOnWalkDriverWeatherTurnFired</c>,
/// which reads the compiled call graph of
/// <c>DrawLandscapeDynamicsPhase</c> itself and asserts it calls
/// <c>DrawWeatherOnce</c> gated on THIS getter.
/// </summary> /// </summary>
[Theory] [Theory]
[InlineData(false)] [InlineData(false)]

View file

@ -61,7 +61,6 @@ public class WalkOutsideViewReassemblyTests
ClipFrameAssembler.ReassembleOutsideViewFromWalk(asm, walkView, W, H); ClipFrameAssembler.ReassembleOutsideViewFromWalk(asm, walkView, W, H);
ClipViewSlice slice = Assert.Single(asm.OutsideViewSlices); ClipViewSlice slice = Assert.Single(asm.OutsideViewSlices);
Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
Assert.True(asm.OutdoorVisible); Assert.True(asm.OutdoorVisible);
Assert.Equal(4, asm.OutsidePlaneCount); Assert.Equal(4, asm.OutsidePlaneCount);
Assert.Equal(slice.Slot, asm.OutdoorSlot); Assert.Equal(slice.Slot, asm.OutdoorSlot);
@ -101,7 +100,6 @@ public class WalkOutsideViewReassemblyTests
ClipViewSlice slice = Assert.Single(asm.OutsideViewSlices); ClipViewSlice slice = Assert.Single(asm.OutsideViewSlices);
Assert.Equal(new Vector4(-1f, -1f, 1f, 1f), slice.NdcAabb); Assert.Equal(new Vector4(-1f, -1f, 1f, 1f), slice.NdcAabb);
Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
} }
[Fact] [Fact]
@ -115,7 +113,6 @@ public class WalkOutsideViewReassemblyTests
Assert.Empty(asm.OutsideViewSlices); Assert.Empty(asm.OutsideViewSlices);
Assert.False(asm.OutdoorVisible); Assert.False(asm.OutdoorVisible);
Assert.False(asm.HasOutsideView); Assert.False(asm.HasOutsideView);
Assert.Equal(TerrainClipMode.Skip, asm.TerrainMode);
Assert.Equal(0, asm.OutsidePlaneCount); Assert.Equal(0, asm.OutsidePlaneCount);
Assert.Equal(0, asm.OutdoorSlot); Assert.Equal(0, asm.OutdoorSlot);
} }