From 252886e84fa8a79445b486179ed68e1169519870 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 4 Sep 2026 10:36:12 +0200 Subject: [PATCH] fix(rendering): repair EnvCell retail CLIP state Exclude deferred EnvCell subsets from the opaque turn, preserve exact CLIP/ALPHA fixed-state groups through leaf replay, and use retail's row-3 override, blend, depth, and texture-class alpha references. Keep the existing building-detail sentinel distinct from the two CLIP references. Bound rejected source payload, replace no-op allocation proofs with actual EnvCell and particle RHI paths, rebuild checked-in SPIR-V, and correct AP-238/AP-240 plus the S4-c2 evidence record. Gates: Release 0W/0E; hermetic 16735/0/0; InstalledDat 255 pass/10 known fail/1 skip; shaders 32/32; focused 239/239; allocation 2/2 at 0 B. The evidence/comment repair re-ran Release 0W/0E, shaders 32/32, affected 37/37, and allocation 2/2; mesh_detail.vert.spv remained byte-identical at SHA-256 5346247ab7d606046943e19b28888c814e08dc6cb27cd9750096ac055457eb57. Mutation proof, with each mutation restored after its named first failure: 1. Restoring the opaque predicate to !IsAdditive fails WholeLeaf_MixedCellDrawsOpaqueAtTurnThenClipAndAlphaAtDrain first at draw count: expected 1, actual 3. 2. Selecting _alphaPipeline for CLIP fails WholeLeaf_ClipDrainBindsExactStateAndTextureClassReference first at the bind sequence: expected [envcell-clip], actual [envcell-alpha]. 3. Disabling CLIP depth write fails that production Theory first at Assert.True(clipPipeline.Depth.Write): expected true, actual false. 4. Swapping palette/DDS references fails the DDS row first: expected 0.784313738, actual 0.392156869; the palette row reports the inverse. 5. Mutating mesh_modern.frag from < to <= fails ClipShaders_UseGreaterEqualForThePerRangeReference first at Assert.Contains("if (color.a < alphaCutoff) discard;"): the required source spelling is absent. 6. Restoring row-3 OverrideClipmap=true fails the real-interface clip Theory first at Assert.False: expected false, actual true. 7. Deleting failed-append rollback fails the flush/end/abort rejection-storm rows first at the bounded pending-count assertion: expected 0, actual 9000. 8. Selecting _transparentDetailPipeline for a ClipMap detail contribution fails the leaf detail pin first at the bind sequence: expected second bind envcell-retail-detail-clip, actual envcell-retail-detail-alpha. 9. Resetting detail ParamB to zero fails the same detail pin first at the second pushed reference: expected 0.784313738, actual 0. 10. Classifying CLIP with exact mask equality excludes legal 0x09 and fails WholeLeaf_PositiveStippleClipMaskUsesClipPipelineAndDdsReference first at pipeline: expected envcell-clip, actual envcell-alpha. 11. Mapping the new blend to SRC_ALPHA/INVSRCALPHA fails AllRetailBlendModesAreRepresentable first at the tuple: expected (One, OneMinusSrcAlpha), actual (SrcAlpha, OneMinusSrcAlpha). 12. Restoring mesh_detail.vert's uParamB > 0.5 category predicate fails ClipShaders_UseGreaterEqualForThePerRangeReference first because vDetailCategory = uParamB == 1.0 is absent. 13. Treating every positive detail uParamB as a cutoff fails that source pin first because isRetailClipReference(uParamB) ? uParamB : 0.05 is absent. 14. Adding arbitrary 0.5 as an accepted reference to either mesh_modern.frag or mesh_detail.frag fails that source pin first at Assert.DoesNotContain("value - 0.5"); both mutations were run and reversed independently. Retail: D3DPolyRender::SetSurface @ 0x0059c4d0; paired binary @ 0x0059c72a, 0x0059c747/0x0059c74f, 0x0059c821, 0x0059c838, 0x0059c866. --- .../retail-divergence-register.md | 4 +- src/AcDream.App/Rendering/Gpu/GpuEnums.cs | 8 + .../Rendering/Gpu/Vk/VulkanViewportMapping.cs | 1 + .../Rendering/ParticleRenderer.Rhi.cs | 10 +- .../Rendering/RetailPViewPassExecutor.cs | 84 ++- .../Rendering/Shaders/mesh_detail.frag | 12 +- .../Rendering/Shaders/mesh_detail.vert | 13 +- .../Rendering/Shaders/mesh_modern.frag | 16 +- .../Shaders/spv/mesh_detail.frag.spv | Bin 3448 -> 3812 bytes .../Shaders/spv/mesh_detail.vert.spv | Bin 3476 -> 3460 bytes .../Shaders/spv/mesh_modern.frag.spv | Bin 3784 -> 4172 bytes .../Shaders/spv/shaders.manifest.json | 6 +- .../Rendering/Wb/EnvCellRenderer.Rhi.cs | 72 ++- .../Rendering/Wb/EnvCellRenderer.cs | 85 ++- .../Rendering/EnvCellAlphaDrawSourceTests.cs | 489 ++++++++++++++---- .../Rendering/Gpu/GpuContractTests.cs | 13 +- .../Rendering/Gpu/RecordingGpuDevice.cs | 91 +++- .../Gpu/Vk/VulkanShaderManifestTests.cs | 8 +- .../Gpu/Vk/VulkanViewportMappingTests.cs | 5 +- .../Rendering/ParticleRendererRouteTests.cs | 165 +++--- .../Rendering/RetailAlphaMeshRouterTests.cs | 7 +- 21 files changed, 812 insertions(+), 277 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 448b0518..4cb3a2f4 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -330,9 +330,9 @@ research and is no longer active. | ~~AP-28~~ | **RETIRED 2026-08-08 (Campaign A slice A2).** The three picked AL parameters and the gain-driven eviction are both gone. `RetailSoundMixer` now carries the byte-decoded retail curve — `g = dist < 5 ? vol : 25·vol/dist²`, clamped to 1, ONE master multiply, `db = ceil(20·log10 g)`, and a hard −50 dB no-allocate floor (audible radius ≈94.2 m at unity) — with pan as retail's `−15·sin(Δbearing)` in whole decibels and a 5-metre integer deadzone. Every AL source is source-relative with `AL_ROLLOFF_FACTOR = 0` and the global distance model is `None`, so AL contributes no attenuation of its own; the old `InverseDistanceClamped` ref-2 m curve was inverse FIRST power (`2/d`), quieter than retail up close and far louder at range with no cutoff at all. Voice eviction now compares the DAT-authored float priority strictly-less in ring order per `SoundManager::PlaySoundInternal` @ `0x0054FEC0` (the row's old `FUN_00550ad0` citation was wrong — that address is inside an `IntrusiveHashTable` constructor). The residual pan-LAW approximation is AP-173; retail's own `s_bPlaySoundOnlyWhenActive` gate is TS-64. | retired | — | — | `SoundManager::GetAttenuation @ 0x00550020`; `SoundManager::PlaySoundInternal @ 0x00550170` and `@ 0x0054FEC0`; `docs/research/2026-08-08-audio-retail-soundmanager-core.md` | | AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter | | ~~AP-34~~ | **RETIRED 2026-09-04 (S4-c2 fix round 1) — residuals AP-238 / AP-239 / AP-240.** Historical: a single acdream-only distance-sorted alpha queue stood in for retail's two independent per-`DrawMesh`-invocation FIFO lists (CLIP/ALPHA, capacity 3000 each) drained at retail's four normal-world `FlushAlphaList` sites (`DrawBuilding`/`DrawBlock`/`PView::DrawCells`/`RenderNormalMode`). S4-c2 (`048d5b12f`) replaced it with `RetailAlphaQueue`'s exact two-list FIFO port of `D3DPolyRender::AddMeshToAlphaList`'s append-only/capacity-drop behavior, routed by `RetailAlphaMeshRouter`'s port of `DrawMesh`'s five-row branch table, under retail's exact no-op threshold rule — no acdream-only sort remains anywhere on the alpha path. | retired | — | — | `D3DPolyRender::AddMeshToAlphaList` 0x0059C230; `D3DPolyRender::FlushAlphaList` 0x0059D2E0; `D3DPolyRender::DrawMesh` 0x0059D4A0 | -| AP-238 | **S4-c2 (2026-09-04); NARROWED FURTHER at fix rounds 1 and 2 — renumbered from the colliding id "AP-236" (that id was already filed AND retired on `main`; ids are never reused).** `RetailAlphaQueue` is retail's own independent CLIP/ALPHA FIFO pair. Fix round 2 now retains each real EnvCell `ObjectRenderBatch`'s `TextureBatchData.RetailSurfaceMask` and routes every transparent subset independently through `RetailAlphaMeshRouter`: canonical `0x08` Base1ClipMap subsets enter CLIP, `0x02` alpha-family subsets enter ALPHA, table-Immediate subsets draw at the cell turn, and detail-active eligible subsets remain immediate with detail. A mixed cell can therefore contribute to both lists. The remaining residual is only token granularity: acdream coalesces to at most ONE token per `(cell,list)` and replays a list-filtered subset view, where retail appends one entry per surface subset. | `src/AcDream.App/Rendering/RetailPViewPassExecutor.cs` (`SubmitOrDrawTransparentCellShell`, `EnvCellAlphaDrawSource`); `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` / `.Rhi.cs` (`GetTransparentRoutes`, `RouteTransparentBatch`, filtered `RenderTransparentOrdered`); `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs` (`ObjectRenderBatch.RetailSurfaceMask`); `src/AcDream.App/Rendering/RetailAlphaQueue.cs` | Per-list replay preserves the exact subset class and the queue's adjacent-only batching preserves interleaving around other sources; the approximation exists only within one cell/list when several same-list subsets are represented by one token. | At a genuine depth-overlap, collapsing multiple same-list subsets to one `(cell,list)` token can visibly change compositing if other translucent content should fall between those subsets in retail's FIFO. | `RenderDeviceD3D::DrawEnvCell` 0x0059F170; `D3DPolyRender::DrawMesh` 0x0059D4A0; `D3DPolyRender::AddMeshToAlphaList` 0x0059C230; canonical installed-DAT case `F4180104` surface `08000BFF` mask `0x08` | +| AP-238 | **S4-c2 (2026-09-04); NARROWED FURTHER by fix rounds 1/2 and the owner-authorized repair — renumbered from the colliding id "AP-236" (that id was already filed AND retired on `main`; ids are never reused).** `RetailAlphaQueue` is retail's independent CLIP/ALPHA FIFO pair. The repaired production leaf excludes every non-additive transparent EnvCell subset from its preceding opaque draw, retains each real batch's `RetailSurfaceMask`, and routes canonical `0x08` plus legal positive-stipple `0x09` to CLIP with row-3 `overrideClipmap=false`; `0x02` routes to ALPHA. EnvCell CLIP now uses `SetSurface`'s exact `ONE/INVSRCALPHA`, depth-test/write-on state and `GREATER_EQUAL` reference (100/255 paletted, 200/255 DDS); detail-active row 1 keeps that CLIP base/reference before its detail contribution. A mixed cell can contribute to both lists without cross-feeding. The only remaining residual is token granularity: acdream coalesces to at most ONE token per `(cell,list)` and replays a list-filtered subset view, where retail appends one entry per surface subset. | `src/AcDream.App/Rendering/RetailPViewPassExecutor.cs` (`SubmitOrDrawTransparentCellShell`, `EnvCellAlphaDrawSource`); `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` / `.Rhi.cs` (`BatchBelongsToPass`, retained-mask state grouping, filtered `RenderTransparentOrdered`); `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs` (`ObjectRenderBatch.RetailSurfaceMask`); `src/AcDream.App/Rendering/RetailAlphaQueue.cs` | Per-list replay preserves the exact subset class/state and the queue's adjacent-only batching preserves interleaving around other sources; the approximation exists only within one cell/list when several same-list subsets are represented by one token. | At a genuine depth-overlap, collapsing multiple same-list subsets to one `(cell,list)` token can visibly change compositing if other translucent content should fall between those subsets in retail's FIFO. | `RenderDeviceD3D::DrawEnvCell` 0x0059F170; `D3DPolyRender::DrawMesh` 0x0059D4A0; `D3DPolyRender::AddMeshToAlphaList` 0x0059C230; `D3DPolyRender::SetSurface` 0x0059C4D0 (paired binary: blend ids 2/6 @0x0059C72A, refs `0x64`/`0xC8` at globals 0x00820D8C/0x00820D90, func 7, depth-write byte 1 @0x0059C866); canonical installed-DAT case `F4180104` surface `08000BFF` mask `0x08` | | AP-239 | **Filed 2026-09-04 (S4-c2); renumbered from the colliding id "AP-237" at fix round 1.** `RetailAlphaMeshRouter.MaskFromTranslucencyKind` reconstructs retail's `ConstructMesh` routing mask from acdream's already-collapsed `TranslucencyKind`. A raw `Surface.Type` with Translucent(0x10) and Base1ClipMap(0x04), but no Alpha/InvAlpha/Additive bit, collapses to `AlphaBlend` and reconstructs as mask `0x02` (ALPHA), while retail retains mask `0x08` (CLIP). `GroupKey` does not retain the raw ClipMap bit required to distinguish the two origins. The original cloud example was disproven; a 2026-09-04 scan found 27 real `Type=0x14` surfaces among 6,152, including surface `0x08000015` referenced by GfxObj `0x010001EC`. | `src/AcDream.App/Rendering/RetailAlphaMeshRouter.cs` (`MaskFromTranslucencyKind`); `src/AcDream.Core/Meshing/TranslucencyKind.cs` (`FromSurfaceType`) | The affected surface keeps the same blend state, but choosing the wrong FIFO changes compositing order; at a genuine overlap that order difference can be visibly different. | A translucent+clipmap surface (e.g. GfxObj `0x010001EC`) interleaves with ALPHA-list content instead of CLIP-list content and can composite differently at a depth overlap. | `D3DPolyRender::ConstructMesh` 0x0059DFA0 (mask priority); `D3DPolyRender::SetSurface` 0x0059C4D0 | -| AP-240 | **Filed 2026-09-04 (S4-c2 fix round 1, M2); narrowed at fix round 2.** `WbDrawDispatcher.IsOpaque(t) => t == Opaque \|\| t == ClipMap` keeps clip-mapped GfxObj/scenery/building subsets out of `_translucentDraws`/`SubmitWalkAlphaInstance`/`SubmitToAlphaQueue`; retail gives those subsets mask `0x08` and appends them to CLIP. They instead draw immediately on acdream's `OpaqueAlphaToCoverage` pipeline. Fix round 2 proves CLIP has two other production feeder families: clip-mapped mesh particles and exact-mask EnvCell subsets (canonical `F4180104` / surface `08000BFF`). The residual is therefore only ordinary GfxObj/scenery/building content, not an empty or particle-only CLIP list. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IsOpaque`); `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` (`RouteTransparentBatch`); `src/AcDream.App/Rendering/ParticleRenderer.cs`; `src/AcDream.App/Rendering/RetailAlphaMeshRouter.cs` | The cutout itself remains present, but immediate opaque-path placement differs from retail's deferred FIFO order. Changing it is a VisualMaster-era pipeline decision beyond this bounded chunk. | At an overlap with deferred CLIP content from particles or EnvCells, clip-mapped GfxObj/scenery/building edges can visibly composite in the wrong relative order. | `D3DPolyRender::ConstructMesh` 0x0059DFA0 (mask priority, row 3 → CLIP); `D3DPolyRender::DrawMesh` 0x0059D4A0; `D3DPolyRender::AddMeshToAlphaList` 0x0059C230 | +| AP-240 | **Filed 2026-09-04 (S4-c2 fix round 1, M2); narrowed at fix round 2 and the owner-authorized repair.** `WbDrawDispatcher.IsOpaque(t) => t == Opaque \|\| t == ClipMap` keeps clip-mapped GfxObj/scenery/building subsets out of `_translucentDraws`/`SubmitWalkAlphaInstance`/`SubmitToAlphaQueue`; retail gives those subsets mask `0x08` and appends them to CLIP. They instead draw immediately on acdream's `OpaqueAlphaToCoverage` pipeline. CLIP has two genuine production feeder families outside this residual: clip-mapped mesh particles and exact-mask EnvCell subsets (canonical `F4180104` / surface `08000BFF`), with EnvCell now proven through the real leaf and exact `SetSurface` CLIP state. The residual is only ordinary GfxObj/scenery/building content; no ordinary-Wb A2C behavior changed in this repair. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IsOpaque`); `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` (`RouteTransparentBatch`); `src/AcDream.App/Rendering/ParticleRenderer.cs`; `src/AcDream.App/Rendering/RetailAlphaMeshRouter.cs` | The cutout itself remains present, but immediate opaque-path placement differs from retail's deferred FIFO order. Changing it is a VisualMaster-era pipeline decision beyond this bounded chunk. | At an overlap with deferred CLIP content from particles or EnvCells, clip-mapped GfxObj/scenery/building edges can visibly composite in the wrong relative order. | `D3DPolyRender::ConstructMesh` 0x0059DFA0 (mask priority, row 3 → CLIP); `D3DPolyRender::DrawMesh` 0x0059D4A0; `D3DPolyRender::AddMeshToAlphaList` 0x0059C230 | | AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp). **#215 cell identity:** the pre-collapse/recenter decision compares the player's current `Position.objcell_id` landblock with the received destination `objcell_id`; it never reconstructs the source from XYZ because dungeon frame origins may be negative. **#145/#138 teleport-hold suppression:** during a teleport arrival HOLD the player is unplaced, so `CurrCell` is the frozen SOURCE cell, not the destination; the gate is suppressed for the hold (`DungeonStreamingGate.Compute(isTeleportHold:true)` → not-inside-dungeon) so a teleport OUT of a dungeon follows the destination (the PortalSpace observer pin) and `ExitDungeonExpand`s, instead of re-pinning streaming onto the source dungeon (which left the outdoor destination un-hydrated → 600-frame readiness timeout → force-snap to ocean — the #145 "second teleport does nothing" + #138 incomplete-world) | `src/AcDream.App/Streaming/TeleportLandblockTransition.cs` (source/destination cell-ID classification) + `src/AcDream.App/Streaming/DungeonStreamingGate.cs` (`Compute` — per-frame predicate + teleport-hold suppression) + `src/AcDream.App/World/LiveEntityHydrationPorts.cs` (`LiveEntityWorldOriginCoordinator.TryInitialize` — login pre-collapse) + `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition` — first accepted canonical Position) + `GameWindow:AimTeleportDestination`/`IsSealedDungeonCell` (teleport pre-collapse and DAT predicate) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly. The cell-ID comparison matches retail's complete `Position` flow. | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277; retail `SmartBox::TeleportPlayer` 0x00453910 | | AP-43 | Per-object torch (point/spot) lighting AND sun are both gated on the OBJECT's own cell via the same `IndoorObjectReceivesTorches(ParentCellId)` predicate (`(id & 0xFFFF) >= 0x0100`): indoor objects (EnvCell-parented) get torches + NO sun; outdoor objects get the SUN + ambient + NO torches. This is the faithful per-draw port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and `PView::DrawCells` (0x005a4840) calls `useSunlightSet(1)` (0x005a485a) for the outdoor stage and `useSunlightSet(0)` (0x005a49f3) for the interior-cell stage. **#142 (2026-06-20):** the sun gate is now PER-INSTANCE in the shader (binding=6 `instanceIndoor[]` flag in `mesh_modern.vert`, filled by `AppendCurrentLightSet`) — it was previously a per-FRAME global keyed on the PLAYER cell (`UpdateSunFromSky`). The per-frame global is retained for sealed dungeons (correctly kills the sun frame-wide when no sky is visible). **Residual:** the `ebp_2` second seen-outside test in `CellManager::ChangePosition` (0x004559B0) is unaudited — unclear whether it changes the ambient/sun regime for a subset of cells. No observed behavioral impact in tested cells. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches`, `ComputeEntityLightSet`, `AppendCurrentLightSet`, `_instIndoorSsbo`/`_indoorData`/`InstanceGroup.IndoorFlags`); `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (binding=6 `instanceIndoor[]` gate on sun loop); per-frame sun `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameEnvironmentPreparation.UpdateSunFromSky`) | Torches: outdoor objects never torch-lit (exact retail). Sun: indoor objects (furniture, NPCs, player in a windowed building) never sun-lit (exact retail per-stage). Ambient: per-player-cell regime unchanged (exact retail `ChangePosition`). | The `ebp_2` unaudited test in `ChangePosition` could affect a narrow class of cells (entrance cells? sub-cells with special flags?) — no symptom observed; audit it if a lighting edge case arises in an unusual cell type | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; per-stage `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3); `minimize_object_lighting` 0x0054d480; `CellManager::ChangePosition` 0x004559B0 (ambient + seen_outside) | | AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 | diff --git a/src/AcDream.App/Rendering/Gpu/GpuEnums.cs b/src/AcDream.App/Rendering/Gpu/GpuEnums.cs index 444daa9e..e8508e29 100644 --- a/src/AcDream.App/Rendering/Gpu/GpuEnums.cs +++ b/src/AcDream.App/Rendering/Gpu/GpuEnums.cs @@ -121,6 +121,14 @@ internal enum GpuBlendMode /// Straight alpha: SrcAlpha, OneMinusSrcAlpha. StraightAlpha, + /// + /// Premultiplied/source-over: One, OneMinusSrcAlpha. Retail pure + /// Base1ClipMap surfaces select exactly this pair in + /// D3DPolyRender::SetSurface @0x0059c72a when the per-entry + /// override-clipmap byte is clear. + /// + PremultipliedAlpha, + /// Additive: SrcAlpha, One. Additive, diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs index 5beb3de2..ca130289 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs @@ -174,6 +174,7 @@ internal static class VulkanViewportMapping internal static (BlendFactor Source, BlendFactor Destination) BlendFactorsOf(GpuBlendMode blend) => blend switch { GpuBlendMode.StraightAlpha => (BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha), + GpuBlendMode.PremultipliedAlpha => (BlendFactor.One, BlendFactor.OneMinusSrcAlpha), GpuBlendMode.Additive => (BlendFactor.SrcAlpha, BlendFactor.One), // Retail's third mode, found at slice V4c in WbDrawDispatcher.ApplyRetailBlend. GpuBlendMode.InverseAlpha => (BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha), diff --git a/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs b/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs index 13b65b6f..e8206adc 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs @@ -131,7 +131,7 @@ public sealed unsafe partial class ParticleRenderer /// /// The RHI arm's constructor. No GL context, no Shader, no - /// BindlessSupport: the five pipelines compile particle and + /// BindlessSupport: the six pipelines compile particle and /// particle_mesh from the committed SPIR-V, and both texture sources /// already hand out the device's own GpuTextureSlot (V4t). /// @@ -267,9 +267,11 @@ public sealed unsafe partial class ParticleRenderer }); /// - /// One mesh-particle pipeline. Same depth bracket as the billboards (see - /// for the world - /// GL_LESS citation); the winding is CW because + /// One mesh-particle pipeline. Every variant uses retail's world + /// GL_LESS compare; delayed alpha variants match the billboard + /// no-write bracket, while row 5's opaque variant depth-writes. See + /// for the compare + /// citation. The winding is CW because /// PrepareMeshPipeline sets glFrontFace(GL_CW), and the cull /// mode stays DYNAMIC because it is resolved per sub-batch from the DAT's /// own CullMode. diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs index ff9f18b7..7a5d1159 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs @@ -263,13 +263,31 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin if ((routes & EnvCellTransparentRoute.Clip) != 0) { int token = clipSource.AddPendingCellId(cellId); - queue.TryAppend(RetailAlphaList.Clip, clipSource, token, overrideClipmap: true); + // This fixed source is ordinary DrawMesh row 3: pure ClipMap, + // multipass false, therefore CLIP with overrideClipmap=false. + // If the queue drops at 3,000, retain no source-owned payload; + // TryAppend has already registered the source for exact reset. + if (!queue.TryAppend( + RetailAlphaList.Clip, + clipSource, + token, + overrideClipmap: false)) + { + clipSource.RollbackPendingCellId(token); + } } if ((routes & EnvCellTransparentRoute.Alpha) != 0) { int token = alphaSource.AddPendingCellId(cellId); - queue.TryAppend(RetailAlphaList.Alpha, alphaSource, token, overrideClipmap: false); + if (!queue.TryAppend( + RetailAlphaList.Alpha, + alphaSource, + token, + overrideClipmap: false)) + { + alphaSource.RollbackPendingCellId(token); + } } } @@ -297,13 +315,27 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin if ((routes & EnvCellTransparentRoute.Clip) != 0) { int token = clipSource.AddPendingCellId(cellId); - queue.TryAppend(RetailAlphaList.Clip, clipSource, token, overrideClipmap: true); + if (!queue.TryAppend( + RetailAlphaList.Clip, + clipSource, + token, + overrideClipmap: false)) + { + clipSource.RollbackPendingCellId(token); + } } if ((routes & EnvCellTransparentRoute.Alpha) != 0) { int token = alphaSource.AddPendingCellId(cellId); - queue.TryAppend(RetailAlphaList.Alpha, alphaSource, token, overrideClipmap: false); + if (!queue.TryAppend( + RetailAlphaList.Alpha, + alphaSource, + token, + overrideClipmap: false)) + { + alphaSource.RollbackPendingCellId(token); + } } } @@ -327,15 +359,26 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin EnvCellTransparentRoute route, bool detailSurfaceActive); - internal sealed class EnvCellAlphaDrawSource( - RenderEnvCellsByRoute renderTransparentOrdered, - EnvCellTransparentRoute route) - : IRetailAlphaDrawSource + internal sealed class EnvCellAlphaDrawSource : IRetailAlphaDrawSource { + private readonly RenderEnvCellsByRoute _renderTransparentOrdered; + private readonly EnvCellTransparentRoute _route; + private readonly Action? _resetObserver; private readonly List _pendingCellIds = new(); private readonly List _preparedCellIds = new(); private readonly List _drawScratch = new(); + internal EnvCellAlphaDrawSource( + RenderEnvCellsByRoute renderTransparentOrdered, + EnvCellTransparentRoute route, + Action? resetObserver = null) + { + _renderTransparentOrdered = renderTransparentOrdered + ?? throw new ArgumentNullException(nameof(renderTransparentOrdered)); + _route = route; + _resetObserver = resetObserver; + } + /// Returns this cell's token — its index into /// at the moment it was added. internal int AddPendingCellId(uint cellId) @@ -345,6 +388,22 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin return token; } + /// + /// Rolls back only the just-reserved tail token after queue rejection. + /// Accepted indices therefore never move, while a rejection storm can + /// neither grow the pending count nor force a second geometric growth + /// beyond the 3,000-entry list's warmed capacity. + /// + internal void RollbackPendingCellId(int token) + { + if (token != _pendingCellIds.Count - 1) + { + throw new InvalidOperationException( + "Only the just-reserved EnvCell alpha token can be rolled back."); + } + _pendingCellIds.RemoveAt(token); + } + public void PrepareAlphaDraws(ReadOnlySpan tokens) { _preparedCellIds.Clear(); @@ -359,7 +418,10 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin _drawScratch.Clear(); for (int i = 0; i < drawCount; i++) _drawScratch.Add(_preparedCellIds[firstPreparedDraw + i]); - renderTransparentOrdered(_drawScratch, route, detailSurfaceActive: false); + _renderTransparentOrdered( + _drawScratch, + _route, + detailSurfaceActive: false); } public void ResetAlphaSubmissions() @@ -367,9 +429,13 @@ internal sealed partial class RetailPViewPassExecutor : IEnvCellImmediateDrawSin _pendingCellIds.Clear(); _preparedCellIds.Clear(); _drawScratch.Clear(); + _resetObserver?.Invoke(); } internal int PendingCount => _pendingCellIds.Count; + internal int PendingCapacity => _pendingCellIds.Capacity; + internal int PreparedCapacity => _preparedCellIds.Capacity; + internal int DrawCapacity => _drawScratch.Capacity; } /// diff --git a/src/AcDream.App/Rendering/Shaders/mesh_detail.frag b/src/AcDream.App/Rendering/Shaders/mesh_detail.frag index 380c0c22..8d6f09de 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_detail.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh_detail.frag @@ -11,6 +11,10 @@ in flat uint vBatchFlags; in flat uint vDetailCategory; uniform uint uTextureIndexA; // category detail texture, layer 0 +// S4-c2 F3: 100/255 or 200/255 for a pure ClipMap base/detail pair. Exact 1 +// remains mesh_detail.vert's existing building-category sentinel and must use +// the ordinary empty-fragment cutoff. Zero is the ordinary EnvCell category. +uniform float uParamB; // SceneLighting UBO — IDENTICAL layout to mesh_modern.frag binding=1 (same // std140 block, same struct, same binding). Declared here ONLY for fog @@ -50,6 +54,11 @@ vec3 applyFog(vec3 lit, vec3 worldPos) { out vec4 FragColor; +bool isRetailClipReference(float value) { + return abs(value - (100.0 / 255.0)) < 0.000001 + || abs(value - (200.0 / 255.0)) < 0.000001; +} + // VM2 (2026-08-22, live cdb read on the PDB-paired retail client, GUID // 9e847e2f-777c-4bd9-886c-22256bb87f32): retail's RenderDevice reports // m_caps.bCanDoSinglePassDetailing = 1 and the file-static trysinglepass = 1, @@ -100,7 +109,8 @@ void main() { vec4 base = ACDREAM_SAMPLE_ARRAY( vBaseTextureIndex, vec3(vBaseUv, float(vBaseTextureLayer))); - if (base.a < 0.05) + float alphaCutoff = isRetailClipReference(uParamB) ? uParamB : 0.05; + if (base.a < alphaCutoff) discard; vec4 detail = ACDREAM_SAMPLE_ARRAY( diff --git a/src/AcDream.App/Rendering/Shaders/mesh_detail.vert b/src/AcDream.App/Rendering/Shaders/mesh_detail.vert index 6c56e6df..318c6587 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_detail.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_detail.vert @@ -38,9 +38,9 @@ layout(std430, binding = 3) readonly buffer ClipSlotBuf { uint instanceClipSlot[]; }; -// Object renderer only: 1 for a retail building shell, 0 for ordinary -// scenery/creatures/players. EnvCellRenderer sets uParamB=0 and ignores the -// value, but still binds one valid word because Vulkan sees this static use. +// Exact uParamB=1 selects this per-instance building/object category. EnvCell +// zero and its pure-ClipMap 100/255 or 200/255 references retain the bound +// environment category; the fragment shader consumes those CLIP references. layout(std430, binding = 9) readonly buffer InstanceDetailCategoryBuf { uint instanceDetailCategory[]; }; @@ -61,7 +61,10 @@ uniform mat4 uViewProjection; uniform int uDrawIDOffset; uniform uint uTextureIndexB; // absolute transform prefix in the shared pose arena uniform float uParamA; // detail UV tiling -uniform float uParamB; // 1 = require building instance, 0 = EnvCell category +// Exact 1 remains the existing "require building instance" sentinel. S4-c2 +// also passes 100/255 or 200/255 for an EnvCell ClipMap alpha reference; both +// must retain the EnvCell category rather than taking the building branch. +uniform float uParamB; out vec2 vBaseUv; out vec2 vDetailUv; @@ -95,7 +98,7 @@ void main() { vBaseTextureIndex = batch.textureIndex; vBaseTextureLayer = batch.textureLayer; vBatchFlags = batch.flags; - vDetailCategory = uParamB > 0.5 + vDetailCategory = uParamB == 1.0 ? instanceDetailCategory[instanceIndex] : 1u; } diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag index 447ce2e3..5e5d97e4 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag @@ -22,6 +22,10 @@ in flat vec2 vSelectionLighting; // x=luminosity, y=diffuse // transparent overdraw cost otherwise) uniform int uRenderPass; uniform int uLightDebug; // #176 stripe hunt (see mesh_modern.vert) — mode 3 handled here +// S4-c2 F3: only retail's exact pure-ClipMap alpha-test references are +// recognized, supplied per MDI range from TextureKey.PaletteId (100/255 +// paletted, 200/255 DDS/non-paletted). Every other value keeps 0.05. +uniform float uParamB; // SceneLighting UBO — IDENTICAL layout to mesh_instanced.frag binding=1. struct Light { @@ -57,15 +61,23 @@ vec3 applyFog(vec3 lit, vec3 worldPos) { out vec4 FragColor; +bool isRetailClipReference(float value) { + return abs(value - (100.0 / 255.0)) < 0.000001 + || abs(value - (200.0 / 255.0)) < 0.000001; +} + void main() { vec4 color = ACDREAM_SAMPLE_ARRAY(vTextureIndex, vec3(vTexCoord, float(vTextureLayer))); + float alphaCutoff = isRetailClipReference(uParamB) ? uParamB : 0.05; // Two-pass alpha-test (N.5 Decision 2). // A.5 T20: opaque pass writes alpha as-sampled so GL_SAMPLE_ALPHA_TO_COVERAGE // derives the MSAA sample mask from it — ClipMap foliage edges become smooth. // Discard only fully-transparent (α < 0.05); the GPU handles coverage masking. if (uRenderPass == 0) { - if (color.a < 0.05) discard; // opaque pass — kill truly empty only (A2C) + // '<' spells D3DCMP_GREATEREQUAL: equality passes. Retail loads + // 100 or 200 and func 7 at SetSurface 0x0059c73c..0x0059c838. + if (color.a < alphaCutoff) discard; } else { // Transparent pass. // @@ -84,7 +96,7 @@ void main() { // Keep the α<0.05 short-circuit as a fragment-cost optimization // (skip fully-empty pixels — saves blend bandwidth on alpha-keyed // sprites with large transparent margins). - if (color.a < 0.05) discard; + if (color.a < alphaCutoff) discard; } // Per-vertex Gouraud lighting from the vertex shader (ambient + capped diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.frag.spv index caf4e76d995042afe0417c4bafb8de18be87d4dc..b8fbf8e97f1e9160a01e67fbbdf22fc0a65c39c4 100644 GIT binary patch literal 3812 zcmZ9PS!kVA5XZlpdz-W-wrQJ~YTI~Yp$LW42OA~Qv=t#~NwK9uM0}_qXw@#-f_qI( z8tZ}^)ki^8?C#by;)2Gl74*psw|5s5QEL~e`1^hL%=NSsmK~F%3pmAsdnuJ=3=lv#VAe)lSfqNFzSRFH;SSu8M z4GF6jFsswgV*80a7v2ah&-HRP5AHo>JSVDs1q5DnOe5Pf$BU_WY=l^^zmD~ZY z-+bgd;p*nP3+@p!wz|I$p2+2IfHC#IPXovM?#umfb30G%BXE7iy{|r+u!uL7^f}lc zMfM$P`_`O)^J31hNX|ksmt?NT;nN_$e`BlrcS0=Q;Ynn7Qvcn_=KYxaGsvHQGPpGS zUqtuY*MAS(Smuph4k6p0_QP=B*!s1r``wCZKLXd+Z%@1WM8dScn`GppP`~G!66P-N!AI=ogr6Y7Mr6-vY~r^-V#ZaDxSNsPucO2@ z&lX6`Zz6Ktns9TS;(o-DAE#q;-v(KeHS2E<>(~bOScg7aAu;PvuC$l(n1RWowtJD? z$8~82+mXdqkj32`hTLCq@3^Nspy8yumph@+q}$&E&=|A@f8SzP!qyT~{Xxh(p1>YL z7V~}A;N=@X4C$-Q7(?85Hy(kkLA!PChCc8JB?3IMkMtyHy zMb=l_N+|Z`HDq&Wzmi`uckB&FT=E#l-wA8b{uVR=IinVhoWGHK;6FglN4|nL=z{+U zbwd7qtwP3e&whfmZ9;E@|D3S+{(ni>Lb$npgL`}rZNEZd=2Lb*tVdnkxh{tP4w=6j zGIt034@f+E{xf0X9^0~Kch;VdL-ws-?Cf92`dtobkA6=eN59S_`aOxPE&NUu{CW$1 zr;)YA_i_eV%=;}=|Nx2ectl1Jv;;`iz-0P_Ty+j%yF>Fb-gZ^qHL_=div-?;uWk@fSP zz3cnNH}DR!yXvu2$w?t0?r*p-~a#s literal 3448 zcmZ9N{cG1%6vyxOotn68K1?@P~y*}T2)};&Yopavj+;h%7_kO;swXR-}=C`Cp z>CE(NYOW=zHMO8B-(B96$Fe+;Wvw%@XMPnoJUTu)uzSzefeS7?-wcaWtx9B$v&hnh z6x(l`oYK(+#=-Sq0&D|2z%H;CJPl^REI0x>$mjkAU?j~;OOdYCO)h85S^G+h);jY#1DfD~i z^tS5G+~YFN`-^<)`sQU{18Ln6DyX~kIlG1i%QMB@H6&Q&+#A?2_x0$p*SyAEUT`t@ zk%IGV#*G%7J;;p}ob!{ry5Q_h?z)0IBXc()J^y}W%SG&PG^USo;a&LeK!#(%uW?V$Rd(eyhemnSC*5d-Z=tOZLBrtO5HmpY}}V-2W1~ zv3@K1wP!P@|COxUpMGt>L-!2Qg){S5AM%>atwp{?jNG-E z|D6o}rnvsXC%*;AnO8mX-hl4=IYnOU+z8}+!%@rMB6(~5$^OWr{EZV^`(|KI_H4X0 z?BiCX%RY>`1<2WldRu2pl`}YH^mZ4z?`=HKU=m$!166!`{tuY|^6v2+`oBb8+jqAU z_~s(#edxZqo%p?r-9YY2a%%4Z?r{ZoKf0WEHAa+o`~WakpE%nB9JCxtN{*OD2GP z%Mn5B6f1~>rj{)bS+`zr`Am0OBpc||L=J8&S1ASB2{&u{dxp?CzGFL}h>qDf= z`_T6Rkh7k;-+}#T%RAS8vONqv+;! n1@}6-oaYGcjm)(o$LPlQ^Cmb7jMs1eOXy$*sqY^$Tm=3Fw0{gb diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.vert.spv index 50650461e14c3ecfb663d6b224c72ec8e8a0f4b4..5ae4f2379167f739f297d2f00189901e29d5e412 100644 GIT binary patch delta 546 zcmYk3Jxjw-6oyaIv?aEg;vm$D#lb-xbP`0NgS3Cc(ZRvNK~P(5OVv%Hqe%U#b(6aK zv&0V&eBN*a4LqE$_ndp~&2^<)iE{oz(F&fGEV9?DDDir4gIut3W@ZHlpkX=dV0~zk zk*{wCnwGat;`wp4=Q0fx@@o)68jrnvDcGK7#4)KV19d^VD539NI_p$U(rNIHgDETNaw%$2T_dkInScWg;r&xWztfyKVF5#Oy$*=P( ywB|YJE%dCjkga-wui6iK%~GHGCu8!f_G!iRxo#<{sSRr&vGzX1s`h7|t;0V2VKf=Mm!9h@inwF}Q#967Iwc;S% z{9ob+2tIGQfnInxU++2R-Xy7BS1UPxzGwx{N>;Jgt4iv1-g~)V>CDUu5J1y%mSBBo zk&&-64K2%Cl6rnr>$pr4h5R~%Fp7s>z7%XPQko=n1f0z&MjqqTSfGUw%(`WY=g_yY zWzf1czdJc6#9#r9Fr5QpC`v@}#&prs(C{R%>GNkJoMFqC3UD5ORVCN;(jOuWGbP^ySKlnUOM z5L}5%h#D3bTmWehC2mxMF>>LBxM1)fa3O3l@p<0wO!J15{xUPanKN_FoO9kUt*V?} zo7GfgW3o%KYbvsQjLRxBRptJsvOJLFp(LxCGv?Ifaf^GG_ja!Bzq|9=xpPb~Hml0> zn4=+U%&L*%{<~JL($S_4R)ck5J=g?xg1uk}90w;rBe|Sg3wp9q*(9W6dRdHc){<)k z5m%YGQh{@2#<|eNWS)Yo0o}P>m9-+BXAX5ov6fuaGBDzzR%3ySTBQQ#Uh?{I*0&cO zDQDQz3buXH@6fNUpXu0Dc@M`XpZzqSHS|4z+{byTuPx~PBYJh-f9JR-<2;MVr><{Q z@@+&~w}%Sq4t@3@Y|kmqAh=@xh9lp_TIax?YgwRr`ir}@2s|aF_-r@a;fWcT@#2frh^!B zsZT`HR{|~MvZl8FCL$V;&F~y&9%B!UYe5=kU)s=oA96)FZ!%m7IIc|grReUz729{< zT)8%?d(Y*HdDKh9PDQ$AV{S-x?AzR~u|35^trE6-aehbgx$cOciS7uy6Wu)-KO1R1 z>qowM=#H@GqgN!`yj@`R_<{NQwU+=#*h_Qk>4n;^_h#Vw!rzDf?eK6{*mqzbzV4a1 zVXw&jJqssib=gXEYgpfLv7AEYd*F?miheIr+r8a~bjaCDZQr;<|0<;UJU{)~zH>SK z4SPSkoTrKS*o?@*kkjH@pk4 z+`mBH-kwJ~&f}9mo$KZPsz=Q;=&oZ0h?-~7qo)3-c@ABlHP3NQ*6_TPr@3ja;R0|C zo}uy9+MV`tHA(ebZ!54j+mO!#d*k=Y-ZUX!0P=g%eqT)7Or-mJ8R>9;`d$KW0N0}) z=e8Z)^YHG)K3+wacRl8M9o=CreXjvIbE#K1SLFK*PU-$#gE7{PdOOfBuJcbV}JVH-!1&Yxc47`_e(g8 za}R^*EPg~c#vE~mKcOE7_V@}XFyfobze)Lil(C;$^w>{{*x1i#czvrlAKz#lx?G&+ z7<9RKqhrw>o~OQgAZP#7?WgO}miO+=CcY8f`f+zWn{jaR(TDNqa>Ajo4}RC|(*y|j zZCrfUn$V5AG5KS^JnxbH&che`od~Zl;#vxE#WynvUSE7ur=ZI@-!rWtXDd2?*(}fn zjEVSmbk}D57S?Zk2fDoT>N$J4Y3Tg%dtLS~L3&4{ZxUmS*ND$E=k=j&j5+Ptp)w@5!NghNtWOccJU}*VJ_xcTE-DjS4x;vY)Ql7xQCuGf8 z4N|OGwR&7fE4UO~4o1Nk&@lnFfgRugm;}w_a&J8t%xbflNY{djnC7e{*9;=AI&tL! zXJ^LQ=m44LAnQOMaNk^{tC(Ld@-vF*xX9mJ;39vyz}bIZ6K;U>n9se|cNQlD^M~D8 zu$`0sh}YKdGM6)K%JrID??AVnzAKThb5H6k3;Na5dQCom_f+ba^1Fz9WAOTF!5~?0 zM!G6??X+E-Rm6B-aTjvtH?9vjXF1O#cYc8@C2p|5#r%c}oISXAxWIXjau*di`;c>P zQTMpSIUi>ey{J#X#rf4ezd5{{ZOOJrZQrSWm$q+KyK+~h{QX9*$>g)gSwtAqNj-C^ z&qdQ$2J^^eO>O;iu+38j&QX60Qopn5K=ZqhE5d!V;L5<&m293w<_u26_Wiq8u7j$+ zO}Szo^)j*Zkd5 zA902=&|P8opjRc^{Jmi9%+0;}wU+`{*!{V+aj~|2UjXd0Et`?QANQEgb*yYB@?v=3 z*-7Y^Ahqpv1=1zwjI@1wF8!Aw&F5_NYx@n$>Awo;GN1Q6g0vUUuYVQN*b(4FE#9VJj|LDV^MbX;? zw!Im@CFzm>p=A5kjo0=Yi}`P8E7;!q-RkTXA`>1I{G#OIdiE;9l2s1>p3gm&wXG5_$Hi{cXB_FkJ)ccoV;hHb6kzS0Am+XsUB4^1r_p0iaL**pZ*G{ZzQt#O`;FJ%56-18 ze5AWxkI_AkGS2e}dYorIdYtD|bbaI8 zPZRPpAQ$)fdE&}QYki4yd7t{e0CLt-cb@j6E$`duLVg9TA8*II`5MT_9KK1MyesCg zInCi)IL~cdBlqNY@f|SkJfJ_$`#pM`xA-0X0Ix6Nek{ZlznP!l^;z3GKLa`Ud#7^0 z0BfHLdVw(!|0}w^8NW5<_zhj&eU03`IerJ`a7Ox=EZBJqUL&meg E|BACfwEzGB diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json index bb310ded..b4118fd9 100644 --- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json +++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json @@ -231,12 +231,12 @@ "stages": [ { "stage": "vert", - "sourceSha256": "0c1eddb0fc892cb76ef4da47f727cec2516052d63247fa2682918bfd60b84fa8", + "sourceSha256": "2d8b5b42f053a2d42e1ff44db3e331d5bea2d3f7dd36e20d813b7ae22c033181", "compiled": true }, { "stage": "frag", - "sourceSha256": "e037fd28cf71453792c17522c97c4c7797193823ed78ca629a4331b632f177b1", + "sourceSha256": "a5285c51e8d6a1fa91f403f8489819595fa5a20abaaaccc969f220ea2c4eb335", "compiled": true } ] @@ -252,7 +252,7 @@ }, { "stage": "frag", - "sourceSha256": "12f2ceec9a8420bd273650e95251ca56c3bf3c3691fce1a539e4c5aea7abaabf", + "sourceSha256": "d6ae09de279e814b159e7c481d6b66f3cc37c741b3d8a249fbe2bc8156e52d4e", "compiled": true } ] diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs index 5524019b..d5050553 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs @@ -26,9 +26,11 @@ public sealed unsafe partial class EnvCellRenderer private readonly IWorldPassScope? _scope; private IGpuPipeline? _opaquePipeline; private IGpuPipeline? _alphaPipeline; + private IGpuPipeline? _clipPipeline; private IGpuPipeline? _additivePipeline; private IGpuPipeline? _detailPipeline; private IGpuPipeline? _transparentDetailPipeline; + private IGpuPipeline? _clipDetailPipeline; private readonly TerrainAtlas.RetailDetailTextureBinding _environmentDetail; private readonly Func _buildingDetailEnabled; @@ -48,7 +50,7 @@ public sealed unsafe partial class EnvCellRenderer /// /// The RHI arm's constructor. It also completes Initialize's job: the - /// five pipelines ARE this renderer's program, so there is no second step + /// seven pipelines ARE this renderer's program, so there is no second step /// and no Shader to hand in. /// internal EnvCellRenderer( @@ -72,6 +74,12 @@ public sealed unsafe partial class EnvCellRenderer device, "envcell-opaque", GpuBlendMode.None, depthWrite: true, scope.SampleCount); _alphaPipeline = CreateShellPipeline( device, "envcell-alpha", GpuBlendMode.StraightAlpha, depthWrite: false, scope.SampleCount); + _clipPipeline = CreateShellPipeline( + device, + "envcell-clip", + GpuBlendMode.PremultipliedAlpha, + depthWrite: true, + scope.SampleCount); _additivePipeline = CreateShellPipeline( device, "envcell-additive", GpuBlendMode.Additive, depthWrite: false, scope.SampleCount); _detailPipeline = CreateShellPipeline( @@ -92,6 +100,15 @@ public sealed unsafe partial class EnvCellRenderer shaderName: "mesh_detail", depthCompare: RetailDetailTextureContract.DetailDepthCompare( transparent: true)); + _clipDetailPipeline = CreateShellPipeline( + device, + "envcell-retail-detail-clip", + GpuBlendMode.RetailDetail, + depthWrite: true, + scope.SampleCount, + shaderName: "mesh_detail", + depthCompare: RetailDetailTextureContract.DetailDepthCompare( + transparent: false)); _initialized = true; } @@ -274,12 +291,18 @@ public sealed unsafe partial class EnvCellRenderer MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex]; int groupIndex = drawRange.GroupIndex; CullMode cullMode = ResolveRetailCellShellCullMode( - (CullMode)(groupIndex % 4)); + (CullMode)(groupIndex % CullGroupCount)); - bool isAdditive = groupIndex >= 4; - IGpuPipeline rangeBasePipeline = isAdditive - ? _additivePipeline! - : _alphaPipeline!; + bool isAdditive = renderPass == WbRenderPass.Transparent + && groupIndex >= AdditiveGroupBase + && groupIndex < ClipDdsGroupBase; + bool isClip = renderPass == WbRenderPass.Transparent + && groupIndex >= ClipDdsGroupBase; + IGpuPipeline rangeBasePipeline = isClip + ? _clipPipeline! + : isAdditive + ? _additivePipeline! + : _alphaPipeline!; if (renderPass == WbRenderPass.Transparent) { // Blend state is the pipeline's; switching variants mid-pass has @@ -298,6 +321,9 @@ public sealed unsafe partial class EnvCellRenderer ? (int)renderPass | 0x100 : (int)renderPass; pushConstants.DrawIdOffset = drawRange.FirstCommand; + pushConstants.ParamB = isClip + ? groupIndex >= ClipPalettedGroupBase ? 100f / 255f : 200f / 255f + : 0f; encoder.SetPushConstants(in pushConstants); // Retail DrawMesh's two-pass fallback redraws each transparent @@ -317,7 +343,9 @@ public sealed unsafe partial class EnvCellRenderer pushConstants.DrawIdOffset = command; pushConstants.TextureIndexA = 0; pushConstants.ParamA = 0f; - pushConstants.ParamB = 0f; + pushConstants.ParamB = isClip + ? groupIndex >= ClipPalettedGroupBase ? 100f / 255f : 200f / 255f + : 0f; encoder.SetPushConstants(in pushConstants); encoder.MultiDrawIndexedIndirect( commandBuffer, @@ -325,12 +353,17 @@ public sealed unsafe partial class EnvCellRenderer 1, (uint)sizeof(DrawElementsIndirectCommand)); - BindPipelineWithMesh(encoder, _transparentDetailPipeline!, mesh); + BindPipelineWithMesh( + encoder, + isClip ? _clipDetailPipeline! : _transparentDetailPipeline!, + mesh); SetCullMode(encoder, cullMode); pushConstants.DrawIdOffset = command; pushConstants.TextureIndexA = _environmentDetail.TextureSlot.Index; pushConstants.ParamA = _environmentDetail.Tiling; - pushConstants.ParamB = 0f; + pushConstants.ParamB = isClip + ? groupIndex >= ClipPalettedGroupBase ? 100f / 255f : 200f / 255f + : 0f; encoder.SetPushConstants(in pushConstants); encoder.MultiDrawIndexedIndirect( commandBuffer, @@ -348,9 +381,11 @@ public sealed unsafe partial class EnvCellRenderer (uint)sizeof(DrawElementsIndirectCommand)); } - // Retail DrawEnvCell category (2). Replay the already-filtered opaque - // shell commands, including ClipMap built-mesh subsets. No distance - // fade (VM1/VM2): retail's noFadeDetail gates get_alpha_for_z to the + // Retail DrawEnvCell category (2). Replay only the already-filtered + // opaque shell commands. Transparent ClipMap is excluded here: it + // takes the immediate/delayed CLIP path and, when detail is active, + // its own _clipDetailPipeline contribution. No distance fade + // (VM1/VM2): retail's noFadeDetail gates get_alpha_for_z to the // immediate-polygon path only, which built meshes never reach; // attenuation is the sampler's linear mip chain converging to the // texture mean. The existing "Building Detail Textures" option gates @@ -368,7 +403,7 @@ public sealed unsafe partial class EnvCellRenderer { MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex]; CullMode cullMode = ResolveRetailCellShellCullMode( - (CullMode)(drawRange.GroupIndex % 4)); + (CullMode)(drawRange.GroupIndex % CullGroupCount)); SetCullMode(encoder, cullMode); pushConstants.DrawIdOffset = drawRange.FirstCommand; encoder.SetPushConstants(in pushConstants); @@ -468,9 +503,10 @@ public sealed unsafe partial class EnvCellRenderer /// /// Binds one category word for mesh_detail.vert's statically used - /// binding 9. EnvCell draws select their renderer-wide category via - /// uParamB=0, so the value is semantically unused, but Vulkan still - /// requires the declared descriptor to be valid. + /// binding 9. The vertex shader treats only exact uParamB=1 as the + /// object/building-category sentinel; ordinary EnvCell zero and the CLIP + /// references 100/255 and 200/255 therefore keep the environment category. + /// Vulkan still requires the declared descriptor to be valid. /// internal static void BindEnvironmentDetailCategory( IGpuPassEncoder encoder, @@ -491,11 +527,15 @@ public sealed unsafe partial class EnvCellRenderer _opaquePipeline = null; _alphaPipeline?.Dispose(); _alphaPipeline = null; + _clipPipeline?.Dispose(); + _clipPipeline = null; _additivePipeline?.Dispose(); _additivePipeline = null; _detailPipeline?.Dispose(); _detailPipeline = null; _transparentDetailPipeline?.Dispose(); _transparentDetailPipeline = null; + _clipDetailPipeline?.Dispose(); + _clipDetailPipeline = null; } } diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index 1b0f3562..3830ee9a 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -142,8 +142,17 @@ public sealed partial class EnvCellRenderer : private readonly List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> _renderDrawCalls = new(); private readonly Dictionary> _filteredGroups = new(); private readonly HashSet> _filteredOwnedLists = new(); + // S4-c2 F3: four cull modes for each fixed-state family. The original + // eight slots were non-additive/additive only; transparent replay also + // needs distinct pure-ClipMap DDS/paletted ranges because their alpha-test + // references differ and neither state may share an MDI call with ALPHA. + private const int CullGroupCount = 4; + private const int AdditiveGroupBase = 4; + private const int ClipDdsGroupBase = 8; + private const int ClipPalettedGroupBase = 12; + private const int BatchGroupCount = 16; private readonly List<(ObjectRenderBatch batch, int instanceCount, int instanceOffset)>[] _batchesByCullGroup = - Enumerable.Range(0, 8) + Enumerable.Range(0, BatchGroupCount) .Select(_ => new List<(ObjectRenderBatch, int, int)>()) .ToArray(); private readonly List _activeCullGroups = new(8); @@ -1214,17 +1223,8 @@ public sealed partial class EnvCellRenderer : foreach (var batch in call.renderData.Batches) { // WB BaseObjectRenderManager.cs:723-731: pass-filter. - if (renderPass != WbRenderPass.SinglePass) - { - if (batch.IsAdditive) - { - if (renderPass == WbRenderPass.Opaque) continue; - } - else if (!batch.IsTransparent) - { - if (renderPass == WbRenderPass.Transparent) continue; - } - } + if (!BatchBelongsToPass(batch, renderPass)) + continue; if (renderPass == WbRenderPass.Transparent && transparentRoute != EnvCellTransparentRoute.All @@ -1277,17 +1277,8 @@ public sealed partial class EnvCellRenderer : var call = drawCalls[callIndex]; foreach (var batch in call.renderData.Batches) { - if (renderPass != WbRenderPass.SinglePass) - { - if (batch.IsAdditive) - { - if (renderPass == WbRenderPass.Opaque) continue; - } - else if (!batch.IsTransparent) - { - if (renderPass == WbRenderPass.Transparent) continue; - } - } + if (!BatchBelongsToPass(batch, renderPass)) + continue; if (renderPass == WbRenderPass.Transparent && transparentRoute != EnvCellTransparentRoute.All @@ -1296,7 +1287,9 @@ public sealed partial class EnvCellRenderer : continue; } - int groupIndex = (int)batch.CullMode + (batch.IsAdditive ? 4 : 0); + int groupIndex = ResolveBatchGroupIndex( + batch, + renderPass); List<(ObjectRenderBatch batch, int instanceCount, int instanceOffset)> group = _batchesByCullGroup[groupIndex]; if (group.Count == 0) @@ -1357,6 +1350,50 @@ public sealed partial class EnvCellRenderer : SubmitRhi(allInstances, renderPass, totalDraws, uniqueInstanceCount); } + /// + /// Exact WB pass membership, with + /// intentionally unchanged. A non-additive transparent batch is delayed + /// content and must never draw during the preceding opaque cell turn. + /// + internal static bool BatchBelongsToPass( + ObjectRenderBatch batch, + WbRenderPass renderPass) => renderPass switch + { + WbRenderPass.Opaque => !batch.IsAdditive && !batch.IsTransparent, + WbRenderPass.Transparent => batch.IsAdditive || batch.IsTransparent, + WbRenderPass.SinglePass => true, + _ => false, + }; + + private static int ResolveBatchGroupIndex( + ObjectRenderBatch batch, + WbRenderPass renderPass) + { + int cull = (int)batch.CullMode; + if ((uint)cull >= CullGroupCount) + throw new ArgumentOutOfRangeException(nameof(batch), batch.CullMode, "Unknown cell-shell cull mode."); + + // Preserve the old SinglePass grouping exactly: non-additive 0..3, + // additive 4..7. Its submission path still keeps the opaque pipeline. + if (renderPass != WbRenderPass.Transparent) + return cull + (batch.IsAdditive ? AdditiveGroupBase : 0); + + if (batch.IsAdditive) + return cull + AdditiveGroupBase; + + // Fixed state follows ConstructMesh's retained per-subset class, not + // the DrawMesh row action. Detail-active row 1 is Immediate, but a + // mask-0x08 subset (including legal positive-stipple 0x09) still needs + // the CLIP base pipeline/reference. ConstructMesh's priority already + // excludes additive/alpha-family rows before retaining this class. + if ((batch.RetailSurfaceMask & RetailAlphaMeshRouter.MaskClipMap) == 0) + return cull; + + return cull + (batch.Key.PaletteId != 0 + ? ClipPalettedGroupBase + : ClipDdsGroupBase); + } + internal static void AppendMdiDrawRange( List ranges, int groupIndex, diff --git a/tests/AcDream.App.Tests/Rendering/EnvCellAlphaDrawSourceTests.cs b/tests/AcDream.App.Tests/Rendering/EnvCellAlphaDrawSourceTests.cs index 0f032831..5e5c2df4 100644 --- a/tests/AcDream.App.Tests/Rendering/EnvCellAlphaDrawSourceTests.cs +++ b/tests/AcDream.App.Tests/Rendering/EnvCellAlphaDrawSourceTests.cs @@ -1,4 +1,6 @@ using System.Numerics; +using System.Reflection; +using System.Runtime.CompilerServices; using AcDream.App.Rendering; using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Gpu.Vk; @@ -38,44 +40,38 @@ public sealed class EnvCellAlphaDrawSourceTests } /// - /// A mixed cell creates one token in each list and neither set draws before - /// the real queue drain. CLIP drains first and receives only the CLIP - /// replay filter; ALPHA receives only ALPHA. Mutation checks: inverting the - /// detail-off immediate predicate makes Assert.Empty(log) fail; - /// removing the replay filter makes either route assertion fail. + /// The real walk leaf executes the preceding opaque pass, dispatch, and + /// queue drain. The opaque subset draws at the turn; CLIP and ALPHA do not. + /// Each delayed list then replays exactly its own subset. [Fact] - public void MixedCell_DefersOneTokenPerList_AndReplayIsListFiltered() + public void WholeLeaf_MixedCellDrawsOpaqueAtTurnThenClipAndAlphaAtDrain() { using var fixture = new ProductionEnvCellFixture( detailSurfaceActive: false, - 0x08, - 0x02); - EnvCellTransparentRoute routes = fixture.Renderer.GetTransparentRoutes( - ProductionEnvCellFixture.CellId, - detailSurfaceActive: false); + BatchSpec.Opaque, + BatchSpec.ClipDds, + BatchSpec.Alpha); fixture.Queue.BeginFrame(); - RetailPViewPassExecutor.DispatchTransparentCellShell( - ProductionEnvCellFixture.CellId, - routes, - detailSurfaceActive: false, - fixture.Queue, - fixture.ClipSource, - fixture.AlphaSource, - fixture.ImmediateSink); + fixture.Leaf.DrawCellShell(ProductionEnvCellFixture.CellId); - Assert.Equal(EnvCellTransparentRoute.Clip | EnvCellTransparentRoute.Alpha, routes); - Assert.Empty(fixture.Device.Calls.OfType()); + GpuRecordedMultiDrawIndirect turnDraw = Assert.Single( + fixture.Device.Calls.OfType()); + Assert.Equal(1u, turnDraw.DrawCount); + Assert.Equal(["envcell-opaque"], DrawPipelineNames(fixture.Device)); Assert.Equal(1, fixture.Queue.ClipCount); Assert.Equal(1, fixture.Queue.AlphaCount); + Assert.False(Assert.Single(ClipEntries(fixture.Queue)).OverrideClipmap); - fixture.Queue.Flush(RetailAlphaFlushSite.DrawBuilding, 0f); + fixture.Queue.EndFrame(); GpuRecordedMultiDrawIndirect[] draws = [.. fixture.Device.Calls.OfType()]; - Assert.Equal(2, draws.Length); + Assert.Equal(3, draws.Length); Assert.All(draws, static draw => Assert.Equal(1u, draw.DrawCount)); - fixture.Queue.EndFrame(); + Assert.Equal( + ["envcell-opaque", "envcell-clip", "envcell-alpha"], + DrawPipelineNames(fixture.Device)); } [Fact] @@ -83,28 +79,29 @@ public sealed class EnvCellAlphaDrawSourceTests { using var fixture = new ProductionEnvCellFixture( detailSurfaceActive: true, - 0x08); - EnvCellTransparentRoute routes = fixture.Renderer.GetTransparentRoutes( - ProductionEnvCellFixture.CellId, - detailSurfaceActive: true); + BatchSpec.ClipDds); fixture.Queue.BeginFrame(); - RetailPViewPassExecutor.DispatchTransparentCellShell( - ProductionEnvCellFixture.CellId, - routes, - detailSurfaceActive: true, - fixture.Queue, - fixture.ClipSource, - fixture.AlphaSource, - fixture.ImmediateSink); + fixture.Leaf.DrawCellShell(ProductionEnvCellFixture.CellId); - Assert.Equal(EnvCellTransparentRoute.Immediate, routes); - Assert.Equal(1, fixture.ImmediateSink.DrawCount); - Assert.True(fixture.ImmediateSink.LastDetailSurfaceActive); Assert.Equal(2, fixture.Device.Calls.OfType().Count()); + Assert.Equal( + ["envcell-clip", "envcell-retail-detail-clip"], + DrawPipelineNames(fixture.Device)); + Assert.All( + DrawPushConstants(fixture.Device), + constants => Assert.Equal(200f / 255f, constants.ParamB)); + GpuPipelineDescription detailPipeline = fixture.Device.CreatedPipelines + .Single(static pipeline => + pipeline.Description.Name == "envcell-retail-detail-clip") + .Description; + Assert.Equal(GpuBlendMode.RetailDetail, detailPipeline.Blend); + Assert.True(detailPipeline.Depth.Test); + Assert.True(detailPipeline.Depth.Write); + Assert.Equal(GpuCompareOp.Equal, detailPipeline.Depth.Compare); Assert.Equal(0, fixture.Queue.PendingCount); fixture.Queue.EndFrame(); - Assert.Equal(1, fixture.ImmediateSink.DrawCount); + Assert.Equal(2, fixture.Device.Calls.OfType().Count()); } /// Two cell tokens from the SAME , @@ -141,47 +138,202 @@ public sealed class EnvCellAlphaDrawSourceTests } [Fact] - public void ProductionDispatch_WarmedImmediateAndFilteredSourcesDoNotAllocate() + public void ProductionWholeLeaf_WarmedScanSubmitRhiAndFilteredReplayDoNotAllocate() { - var sink = new NoAllocRouteSink(); - var clipSource = new RetailPViewPassExecutor.EnvCellAlphaDrawSource( - sink.Render, - EnvCellTransparentRoute.Clip); - var alphaSource = new RetailPViewPassExecutor.EnvCellAlphaDrawSource( - sink.Render, - EnvCellTransparentRoute.Alpha); - RetailPViewPassExecutor.RenderImmediateEnvCellRoute immediate = sink.DrawImmediate; - var queue = new RetailAlphaQueue(); + using var fixture = new ProductionEnvCellFixture( + detailSurfaceActive: false, + BatchSpec.Opaque, + BatchSpec.ClipDds, + BatchSpec.Alpha, + ringCapacityBytes: 64 * 1024 * 1024); - for (int i = 0; i < 64; i++) - RunMixed(queue, clipSource, alphaSource, immediate); - - long before = GC.GetAllocatedBytesForCurrentThread(); - for (int i = 0; i < 1_000; i++) - RunMixed(queue, clipSource, alphaSource, immediate); - long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + fixture.Device.Clear(); + fixture.Device.RecordingEnabled = false; + long allocated = ZeroAllocationProbe.MeasureWarmed( + fixture.RunWholeLeafFrame, + batchSize: 256, + warmupBatches: 2, + samples: 4); Assert.Equal(0, allocated); } - private static void RunMixed( - RetailAlphaQueue queue, - RetailPViewPassExecutor.EnvCellAlphaDrawSource clipSource, - RetailPViewPassExecutor.EnvCellAlphaDrawSource alphaSource, - RetailPViewPassExecutor.RenderImmediateEnvCellRoute immediate) + [Theory] + [InlineData(false, 200f / 255f)] + [InlineData(true, 100f / 255f)] + public void WholeLeaf_ClipDrainBindsExactStateAndTextureClassReference( + bool paletted, + float expectedReference) { - queue.BeginFrame(); - RetailPViewPassExecutor.DispatchTransparentCellShell( - 0xF4180104u, - EnvCellTransparentRoute.Immediate - | EnvCellTransparentRoute.Clip - | EnvCellTransparentRoute.Alpha, + BatchSpec clip = paletted ? BatchSpec.ClipPaletted : BatchSpec.ClipDds; + using var fixture = new ProductionEnvCellFixture( detailSurfaceActive: false, - queue, - clipSource, - alphaSource, - immediate); - queue.EndFrame(); + clip); + + fixture.Queue.BeginFrame(); + fixture.Leaf.DrawCellShell(ProductionEnvCellFixture.CellId); + Assert.Empty(fixture.Device.Calls.OfType()); + Assert.False(Assert.Single(ClipEntries(fixture.Queue)).OverrideClipmap); + fixture.Queue.EndFrame(); + + Assert.Equal(["envcell-clip"], DrawPipelineNames(fixture.Device)); + Assert.Equal( + expectedReference, + Assert.Single(DrawPushConstants(fixture.Device)).ParamB); + + GpuPipelineDescription clipPipeline = fixture.Device.CreatedPipelines + .Single(static pipeline => pipeline.Description.Name == "envcell-clip") + .Description; + Assert.Equal(GpuBlendMode.PremultipliedAlpha, clipPipeline.Blend); + Assert.True(clipPipeline.Depth.Test); + Assert.True(clipPipeline.Depth.Write); + Assert.Equal(WorldDepthContract.WorldCompare, clipPipeline.Depth.Compare); + Assert.False(clipPipeline.AlphaToCoverage); + + GpuPipelineDescription alphaPipeline = fixture.Device.CreatedPipelines + .Single(static pipeline => pipeline.Description.Name == "envcell-alpha") + .Description; + Assert.Equal(GpuBlendMode.StraightAlpha, alphaPipeline.Blend); + Assert.True(alphaPipeline.Depth.Test); + Assert.False(alphaPipeline.Depth.Write); + } + + [Fact] + public void WholeLeaf_PositiveStippleClipMaskUsesClipPipelineAndDdsReference() + { + using var fixture = new ProductionEnvCellFixture( + detailSurfaceActive: false, + BatchSpec.ClipPositiveStippleDds); + + fixture.Queue.BeginFrame(); + fixture.Leaf.DrawCellShell(ProductionEnvCellFixture.CellId); + + Assert.Empty(fixture.Device.Calls.OfType()); + Assert.Equal(1, fixture.Queue.ClipCount); + Assert.Equal(0, fixture.Queue.AlphaCount); + Assert.False(Assert.Single(ClipEntries(fixture.Queue)).OverrideClipmap); + + fixture.Queue.EndFrame(); + + Assert.Equal(["envcell-clip"], DrawPipelineNames(fixture.Device)); + Assert.Equal( + 200f / 255f, + Assert.Single(DrawPushConstants(fixture.Device)).ParamB); + } + + /// + /// Vulkan has no fixed-function alpha test. The shader's discard-on-less + /// spelling is exactly GREATER_EQUAL: equality survives. Changing either + /// comparison to <= (strict GREATER) fails this named source+manifest pin. + /// + [Fact] + public void ClipShaders_UseGreaterEqualForThePerRangeReference() + { + string root = RepositoryRoot(); + string modern = File.ReadAllText(Path.Combine( + root, "src", "AcDream.App", "Rendering", "Shaders", "mesh_modern.frag")); + string detail = File.ReadAllText(Path.Combine( + root, "src", "AcDream.App", "Rendering", "Shaders", "mesh_detail.frag")); + string detailVertex = File.ReadAllText(Path.Combine( + root, "src", "AcDream.App", "Rendering", "Shaders", "mesh_detail.vert")); + + Assert.Contains("if (color.a < alphaCutoff) discard;", modern, StringComparison.Ordinal); + Assert.DoesNotContain("if (color.a <= alphaCutoff) discard;", modern, StringComparison.Ordinal); + Assert.Contains("isRetailClipReference(uParamB) ? uParamB : 0.05", modern, StringComparison.Ordinal); + Assert.Contains("isRetailClipReference(uParamB) ? uParamB : 0.05", detail, StringComparison.Ordinal); + foreach (string shader in new[] { modern, detail }) + { + Assert.Contains("abs(value - (100.0 / 255.0)) < 0.000001", shader, StringComparison.Ordinal); + Assert.Contains("abs(value - (200.0 / 255.0)) < 0.000001", shader, StringComparison.Ordinal); + Assert.DoesNotContain("uParamB > 0.0", shader, StringComparison.Ordinal); + Assert.DoesNotContain("value - 0.5", shader, StringComparison.Ordinal); + } + Assert.Contains("if (base.a < alphaCutoff)", detail, StringComparison.Ordinal); + Assert.DoesNotContain("if (base.a <= alphaCutoff)", detail, StringComparison.Ordinal); + Assert.Contains( + "vDetailCategory = uParamB == 1.0", + detailVertex, + StringComparison.Ordinal); + } + + [Theory] + [InlineData(0)] // Flush + [InlineData(1)] // EndFrame + [InlineData(2)] // AbortFrame + public void RejectedEnvCellStorm_RollsBackPayloadAndStillResetsFirstUseSource(int completion) + { + const int retainedGeometricBound = 4096; + int rejectedResetCount = 0; + int rejectedDrawCount = 0; + var accepted = new RetailPViewPassExecutor.EnvCellAlphaDrawSource( + static (_, _, _) => { }, + EnvCellTransparentRoute.Clip); + var rejected = new RetailPViewPassExecutor.EnvCellAlphaDrawSource( + (_, _, _) => rejectedDrawCount++, + EnvCellTransparentRoute.Clip, + () => rejectedResetCount++); + var unusedAlpha = new RetailPViewPassExecutor.EnvCellAlphaDrawSource( + static (_, _, _) => { }, + EnvCellTransparentRoute.Alpha); + var queue = new RetailAlphaQueue(); + RetailPViewPassExecutor.RenderImmediateEnvCellRoute neverImmediate = + static (_, _, _) => throw new InvalidOperationException(); + + queue.BeginFrame(); + for (int i = 0; i < RetailAlphaQueue.ListCapacity; i++) + { + RetailPViewPassExecutor.DispatchTransparentCellShell( + (uint)i, + EnvCellTransparentRoute.Clip, + detailSurfaceActive: false, + queue, + accepted, + unusedAlpha, + neverImmediate); + } + for (int i = 0; i < RetailAlphaQueue.ListCapacity * 3; i++) + { + RetailPViewPassExecutor.DispatchTransparentCellShell( + 0xF4180104u, + EnvCellTransparentRoute.Clip, + detailSurfaceActive: false, + queue, + rejected, + unusedAlpha, + neverImmediate); + } + + Assert.Equal(RetailAlphaQueue.ListCapacity, queue.ClipCount); + Assert.Equal(RetailAlphaQueue.ListCapacity, accepted.PendingCount); + Assert.Equal(0, rejected.PendingCount); + Assert.InRange(accepted.PendingCapacity, RetailAlphaQueue.ListCapacity, retainedGeometricBound); + Assert.InRange(rejected.PendingCapacity, 0, retainedGeometricBound); + + switch (completion) + { + case 0: + queue.Flush(RetailAlphaFlushSite.DrawBuilding, 0f); + queue.AbortFrame(); + break; + case 1: + queue.EndFrame(); + break; + case 2: + queue.AbortFrame(); + break; + default: + throw new ArgumentOutOfRangeException(nameof(completion)); + } + + Assert.Equal(1, rejectedResetCount); + Assert.Equal(0, rejectedDrawCount); + Assert.Equal(0, rejected.PendingCount); + Assert.InRange(accepted.PendingCapacity, 0, retainedGeometricBound); + Assert.InRange(accepted.PreparedCapacity, 0, retainedGeometricBound); + Assert.InRange(accepted.DrawCapacity, 0, retainedGeometricBound); + Assert.InRange(rejected.PendingCapacity, 0, retainedGeometricBound); + Assert.InRange(rejected.PreparedCapacity, 0, retainedGeometricBound); + Assert.InRange(rejected.DrawCapacity, 0, retainedGeometricBound); } private static RetailPViewPassExecutor.EnvCellAlphaDrawSource Source( @@ -197,6 +349,97 @@ public sealed class EnvCellAlphaDrawSourceTests }, expectedRoute); + private static List ClipEntries(RetailAlphaQueue queue) => + (List)typeof(RetailAlphaQueue) + .GetField("_clip", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(queue)!; + + private static string[] DrawPipelineNames(RecordingGpuDevice device) + { + var names = new List(); + IReadOnlyList calls = device.Calls; + for (int i = 0; i < calls.Count; i++) + { + if (calls[i] is not GpuRecordedMultiDrawIndirect) + continue; + for (int prior = i - 1; prior >= 0; prior--) + { + if (calls[prior] is GpuRecordedPipelineBind bind) + { + names.Add(bind.PipelineName); + break; + } + } + } + return [.. names]; + } + + private static GpuPushConstants[] DrawPushConstants(RecordingGpuDevice device) + { + var constants = new List(); + IReadOnlyList calls = device.Calls; + for (int i = 0; i < calls.Count; i++) + { + if (calls[i] is not GpuRecordedMultiDrawIndirect) + continue; + for (int prior = i - 1; prior >= 0; prior--) + { + if (calls[prior] is GpuRecordedPushConstants push) + { + constants.Add(push.Constants); + break; + } + } + } + return [.. constants]; + } + + private static string RepositoryRoot() + { + DirectoryInfo? cursor = new(AppContext.BaseDirectory); + while (cursor is not null && !File.Exists(Path.Combine(cursor.FullName, "AcDream.slnx"))) + cursor = cursor.Parent; + return cursor?.FullName + ?? throw new DirectoryNotFoundException("Could not locate AcDream.slnx."); + } + + private readonly record struct BatchSpec( + byte Mask, + bool IsTransparent, + AcDream.Core.Meshing.TranslucencyKind Translucency, + uint PaletteId) + { + internal static BatchSpec Opaque { get; } = new( + 0x00, + IsTransparent: false, + AcDream.Core.Meshing.TranslucencyKind.Opaque, + PaletteId: 0); + + internal static BatchSpec ClipDds { get; } = new( + RetailAlphaMeshRouter.MaskClipMap, + IsTransparent: true, + AcDream.Core.Meshing.TranslucencyKind.ClipMap, + PaletteId: 0); + + internal static BatchSpec ClipPaletted { get; } = new( + RetailAlphaMeshRouter.MaskClipMap, + IsTransparent: true, + AcDream.Core.Meshing.TranslucencyKind.ClipMap, + PaletteId: 0x04000001u); + + internal static BatchSpec ClipPositiveStippleDds { get; } = new( + RetailAlphaMeshRouter.MaskClipMap | RetailAlphaMeshRouter.MaskPositiveStipple, + IsTransparent: true, + AcDream.Core.Meshing.TranslucencyKind.ClipMap, + PaletteId: 0); + + internal static BatchSpec Alpha { get; } = new( + RetailAlphaMeshRouter.MaskAlphaFamily, + IsTransparent: true, + AcDream.Core.Meshing.TranslucencyKind.AlphaBlend, + PaletteId: 0); + } + /// /// Recording-GPU fixture for the production EnvCell scan, dispatch and /// filtered replay path. The two retained masks enter through the real @@ -215,9 +458,12 @@ public sealed class EnvCellAlphaDrawSourceTests public ProductionEnvCellFixture( bool detailSurfaceActive, - params byte[] retainedMasks) + BatchSpec firstBatch, + BatchSpec secondBatch = default, + BatchSpec thirdBatch = default, + int ringCapacityBytes = 8 * 1024 * 1024) { - Device = new RecordingGpuDevice(); + Device = new RecordingGpuDevice(ringCapacityBytes); _frames = new GpuDeviceFrameLifetime(Device); _scope = new VulkanWorldPassScope(sampleCount: 1); _meshManager = new ObjectMeshManager( @@ -236,17 +482,27 @@ public sealed class EnvCellAlphaDrawSourceTests new VertexPositionNormalTexture { Position = new Vector3(0, 1, 0) }, ], }; - var batches = new List(retainedMasks.Length); - for (int i = 0; i < retainedMasks.Length; i++) + BatchSpec[] specs = secondBatch == default + ? [firstBatch] + : thirdBatch == default + ? [firstBatch, secondBatch] + : [firstBatch, secondBatch, thirdBatch]; + var batches = new List(specs.Length); + for (int i = 0; i < specs.Length; i++) { + BatchSpec spec = specs[i]; batches.Add(new TextureBatchData { - Key = new TextureKey { SurfaceId = 0x08000BFFu + (uint)i }, + Key = new TextureKey + { + SurfaceId = 0x08000BFFu + (uint)i, + PaletteId = spec.PaletteId, + }, TextureData = new byte[8 * 8 * 4], Indices = [0, 1, 2], - IsTransparent = true, - Translucency = AcDream.Core.Meshing.TranslucencyKind.AlphaBlend, - RetailSurfaceMask = retainedMasks[i], + IsTransparent = spec.IsTransparent, + Translucency = spec.Translucency, + RetailSurfaceMask = spec.Mask, CullMode = CullMode.Clockwise, IsCellShell = true, SourceSurfaceIndex = i, @@ -291,6 +547,10 @@ public sealed class EnvCellAlphaDrawSourceTests }, }, }); + ((HashSet)typeof(EnvCellRenderer) + .GetField("_transparentCellIds", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(Renderer)!) + .Add(CellId); Queue = new RetailAlphaQueue(); ClipSource = new RetailPViewPassExecutor.EnvCellAlphaDrawSource( @@ -299,7 +559,28 @@ public sealed class EnvCellAlphaDrawSourceTests AlphaSource = new RetailPViewPassExecutor.EnvCellAlphaDrawSource( Renderer.RenderTransparentOrdered, EnvCellTransparentRoute.Alpha); - ImmediateSink = new ProductionImmediateSink(Renderer); + var passes = new RetailPViewPassExecutor( + new NullWorldPassSurface(), + NullRenderFrameGlState.Instance, + ClipFrame.NoClip(), + terrain: null, + Renderer, + (WbDrawDispatcher)RuntimeHelpers.GetUninitializedObject(typeof(WbDrawDispatcher)), + sky: null, + particles: null, + particleRenderer: null, + portalDepthMask: null, + Queue, + (WorldRenderDiagnostics)RuntimeHelpers.GetUninitializedObject(typeof(WorldRenderDiagnostics)), + (TerrainDrawDiagnosticsController)RuntimeHelpers.GetUninitializedObject( + typeof(TerrainDrawDiagnosticsController))); + Leaf = new WalkProductionLeafRenderer( + passes, + new RetailPViewFrameInput(), + new ClipFrameAssembly(), + static () => { }, + static () => { }, + static () => 0); _frames.BeginFrame(); IGpuFrame frame = _frames.CurrentFrame!; @@ -323,7 +604,14 @@ public sealed class EnvCellAlphaDrawSourceTests public RetailPViewPassExecutor.EnvCellAlphaDrawSource AlphaSource { get; } - public ProductionImmediateSink ImmediateSink { get; } + public WalkProductionLeafRenderer Leaf { get; } + + public void RunWholeLeafFrame() + { + Queue.BeginFrame(); + Leaf.DrawCellShell(CellId); + Queue.EndFrame(); + } private static bool DetailOn() => true; @@ -339,22 +627,15 @@ public sealed class EnvCellAlphaDrawSourceTests } } - private sealed class ProductionImmediateSink(EnvCellRenderer renderer) : - IEnvCellImmediateDrawSink + private sealed class NullWorldPassSurface : IWorldPassSurface { - public int DrawCount { get; private set; } + public void PrepareClipFrame() { } - public bool LastDetailSurfaceActive { get; private set; } + public void EnableClipDistances() { } - public void DrawImmediate( - uint cellId, - EnvCellTransparentRoute route, - bool detailSurfaceActive) - { - DrawCount++; - LastDetailSurfaceActive = detailSurfaceActive; - renderer.RenderTransparentOrdered([cellId], route, detailSurfaceActive); - } + public void DisableClipDistances() { } + + public void ClearInteriorDepth() => throw new InvalidOperationException(); } private sealed class NullPreparedAssetSource : IPreparedAssetSource @@ -395,20 +676,4 @@ public sealed class EnvCellAlphaDrawSourceTests } } - private sealed class NoAllocRouteSink - { - public void Render( - IReadOnlyList cells, - EnvCellTransparentRoute route, - bool detailSurfaceActive) - { - } - - public void DrawImmediate( - uint cellId, - EnvCellTransparentRoute route, - bool detailSurfaceActive) - { - } - } } diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs index 30bdf975..d51f1742 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs @@ -74,16 +74,17 @@ public sealed class GpuContractTests } [Fact] - public void BlendModesCoverEveryRetailTranslucencyKind() + public void BlendModesCoverEveryRetailRendererState() { // WbDrawDispatcher.ApplyRetailBlend selects a blend function from each DAT - // surface's TranslucencyKind. Retail has three, and the V0 contract shipped - // with only two — slice V4c found the gap. Mapping InvAlpha onto - // StraightAlpha would silently change how every inverse-alpha surface - // composites, so the contract has to carry all three. - Assert.Equal(5, Enum.GetValues().Length); + // surface's TranslucencyKind. Retail also selects ONE/INVSRCALPHA for a + // pure Base1ClipMap with override=false (SetSurface @0x0059c72a), while + // the detail combine is a separate renderer state. Collapsing any of + // these modes changes the corresponding subset's composition. + Assert.Equal(6, Enum.GetValues().Length); Assert.Contains(GpuBlendMode.None, Enum.GetValues()); Assert.Contains(GpuBlendMode.StraightAlpha, Enum.GetValues()); + Assert.Contains(GpuBlendMode.PremultipliedAlpha, Enum.GetValues()); Assert.Contains(GpuBlendMode.Additive, Enum.GetValues()); Assert.Contains(GpuBlendMode.InverseAlpha, Enum.GetValues()); Assert.Contains(GpuBlendMode.RetailDetail, Enum.GetValues()); diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs b/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs index a8afbd27..d3692f1e 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs @@ -135,6 +135,14 @@ internal sealed class RecordingGpuDevice : IGpuDevice, IGpuPipelineFormatVariant /// Every recorded call, in submission order. public IReadOnlyList Calls => _calls; + /// + /// Allocation measurements may drive the real renderer/RHI submission + /// path without allocating one diagnostic record per call. Resource, + /// frame, ring, and encoder behavior stays identical; only call-record + /// object construction is suppressed when this is false. + /// + public bool RecordingEnabled { get; set; } = true; + /// Backing store for ring allocations, so tests can read what a renderer wrote. public ReadOnlySpan RingBytes => _ring; @@ -410,7 +418,8 @@ internal sealed class RecordingGpuDevice : IGpuDevice, IGpuPipelineFormatVariant } _ringCursor = aligned + (uint)byteCount; - _calls.Add(new GpuRecordedRingAllocation(usage, byteCount, aligned)); + if (RecordingEnabled) + _calls.Add(new GpuRecordedRingAllocation(usage, byteCount, aligned)); return new GpuRingAllocation(RingBuffer, aligned, _ring.AsSpan((int)aligned, byteCount)); } @@ -460,7 +469,8 @@ internal sealed class RecordingGpuFrame(RecordingGpuDevice device, long serial, "Published host writes require a host-writable storage buffer.", nameof(buffer)); } - device.Record(new GpuRecordedHostStorageVisibility(buffer.Name)); + if (device.RecordingEnabled) + device.Record(new GpuRecordedHostStorageVisibility(buffer.Name)); } public IGpuPassEncoder BeginPass(GpuPassDescription description) @@ -549,60 +559,97 @@ internal sealed class RecordingGpuPassEncoder(RecordingGpuDevice device, GpuPass throw new InvalidOperationException("Pipeline and pass colour-attachment intents must match."); if (pipeline.Description.ViewMask != Pass.ViewMask) throw new InvalidOperationException("Pipeline and pass view masks must match."); - device.Record(new GpuRecordedPipelineBind(pipeline.Description.Name)); + if (device.RecordingEnabled) + device.Record(new GpuRecordedPipelineBind(pipeline.Description.Name)); } public void BindStorageBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes) { ArgumentNullException.ThrowIfNull(buffer); - device.Record(new GpuRecordedStorageBind(binding, buffer.Name, offsetBytes, sizeBytes)); + if (device.RecordingEnabled) + device.Record(new GpuRecordedStorageBind(binding, buffer.Name, offsetBytes, sizeBytes)); } public void BindUniformBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes) { ArgumentNullException.ThrowIfNull(buffer); - device.Record(new GpuRecordedUniformBind(binding, buffer.Name, offsetBytes, sizeBytes)); + if (device.RecordingEnabled) + device.Record(new GpuRecordedUniformBind(binding, buffer.Name, offsetBytes, sizeBytes)); } public void BindVertexBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes) { ArgumentNullException.ThrowIfNull(buffer); - device.Record(new GpuRecordedVertexBind(binding, buffer.Name, offsetBytes)); + if (device.RecordingEnabled) + device.Record(new GpuRecordedVertexBind(binding, buffer.Name, offsetBytes)); } public void BindIndexBuffer(IGpuBuffer buffer, uint offsetBytes, GpuIndexType indexType) { ArgumentNullException.ThrowIfNull(buffer); - device.Record(new GpuRecordedIndexBind(buffer.Name, offsetBytes, indexType)); + if (device.RecordingEnabled) + device.Record(new GpuRecordedIndexBind(buffer.Name, offsetBytes, indexType)); } - public void SetPushConstants(in GpuPushConstants constants) => - device.Record(new GpuRecordedPushConstants(constants)); + public void SetPushConstants(in GpuPushConstants constants) + { + if (device.RecordingEnabled) + device.Record(new GpuRecordedPushConstants(constants)); + } - public void SetViewport(int x, int y, int width, int height) => - device.Record(new GpuRecordedViewport(x, y, width, height)); + public void SetViewport(int x, int y, int width, int height) + { + if (device.RecordingEnabled) + device.Record(new GpuRecordedViewport(x, y, width, height)); + } - public void SetScissor(int x, int y, int width, int height) => - device.Record(new GpuRecordedScissor(x, y, width, height)); + public void SetScissor(int x, int y, int width, int height) + { + if (device.RecordingEnabled) + device.Record(new GpuRecordedScissor(x, y, width, height)); + } - public void SetCullMode(GpuCullMode cullMode) => device.Record(new GpuRecordedCullMode(cullMode)); + public void SetCullMode(GpuCullMode cullMode) + { + if (device.RecordingEnabled) + device.Record(new GpuRecordedCullMode(cullMode)); + } - public void SetFrontFace(GpuFrontFace frontFace) => device.Record(new GpuRecordedFrontFace(frontFace)); + public void SetFrontFace(GpuFrontFace frontFace) + { + if (device.RecordingEnabled) + device.Record(new GpuRecordedFrontFace(frontFace)); + } - public void SetStencil(in GpuStencilState stencil) => device.Record(new GpuRecordedStencil(stencil)); + public void SetStencil(in GpuStencilState stencil) + { + if (device.RecordingEnabled) + device.Record(new GpuRecordedStencil(stencil)); + } - public void SetDepthWrite(bool enabled) => device.Record(new GpuRecordedDepthWrite(enabled)); + public void SetDepthWrite(bool enabled) + { + if (device.RecordingEnabled) + device.Record(new GpuRecordedDepthWrite(enabled)); + } - public void DrawIndexed(uint indexCount, uint instanceCount, uint firstIndex, int vertexOffset, uint firstInstance) => - device.Record(new GpuRecordedDrawIndexed(indexCount, instanceCount, firstIndex, vertexOffset, firstInstance)); + public void DrawIndexed(uint indexCount, uint instanceCount, uint firstIndex, int vertexOffset, uint firstInstance) + { + if (device.RecordingEnabled) + device.Record(new GpuRecordedDrawIndexed(indexCount, instanceCount, firstIndex, vertexOffset, firstInstance)); + } - public void Draw(uint vertexCount, uint instanceCount, uint firstVertex, uint firstInstance) => - device.Record(new GpuRecordedDraw(vertexCount, instanceCount, firstVertex, firstInstance)); + public void Draw(uint vertexCount, uint instanceCount, uint firstVertex, uint firstInstance) + { + if (device.RecordingEnabled) + device.Record(new GpuRecordedDraw(vertexCount, instanceCount, firstVertex, firstInstance)); + } public void MultiDrawIndexedIndirect(IGpuBuffer commands, uint offsetBytes, uint drawCount, uint strideBytes) { ArgumentNullException.ThrowIfNull(commands); - device.Record(new GpuRecordedMultiDrawIndirect(commands.Name, offsetBytes, drawCount, strideBytes)); + if (device.RecordingEnabled) + device.Record(new GpuRecordedMultiDrawIndirect(commands.Name, offsetBytes, drawCount, strideBytes)); } public IDisposable BeginTimerScope(string scopeName) diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs index 282efc3a..0c644465 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs @@ -41,7 +41,13 @@ public sealed class VulkanShaderManifestTests { ["debug_line.frag.spv"] = "02fc04880bc5eb74353566f914675244038125c71443964decdc28e8199264df", ["debug_line.vert.spv"] = "f9c6a9b575bb07a426fb6ade677bca96a7752ca6b120e8f6451363ba73b51140", - ["mesh_modern.frag.spv"] = "b702b644862aca31ce1fb0677adc5872b39c4ea87f595a89363b44d10f2cc50e", + // Re-pinned 2026-09-04 (Campaign OVERHAUL S4-c2 owner-authorized + // repair): Vulkan expresses SetSurface's pure-ClipMap alpha test + // through uParamB, recognizing only the paletted 100/255 and DDS + // 200/255 references and using discard-on-less so equality passes. + // The paired binary selects those globals and + // D3DCMP_GREATEREQUAL at 0x0059c73c..0x0059c838. + ["mesh_modern.frag.spv"] = "1b3a59d0cc7c5bb6acf793d57d9673d860a7eb50bdd51770781f59feac4604a1", // mesh_modern.vert re-pinned 2026-09-03 (S3 review fix round 1, // F5): the binding=2 ClipRegionBuf (CellClip) SSBO, its // gl_ClipDistance loop, and the gl_PerVertex redeclaration are diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs index 2d4147d9..099b23a4 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanViewportMappingTests.cs @@ -132,11 +132,14 @@ public sealed class VulkanViewportMappingTests } [Fact] - public void AllThreeRetailBlendModesAreRepresentable() + public void AllRetailBlendModesAreRepresentable() { Assert.Equal( (BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha), VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.StraightAlpha)); + Assert.Equal( + (BlendFactor.One, BlendFactor.OneMinusSrcAlpha), + VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.PremultipliedAlpha)); Assert.Equal( (BlendFactor.SrcAlpha, BlendFactor.One), VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.Additive)); diff --git a/tests/AcDream.App.Tests/Rendering/ParticleRendererRouteTests.cs b/tests/AcDream.App.Tests/Rendering/ParticleRendererRouteTests.cs index dd50f571..941ca515 100644 --- a/tests/AcDream.App.Tests/Rendering/ParticleRendererRouteTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ParticleRendererRouteTests.cs @@ -1,3 +1,4 @@ +using System.Collections; using System.Numerics; using System.Reflection; using System.Runtime.CompilerServices; @@ -9,6 +10,7 @@ using AcDream.App.Tests.Rendering.Gpu; using AcDream.Content; using AcDream.Core.Meshing; using AcDream.Core.Vfx; +using Chorizite.Core.Render.Enums; using Microsoft.Extensions.Logging.Abstractions; namespace AcDream.App.Tests.Rendering; @@ -189,54 +191,110 @@ public sealed class ParticleRendererRouteTests } [Fact] - public void ProductionDispatch_WarmedAppendAndImmediatePathsDoNotAllocate() + public void ProductionImmediateMesh_WarmedDrawImmediateParticleSubmissionRhiDoesNotAllocate() { - var queue = new RetailAlphaQueue(); - var sink = new NoAllocParticleSink(); - ParticleRenderer.ReserveDeferredParticleDraw reserve = sink.Reserve; - ParticleRenderer.DrawImmediateParticle immediate = sink.DrawImmediate; + using var device = new RecordingGpuDevice(64 * 1024 * 1024); + using var manager = new ObjectMeshManager( + new VulkanMeshPipelineDevice(device.Retirement), + device, + new NullPreparedAssetSource(), + NullLogger.Instance); + ObjectRenderData renderData = Assert.IsType(manager.UploadMeshData( + new ObjectMeshData + { + ObjectId = 0x010001ECu, + Vertices = + [ + new VertexPositionNormalTexture { Position = Vector3.Zero }, + new VertexPositionNormalTexture { Position = Vector3.UnitX }, + new VertexPositionNormalTexture { Position = Vector3.UnitY }, + ], + TextureBatches = + { + [(8, 8, TextureFormat.RGBA8)] = + [ + new TextureBatchData + { + Key = new TextureKey { SurfaceId = 0x08000015u }, + TextureData = new byte[8 * 8 * 4], + Indices = [0, 1, 2], + Translucency = TranslucencyKind.Opaque, + CullMode = DatReaderWriter.Enums.CullMode.Clockwise, + }, + ], + }, + })); + var adapter = (WbMeshAdapter)RuntimeHelpers.GetUninitializedObject(typeof(WbMeshAdapter)); + typeof(WbMeshAdapter).GetField( + "_meshManager", + BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(adapter, manager); + var frames = new GpuDeviceFrameLifetime(device); + var scope = new VulkanWorldPassScope(sampleCount: 1); + using var renderer = new ParticleRenderer( + device, + frames, + scope, + new ParticleSystem(new EmitterDescRegistry(), new Random(42)), + meshAdapter: adapter); - for (int i = 0; i < 64; i++) - RunPair(queue, sink, reserve, immediate); + Type keyType = typeof(ParticleRenderer).GetNestedType( + "MeshBatchKey", BindingFlags.NonPublic)!; + Type instanceType = typeof(ParticleRenderer).GetNestedType( + "MeshParticleInstance", BindingFlags.NonPublic)!; + Type drawType = typeof(ParticleRenderer).GetNestedType( + "MeshParticleDraw", BindingFlags.NonPublic)!; + object key = Activator.CreateInstance( + keyType, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + args: [0x010001ECu, 0], + culture: null)!; + object instance = Activator.CreateInstance( + instanceType, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + args: [Matrix4x4.Identity, 0xFFFFFFFFu, 0f, 0u], + culture: null)!; + object draw = Activator.CreateInstance( + drawType, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + args: [key, renderData.Batches[0], instance], + culture: null)!; + var drawList = (IList)typeof(ParticleRenderer).GetField( + "_meshDrawListScratch", + BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(renderer)!; + drawList.Add(draw); + var immediate = (ParticleRenderer.DrawImmediateParticle)typeof(ParticleRenderer) + .GetField("_drawImmediateParticle", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(renderer)!; - long before = GC.GetAllocatedBytesForCurrentThread(); - for (int i = 0; i < 1_000; i++) - RunPair(queue, sink, reserve, immediate); - long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + frames.BeginFrame(); + renderer.BeginFrame(frameSlot: 0); + IGpuFrame frame = frames.CurrentFrame!; + using IGpuPassEncoder pass = frame.BeginPass(GpuPassDescription.BackbufferClear( + "s4-c2-particle-allocation", + Vector4.Zero, + sampleCount: 1)); + using IDisposable publication = scope.Publish(pass); + + device.Clear(); + device.RecordingEnabled = false; + long allocated = ZeroAllocationProbe.MeasureWarmed( + () => immediate( + Matrix4x4.Identity, + ParticleSubmissionKind.Mesh, + drawIndex: 0, + opaqueDepthState: true), + batchSize: 256, + warmupBatches: 2, + samples: 4); Assert.Equal(0, allocated); } - private static void RunPair( - RetailAlphaQueue queue, - NoAllocParticleSink sink, - ParticleRenderer.ReserveDeferredParticleDraw reserve, - ParticleRenderer.DrawImmediateParticle immediate) - { - queue.BeginFrame(); - ParticleRenderer.DeferToRetailAlphaQueue( - ParticleSubmissionKind.Mesh, - TranslucencyKind.Opaque, - 0x80FFFFFFu, - queue, - sink, - reserve, - immediate, - Matrix4x4.Identity, - drawIndex: 0); - ParticleRenderer.DeferToRetailAlphaQueue( - ParticleSubmissionKind.Mesh, - TranslucencyKind.Opaque, - 0xFFFFFFFFu, - queue, - sink, - reserve, - immediate, - Matrix4x4.Identity, - drawIndex: 1); - queue.EndFrame(); - } - private sealed class RecordingSource : IRetailAlphaDrawSource { public int PrepareCount { get; private set; } @@ -252,31 +310,6 @@ public sealed class ParticleRendererRouteTests } } - private sealed class NoAllocParticleSink : IRetailAlphaDrawSource - { - private int _nextToken; - - public int Reserve() => _nextToken++; - - public void DrawImmediate( - Matrix4x4 viewProjection, - ParticleSubmissionKind kind, - int drawIndex, - bool opaqueDepthState) - { - } - - public void PrepareAlphaDraws(ReadOnlySpan tokens) - { - } - - public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount) - { - } - - public void ResetAlphaSubmissions() => _nextToken = 0; - } - private sealed class NullPreparedAssetSource : IPreparedAssetSource { public PreparedAssetSourceStats Stats => default; diff --git a/tests/AcDream.App.Tests/Rendering/RetailAlphaMeshRouterTests.cs b/tests/AcDream.App.Tests/Rendering/RetailAlphaMeshRouterTests.cs index 79c87960..630a4604 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailAlphaMeshRouterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailAlphaMeshRouterTests.cs @@ -240,9 +240,10 @@ public sealed class RetailAlphaMeshRouterTests /// /// 's - /// reachable mapping (opaque/ClipMap are pre-filtered upstream and never - /// actually call this from a live site, but the function itself must - /// still map them defensively). + /// complete mapping. Ordinary Wb callers pre-filter opaque/ClipMap and + /// retain AP-239's earlier subset-mask reconstruction distinction. Mesh + /// particles, however, genuinely call this method without that ordinary- + /// Wb pre-filter, including for opaque-classified batches. /// [Theory] [InlineData(TranslucencyKind.AlphaBlend, RetailAlphaMeshRouter.MaskAlphaFamily)]