feat(render): add GpuBlendMode.InverseAlpha and re-scope Campaign V4c

A scouting pass over V4c stopped before writing code and reported two structural blockers. Both verified against source.

The pinned V0 contract was missing a blend mode. WbDrawDispatcher.ApplyRetailBlend selects three blend functions from each DAT surface's TranslucencyKind, and InvAlpha - OneMinusSrcAlpha over SrcAlpha - had no representation. Blend is baked into the pipeline and is not dynamic, so it could not be handled at the encoder, and folding it onto StraightAlpha would have silently changed how every inverse-alpha surface composites. ParticleRenderer needs it too. The contract grows here, in one reviewed commit, rather than a slice inventing a workaround for it.

Retiring V2's interim handle table turns out to be its own slice. The renderers only intern bindless handles; the raw ulong is produced by the texture caches, baked into ObjectRenderBatch, and carried by GroupKey - the bucketing key V4c is forbidden to change - and by CachedBatch, where it gates cache validity. That is now V4t, with its own pixel gate. Until it lands, the world renderers bind their existing interim tables through the encoder as ordinary storage buffers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-27 20:20:33 +02:00
parent a475732587
commit 111e72362f
4 changed files with 65 additions and 1 deletions

View file

@ -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

View file

@ -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}."),
};

View file

@ -120,6 +120,18 @@ internal enum GpuBlendMode
/// <summary>Additive: <c>SrcAlpha, One</c>.</summary>
Additive,
/// <summary>
/// Retail's inverse-alpha translucency: <c>OneMinusSrcAlpha, SrcAlpha</c>.
///
/// 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 <see cref="StraightAlpha"/> would have been a retail-fidelity
/// regression, not a simplification, so the contract grew instead.
/// `ParticleRenderer` needs it too (slice V4e).
/// </summary>
InverseAlpha,
}
internal enum GpuCompareOp

View file

@ -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<GpuBlendMode>().Length);
Assert.Contains(GpuBlendMode.None, Enum.GetValues<GpuBlendMode>());
Assert.Contains(GpuBlendMode.StraightAlpha, Enum.GetValues<GpuBlendMode>());
Assert.Contains(GpuBlendMode.Additive, Enum.GetValues<GpuBlendMode>());
Assert.Contains(GpuBlendMode.InverseAlpha, Enum.GetValues<GpuBlendMode>());
}
[Fact]
public void UnassignedTextureSlotIsNeverAValidIndex()
{