docs(render): Campaign V slice V3 - clip-space and sRGB audit

V3 exists to verify the assumptions the whole Vulkan design rests on, before
V6 builds on them. The central claim held. One plan assumption did not, and
catching it here is the slice paying for itself.

Confirmed: every projection reaching a shader is built by
Matrix4x4.CreatePerspectiveFieldOfView, so NDC z is already [0,1] - Vulkan's
own convention - and no projection rework is needed. There are no orthographic
projections in production at all; the retained UI converts pixel coordinates
straight to NDC with a constant z, so V4a has no matrix to convert. Phase U.3's
clip planes are derived and compared entirely in clip space with plane.z always
zero, making them insensitive to both the depth convention and the viewport Y
flip. SkyProjection.WithDepthRange is the only hand-written matrix edit and it
re-derives the same D3D-convention mapping rather than a GL-style depth scale.

Corrected: the plan specified a B8G8R8A8_SRGB swapchain "matching the GL
FramebufferSrgb contract." That contract does not exist. FramebufferSrgb is
enabled only inside the throwaway capability probe and disabled immediately,
never on the real backbuffer; no texture uses an sRGB internal format; no
shader converts gamma. The renderer is UNORM end to end, so the correct
swapchain format is B8G8R8A8_UNORM. Shipping _SRGB would have applied an encode
to already-display-space values - a global brightening on every frame that
nothing before V7 would have caught.

Two acceptance items carried forward to V6/V7: the Vulkan encoder must flip
scissor rectangles itself, because vkCmdSetScissor is top-left-origin and the
negative viewport height does not affect it; and the V7 differential must
launch both backends with ACDREAM_MSAA_SAMPLES=0, since MSAA is fixed at window
creation and cannot be toggled mid-session.

Filed #248 for FrustumCuller's near-plane extraction, which uses the GL
[-1,1] Gribb-Hartmann formula against [0,1] matrices. It is provably
over-inclusive rather than over-culling, and it is pure CPU math untouched by
the backend swap, so it is tracked rather than fixed inside this campaign.

No code changed, so the pixel gate is trivially satisfied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-27 16:20:34 +02:00
parent 22b5abba57
commit ec414d60cd
2 changed files with 108 additions and 6 deletions

View file

@ -57,6 +57,11 @@ What does NOT go here:
multi-session host, shared immutable content, deterministic bot API,
1/5/10/30-session and two-hour simulated gates, connected portal/movement
parity, resource ceilings, and graceful zero-debt teardown.
- **Active rendering campaign:** Campaign V, OpenGL → Vulkan, executes from
[`plans/2026-07-27-vulkan-campaign.md`](plans/2026-07-27-vulkan-campaign.md).
V0 pinned the RHI contract, V1 landed the GL backend, V2 migrated the shaders
and CPU batch data to texture-table indices, and V3 audited clip space and
sRGB. `#248` came out of the V3 audit.
- **Deferred Linux graphical track:** L0 completed at `66f114b2`; L1's backend
selection/capability implementation checkpoint landed at `11501d52`.
Native Windows passes and WSLg correctly rejects its missing mandatory
@ -92,6 +97,42 @@ Copy this block when adding a new issue:
---
## #248 — FrustumCuller extracts the near plane with the GL-convention formula
**Status:** OPEN
**Severity:** LOW (correctness hygiene; not currently exploitable)
**Filed:** 2026-07-27
**Component:** rendering / culling
**Description:** `FrustumCuller.FromViewProjection`
(`src/AcDream.App/Rendering/FrustumCuller.cs:34-55`) extracts the near plane as
`Normalize(col4 + col3)` — the classic Gribb-Hartmann formula for OpenGL's
`[-1,1]` NDC z range. Every acdream projection is built by
`Matrix4x4.CreatePerspectiveFieldOfView`, whose NDC z range is `[0,1]`, for which
the correct near-plane extraction is `col3` alone.
**Why it is not currently a bug:** the mismatched formula places the effective
near threshold at `-n·f/(2f-n)` — roughly 0.5 m instead of 1.0 m for the retail
chase camera — which makes the AABB test strictly *more* permissive near the eye.
It can keep something the true frustum would drop, never the reverse, so it
produces no missing geometry. The far plane (`col4 - col3`) is identical under
both conventions and is unaffected.
**Provenance:** found by the Campaign V slice V3 clip-space audit. This is the
same class of mistake as the `PortalProjection` near-test bug documented at
`src/AcDream.App/Rendering/PortalProjection.cs:12-19`, which *was* user-visible
(it clipped a doorway the camera stood close to, culling the cell behind it).
Filed rather than fixed inline because it is pure CPU math, untouched by the
OpenGL → Vulkan migration, and therefore outside Campaign V's scope.
**Fix:** extract `near = Normalize(col3)`.
**Acceptance:** culling behaviour unchanged in the offline pixel gate and the
connected route; a unit test pinning the extracted near-plane distance to the
camera's actual near value.
---
## #247 — Loot ordering and local dropped-item projection regressed
**Status:** DONE — 2026-07-26; connected loot/drop/selection gate passed

View file

