diff --git a/docs/plans/2026-07-27-vulkan-campaign.md b/docs/plans/2026-07-27-vulkan-campaign.md index f53a4e70..e2ce24cd 100644 --- a/docs/plans/2026-07-27-vulkan-campaign.md +++ b/docs/plans/2026-07-27-vulkan-campaign.md @@ -501,7 +501,8 @@ because sample positions are not specified across implementations. | **V3** | Clip-space and sRGB audit: verify every projection producer is [0,1] convention, confirm clip-plane derivation, record the sRGB swapchain decision and the depth-precision divergence class here. | pixel gate + connected lifecycle | | **V4a** | `TextRenderer` (three fence-buffered VBO sets → ring allocations), `BitmapFont`, `DebugLineRenderer`, the UI RenderSurface upload path, `UiViewport`'s texture handoff. | pixel gate (UI-heavy checkpoints) | | **V4b** | `GlobalMeshBuffer` + `ObjectMeshManager` onto `IGpuBuffer`; arena, LRU and ledger logic untouched. | pixel gate | -| **V4c** | **The large one.** `WbDrawDispatcher` + `EnvCellRenderer`: per-frame uploads → rings, MDI brackets → pipelines + `MultiDrawIndexedIndirect`, `ClipFrame`, `SceneLightingUboBinding`, timer scopes. `RetailAlphaQueue` untouched. | pixel gate at several checkpoints + connected lifecycle | +| **V4c** | **The large one.** `WbDrawDispatcher` + `EnvCellRenderer`: per-frame uploads → rings, MDI brackets → pipelines + `MultiDrawIndexedIndirect`, loose uniforms → push constants, timer scopes. `RetailAlphaQueue` and all bucketing untouched. **Narrowed after the V4c scouting report — see §5.3.** | pixel gate at several checkpoints + connected lifecycle | +| **V4t** | **World texture stack** (added 2026-07-27, see §5.3): `TextureCache`, `CompositeTextureArrayCache`, `ManagedGLTextureArray`, `TerrainAtlas` and `ObjectMeshManager`'s material path onto `IGpuTexture`/`IGpuSampler`; retype `GroupKey`, `CachedBatch` and `ObjectRenderBatch` from `ulong` bindless handle to `GpuTextureSlot`; retire the interim per-renderer handle tables for V4c, V4d and V4e at once. | pixel gate | | **V4d** | `TerrainModernRenderer` + `TerrainAtlas`. | pixel gate | | **V4e** | `ParticleRenderer` (after V4c — shared alpha-queue contract). | pixel gate (particle-heavy checkpoint) | | **V4f** | `SkyRenderer` + weather. | pixel gate (dawn/dusk, day group pinned) | @@ -536,6 +537,41 @@ recycling — becomes reachable. Two small, separately pixel-gated changes beat entangled one; separating the data-model change from the RHI plumbing change is precisely what de-risks V4c, the largest slice in the campaign. +### 5.3 Why V4c was narrowed, and where V4t came from + +A scouting pass over V4c (2026-07-27) stopped before writing code and reported two +structural blockers. Both were verified against source; both were real. + +**The contract was missing a blend mode.** `WbDrawDispatcher.ApplyRetailBlend` +(`WbDrawDispatcher.cs:3191`) selects one of *three* blend functions from each DAT +surface's `TranslucencyKind`: `AlphaBlend` → `(SrcAlpha, OneMinusSrcAlpha)`, +`Additive` → `(SrcAlpha, One)`, and **`InvAlpha` → `(OneMinusSrcAlpha, SrcAlpha)`**. +The V0 contract shipped `GpuBlendMode` with only the first two. Blend is baked into +the pipeline and is not dynamic, so this could not be worked around at the encoder; +mapping `InvAlpha` onto `StraightAlpha` would have silently changed how every +inverse-alpha surface composites. `ParticleRenderer` hits the same wall twice, so +V4e was blocked on it too. Fixed by adding `GpuBlendMode.InverseAlpha` to the +contract with a test asserting all three retail kinds are representable. This is +the correct outcome of a pinned contract meeting reality: the contract grew, in one +reviewed commit, rather than a slice inventing a workaround. + +**Retiring the interim handle table is its own slice.** §5.2 assumed V4c could +switch to the device's texture table. It cannot: the renderers do not own the +bindless handles, they only intern them. A raw `ulong` is produced by +`TextureCache`, `CompositeTextureArrayCache`, `ManagedGLTextureArray` and +`TerrainAtlas`, baked into `ObjectRenderBatch`, and carried by **`GroupKey`** — the +bucketing key V4c is explicitly forbidden to change — and by `CachedBatch`, where it +is compared for cache validity. Switching to `GpuTextureSlot` therefore means +porting the whole texture stack and retyping three data-model records, which is most +of V4d and V4e plus work no slice contained. That is now **V4t**, with its own pixel +gate. Until it lands, V4c/V4d/V4e bind their existing interim tables through the +encoder as ordinary storage buffers at binding 9 — no new escape hatch. + +**Also deferred to V4h:** `ClipFrame`'s region buffer (binding 2) is read by terrain +as well, and the `SceneLighting` UBO (binding 1) by terrain and the four viewport and +portal renderers. GL binding points are global, so the safe move while those consumers +are still raw GL is to leave both bound as they are and convert them with the spine. + **Sequencing invariants.** The app ships on GL until V10. V0→V1→V2→V3→V4a…V4h are strictly sequential. The only permitted parallelism is V5 alongside V4d and/or V4f (fully disjoint files), and optionally V9's `.github`/`tools`-only diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlEnumMapping.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlEnumMapping.cs index 6a4a938b..a2da95f7 100644 --- a/src/AcDream.App/Rendering/Gpu/Gl/GlEnumMapping.cs +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlEnumMapping.cs @@ -76,6 +76,7 @@ internal static class GlEnumMapping { GpuBlendMode.StraightAlpha => (BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha), GpuBlendMode.Additive => (BlendingFactor.SrcAlpha, BlendingFactor.One), + GpuBlendMode.InverseAlpha => (BlendingFactor.OneMinusSrcAlpha, BlendingFactor.SrcAlpha), // GpuBlendMode.None never reaches glBlendFunc — blending is disabled instead. _ => throw new NotSupportedException($"No GL blend factors for {blend}."), }; diff --git a/src/AcDream.App/Rendering/Gpu/GpuEnums.cs b/src/AcDream.App/Rendering/Gpu/GpuEnums.cs index 76685b03..edd4012a 100644 --- a/src/AcDream.App/Rendering/Gpu/GpuEnums.cs +++ b/src/AcDream.App/Rendering/Gpu/GpuEnums.cs @@ -120,6 +120,18 @@ internal enum GpuBlendMode /// Additive: SrcAlpha, One. Additive, + + /// + /// Retail's inverse-alpha translucency: OneMinusSrcAlpha, SrcAlpha. + /// + /// Added at slice V4c, which found that `WbDrawDispatcher.ApplyRetailBlend` + /// selects three blend functions from each DAT surface's `TranslucencyKind`, + /// and this third one had no representation in the V0 contract. Mapping it + /// onto would have been a retail-fidelity + /// regression, not a simplification, so the contract grew instead. + /// `ParticleRenderer` needs it too (slice V4e). + /// + InverseAlpha, } internal enum GpuCompareOp diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs index 44a909fe..28cfc37b 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs @@ -71,6 +71,21 @@ public sealed class GpuContractTests Assert.Equal(144, GpuBindingModel.ClipRegionStrideBytes); } + [Fact] + public void BlendModesCoverEveryRetailTranslucencyKind() + { + // 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(4, Enum.GetValues().Length); + Assert.Contains(GpuBlendMode.None, Enum.GetValues()); + Assert.Contains(GpuBlendMode.StraightAlpha, Enum.GetValues()); + Assert.Contains(GpuBlendMode.Additive, Enum.GetValues()); + Assert.Contains(GpuBlendMode.InverseAlpha, Enum.GetValues()); + } + [Fact] public void UnassignedTextureSlotIsNeverAValidIndex() {