@ -240,8 +240,9 @@ straight into `GpuMemoryTracker` for exact accounting, which VMA would obscure.
write-to-array-then-`BufferSubData` (driver validation, copy, rename tracking)
simply stops existing.
- **Textures** — device-local pool. Formats stay UNORM (`BC1/2/3_UNORM`,
`R8G8B8A8_UNORM`, `R8_UNORM`); sRGB correctness lives at the framebuffer, as
it does on GL today. 2D arrays are allocated full-size and filled
`R8G8B8A8_UNORM`, `R8_UNORM`), matching GL exactly. There is no sRGB anywhere
in the pipeline — not on upload, not in the shaders, not at the framebuffer
(V3 audit, §4.10). 2D arrays are allocated full-size and filled
incrementally, mirroring `ManagedGLTextureArray`.
- **Mip generation** — DAT surfaces ship no mips. Uncompressed formats get a
`vkCmdBlitImage` chain at upload. **BC formats cannot be blit targets**, so
@ -355,16 +356,76 @@ Budget: roughly 46 batched `vkCmdPipelineBarrier2` calls per frame.
Surface through Silk windowing (`GraphicsAPI.DefaultVulkan`, `IWindow.VkSurface`)
so the existing GLFW platform selection, `ACDREAM_DISPLAY_PROTOCOL`, and window
lifecycle are unchanged. Format `B8G8R8A8_SRGB` preferred (matching the GL
`FramebufferSrgb` contract: shaders write linear, the attachment encodes);
screenshots swizzle BGRA→RGBA on the CPU to preserve
lifecycle are unchanged. Format **`B8G8R8A8_UNORM`** — see §4.10, this was
corrected at V3 and is the single highest-severity finding of the audit.
Screenshots swizzle BGRA→RGBA on the CPU to preserve
`FrameScreenshotController`'s RGBA byte contract. Present modes: `FIFO` when
VSync is on; `IMMEDIATE` preferred then `MAILBOX` when off, with
`FramePacingController` and its platform waiters continuing to drive the software
cap. `OUT_OF_DATE` recreates immediately, `SUBOPTIMAL` at the next frame
boundary, both through `FramebufferResizeController`.
### 4.10 Capability gate
### 4.10 V3 audit findings (2026-07-27)
Slice V3 audited every projection producer, every depth-range assumption, the
clip-plane derivation, the sRGB path, and MSAA control. The central claim held —
**but one plan assumption was wrong, and catching it is why V3 exists.**
**Confirmed.** Every projection that reaches a shader is built by
`Matrix4x4.CreatePerspectiveFieldOfView` (world, portal tunnel, paperdoll,
appraisal cameras; terrain, mesh, particles, debug lines and sky all consume the
same matrices). There are **no orthographic projections in production code at
all** — the retained UI's `ui_text.vert` converts pixel coordinates straight to
NDC with a constant `z = 0`, so V4a has no matrix to convert, only a Y-sign to
check. So: no projection rework, exactly as designed.
`SkyProjection.WithDepthRange` is the only hand-written matrix edit, assigning
`M33`/`M43` directly. It re-derives the *same* D3D-convention near/far mapping
(it even throws on an orthographic input) rather than a GL-style `2/(f-n)`
scale — correct, but the sharpest edge in the codebase and a required
cross-check at V6.
Phase U.3's clip planes are derived and consumed entirely in clip space with
`plane.z` always 0, so they are insensitive to both the depth convention and the
viewport Y flip. No change needed.
**Corrected — sRGB.** The plan previously specified a `B8G8R8A8_SRGB` swapchain
"matching the GL `FramebufferSrgb` contract." That contract does not exist.
`EnableCap.FramebufferSrgb` is enabled only inside the throwaway 2×2 capability
probe (`GraphicalGlFunctionProbe.cs:419-429`) and disabled immediately; it is
never enabled on the real backbuffer. No texture is uploaded in an sRGB internal
format (`TextureFormatExtensions` has none), and no shader performs any gamma
conversion. The renderer is plain UNORM end to end. **The correct Vulkan
swapchain format is `B8G8R8A8_UNORM`**; shipping `_SRGB` would have applied an
unwanted encode to already-display-space values — a global brightening across
every frame, and precisely the failure mode §6 lists as "cannot pass silently."
It would have passed silently right up to V7.
Separately: the capability gate *requires* sRGB-framebuffer support that the
renderer never uses. Harmless today, but the Vulkan gate must not carry the
stale requirement forward.
**MSAA.** `ACDREAM_MSAA_SAMPLES` overrides the quality preset
(`QualityPreset.cs:43-59`) and `0` forces MSAA off, but it is read at window
creation and cannot change mid-session. The V7 differential script must therefore
*launch* both backends with `ACDREAM_MSAA_SAMPLES=0` rather than toggling a
setting.
**Two concrete acceptance items carried to V6/V7.**
1. **Scissor Y convention.** `NdcScissorRect.ToPixels` emits GL bottom-left-origin
pixel rectangles. Vulkan's `vkCmdSetScissor` is always top-left-origin — the
negative viewport height does *not* flip the scissor. The contract already says
callers keep GL convention and the backend converts
(`IGpuPassEncoder.SetScissor`), so the Vulkan encoder must do that flip. A
scissored aperture — a doorway — is the right differential-gate target.
2. **`FrustumCuller` near plane** extracts `col4 + col3`, the GL `[-1,1]` Gribb-
Hartmann formula, against `[0,1]`-convention matrices; the correct extraction
is `col3` alone. Proven over-inclusive rather than over-culling, so it is not a
visibility bug, and it is pure CPU math untouched by the backend swap. Filed as
a tracked issue rather than fixed here — it is not Campaign V's scope.
### 4.11 Capability gate
Mirrors the GL three-layer shape exactly — passive record, **active** probes, an
`Evaluate` that throws `NotSupportedException``Program.cs` exit code 4 → an