diff --git a/CLAUDE.md b/CLAUDE.md index cca915be..23986101 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -572,6 +572,7 @@ render/streaming work at `claude-memory/project_render_pipeline_digest.md`. Documentation entry point: [`docs/README.md`](docs/README.md). For canonical state, read in this order: +- [`docs/plans/2026-07-27-vulkan-campaign.md`](docs/plans/2026-07-27-vulkan-campaign.md) — **ACTIVE: Campaign V, OpenGL → Vulkan.** The pinned RHI contract, the Vulkan technical decisions, the V0–V11 slice table with per-slice gates, and the subagent execution rules. Read this before touching anything under `src/AcDream.App/Rendering/`. ImGui dev tools and UI Studio are NOT ported and are deleted at V11. - [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone - [`docs/plans/2026-04-11-roadmap.md`](docs/plans/2026-04-11-roadmap.md) — what's shipped, what's in flight, what's next - [`docs/ISSUES.md`](docs/ISSUES.md) — open + recently closed bugs (tactical) diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index c7d143be..ca757b60 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -14,6 +14,26 @@ correctly report that Mesa D3D12/llvmpipe lacks mandatory bindless textures. A supported physical Linux AMD/NVIDIA driver row remains the first gate when Slice L resumes; the physical gate and L2–L6 are deferred. +**Campaign V — OpenGL → Vulkan (active, started 2026-07-27):** the renderer +migrates to a single Vulkan 1.3 backend on Windows x64 and Linux x64, and the +OpenGL backend is deleted at the end. The plan is +[`2026-07-27-vulkan-campaign.md`](2026-07-27-vulkan-campaign.md). Motivation is +compatibility and efficiency, not rescue: mandatory `GL_ARB_bindless_texture` is +the exact floor that parked Slice L (Mesa D3D12/llvmpipe lack it), while Vulkan's +descriptor indexing is core, and per-frame data can be written straight into +mapped memory instead of copied through `BufferSubData`. Method follows the +I5→I6→I7 precedent: a Vulkan-shaped RHI implemented **first on GL** so each of the +twelve renderers ports one at a time under a strict pixel gate on the still- +shipping backend, then a Vulkan implementation of the same contract, a +GL-versus-Vulkan differential, a perf gate, cutover, and deletion. Slices V0–V11. +V0 pinned the contract (`src/AcDream.App/Rendering/Gpu/`, `RecordingGpuDevice`, +55 contract tests). The ImGui developer stack and UI Studio are **not** ported and +are deleted at V11; re-homing the dev panels onto the retained UI is a tracked +follow-up. Targets versus the GL baseline (520 FPS, CPU/GPU p50 1.869/1.096 ms, +652 MiB working set): CPU p50 ≤ 1.60 ms, GPU p50 ≤ 1.00 ms, working set +≤ 600 MiB, 0 B/frame managed allocation — with parity on all four as the cutover +floor. + --- ## Current program: world interaction completion (M4 prelude) diff --git a/docs/plans/2026-07-27-vulkan-campaign.md b/docs/plans/2026-07-27-vulkan-campaign.md new file mode 100644 index 00000000..2d955a83 --- /dev/null +++ b/docs/plans/2026-07-27-vulkan-campaign.md @@ -0,0 +1,458 @@ +# Campaign V — OpenGL → Vulkan rendering migration + +**Status:** Active. V0 (pinned RHI contract) landed 2026-07-27. +**Scope:** Windows x64 + Linux x64. No macOS. +**End state:** one Vulkan 1.3 backend; the OpenGL backend is deleted. + +--- + +## 1. Why + +acdream's mandatory modern GL path (GL 4.3 core + `ARB_bindless_texture` + +`ARB_shader_draw_parameters` + MDI + SSBOs, with no fallback) is built on an API +that is no longer evolving, and its hardware floor is narrow: bindless textures +are absent on Intel integrated GPUs and on every Mesa software / D3D12 stack. +That floor is exactly what parked Slice L at its L1 checkpoint — WSLg correctly +rejects our renderer because Mesa's D3D12 and llvmpipe drivers do not advertise +`GL_ARB_bindless_texture`. + +Vulkan 1.3 makes the same rendering strategy portable: descriptor indexing (the +bindless replacement) is a core feature, not a vendor extension, and it works on +RADV, NVIDIA, Intel, and lavapipe. Two secondary wins follow: explicit present +control (a direct lead on issue #235's capped/RDP cadence alias) and lower CPU +cost per frame, because per-frame data can be written straight into mapped +memory instead of copied through `BufferSubData`. + +**This is a compatibility and efficiency campaign, not a rescue.** The GL path +works and is fast. Nothing here changes what the game looks like. + +--- + +## 2. Goal and acceptance + +Replace the OpenGL renderer with a single Vulkan 1.3 backend, preserving retail +pixels exactly and improving cost. + +| Dimension | GL baseline | Campaign target | Cutover floor | +|---|---|---|---| +| CPU frame p50 | 1.869 ms | ≤ 1.60 ms | ≤ 1.869 ms | +| GPU frame p50 | 1.096 ms | ≤ 1.00 ms | ≤ 1.096 ms | +| Working set | 652 MiB | ≤ 600 MiB | ≤ 652 MiB | +| Private set | 928 MiB | ≤ 860 MiB | ≤ 928 MiB | +| Managed alloc / frame | ~0 B | 0 B | 0 B | +| CPU/GPU p99 | measured at V8 | ≤ GL p99 | ≤ GL p99 | + +Pixel acceptance: `dotnet AcDream.Cli.dll compare-screenshots expected.png +actual.png out.json` at channel tolerance 2 and maximum differing fraction +0.001, MSAA off, `ACDREAM_DAY_GROUP` pinned, at every deterministic checkpoint +of the connected lifecycle route. + +**Out of scope (user decision, 2026-07-27):** the ImGui developer stack +(`AcDream.UI.ImGui`, `ImGuiBootstrapper`, the DevTools menu bar) is not ported, +and UI Studio (`StudioWindow`, `PanelFbo`) is parked. Both are deleted at V11 +and remain recoverable from git. A follow-up issue tracks re-homing the +Settings and Debug panels onto the retained UI through a new `IPanelRenderer` +implementation — the panels themselves need no rewrite because they already +target only `AcDream.UI.Abstractions`. Until that lands, keybind remapping falls +back to editing `keybinds.json`. + +--- + +## 3. Architecture: a Vulkan-shaped RHI, implemented by GL first + +### 3.1 The decision + +The renderers port **one at a time onto a minimal Vulkan-shaped RHI while GL is +still the shipping backend**. Each port slice is pixel-gated against its parent +commit on the *same* backend, so a divergence is attributed to one slice rather +than discovered at a big-bang integration. Only then does the Vulkan backend +implement the same interfaces, gated by a GL-versus-Vulkan differential. + +The alternatives were rejected for concrete reasons: + +- **Per-renderer duplication** (`WbDrawDispatcherVk` beside the GL one) would + fork roughly 15,000 lines of CPU logic — bucketing, `RetailAlphaQueue` + ordering, LRU eviction, arena management — that has nothing to do with the + graphics API and everything to do with retail fidelity. `WbDrawDispatcher` is + 4,449 + 809 lines but holds only ~62 GL call sites, clustered in the per-frame + uploads, the two multi-draw brackets, and teardown. The API surface is small; + the fidelity logic is large. Forking the wrong one of those is how subtle + regressions enter. +- **A serialized command IR** adds a third representation and a per-frame + translation cost, against the efficiency goal, for generality nothing asked + for. The RHI *is* the prepared-frame-data seam, expressed as typed calls. + +GL 4.3 implements every Vulkan-shaped concept cheaply: pipelines become a +program bind plus a cached state apply; ring allocations sit on the existing +fence-bounded dynamic buffers; a descriptor-table index becomes an indirection +through a storage buffer of bindless handles; passes are a no-op bracket. **The +GL backend is deliberately behaviour-preserving and never improved** — it keeps +`BufferSubData` — which is precisely what makes each port slice's pixel gate a +strict identity check. The efficiency wins land in the Vulkan backend only. + +### 3.2 Location and isolation + +Namespaces inside `AcDream.App`, not a new project: + +- `AcDream.App.Rendering.Gpu` — the contract (landed at V0) +- `AcDream.App.Rendering.Gpu.Gl` — GL backend (deleted at V11) +- `AcDream.App.Rendering.Gpu.Vk` — Vulkan backend + +A separate project would force a public surface or `InternalsVisibleTo` churn +for twelve `internal` renderers, and its only benefit — compile-time proof that +renderers cannot reach GL — arrives anyway at V11 when the `Silk.NET.OpenGL` +package reference is dropped. Until then the guarantee comes from an +architecture test added at V4h that asserts no type outside `Gpu.Gl` and a small +allowlist references `Silk.NET.OpenGL`. Deletion at cutover is one directory and +one `PackageReference`. + +### 3.3 The contract (pinned at V0) + +`src/AcDream.App/Rendering/Gpu/`: + +| Type | Responsibility | +|---|---| +| `IGpuDevice` | Resource creation, the global texture table, frame lifecycle, the deferred device-action queue (replaces `QueueGLAction`), backbuffer capture, retirement queue. | +| `IGpuFrame` | One frame: ring allocations, `BeginPass`, submit/present on `End`. | +| `IGpuPassEncoder` | Records one pass: bind pipeline/buffers, push constants, dynamic cull/front-face/depth-write, viewport/scissor, `Draw`, `DrawIndexed`, `MultiDrawIndexedIndirect`, timer scopes. | +| `GpuRingAllocation` | A `ref struct` slice of the frame's upload ring: buffer, aligned offset, CPU-writable span. Replaces every per-frame `BufferSubData`. | +| `IGpuBuffer` / `IGpuTexture` / `IGpuSampler` | Resources. Disposal routes through the retirement queue, never freeing under a live frame. | +| `IGpuPipeline` + `GpuPipelineDescription` | Shader pair plus all state Vulkan bakes: blend, depth, cull default, front face, alpha-to-coverage, topology, sample count. | +| `GpuPassDescription` | Attachments with load/store ops, clear values, sample count, resolve. | +| `IGpuRenderTarget` | Offscreen colour(+depth) whose colour is sampleable after the pass. | +| `IGpuTimerPool` | GPU timings from retired frames. | +| `GpuCapabilityRecord` | Backend-neutral capability view; computes `SupportFailures`, feeding the existing exit-code-4 contract. | +| `GpuTextureSlot` | Index into the global texture table — the backend-neutral replacement for a bindless handle. | +| `RecordingGpuDevice` (in the test project) | In-memory double: records calls in order and backs ring allocations with real memory, so renderer tests run with no GPU. | + +**Design notes worth keeping in mind while implementing:** + +- `GpuTextureSlot.Unassigned` is a loud sentinel (`uint.MaxValue`), never a + usable slot, and must never reach a shader. Renderers needing a fallback take + `IGpuDevice.DefaultTextureSlot`, a really registered 1×1 white texture. This + is deliberate: silently resolving an unset index to slot 0 is the failure mode + that produced the magenta 1×1 UI placeholder bug. +- `GpuRingAllocation` is a `ref struct` so the compiler forbids storing it past + the frame that owns the memory. +- Renderers always speak GL conventions for winding and viewport origin. The + Vulkan backend renders with a negative viewport height and inverts front-face + in exactly one mapping function. No renderer performs that flip itself. + +### 3.4 The binding model (`GpuBindingModel`) + +Dual-legal for GL GLSL and Vulkan GLSL, exploiting `GL_KHR_vulkan_glsl`'s rule +that an omitted `set` qualifier means set 0. + +- **set 0** — storage buffers, bindings 0–8 exactly as the shaders declare them + today (instances, batches, clip regions, clip slots, global lights, instance + light sets, instance indoor, instance alpha, selection lighting), plus + **binding 9 = texture table**, which is the GL-only emulation (a buffer of + `uvec2` bindless handles) and is deleted with the GL backend. +- **set 1** — uniform buffers. `SceneLighting` keeps `binding = 1`. Today + `mesh_modern` relies on GL keeping SSBO and UBO binding tables separate so the + `BatchBuffer` SSBO and the `SceneLighting` UBO can both be binding 1. Vulkan + has one binding namespace per set, so moving UBOs to their own set preserves + both numbers and removes the collision. +- **set 2** — the global sampled-texture descriptor array: variable count, + partially bound, update-after-bind, capacity 16384. +- **Push constants** — one shared 96-byte `GpuPushConstants` block (of the 128 + Vulkan guarantees): `ViewProjection`, `DrawIdOffset`, `LightingMode`, + `RenderPass`, `LightDebug`, `TextureIndexA/B`, two spare scalars. One shared + block means one pipeline layout, so switching pipelines mid-pass does not + invalidate bound descriptors. The GL backend maps each field to the + correspondingly named uniform and skips those a program does not declare. + +`BatchData`'s `uvec2 textureHandle` becomes `uint textureIndex` plus a pad word +at V2. The 16-byte std430 stride is unchanged, so every existing CPU writer +keeps its offsets. **That single change is what makes the CPU-side data model +backend-neutral, and it lands on GL, pixel-gated, long before Vulkan exists.** + +--- + +## 4. Vulkan technical decisions + +### 4.1 Floor + +Vulkan 1.3 core plus `VK_KHR_swapchain` (and the platform surface extensions). +Optional and never required: `VK_EXT_memory_budget` (telemetry), +`VK_EXT_debug_utils` (object naming in dev builds), `VK_KHR_present_wait` (an +issue #235 experiment). + +Required device features, each with a reason: + +| Feature | Why | +|---|---| +| `multiDrawIndirect` | The three MDI dispatch sites are the entire draw architecture. | +| `drawIndirectFirstInstance` | Indirect commands carry a non-zero `firstInstance` as the per-group instance base. | +| `shaderDrawParameters` | `gl_DrawID`. Resets per `vkCmdDrawIndexedIndirect` exactly as GL's does, so the issue #52 `uDrawIDOffset` pattern carries over unchanged. | +| `shaderClipDistance` (≥ 8) | Phase U.3's per-cell screen-space clip gate. | +| `textureCompressionBC` | DXT1/3/5 DAT surfaces upload as BC1/2/3 with no transcode. | +| `samplerAnisotropy` | Sampler-quality parity. | +| `timelineSemaphore` | One monotonic serial replaces the GL fence array; the existing retirement ledger keeps its serial keys. | +| `hostQueryReset` | Reset timestamp pools from the CPU instead of burning command-buffer calls. | +| descriptor-indexing set (`runtimeDescriptorArray`, `descriptorBindingPartiallyBound`, `…SampledImageUpdateAfterBind`, `…UpdateUnusedWhilePending`, `…VariableDescriptorCount`) | The global texture table replacing bindless handles. | +| `dynamicRendering`, `synchronization2`, `maintenance4` | No render-pass/framebuffer objects; barrier2; relaxed shader interface rules. | + +Explicitly **not** required: `bufferDeviceAddress` (every buffer is descriptor +bound; it would buy nothing and costs capture-tool compatibility), any +compute/geometry/tessellation feature (acdream has no such shaders), +`fillModeNonSolid` (debug lines use `LINE_LIST` topology). + +Limits to assert in the probe: `maxPushConstantsSize ≥ 128`, +`timestampComputeAndGraphics`, `maxDescriptorSetUpdateAfterBindSampledImages ≥ +16384`, `maxPerStageDescriptorUpdateAfterBindSampledImages ≥ 16384`. + +### 4.2 Bindings layer + +`Silk.NET.Vulkan` + `Silk.NET.Vulkan.Extensions.KHR` at **2.23.0**, matching the +pinned Silk family. It is blittable-struct and function-pointer based, so with +`stackalloc`/`fixed` for the small arrays passed to submits and barriers it +allocates nothing per frame — which the 0 B/frame target requires. + +**No VMA dependency.** Silk does not ship it, third-party .NET bindings are a +native-binary and maintenance liability across win-x64/linux-x64/CI-lavapipe, +and acdream's allocation profile is tame: two mesh arena buffers, one staging +ring, a handful of per-frame buffers, ~4 render targets, and a texture pool. A +custom allocator (~400 lines, first-fit free list over 128 MiB device-local +blocks per memory type, dedicated allocations at ≥ 32 MiB) keeps +`vkAllocateMemory` counts two orders of magnitude below the limit and plugs +straight into `GpuMemoryTracker` for exact accounting, which VMA would obscure. + +### 4.3 Memory + +- **Mesh arena** — two `DEVICE_LOCAL` buffers mirroring `GlobalMeshBuffer` + exactly: 384 MiB vertex, 128 MiB index (`VK_INDEX_TYPE_UINT16`; the existing + cap is already expressed in `sizeof(ushort)`). Keep the reclaimable-range + allocator, growth quanta, budgeted incremental grow-and-copy (now + `vkCmdCopyBuffer`), retirement-ledger deletes, and the 896 MiB dual-generation + ceiling. +- **Staging ring** — one persistently mapped `HOST_VISIBLE|COHERENT` buffer + (48 MiB), watermarked per flight slot, recycled when the slot retires. + Oversized uploads take a temporary dedicated buffer retired through the ledger. +- **Per-frame data** — the CPU win. Each MDI renderer gets, per flight slot, one + persistently mapped buffer holding its instance/batch/clip/light/indoor/alpha/ + selection sections at fixed aligned offsets, plus indirect commands and the + SceneLighting block. Prefer `DEVICE_LOCAL|HOST_VISIBLE` (ReBAR — present on + the RX 9070 XT, RADV, and modern NVIDIA), fall back to `HOST_VISIBLE|COHERENT`. + The bucketing code writes structs **directly into mapped memory**; today's + 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 + 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 + their chains are built on the CPU at decode time (box filter + a small managed + BC encoder, deterministic and unit-testable) — which also replaces today's + driver-defined behaviour for `glGenerateMipmap` on compressed arrays. Escape + hatch if encoder quality ever trips the pixel gate: store the affected + textures as RGBA8 and blit their mips. + +### 4.4 Descriptors + +Two persistent sets, one shared pipeline layout, **zero descriptor writes per +frame**. + +- **Set 0** — one `COMBINED_IMAGE_SAMPLER` binding, 16384 variable count, + `PARTIALLY_BOUND | UPDATE_AFTER_BIND | UPDATE_UNUSED_WHILE_PENDING`, fragment + stage. A slot is a (view, sampler) pair — exact parity with bindless handles, + which are also per texture+sampler. Registration appends one descriptor write; + eviction returns the slot to a free list gated on frame retirement, and the + slot is defensively overwritten with a dummy before reuse. This removes the + entire `MakeTextureHandleResident` churn. +- **Set 1** — per-renderer, per-flight-slot storage buffers at the nine + `GpuBindingModel` bindings plus the SceneLighting UBO, all pointing into that + renderer's mapped per-slot buffer at fixed offsets. Written once at startup; + rewritten only when a buffer grows, gated on that slot's retirement. Bindings a + given renderer does not use still bind a shared dummy range so there is one + layout and no permutations. + +### 4.5 Pipelines + +Core 1.3 dynamic state covers viewport, scissor, cull mode, front face, depth +test/write/compare, stencil test/ops, and topology class — which folds the GL +pass matrix's cull/depth-mask/stencil toggles into command-time calls. Blend and +alpha-to-coverage are **not** dynamic, so they define the pipeline list: +mesh opaque / alpha / additive, terrain, sky, particle alpha / additive, +particle-mesh alpha / additive, debug line, UI text, plus offscreen variants +only where the target's format or sample count differs. **Expect 11–14 +pipelines.** + +All are known statically and **built at startup** against a `VkPipelineCache` +persisted to `ApplicationPathSet.CacheDirectory` (validated by header UUID). +First launch pays a few hundred milliseconds once; later launches are +milliseconds, and no frame ever compiles — which also removes GL's hidden +first-draw driver-recompile hitches. + +Depth/stencil: prefer `D32_SFLOAT_S8_UINT`, fall back `D24_UNORM_S8_UINT`. The +stencil aspect is required by #117's portal punch. + +### 4.6 Shaders + +The eight GLSL pairs stay the single source of truth. Vulkan-dialect changes: +`set`/`binding` qualifiers per §3.4; `texture(uTextures[nonuniformEXT(idx)], …)` +replacing the bindless `sampler2DArray(handle)` reconstruction; `gl_DrawIDARB` → +`gl_DrawID`; `gl_BaseInstanceARB + gl_InstanceID` → `gl_InstanceIndex` (Vulkan's +already includes `firstInstance`); the loose uniforms move into the push-constant +block. std430 SSBO layouts, the std140 SceneLighting block, and +`gl_ClipDistance[8]` port byte-identically. + +`nonuniformEXT` is **required, not optional**: within one MDI dispatch different +draws read different `Batches[]` entries, and "dynamically uniform" is defined +over the whole dispatch on some implementations. The qualifier costs nothing +measurable on RDNA or NVIDIA and removes a class of silent corruption. + +**Compilation: committed `.spv` artifacts** produced by +`tools/compile-shaders.ps1` (glslang/glslc), plus a test that hashes the GLSL +sources into a committed manifest and fails when they drift. CI runners have no +Vulkan SDK, and runtime shaderc would add a native dependency and startup cost +for shaders that never change at runtime. + +### 4.7 Clip space — no projection change needed + +**Verified:** acdream's cameras already build projections with +`Matrix4x4.CreatePerspectiveFieldOfView`, which is D3D convention with NDC z in +[0, 1] — documented at `src/AcDream.App/Rendering/PortalProjection.cs:12-13`, +where the GL-convention near test was previously a real bug. Vulkan's clip +convention *is* [0, 1], so the matrices are consumed as-is. The GL path has been +compressing [0, 1] clip z into the upper half of the depth buffer, so Vulkan +doubles effective depth precision for free. + +Consequence to expect at V7: **window-space depth values shift, so z-fight +patterns on near-coplanar retail geometry may differ.** This is the one +pre-approved divergence class; each instance gets a compare mask or a per-stop +relaxation plus a divergence-register row. + +Y-flip is handled by a negative viewport height (core since 1.1), which keeps +winding and cull semantics identical to GL. Reversed-Z remains an easy future +option and is explicitly not required for parity. + +### 4.8 Sync and the frame + +Two frames in flight; one primary command buffer per frame from a per-slot +`vkResetCommandPool`; no secondary buffers (single render thread); one +graphics+present queue with transfers riding it (an async transfer queue is a +deferred option, not a need — uploads are already budget-throttled). Per-slot +binary acquire semaphores, per-image binary render-done semaphores, and **one +timeline semaphore whose value is the frame serial** — so `GpuFrameFlightController` +ports almost mechanically, its `SortedDictionary>` retirement +ledger keeping its keys. + +Frame skeleton (synchronization2 throughout): wait timeline ≥ serial − 2 → run +retirements → reset pool → write per-frame data into mapped slot buffers → +acquire → record [uploads: copies, one batched image barrier to +`TRANSFER_DST`, copies/blits, one batched barrier to `SHADER_READ_ONLY` plus a +buffer barrier to vertex/indirect stages] → [offscreen passes] → [main pass: +MSAA colour `CLEAR/DONT_CARE` resolving to the swapchain, transient depth +`CLEAR/DONT_CARE`, sky → terrain → entities → envcells → particles → weather → +UI] → [optional screenshot copy] → barrier to `PRESENT_SRC` → submit → present. +Budget: roughly 4–6 batched `vkCmdPipelineBarrier2` calls per frame. + +### 4.9 Swapchain, present, pacing + +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 +`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 + +Mirrors the GL three-layer shape exactly — passive record, **active** probes, an +`Evaluate` that throws `NotSupportedException` → `Program.cs` exit code 4 → an +atomic `graphical-capabilities.json`. The Vulkan active probe is stronger than +the GL one: it creates the real device with the production feature chain, builds +the real descriptor layouts and one real pipeline from the committed `.spv`, and +renders a 64×64 offscreen triangle sampling a table slot, then reads the pixels +back. Device selection: discrete > integrated > virtual > CPU, tie-broken by +largest device-local heap, with an `ACDREAM_VULKAN_DEVICE` override recorded in +the report. + +--- + +## 5. Slice sequence + +Every slice ends with `dotnet build` and the App test suite green, its gate +passed, and one commit. GL remains the default backend through V9; all Vulkan +work is dark behind `ACDREAM_RENDER_BACKEND` (default `gl`). + +**Run the suite in Release: `dotnet test … -c Release`.** Some tests assert +Release-only behaviour and legitimately fail in Debug — +`LandblockBuildOriginTests.FarLoad_StripsEnvCellsAndPhysicsEvenWhenEntityListIsAlreadyEmpty` +covers the far-tier strip that `LandblockStreamer.cs:505` deliberately turns into +a loud `Debug.Assert` in Debug builds. A Debug run therefore shows one failure +that is neither a regression nor yours. The V0 baseline is **3,785 passed / +3 skipped** in Release (3,763 pre-campaign plus 22 contract tests). + +"Pixel gate" means: capture the deterministic checkpoints from the parent +commit's build, capture again at slice HEAD, compare with the +`compare-screenshots` CLI at tolerance 2 / fraction 0.001, MSAA off, +`ACDREAM_DAY_GROUP` pinned. + +| Slice | Scope | Gate | +|---|---|---| +| **V0** ✅ | Pinned RHI contract, `RecordingGpuDevice`, contract tests, this document, roadmap entry. | build + tests + contract tests | +| **V1** | GL backend: `GlGpuDevice` (no Chorizite inheritance), buffers (`BufferSubData`, behaviour-preserving), ring over the existing fence-bounded pattern, textures + the binding-9 handle table, samplers, pipelines, the ambient-encoder relaxation, timers, `IGpuFenceApi` re-home, backbuffer capture. Constructed in composition; no consumers yet. | build + tests + GL unit tests + pixel gate (trivially identical — a tripwire) | +| **V2** | Shader dialect + texture-index migration **on GL**: `uvec2 textureHandle` → `uint textureIndex`, binding-9 table, `common.glsl` preamble, CPU batch-struct change, caches registering into the device table. Sub-commits: V2a mesh, V2b terrain, V2c particles. | pixel gate per sub-commit | +| **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 | +| **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) | +| **V4g** | `PrivateEntityViewportRenderer` → `IGpuRenderTarget`; `PortalDepthMaskRenderer` + `PortalTunnelPresentation` → stencil/depth-mask pipelines. | pixel gate incl. paperdoll and portal transit | +| **V4h** | Frame-spine formalization: pass executors emit `BeginPass`/`EndPass`, the ambient relaxation is removed, flight/screenshot/resize/profiler move onto the RHI, `OpenGLGraphicsDevice`'s live role retires, Chorizite consumers are audited, and the architecture test lands. **Milestone: seam complete.** | pixel + connected lifecycle + R6 soak + complete Release suite + interim perf (RHI-on-GL CPU p50 ≤ 1.95 ms) | +| **V5** | Vulkan bring-up, dark: `ACDREAM_RENDER_BACKEND`, surface/instance/device/queues/swapchain, the capability record/probe/guard with the exit-4 contract, a clear-colour loop with screenshot and clean shutdown. | VK boots to clear on the RX 9070 XT; forced-unsupported knob → exit 4 | +| **V6** | Vulkan RHI backend, dark, three sequential commits: **a** allocator/buffers/staging/rings/timeline; **b** textures/BC mips/samplers/descriptor table/render targets/MSAA resolve; **c** `.spv` toolchain, pipelines, pipeline cache, negative viewport, push constants, timestamps, readback, debug names. **Milestone: full game frame on Vulkan.** | per-commit build + tests; VK renders world, UI, paperdoll, portals | +| **V7** | GL-versus-Vulkan differential: `tools/run-backend-differential-gate.ps1`, strict paired-PNG compare, divergences fixed in the Vulkan backend only, then lifecycle + R6 soak natively on Vulkan, one validation-layer-clean run, one RenderDoc capture. **Milestone: parity.** | every differential checkpoint passes; both connected routes green on VK | +| **V8** | Perf gate on the RX 9070 XT, uncapped, both backends, same route. | §2 acceptance table; parity is the floor | +| **V9** | Linux + CI: X11/Wayland surfaces; a `linux-vulkan` job on lavapipe (probe accepts on a real 1.3 software device, a short real render under xvfb, forced-unsupported → exit 4, `.spv` freshness). Physical Linux GPU row deferred post-cutover, as for Slice L. | CI green including the new job | +| **V10** | Cutover: Vulkan default, GL reachable by env var for one slice, gate scripts default to VK. | complete Release suite + retail expected PNGs **on VK** (baselines not regenerated) + both connected routes + **user visual sign-off** | +| **V11** | GL deletion and closeout: delete `Gpu/Gl`, `OpenGLGraphicsDevice`, `ManagedGL*`, `GLSLShader`, `GLHelpers`, `GLStateScope`, `RenderStateCache`, `BindlessSupport`, `GraphicalGlFunctionProbe`, the GL branch in `GameWindow`, the ImGui project and Studio; drop the GL and (if the audit is clean) Chorizite packages; file the retained-UI dev-panels follow-up; swap CI assertions to VK; update the divergence register, architecture doc, code-structure doc, and rendering memory crib; re-measure memory. | complete Release suite + both connected routes + working-set re-measure | + +**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 +work alongside V8. While V4c runs, nothing else touches `Rendering/Wb`. + +--- + +## 6. Risk register + +| Risk | Mitigation | +|---|---| +| Y-flip and winding | Negative viewport height; the front-face inversion lives in one backend mapping function; the differential catches any residue. | +| Depth-precision shift (z-fight patterns) | The only pre-approved divergence class; each instance gets a mask or per-stop relaxation plus a divergence-register row. | +| sRGB mismatch (global gamma shift) | Decided at V3 from the actual GL state; a mismatch fails every pixel at V7, so it cannot pass silently. | +| MSAA sample positions differ across backends | Strict gates run MSAA off; MSAA on gets a relaxed (0.01) visual smoke; a register row lands at V11. | +| ~15,000 lines of renderer churn destabilizing retail fidelity | CPU logic never forks; each port is self-differential on the still-shipping backend; V0 pins the contract so subagents never negotiate APIs; the architecture test prevents seam erosion. | +| Driver matrix — only one physical GPU (RX 9070 XT) | Conservative universal feature floor; lavapipe in CI as a second real implementation; one validation-layer-clean run at V7; the physical Linux row is deferred exactly as Slice L deferred it. | +| Swapchain lifecycle (resize, minimize, RDP) | Owned explicitly at V5 and exercised by the connected lifecycle gate. | +| App tests breaking as renderers change signatures | `RecordingGpuDevice` ships at V0; each slice updates its renderers' test constructions in the same commit. | +| Hidden Chorizite consumers | V1 builds the device root without Chorizite inheritance; V4h audits the remainder; the package drops at V11 only if that audit is clean. | +| `.spv` staleness | Single GLSL source, committed `.spv`, regeneration script, and a CI hash-freshness check. | + +--- + +## 7. Execution rules for slice subagents + +- Sonnet implementers by default. V0, V4c, V4h, and V6 are load-bearing — their + diffs are reviewed in the parent session before commit. +- **One agent per slice, always.** Coupled sub-slices (V2a–c, V6a–c) are + sequential commits by the same agent. Never fan out across files two slices + share. +- Every subagent prompt carries: this document's section numbers for the pinned + contract, the slice's file list, the gate definition, "build and tests green, + one commit," and the divergence-register same-commit rule. +- No slice regenerates expected retail baselines. They are immutable for the + duration of the campaign. +- Connected gates need the live ACE server and the user's machine. The visual + sign-off at V10 is a required user stop; there are no others besides gate + failures. diff --git a/src/AcDream.App/Rendering/Gpu/GpuBindingModel.cs b/src/AcDream.App/Rendering/Gpu/GpuBindingModel.cs new file mode 100644 index 00000000..2543be4b --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/GpuBindingModel.cs @@ -0,0 +1,129 @@ +namespace AcDream.App.Rendering.Gpu; + +/// +/// Campaign V (V0): the PINNED binding model shared by the CPU renderers, the +/// GLSL sources, and both RHI backends. Every number here appears in a shader +/// file; changing one is a contract change that must land in the same commit as +/// the matching shader edit. +/// +/// The model is deliberately Vulkan-shaped and dual-legal: +/// +/// set 0 — storage buffers, bindings 0..9. GLSL omits the set qualifier, and +/// GL_KHR_vulkan_glsl defines an omitted set as set 0, so one source +/// file compiles for both backends. +/// set 1 — uniform buffers. Vulkan has ONE binding namespace per set, while +/// GL keeps GL_SHADER_STORAGE_BUFFER and GL_UNIFORM_BUFFER tables +/// separate. Today mesh_modern.vert exploits that: the BatchBuffer +/// SSBO and the SceneLighting UBO both sit at binding=1. Moving UBOs +/// to their own set preserves both numbers and removes the collision. +/// set 2 — the global sampled-texture table that replaces ARB_bindless_texture. +/// Vulkan binds it as one variable-count, partially-bound, +/// update-after-bind descriptor array. GL emulates it with a storage +/// buffer of uvec2 handles at set 0 binding 9 (), +/// which is why both a set index and a storage binding exist here. +/// +/// A batch no longer carries a 64-bit bindless handle; it carries a +/// index into the table. That single change is what +/// makes the CPU-side data model backend-neutral (Campaign V slice V2), and it +/// lands on GL — pixel-gated — long before any Vulkan code exists. +/// +internal static class GpuBindingModel +{ + // ---- set 0: storage buffers (identical numbering to today's SSBO bindings) ---- + + /// Per-instance transforms. std430 InstanceData { mat4 transform; }. + public const uint StorageInstances = 0; + + /// Per-draw batch metadata. std430 BatchData (see ). + public const uint StorageBatches = 1; + + /// Phase U.3 shared per-frame clip regions (CellClip, 144 B/slot). Slot 0 = no-clip. + public const uint StorageClipRegions = 2; + + /// Phase U.3 per-instance clip-slot index, parallel to . + public const uint StorageClipSlots = 3; + + /// A7 Fix B global point/spot light array. + public const uint StorageGlobalLights = 4; + + /// A7 Fix B per-instance light set: 8 indices into the global light array, -1 = unused. + public const uint StorageInstanceLightSets = 5; + + /// #142 per-instance indoor flag (1 = parented to an EnvCell, skip the sun). + public const uint StorageInstanceIndoor = 6; + + /// #188 per-instance opacity multiplier for TransparentPartHook fades. + public const uint StorageInstanceAlpha = 7; + + /// Retail SmartBox selection lighting: one vec2 (luminosity, diffuse) per instance. + public const uint StorageInstanceSelectionLighting = 8; + + /// + /// GL-only emulation of the Vulkan texture table: a storage buffer of uvec2 + /// bindless handles indexed by . The Vulkan + /// backend binds instead and never uses this + /// binding; it is deleted with the GL backend at slice V11. + /// + public const uint StorageTextureTable = 9; + + /// One past the highest storage binding — the count both backends must support. + public const uint StorageBindingCount = 10; + + // ---- set 1: uniform buffers ---- + + /// + /// SceneLighting std140 block. Keeps binding=1 so the existing shader source + /// and SceneLightingUboBinding layout are untouched; the set index is + /// what disambiguates it from under Vulkan. + /// + public const uint UniformSceneLighting = 1; + + /// Set index carrying every uniform buffer. + public const uint UniformSet = 1; + + // ---- set 2: the global texture table ---- + + /// Set index of the sampled-texture descriptor array (Vulkan) / logical table (GL). + public const uint TextureTableSet = 2; + + /// Binding of the descriptor array within . + public const uint TextureTableBinding = 0; + + /// + /// Upper bound on simultaneously registered textures. Vulkan requires + /// maxDescriptorSetUpdateAfterBindSampledImages to reach this; the + /// capability probe asserts it rather than discovering it at draw time. + /// + public const uint TextureTableCapacity = 16384; + + // ---- push constants ---- + + /// + /// Bytes actually written by . Vulkan guarantees + /// at least , so 32 bytes of headroom remain + /// for later slices; any growth updates this constant and the shader block in + /// the same commit. + /// + public const int PushConstantBytes = 96; + + /// The Vulkan-guaranteed minimum push-constant budget. A hard ceiling for us. + public const int MaxPushConstantBytes = 128; + + // ---- shared layout facts the CPU writers and the shaders must agree on ---- + + /// + /// std430 stride of BatchData. The bindless uvec2 textureHandle + /// becomes uint textureIndex plus one pad word at slice V2, so the + /// stride is unchanged and every existing CPU writer keeps its offsets. + /// + public const int GpuBatchDataStrideBytes = 16; + + /// Clip planes per CellClip slot; also the required gl_ClipDistance size. + public const int ClipPlanesPerSlot = 8; + + /// std430 stride of one CellClip slot: 16 B header + 8 × vec4. + public const int ClipRegionStrideBytes = 16 + (ClipPlanesPerSlot * 16); + + /// Lights selected per object by retail's minimize_object_lighting. + public const int MaxLightsPerObject = 8; +} diff --git a/src/AcDream.App/Rendering/Gpu/GpuCapabilityRecord.cs b/src/AcDream.App/Rendering/Gpu/GpuCapabilityRecord.cs new file mode 100644 index 00000000..3a64564e --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/GpuCapabilityRecord.cs @@ -0,0 +1,116 @@ +namespace AcDream.App.Rendering.Gpu; + +/// +/// Backend-neutral view of what the device can do. Both backends fill this from +/// their own probe layer — GraphicalCapabilityRecord for GL, and its +/// Vulkan sibling added at slice V5 — and the renderers consult only this. +/// +/// The alignment fields are load-bearing rather than informational: ring +/// allocations must satisfy them, and getting one wrong produces a driver error +/// on Vulkan and silently wrong data on some GL implementations. +/// +internal sealed record GpuCapabilityRecord +{ + public required GpuBackendKind Backend { get; init; } + + /// Adapter name, e.g. "AMD Radeon RX 9070 XT". + public required string DeviceName { get; init; } + + /// Driver identification string for diagnostics and bug reports. + public required string DriverInfo { get; init; } + + /// API version actually in use, e.g. "OpenGL 4.6" or "Vulkan 1.3.280". + public required string ApiVersion { get; init; } + + /// Simultaneously registerable texture-table slots. Must reach . + public required uint MaxTextureTableSlots { get; init; } + + /// Storage-buffer bindings available. Must reach . + public required uint MaxStorageBufferBindings { get; init; } + + /// Push-constant bytes available. Must reach . + public required uint MaxPushConstantBytes { get; init; } + + /// Required alignment for a storage-buffer binding offset. + public required uint MinStorageBufferOffsetAlignment { get; init; } + + /// Required alignment for a uniform-buffer binding offset. + public required uint MinUniformBufferOffsetAlignment { get; init; } + + /// Clip distances usable by a shader. Phase U.3's per-cell clip gate needs 8. + public required uint MaxClipDistances { get; init; } + + /// Highest supported multisample count for the backbuffer. + public required uint MaxSampleCount { get; init; } + + /// Multi-draw-indirect. Mandatory — it is the entire draw architecture. + public required bool SupportsMultiDrawIndirect { get; init; } + + /// Shader draw parameters (gl_DrawID). Mandatory — batch lookup depends on it. + public required bool SupportsDrawParameters { get; init; } + + /// BC1/2/3 sampling. Mandatory — DAT surfaces upload as DXT without transcoding. + public required bool SupportsTextureCompressionBc { get; init; } + + /// GPU timestamps. Optional: absence degrades profiling, not rendering. + public required bool SupportsTimestampQueries { get; init; } + + /// + /// Whether per-frame data can be written straight into mapped memory the GPU + /// reads. Both backends report this; only Vulkan currently answers true, and + /// it is the mechanism behind Campaign V's CPU-cost target. + /// + public required bool SupportsPersistentlyMappedRings { get; init; } + + /// + /// Every mandatory capability this device fails to provide, phrased as + /// operator-facing sentences. Empty means the device can run acdream. + /// Startup turns a non-empty list into the same NotSupportedException + /// and exit-code-4 contract the GL gate already publishes. + /// + public IReadOnlyList SupportFailures + { + get + { + List failures = []; + + if (!SupportsMultiDrawIndirect) + failures.Add("Multi-draw-indirect is required to submit world geometry."); + if (!SupportsDrawParameters) + failures.Add("Shader draw parameters (gl_DrawID) are required to select per-draw batch data."); + if (!SupportsTextureCompressionBc) + failures.Add("BC (DXT) texture compression is required to upload DAT surfaces."); + if (MaxTextureTableSlots < GpuBindingModel.TextureTableCapacity) + { + failures.Add( + $"The texture table needs {GpuBindingModel.TextureTableCapacity} slots; " + + $"this device provides {MaxTextureTableSlots}."); + } + + if (MaxStorageBufferBindings < GpuBindingModel.StorageBindingCount) + { + failures.Add( + $"{GpuBindingModel.StorageBindingCount} storage-buffer bindings are required; " + + $"this device provides {MaxStorageBufferBindings}."); + } + + if (MaxPushConstantBytes < GpuBindingModel.PushConstantBytes) + { + failures.Add( + $"{GpuBindingModel.PushConstantBytes} push-constant bytes are required; " + + $"this device provides {MaxPushConstantBytes}."); + } + + if (MaxClipDistances < GpuBindingModel.ClipPlanesPerSlot) + { + failures.Add( + $"{GpuBindingModel.ClipPlanesPerSlot} clip distances are required by the per-cell clip gate; " + + $"this device provides {MaxClipDistances}."); + } + + return failures; + } + } + + public bool IsSupported => SupportFailures.Count == 0; +} diff --git a/src/AcDream.App/Rendering/Gpu/GpuEnums.cs b/src/AcDream.App/Rendering/Gpu/GpuEnums.cs new file mode 100644 index 00000000..76685b03 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/GpuEnums.cs @@ -0,0 +1,191 @@ +namespace AcDream.App.Rendering.Gpu; + +/// Which RHI backend is servicing the device. +internal enum GpuBackendKind +{ + /// Test double — records calls, owns no driver objects. + Recording, + + /// OpenGL 4.3 + bindless/MDI. Deleted at Campaign V slice V11. + OpenGl, + + /// Vulkan 1.3 core. + Vulkan, +} + +/// +/// How a buffer is consumed. Flags rather than a single role because the mesh +/// arena is simultaneously a vertex/index source and a transfer target, and the +/// Vulkan backend must name every usage at creation time. +/// +[Flags] +internal enum GpuBufferUsage +{ + None = 0, + Vertex = 1 << 0, + Index = 1 << 1, + Storage = 1 << 2, + Uniform = 1 << 3, + Indirect = 1 << 4, + TransferSource = 1 << 5, + TransferDestination = 1 << 6, +} + +/// +/// Where a buffer's memory lives. The distinction is invisible on GL (the driver +/// decides) but load-bearing on Vulkan: is what lets +/// per-frame data be written straight into mapped memory instead of copied +/// through BufferSubData, which is Campaign V's single largest CPU win. +/// +internal enum GpuMemoryResidency +{ + /// Device-local, written only through staged transfers. Mesh arenas, textures. + DeviceLocal, + + /// Persistently mapped and CPU-writable. Per-frame rings and staging. + HostWritable, + + /// Mapped and CPU-readable. Screenshot and diagnostic readback only. + HostReadable, +} + +/// Which alignment and usage a per-frame ring allocation must satisfy. +internal enum GpuRingUsage +{ + Storage, + Uniform, + Indirect, + Vertex, + Index, +} + +/// +/// Texture formats acdream actually produces from DAT surfaces. BC1/2/3 are the +/// DXT1/3/5 compressed surfaces uploaded verbatim; RGBA8 covers decoded and +/// composited art; R8 is the stb-baked font atlas. +/// +internal enum GpuTextureFormat +{ + Rgba8Unorm, + R8Unorm, + Bc1Unorm, + Bc2Unorm, + Bc3Unorm, + + /// Colour attachment format for offscreen targets (paperdoll, appraisal). + Rgba8UnormRenderTarget, + + /// Combined depth+stencil attachment. #117's portal punch needs the stencil aspect. + Depth24Stencil8, +} + +/// Texture shape. acdream uses 2D for UI art and 2D arrays for every world material. +internal enum GpuTextureKind +{ + Texture2D, + Texture2DArray, +} + +internal enum GpuFilter +{ + Nearest, + Linear, +} + +internal enum GpuMipFilter +{ + None, + Nearest, + Linear, +} + +internal enum GpuAddressMode +{ + Repeat, + ClampToEdge, +} + +/// +/// Colour blending. These are the only combinations the GL pass matrix actually +/// sets, and each becomes one VkPipeline variant because core Vulkan 1.3 +/// does not make blend state dynamic. +/// +internal enum GpuBlendMode +{ + /// Opaque: blending disabled. + None, + + /// Straight alpha: SrcAlpha, OneMinusSrcAlpha. + StraightAlpha, + + /// Additive: SrcAlpha, One. + Additive, +} + +internal enum GpuCompareOp +{ + Never, + Less, + LessOrEqual, + Equal, + Greater, + GreaterOrEqual, + Always, +} + +internal enum GpuCullMode +{ + None, + Back, + Front, +} + +/// +/// Triangle winding treated as front-facing. Both backends receive the SAME value +/// from renderers; the Vulkan backend inverts it internally because it renders +/// with a negative viewport height, which mirrors framebuffer space. That flip +/// lives in exactly one mapping function so no renderer ever reasons about it. +/// +internal enum GpuFrontFace +{ + CounterClockwise, + Clockwise, +} + +internal enum GpuPrimitiveTopology +{ + TriangleList, + LineList, +} + +internal enum GpuIndexType +{ + UInt16, + UInt32, +} + +/// What happens to an attachment's existing contents when a pass begins. +internal enum GpuLoadOp +{ + /// Contents are undefined on entry — the cheapest option, and the default for MSAA targets. + DontCare, + + /// Contents are cleared to the attachment's clear value. + Clear, + + /// Existing contents are preserved and readable. + Load, +} + +/// What happens to an attachment's contents when a pass ends. +internal enum GpuStoreOp +{ + /// Contents are discarded. Correct for MSAA colour that is resolved, and for depth. + DontCare, + + /// Contents are written back to memory. + Store, + + /// Multisampled contents are resolved into the pass's resolve target and then discarded. + Resolve, +} diff --git a/src/AcDream.App/Rendering/Gpu/GpuPassDescription.cs b/src/AcDream.App/Rendering/Gpu/GpuPassDescription.cs new file mode 100644 index 00000000..f9fd91e2 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/GpuPassDescription.cs @@ -0,0 +1,80 @@ +using System.Numerics; + +namespace AcDream.App.Rendering.Gpu; + +/// +/// The colour attachment for a pass. +/// +/// +/// The offscreen target to render into, or null for the backbuffer. On GL null +/// means framebuffer 0; on Vulkan it means the acquired swapchain image (or the +/// multisampled scratch image that resolves into it when +/// is ). +/// +/// What happens to existing contents on entry. +/// What happens to contents on exit. +/// Clear value used when is . +internal readonly record struct GpuColorAttachment( + IGpuRenderTarget? Target, + GpuLoadOp Load, + GpuStoreOp Store, + Vector4 ClearColor); + +/// +/// The depth/stencil attachment for a pass. Depth is transient in every acdream +/// pass — nothing reads it after the frame — so is normally +/// , which lets Vulkan skip writing it back to +/// memory entirely. +/// +/// What happens to existing contents on entry. +/// What happens to contents on exit. +/// Depth clear value. acdream renders with NDC z in [0,1], so far = 1. +/// Stencil clear value; #117's portal punch uses the stencil aspect. +internal readonly record struct GpuDepthAttachment( + GpuLoadOp Load, + GpuStoreOp Store, + float ClearDepth, + uint ClearStencil); + +/// +/// One rendering pass: a set of attachments, their load/store behaviour, and the +/// sample count every pipeline used inside must match. +/// +/// GL has no such object — its "pass" is implicit in whatever framebuffer happens +/// to be bound — so making passes explicit is the single largest structural change +/// the RHI imposes on the existing renderers. The GL backend therefore accepts an +/// ambient encoder during Campaign V slices V1..V4g (draws outside any declared +/// pass, preserving today's behaviour) and slice V4h removes that relaxation once +/// every renderer declares its passes. +/// +internal sealed record GpuPassDescription +{ + /// Stable identifier, surfaced as a debug label in captures. + public required string Name { get; init; } + + /// The colour attachment. Required — acdream has no colour-less passes. + public required GpuColorAttachment Color { get; init; } + + /// Depth/stencil attachment, or null for 2-D passes that need no depth. + public GpuDepthAttachment? Depth { get; init; } + + /// Samples per pixel. Must equal of every pipeline bound inside. + public int SampleCount { get; init; } = 1; + + /// Clears colour and depth to the standard frame-start values against the backbuffer. + public static GpuPassDescription BackbufferClear(string name, Vector4 clearColor, int sampleCount) => new() + { + Name = name, + Color = new GpuColorAttachment( + Target: null, + Load: GpuLoadOp.Clear, + Store: sampleCount > 1 ? GpuStoreOp.Resolve : GpuStoreOp.Store, + ClearColor: clearColor), + Depth = new GpuDepthAttachment( + Load: GpuLoadOp.Clear, + Store: GpuStoreOp.DontCare, + ClearDepth: 1f, + ClearStencil: 0), + SampleCount = sampleCount, + }; +} diff --git a/src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs b/src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs new file mode 100644 index 00000000..66e7f7c9 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs @@ -0,0 +1,113 @@ +using System.Collections.Immutable; + +namespace AcDream.App.Rendering.Gpu; + +/// Vertex attribute component layout. Only the formats acdream's meshes actually use. +internal enum GpuVertexFormat +{ + Float1, + Float2, + Float3, + Float4, + UByte4Normalized, +} + +/// One vertex attribute, matching a layout(location = N) in declaration. +internal readonly record struct GpuVertexAttribute( + uint Location, + GpuVertexFormat Format, + uint OffsetBytes); + +/// Interleaved vertex layout for a single bound vertex buffer. +internal sealed record GpuVertexLayout(uint StrideBytes, ImmutableArray Attributes) +{ + /// + /// The world mesh vertex shared by mesh_modern, EnvCells, and terrain: + /// position, normal, texcoord — 32 bytes, matching the format + /// ObjectMeshManager packs into GlobalMeshBuffer. + /// + public static GpuVertexLayout WorldMesh { get; } = new( + StrideBytes: 32, + [ + new GpuVertexAttribute(0, GpuVertexFormat.Float3, 0), + new GpuVertexAttribute(1, GpuVertexFormat.Float3, 12), + new GpuVertexAttribute(2, GpuVertexFormat.Float2, 24), + ]); + + /// Empty layout for pipelines whose vertices come entirely from storage buffers. + public static GpuVertexLayout None { get; } = new(0, []); +} + +/// +/// Names one GLSL shader pair. The backend resolves it: the GL backend loads +/// Rendering/Shaders/{Name}.vert and .frag and compiles at startup; +/// the Vulkan backend loads the committed Rendering/Shaders/spv/{Name}.vert.spv +/// and .frag.spv produced by tools/compile-shaders.ps1. One source +/// of truth (the GLSL), two consumption paths. +/// +internal readonly record struct GpuShaderSet(string Name); + +/// Depth-buffer behaviour baked into a pipeline. +/// Whether depth testing is enabled at all. +/// Default depth-write state; overridable per draw via dynamic state. +/// Comparison function when testing is enabled. +internal readonly record struct GpuDepthState(bool Test, bool Write, GpuCompareOp Compare) +{ + /// Standard opaque geometry: test and write, nearer wins. + public static GpuDepthState OpaqueDefault { get; } = new(Test: true, Write: true, GpuCompareOp.LessOrEqual); + + /// Translucent geometry: test against existing depth but do not occlude later draws. + public static GpuDepthState TranslucentDefault { get; } = new(Test: true, Write: false, GpuCompareOp.LessOrEqual); + + /// Sky and 2-D overlays: depth is irrelevant. + public static GpuDepthState Disabled { get; } = new(Test: false, Write: false, GpuCompareOp.Always); +} + +/// +/// Everything a draw needs beyond its buffers: the shader pair and all fixed +/// state. Vulkan bakes this into one VkPipeline at startup, which is why +/// runtime shader compilation and driver state revalidation both disappear. +/// +/// Equality is NOT part of this contract — pipelines are created explicitly and +/// held by their renderer. is the identity used by debug +/// tooling and by the backend's own pipeline cache key. +/// +internal sealed record GpuPipelineDescription +{ + /// Stable identifier, e.g. "mesh-opaque". Surfaced to RenderDoc and validation layers. + public required string Name { get; init; } + + /// The GLSL pair this pipeline draws with. + public required GpuShaderSet Shaders { get; init; } + + /// Vertex input layout; for buffer-fed geometry. + public required GpuVertexLayout VertexLayout { get; init; } + + public GpuPrimitiveTopology Topology { get; init; } = GpuPrimitiveTopology.TriangleList; + + public GpuBlendMode Blend { get; init; } = GpuBlendMode.None; + + public GpuDepthState Depth { get; init; } = GpuDepthState.OpaqueDefault; + + /// Default cull mode; overridable per draw via . + public GpuCullMode Cull { get; init; } = GpuCullMode.Back; + + /// + /// Winding treated as front-facing, expressed in GL convention. The Vulkan + /// backend inverts it internally to compensate for its negative viewport + /// height; no renderer performs that flip itself. + /// + public GpuFrontFace FrontFace { get; init; } = GpuFrontFace.CounterClockwise; + + /// + /// Alpha-to-coverage for foliage. Only meaningful when the pass is + /// multisampled; the backend ignores it at one sample. + /// + public bool AlphaToCoverage { get; init; } + + /// Whether the pipeline writes colour at all. False for depth/stencil-only prepasses. + public bool ColorWrite { get; init; } = true; + + /// Sample count of the passes this pipeline is used in. Must match the pass. + public int SampleCount { get; init; } = 1; +} diff --git a/src/AcDream.App/Rendering/Gpu/GpuPushConstants.cs b/src/AcDream.App/Rendering/Gpu/GpuPushConstants.cs new file mode 100644 index 00000000..e7ff3827 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/GpuPushConstants.cs @@ -0,0 +1,80 @@ +using System.Numerics; +using System.Runtime.InteropServices; + +namespace AcDream.App.Rendering.Gpu; + +/// +/// The single push-constant block shared by every acdream pipeline — 96 of the +/// 128 bytes Vulkan guarantees, leaving 32 bytes of headroom for later slices. +/// +/// One shared block (rather than a per-shader block) is what lets every world +/// pipeline share ONE pipeline layout, so switching pipelines mid-pass does not +/// invalidate bound descriptor sets or push constants. Shaders declare only the +/// fields they read; unused fields cost nothing. +/// +/// The GL backend maps each field to the correspondingly named uniform and skips +/// any the program does not declare (location -1). The Vulkan backend writes the +/// struct verbatim with one vkCmdPushConstants. +/// +/// Layout is asserted by GpuContractTests; changing it is a contract change +/// that must land together with the matching edit to the shared GLSL preamble. +/// +[StructLayout(LayoutKind.Sequential, Pack = 4)] +internal struct GpuPushConstants +{ + /// + /// GLSL uViewProjection. acdream's cameras build this with + /// Matrix4x4.CreatePerspectiveFieldOfView, whose NDC z range is [0,1] — + /// already Vulkan's convention (see PortalProjection.cs). No projection + /// rework is needed for the migration, and Vulkan gains the half of the depth + /// range GL was discarding. + /// + public Matrix4x4 ViewProjection; + + /// + /// GLSL uDrawIDOffset. Issue #52: the draw index resets to 0 at the + /// start of each multi-draw-indirect call, so a pass that begins partway into + /// the batch array must offset its lookup. Vulkan's gl_DrawID resets + /// identically per vkCmdDrawIndexedIndirect, so the pattern carries over + /// unchanged. + /// + public int DrawIdOffset; + + /// GLSL uLightingMode: 0 = object (plain Lambert + sun), 1 = EnvCell (half-Lambert wrap, no sun). + public int LightingMode; + + /// GLSL uRenderPass: 0 = opaque, 1 = translucent. + public int RenderPass; + + /// GLSL uLightDebug: #176 stripe-hunt isolation modes; 0 = off. + public int LightDebug; + + /// + /// GLSL uTextureIndexA. Primary texture-table slot for pipelines whose + /// texture is per-pass rather than per-batch — currently the terrain atlas. + /// + public uint TextureIndexA; + + /// GLSL uTextureIndexB. Secondary per-pass slot — currently the terrain alpha-mask array. + public uint TextureIndexB; + + /// GLSL uParamA. Spare scalar; unclaimed at V0. + public float ParamA; + + /// GLSL uParamB. Spare scalar; unclaimed at V0. + public float ParamB; + + /// Neutral defaults: identity transform, opaque object lighting, no debug mode. + public static GpuPushConstants Default => new() + { + ViewProjection = Matrix4x4.Identity, + DrawIdOffset = 0, + LightingMode = 0, + RenderPass = 0, + LightDebug = 0, + TextureIndexA = 0, + TextureIndexB = 0, + ParamA = 0f, + ParamB = 0f, + }; +} diff --git a/src/AcDream.App/Rendering/Gpu/GpuResourceDescriptions.cs b/src/AcDream.App/Rendering/Gpu/GpuResourceDescriptions.cs new file mode 100644 index 00000000..f16605fb --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/GpuResourceDescriptions.cs @@ -0,0 +1,117 @@ +namespace AcDream.App.Rendering.Gpu; + +/// +/// Creation parameters for a GPU buffer. is not +/// cosmetic: the Vulkan backend publishes it through VK_EXT_debug_utils +/// so RenderDoc captures and validation-layer messages name our objects. +/// +/// Stable identifier, e.g. "mesh-arena-vertex". +/// Allocation size. Growth is a create-copy-retire cycle, never a resize. +/// Every way the buffer will be consumed. +/// Where the memory lives and whether the CPU may write it directly. +internal readonly record struct GpuBufferDescription( + string Name, + long SizeBytes, + GpuBufferUsage Usage, + GpuMemoryResidency Residency); + +/// Creation parameters for a sampled texture or a render-target image. +/// Stable identifier for debug tooling. +/// 2D, or the 2D array every world material uses. +/// Pixel format; BC formats are uploaded as compressed blocks. +/// Width in texels of mip level 0. +/// Height in texels of mip level 0. +/// Array layers; 1 for . +/// +/// Levels to allocate. 1 disables mipping. The backend never silently generates +/// mips: is an explicit call, because +/// Vulkan cannot blit-generate compressed mips and must take a CPU-built chain. +/// +internal readonly record struct GpuTextureDescription( + string Name, + GpuTextureKind Kind, + GpuTextureFormat Format, + int Width, + int Height, + int LayerCount, + int MipLevelCount); + +/// +/// Sampler state. The set of distinct samplers acdream uses is tiny (wrap/clamp +/// × nearest/linear), which is what makes a combined image-sampler descriptor +/// table practical: a texture registered twice with different samplers simply +/// occupies two table slots, exactly as it holds two bindless handles today. +/// +internal readonly record struct GpuSamplerDescription( + GpuFilter MinFilter, + GpuFilter MagFilter, + GpuMipFilter MipFilter, + GpuAddressMode AddressU, + GpuAddressMode AddressV, + float MaxAnisotropy) +{ + /// Trilinear repeat — the default for world materials. + public static GpuSamplerDescription WorldRepeat { get; } = new( + GpuFilter.Linear, + GpuFilter.Linear, + GpuMipFilter.Linear, + GpuAddressMode.Repeat, + GpuAddressMode.Repeat, + MaxAnisotropy: 1f); + + /// Trilinear clamped — atlas pages and anything whose edges must not wrap. + public static GpuSamplerDescription WorldClamp { get; } = new( + GpuFilter.Linear, + GpuFilter.Linear, + GpuMipFilter.Linear, + GpuAddressMode.ClampToEdge, + GpuAddressMode.ClampToEdge, + MaxAnisotropy: 1f); + + /// Unfiltered clamped — retail UI icons and the composited 32×32 item art. + public static GpuSamplerDescription UiNearest { get; } = new( + GpuFilter.Nearest, + GpuFilter.Nearest, + GpuMipFilter.None, + GpuAddressMode.ClampToEdge, + GpuAddressMode.ClampToEdge, + MaxAnisotropy: 1f); +} + +/// An offscreen colour(+depth) bundle: paperdoll, creature appraisal, portal masking. +/// Stable identifier for debug tooling. +/// Colour attachment width in pixels. +/// Colour attachment height in pixels. +/// Colour attachment format. +/// Depth/stencil format, or null for a colour-only target. +/// 1 for single-sampled. Offscreen targets stay single-sampled. +internal readonly record struct GpuRenderTargetDescription( + string Name, + int Width, + int Height, + GpuTextureFormat ColorFormat, + GpuTextureFormat? DepthFormat, + int SampleCount); + +/// +/// A slot in the device's global texture table — the backend-neutral replacement +/// for a 64-bit ARB_bindless_texture handle. Renderers write +/// into batch data; the shader indexes the descriptor array +/// (Vulkan) or the uvec2 handle buffer (GL) with it. +/// +/// is a loud sentinel, never a usable slot. It exists so +/// an unset index is an assertable programming error rather than a silent +/// resolve to slot 0 — the failure mode that produced the magenta 1×1 UI +/// placeholder bug. Renderers that genuinely need a fallback ask the device for +/// , which is a real registered texture. +/// +internal readonly record struct GpuTextureSlot(uint Index) +{ + /// Sentinel for "no texture assigned". Must never reach a shader. + public static GpuTextureSlot Unassigned { get; } = new(uint.MaxValue); + + public bool IsAssigned => Index != uint.MaxValue; + + public override string ToString() => + IsAssigned ? $"slot#{Index}" : "slot#unassigned"; +} diff --git a/src/AcDream.App/Rendering/Gpu/GpuResources.cs b/src/AcDream.App/Rendering/Gpu/GpuResources.cs new file mode 100644 index 00000000..17709943 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/GpuResources.cs @@ -0,0 +1,109 @@ +namespace AcDream.App.Rendering.Gpu; + +/// +/// A GPU buffer. Disposal does not free immediately: every backend routes the +/// physical release through the device's retirement queue so the memory outlives +/// any frame still referencing it. That is the same contract +/// GpuFrameFlightController already enforces for GL names today. +/// +internal interface IGpuBuffer : IDisposable +{ + string Name { get; } + long SizeBytes { get; } + GpuBufferUsage Usage { get; } + GpuMemoryResidency Residency { get; } + + /// + /// Writes at . On a + /// buffer this stages through a + /// transfer; on a host-writable buffer it is a direct memory write. Per-frame + /// data should not use this at all — take a ring allocation and write into it. + /// + void Upload(long offsetBytes, ReadOnlySpan data); + + /// + /// Device-side copy, used by the mesh arena's grow-and-copy migration so + /// arena growth never round-trips through system memory. + /// + void CopyTo(IGpuBuffer destination, long sourceOffsetBytes, long destinationOffsetBytes, long byteCount); + + /// + /// Reads back into . Only valid on + /// buffers; diagnostics only. + /// + void Read(long offsetBytes, Span destination); +} + +/// A sampled texture or an attachment image. +internal interface IGpuTexture : IDisposable +{ + string Name { get; } + GpuTextureKind Kind { get; } + GpuTextureFormat Format { get; } + int Width { get; } + int Height { get; } + int LayerCount { get; } + int MipLevelCount { get; } + + /// + /// Uploads one mip level of one array layer. is raw + /// texels for uncompressed formats and raw blocks for BC formats. + /// + void Upload(int mipLevel, int layer, ReadOnlySpan data); + + /// + /// Fills mip levels 1..N-1 from level 0. + /// + /// Explicit rather than automatic because the two backends cannot do this the + /// same way: GL calls glGenerateMipmap, while Vulkan blits uncompressed + /// images and CANNOT blit compressed ones. For BC formats the Vulkan backend + /// requires the caller to have supplied a CPU-built chain via + /// and this call throws — the GL path's reliance on + /// driver-defined compressed-mip regeneration is the behaviour we are + /// deliberately not carrying forward. + /// + void GenerateMipChain(); +} + +/// Immutable sampler state. Owned and de-duplicated by the device. +internal interface IGpuSampler : IDisposable +{ + GpuSamplerDescription Description { get; } +} + +/// +/// A compiled shader program plus every piece of fixed pipeline state it draws +/// with. This is the type that replaces the imperative +/// Enable/BlendFunc/DepthMask/CullFace brackets scattered through the GL +/// renderers: state that Vulkan bakes at creation lives here, and only the state +/// core Vulkan 1.3 makes dynamic stays callable per draw +/// ( and friends). +/// +internal interface IGpuPipeline : IDisposable +{ + GpuPipelineDescription Description { get; } +} + +/// An offscreen render target whose colour attachment is sampleable once the pass ends. +internal interface IGpuRenderTarget : IDisposable +{ + GpuRenderTargetDescription Description { get; } + + /// The colour attachment, for registering into the texture table or blitting into UI. + IGpuTexture ColorTexture { get; } +} + +/// +/// GPU-side timing. Backed by GL TimeElapsed queries or Vulkan timestamp +/// queries; results are only readable once the issuing frame has retired, so +/// reports the most recent completed measurement rather +/// than blocking. +/// +internal interface IGpuTimerPool +{ + /// True when the backend can measure GPU time at all. + bool IsSupported { get; } + + /// Milliseconds measured for in the most recent retired frame. + bool TryResolve(string scopeName, out double milliseconds); +} diff --git a/src/AcDream.App/Rendering/Gpu/IGpuDevice.cs b/src/AcDream.App/Rendering/Gpu/IGpuDevice.cs new file mode 100644 index 00000000..710accbb --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/IGpuDevice.cs @@ -0,0 +1,88 @@ +namespace AcDream.App.Rendering.Gpu; + +/// +/// The RHI root: creates every GPU resource, owns the global texture table, and +/// drives the frame loop. One instance per graphics context, constructed during +/// composition and threaded into renderers in place of the raw GL handle. +/// +/// Campaign V (see docs/plans/2026-07-27-vulkan-campaign.md) implements +/// this interface twice: first on OpenGL — behaviour-preserving, so each renderer +/// port is pixel-gated against the previous commit on the shipping backend — and +/// then on Vulkan, gated by a GL-versus-Vulkan differential. The GL +/// implementation is deleted at slice V11. +/// +internal interface IGpuDevice : IDisposable +{ + GpuBackendKind Backend { get; } + + GpuCapabilityRecord Capabilities { get; } + + /// + /// Frame-flight-gated resource release. Resource disposal routes through here + /// so nothing is freed while a submitted frame may still reference it. + /// + IGpuResourceRetirementQueue Retirement { get; } + + /// GPU timing results from retired frames. + IGpuTimerPool Timers { get; } + + /// + /// A registered 1×1 opaque-white texture. Renderers that need a defined + /// fallback use this rather than assuming slot 0 means anything — an + /// unregistered is + /// and must never reach a shader. + /// + GpuTextureSlot DefaultTextureSlot { get; } + + IGpuBuffer CreateBuffer(in GpuBufferDescription description); + + IGpuTexture CreateTexture(in GpuTextureDescription description); + + /// Creates or returns a cached sampler; sampler state is de-duplicated by value. + IGpuSampler CreateSampler(in GpuSamplerDescription description); + + /// + /// Compiles and links a pipeline. Both backends build every pipeline during + /// startup, so no frame ever pays a shader-compile or state-revalidation cost. + /// + IGpuPipeline CreatePipeline(GpuPipelineDescription description); + + IGpuRenderTarget CreateRenderTarget(in GpuRenderTargetDescription description); + + /// + /// Publishes a (texture, sampler) pair into the global table and returns the + /// slot shaders index it by. The same texture registered with two samplers + /// occupies two slots — matching how it holds two bindless handles today. + /// + GpuTextureSlot RegisterTexture(IGpuTexture texture, IGpuSampler sampler); + + /// + /// Returns a slot to the free list. The slot is not reused until the frames + /// that could still reference it have retired, so eviction (the texture and + /// mesh caches' LRU) cannot alias a live draw onto a new texture. + /// + void ReleaseTextureSlot(GpuTextureSlot slot); + + /// Opens the next frame, waiting for its flight slot to retire first. + IGpuFrame BeginFrame(); + + /// + /// Defers to the render thread. Replaces + /// OpenGLGraphicsDevice.QueueGLAction; loader threads use it to hand + /// GPU work back to the thread that owns the context or queue. + /// + void QueueDeviceAction(Action action); + + /// Runs queued device actions. Called once per frame from the render thread. + void ProcessDeviceActions(); + + /// + /// Reads the presented image back as tightly packed top-left-origin RGBA8. + /// This is the seam the automated screenshot gates already use, so the + /// pixel-comparison tooling is unaffected by the backend swap. + /// + byte[] CaptureBackbuffer(int width, int height); + + /// Blocks until all submitted work completes and every pending retirement has run. + void WaitIdle(); +} diff --git a/src/AcDream.App/Rendering/Gpu/IGpuFrame.cs b/src/AcDream.App/Rendering/Gpu/IGpuFrame.cs new file mode 100644 index 00000000..1aff1d76 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/IGpuFrame.cs @@ -0,0 +1,79 @@ +using System.Runtime.InteropServices; + +namespace AcDream.App.Rendering.Gpu; + +/// +/// A slice of the current frame's upload ring: the buffer to bind, the byte +/// offset to bind it at, and CPU-writable memory to fill. +/// +/// This type replaces every per-frame BufferSubData in the renderers, and +/// it is the reason the Vulkan backend costs less CPU than GL. Today a renderer +/// writes its instance/batch/indirect data into a managed array and then hands +/// that array to the driver, which validates it, copies it, and tracks a renamed +/// backing store. With a ring allocation the renderer writes ONCE, directly into +/// memory the GPU will read — the upload stops existing as a separate step. +/// +/// It is a ref struct on purpose: the memory is only valid until the frame +/// that produced it retires, so the compiler prevents storing it in a field. +/// +internal readonly ref struct GpuRingAllocation +{ + public GpuRingAllocation(IGpuBuffer buffer, uint offsetBytes, Span data) + { + Buffer = buffer; + OffsetBytes = offsetBytes; + Data = data; + } + + /// The ring buffer to bind. Backends may hand out many allocations from one buffer. + public IGpuBuffer Buffer { get; } + + /// Byte offset of this allocation, already aligned for its . + public uint OffsetBytes { get; } + + /// CPU-writable memory for this allocation. Valid until the owning frame retires. + public Span Data { get; } + + public bool IsEmpty => Data.IsEmpty; + + /// Reinterprets the allocation as a typed span so callers write structs, not bytes. + public Span AsSpan() where T : unmanaged => MemoryMarshal.Cast(Data); +} + +/// +/// One frame's recording context. Obtained from +/// and closed by , which submits the recorded work and presents. +/// +/// The frame owns the ring: allocations are recycled once the GPU has finished the +/// frame that made them, which is exactly the bound +/// GpuFrameFlightController already enforces with GL fences and which the +/// Vulkan backend expresses with a single timeline semaphore. +/// +internal interface IGpuFrame : IDisposable +{ + /// Frames-in-flight slot index this frame occupies. + int SlotIndex { get; } + + /// Monotonic frame serial. Matches the retirement-ledger key used for resource release. + long Serial { get; } + + /// + /// Reserves bytes of CPU-writable ring memory, + /// aligned as requires. Throws if the request exceeds + /// the ring's per-frame capacity — silently truncating a draw's data would + /// corrupt the frame invisibly. + /// + GpuRingAllocation AllocateRing(int byteCount, GpuRingUsage usage); + + /// + /// Opens a rendering pass. The returned encoder must be disposed before the + /// next pass begins; nesting is not supported and no acdream pass needs it. + /// + IGpuPassEncoder BeginPass(GpuPassDescription description); + + /// + /// Submits the frame and presents it. Idempotent with + /// so a failed frame still closes its slot rather than stalling the ring. + /// + void End(); +} diff --git a/src/AcDream.App/Rendering/Gpu/IGpuPassEncoder.cs b/src/AcDream.App/Rendering/Gpu/IGpuPassEncoder.cs new file mode 100644 index 00000000..acbd9b8d --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/IGpuPassEncoder.cs @@ -0,0 +1,79 @@ +namespace AcDream.App.Rendering.Gpu; + +/// +/// Records draw work inside one . Disposing the +/// encoder closes the pass. +/// +/// The surface is deliberately small: it is exactly what acdream's twelve +/// renderers do, expressed the way Vulkan wants it. Everything that Vulkan bakes +/// into a pipeline (blend, depth compare, alpha-to-coverage, topology) is absent +/// here by design — those live in . Only the +/// state core Vulkan 1.3 makes dynamic is settable per draw. +/// +internal interface IGpuPassEncoder : IDisposable +{ + /// The pass this encoder is recording into. + GpuPassDescription Pass { get; } + + /// Binds the shader program and all baked fixed state. + void BindPipeline(IGpuPipeline pipeline); + + /// + /// Binds a storage buffer range to a storage + /// binding. Ranges come straight from for + /// per-frame data, or from a long-lived buffer for persistent data. + /// + void BindStorageBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes); + + /// Binds a uniform buffer range — currently only the SceneLighting block. + void BindUniformBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes); + + /// Binds the interleaved vertex source matching the pipeline's vertex layout. + void BindVertexBuffer(IGpuBuffer buffer, uint offsetBytes); + + /// Binds the index source. + void BindIndexBuffer(IGpuBuffer buffer, uint offsetBytes, GpuIndexType indexType); + + /// Writes the shared push-constant block. Survives pipeline changes within a pass. + void SetPushConstants(in GpuPushConstants constants); + + /// + /// Sets the drawable rectangle. Callers always pass GL-convention coordinates + /// (origin bottom-left); the Vulkan backend converts by emitting a negative + /// viewport height, so no renderer performs a Y flip itself. + /// + void SetViewport(int x, int y, int width, int height); + + /// Sets the scissor rectangle in the same convention as . + void SetScissor(int x, int y, int width, int height); + + /// Dynamic cull override — how the world dispatcher draws double-sided geometry. + void SetCullMode(GpuCullMode cullMode); + + /// Dynamic winding override, in GL convention. The Vulkan backend applies its own inversion. + void SetFrontFace(GpuFrontFace frontFace); + + /// Dynamic depth-write override — how the translucent pass stops occluding later draws. + void SetDepthWrite(bool enabled); + + /// Draws indexed geometry directly, without an indirect buffer. + void DrawIndexed(uint indexCount, uint instanceCount, uint firstIndex, int vertexOffset, uint firstInstance); + + /// Draws non-indexed geometry — the retained UI's batched sprite/glyph quads. + void Draw(uint vertexCount, uint instanceCount, uint firstVertex, uint firstInstance); + + /// + /// The production draw call: one submission covering + /// commands read from . Each command's draw index is + /// visible to the shader as gl_DrawID, offset by + /// . + /// + void MultiDrawIndexedIndirect(IGpuBuffer commands, uint offsetBytes, uint drawCount, uint strideBytes); + + /// + /// Opens a GPU timing scope whose result becomes readable through + /// once this frame retires. Returns a + /// no-op disposable when the backend cannot measure GPU time. + /// + IDisposable BeginTimerScope(string scopeName); +} diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs new file mode 100644 index 00000000..44a909fe --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs @@ -0,0 +1,166 @@ +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using AcDream.App.Rendering.Gpu; + +namespace AcDream.App.Tests.Rendering.Gpu; + +/// +/// Campaign V slice V0 — the pinned RHI contract. +/// +/// These are not behaviour tests; they are the tripwires that stop the contract +/// drifting out from under the shaders and the two backends. Every constant +/// asserted here also appears in a GLSL source file or in a backend's binding +/// setup, so a change that lands in only one place fails here rather than as a +/// corrupted frame. +/// +public sealed class GpuContractTests +{ + [Fact] + public void PushConstantBlockMatchesThePinnedLayout() + { + Assert.Equal(GpuBindingModel.PushConstantBytes, Unsafe.SizeOf()); + Assert.True(GpuBindingModel.PushConstantBytes <= GpuBindingModel.MaxPushConstantBytes); + + Assert.Equal(0, (int)Marshal.OffsetOf(nameof(GpuPushConstants.ViewProjection))); + Assert.Equal(64, (int)Marshal.OffsetOf(nameof(GpuPushConstants.DrawIdOffset))); + Assert.Equal(68, (int)Marshal.OffsetOf(nameof(GpuPushConstants.LightingMode))); + Assert.Equal(72, (int)Marshal.OffsetOf(nameof(GpuPushConstants.RenderPass))); + Assert.Equal(76, (int)Marshal.OffsetOf(nameof(GpuPushConstants.LightDebug))); + Assert.Equal(80, (int)Marshal.OffsetOf(nameof(GpuPushConstants.TextureIndexA))); + Assert.Equal(84, (int)Marshal.OffsetOf(nameof(GpuPushConstants.TextureIndexB))); + Assert.Equal(88, (int)Marshal.OffsetOf(nameof(GpuPushConstants.ParamA))); + Assert.Equal(92, (int)Marshal.OffsetOf(nameof(GpuPushConstants.ParamB))); + } + + [Fact] + public void StorageBindingsMatchTheShaderSources() + { + // mesh_modern.vert declares std430 bindings 0..8 in exactly this order; + // binding 9 is the GL-only texture handle table added by slice V2. + Assert.Equal(0u, GpuBindingModel.StorageInstances); + Assert.Equal(1u, GpuBindingModel.StorageBatches); + Assert.Equal(2u, GpuBindingModel.StorageClipRegions); + Assert.Equal(3u, GpuBindingModel.StorageClipSlots); + Assert.Equal(4u, GpuBindingModel.StorageGlobalLights); + Assert.Equal(5u, GpuBindingModel.StorageInstanceLightSets); + Assert.Equal(6u, GpuBindingModel.StorageInstanceIndoor); + Assert.Equal(7u, GpuBindingModel.StorageInstanceAlpha); + Assert.Equal(8u, GpuBindingModel.StorageInstanceSelectionLighting); + Assert.Equal(9u, GpuBindingModel.StorageTextureTable); + Assert.Equal(10u, GpuBindingModel.StorageBindingCount); + } + + [Fact] + public void UniformAndTextureTableLiveInSeparateSets() + { + // The SceneLighting UBO keeps binding=1 even though the BatchBuffer SSBO + // also uses binding=1. GL tolerates that because its SSBO and UBO binding + // tables are separate; Vulkan does not, so the set index disambiguates. + Assert.Equal(GpuBindingModel.StorageBatches, GpuBindingModel.UniformSceneLighting); + Assert.NotEqual(0u, GpuBindingModel.UniformSet); + Assert.NotEqual(GpuBindingModel.UniformSet, GpuBindingModel.TextureTableSet); + } + + [Fact] + public void ClipRegionStrideMatchesTheUploadedLayout() + { + // ClipFrame lays these bytes out on the CPU; ClipFrameLayoutTests pins the + // producer side, this pins the contract side. 16 B header + 8 x vec4. + Assert.Equal(8, GpuBindingModel.ClipPlanesPerSlot); + Assert.Equal(144, GpuBindingModel.ClipRegionStrideBytes); + } + + [Fact] + public void UnassignedTextureSlotIsNeverAValidIndex() + { + Assert.False(GpuTextureSlot.Unassigned.IsAssigned); + Assert.True(new GpuTextureSlot(0).IsAssigned); + Assert.Equal("slot#unassigned", GpuTextureSlot.Unassigned.ToString()); + Assert.Equal("slot#7", new GpuTextureSlot(7).ToString()); + } + + [Fact] + public void WorldMeshVertexLayoutMatchesTheMeshShaderInputs() + { + GpuVertexLayout layout = GpuVertexLayout.WorldMesh; + + Assert.Equal(32u, layout.StrideBytes); + Assert.Equal(3, layout.Attributes.Length); + Assert.Equal(new GpuVertexAttribute(0, GpuVertexFormat.Float3, 0), layout.Attributes[0]); + Assert.Equal(new GpuVertexAttribute(1, GpuVertexFormat.Float3, 12), layout.Attributes[1]); + Assert.Equal(new GpuVertexAttribute(2, GpuVertexFormat.Float2, 24), layout.Attributes[2]); + } + + [Fact] + public void MultisampledBackbufferPassResolvesWhileDepthIsDiscarded() + { + GpuPassDescription multisampled = GpuPassDescription.BackbufferClear("world", Vector4.Zero, sampleCount: 4); + Assert.Equal(GpuStoreOp.Resolve, multisampled.Color.Store); + Assert.Null(multisampled.Color.Target); + Assert.Equal(GpuStoreOp.DontCare, multisampled.Depth!.Value.Store); + Assert.Equal(1f, multisampled.Depth!.Value.ClearDepth); + + GpuPassDescription single = GpuPassDescription.BackbufferClear("world", Vector4.Zero, sampleCount: 1); + Assert.Equal(GpuStoreOp.Store, single.Color.Store); + } + + [Fact] + public void CapabilityRecordAcceptsADeviceThatMeetsEveryRequirement() + { + GpuCapabilityRecord record = SupportedRecord(); + + Assert.Empty(record.SupportFailures); + Assert.True(record.IsSupported); + } + + [Fact] + public void CapabilityRecordNamesEveryMissingRequirement() + { + GpuCapabilityRecord record = SupportedRecord() with + { + SupportsMultiDrawIndirect = false, + SupportsDrawParameters = false, + SupportsTextureCompressionBc = false, + MaxTextureTableSlots = 16, + MaxStorageBufferBindings = 4, + MaxPushConstantBytes = 32, + MaxClipDistances = 0, + }; + + Assert.False(record.IsSupported); + Assert.Equal(7, record.SupportFailures.Count); + Assert.Contains(record.SupportFailures, failure => failure.Contains("Multi-draw-indirect", StringComparison.Ordinal)); + Assert.Contains(record.SupportFailures, failure => failure.Contains("gl_DrawID", StringComparison.Ordinal)); + Assert.Contains(record.SupportFailures, failure => failure.Contains("BC (DXT)", StringComparison.Ordinal)); + Assert.Contains(record.SupportFailures, failure => failure.Contains("clip distances", StringComparison.Ordinal)); + } + + [Fact] + public void TimestampSupportIsOptional() + { + // Losing GPU timing degrades profiling; it must never refuse to start. + GpuCapabilityRecord record = SupportedRecord() with { SupportsTimestampQueries = false }; + Assert.True(record.IsSupported); + } + + private static GpuCapabilityRecord SupportedRecord() => new() + { + Backend = GpuBackendKind.Vulkan, + DeviceName = "test-adapter", + DriverInfo = "test-driver", + ApiVersion = "Vulkan 1.3.0", + MaxTextureTableSlots = GpuBindingModel.TextureTableCapacity, + MaxStorageBufferBindings = GpuBindingModel.StorageBindingCount, + MaxPushConstantBytes = GpuBindingModel.MaxPushConstantBytes, + MinStorageBufferOffsetAlignment = 64, + MinUniformBufferOffsetAlignment = 256, + MaxClipDistances = GpuBindingModel.ClipPlanesPerSlot, + MaxSampleCount = 8, + SupportsMultiDrawIndirect = true, + SupportsDrawParameters = true, + SupportsTextureCompressionBc = true, + SupportsTimestampQueries = true, + SupportsPersistentlyMappedRings = true, + }; +} diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs b/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs new file mode 100644 index 00000000..064cd871 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs @@ -0,0 +1,530 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; + +namespace AcDream.App.Tests.Rendering.Gpu; + +/// +/// One recorded RHI call. Renderer tests assert against the ordered sequence +/// instead of against a live driver, which is what keeps the App suite runnable +/// on a machine with no GPU while renderers migrate onto +/// during Campaign V. +/// +internal abstract record GpuRecordedCall; + +internal sealed record GpuRecordedFrameBegin(long Serial, int SlotIndex) : GpuRecordedCall; + +internal sealed record GpuRecordedFrameEnd(long Serial) : GpuRecordedCall; + +internal sealed record GpuRecordedRingAllocation(GpuRingUsage Usage, int ByteCount, uint OffsetBytes) : GpuRecordedCall; + +internal sealed record GpuRecordedPassBegin(string Name, int SampleCount) : GpuRecordedCall; + +internal sealed record GpuRecordedPassEnd(string Name) : GpuRecordedCall; + +internal sealed record GpuRecordedPipelineBind(string PipelineName) : GpuRecordedCall; + +internal sealed record GpuRecordedStorageBind(uint Binding, string BufferName, uint OffsetBytes, uint SizeBytes) + : GpuRecordedCall; + +internal sealed record GpuRecordedUniformBind(uint Binding, string BufferName, uint OffsetBytes, uint SizeBytes) + : GpuRecordedCall; + +internal sealed record GpuRecordedVertexBind(string BufferName, uint OffsetBytes) : GpuRecordedCall; + +internal sealed record GpuRecordedIndexBind(string BufferName, uint OffsetBytes, GpuIndexType IndexType) + : GpuRecordedCall; + +internal sealed record GpuRecordedPushConstants(GpuPushConstants Constants) : GpuRecordedCall; + +internal sealed record GpuRecordedViewport(int X, int Y, int Width, int Height) : GpuRecordedCall; + +internal sealed record GpuRecordedScissor(int X, int Y, int Width, int Height) : GpuRecordedCall; + +internal sealed record GpuRecordedCullMode(GpuCullMode CullMode) : GpuRecordedCall; + +internal sealed record GpuRecordedFrontFace(GpuFrontFace FrontFace) : GpuRecordedCall; + +internal sealed record GpuRecordedDepthWrite(bool Enabled) : GpuRecordedCall; + +internal sealed record GpuRecordedDrawIndexed( + uint IndexCount, + uint InstanceCount, + uint FirstIndex, + int VertexOffset, + uint FirstInstance) : GpuRecordedCall; + +internal sealed record GpuRecordedDraw( + uint VertexCount, + uint InstanceCount, + uint FirstVertex, + uint FirstInstance) : GpuRecordedCall; + +internal sealed record GpuRecordedMultiDrawIndirect( + string BufferName, + uint OffsetBytes, + uint DrawCount, + uint StrideBytes) : GpuRecordedCall; + +internal sealed record GpuRecordedTextureRegistration(string TextureName, GpuSamplerDescription Sampler, uint Slot) + : GpuRecordedCall; + +internal sealed record GpuRecordedTextureRelease(uint Slot) : GpuRecordedCall; + +/// +/// In-memory that owns no driver objects. Ring +/// allocations are backed by a real byte array, so a test can drive a renderer +/// and then read back exactly what it wrote — the same bytes a driver would have +/// seen. Everything else is recorded into in submission order. +/// +internal sealed class RecordingGpuDevice : IGpuDevice +{ + private const int DefaultRingCapacityBytes = 8 * 1024 * 1024; + + private readonly List _calls = []; + private readonly List _queuedActions = []; + private readonly Dictionary _samplers = []; + private readonly byte[] _ring; + private readonly Stack _freeTextureSlots = new(); + + private uint _nextTextureSlot; + private uint _ringCursor; + private long _serial; + private RecordingGpuFrame? _openFrame; + private bool _disposed; + + public RecordingGpuDevice(int ringCapacityBytes = DefaultRingCapacityBytes) + { + ArgumentOutOfRangeException.ThrowIfLessThan(ringCapacityBytes, 1); + _ring = new byte[ringCapacityBytes]; + RingBuffer = new RecordingGpuBuffer(new GpuBufferDescription( + "test-ring", + ringCapacityBytes, + GpuBufferUsage.Storage | GpuBufferUsage.Uniform | GpuBufferUsage.Indirect, + GpuMemoryResidency.HostWritable)); + + RecordingGpuTexture placeholder = new("default-white", GpuTextureKind.Texture2D, GpuTextureFormat.Rgba8Unorm, 1, 1, 1, 1); + DefaultTextureSlot = RegisterTexture(placeholder, CreateSampler(GpuSamplerDescription.UiNearest)); + } + + /// Every recorded call, in submission order. + public IReadOnlyList Calls => _calls; + + /// Backing store for ring allocations, so tests can read what a renderer wrote. + public ReadOnlySpan RingBytes => _ring; + + /// Number of ring bytes handed out during the currently open (or most recent) frame. + public uint RingBytesAllocated => _ringCursor; + + public int OpenFrameCount { get; private set; } + + public int LiveTextureSlotCount => (int)_nextTextureSlot - _freeTextureSlots.Count; + + public GpuBackendKind Backend => GpuBackendKind.Recording; + + public GpuCapabilityRecord Capabilities { get; init; } = new() + { + Backend = GpuBackendKind.Recording, + DeviceName = "recording", + DriverInfo = "in-memory test double", + ApiVersion = "n/a", + MaxTextureTableSlots = GpuBindingModel.TextureTableCapacity, + MaxStorageBufferBindings = GpuBindingModel.StorageBindingCount, + MaxPushConstantBytes = GpuBindingModel.MaxPushConstantBytes, + MinStorageBufferOffsetAlignment = 256, + MinUniformBufferOffsetAlignment = 256, + MaxClipDistances = GpuBindingModel.ClipPlanesPerSlot, + MaxSampleCount = 8, + SupportsMultiDrawIndirect = true, + SupportsDrawParameters = true, + SupportsTextureCompressionBc = true, + SupportsTimestampQueries = true, + SupportsPersistentlyMappedRings = true, + }; + + public IGpuResourceRetirementQueue Retirement => ImmediateGpuResourceRetirementQueue.Instance; + + public IGpuTimerPool Timers { get; } = new RecordingGpuTimerPool(); + + public GpuTextureSlot DefaultTextureSlot { get; } + + public void Clear() => _calls.Clear(); + + public IGpuBuffer CreateBuffer(in GpuBufferDescription description) => + new RecordingGpuBuffer(description); + + public IGpuTexture CreateTexture(in GpuTextureDescription description) => + new RecordingGpuTexture( + description.Name, + description.Kind, + description.Format, + description.Width, + description.Height, + description.LayerCount, + description.MipLevelCount); + + public IGpuSampler CreateSampler(in GpuSamplerDescription description) + { + if (_samplers.TryGetValue(description, out RecordingGpuSampler? existing)) + return existing; + + RecordingGpuSampler created = new(description); + _samplers.Add(description, created); + return created; + } + + public IGpuPipeline CreatePipeline(GpuPipelineDescription description) + { + ArgumentNullException.ThrowIfNull(description); + return new RecordingGpuPipeline(description); + } + + public IGpuRenderTarget CreateRenderTarget(in GpuRenderTargetDescription description) => + new RecordingGpuRenderTarget(description); + + public GpuTextureSlot RegisterTexture(IGpuTexture texture, IGpuSampler sampler) + { + ArgumentNullException.ThrowIfNull(texture); + ArgumentNullException.ThrowIfNull(sampler); + + uint slot = _freeTextureSlots.Count > 0 ? _freeTextureSlots.Pop() : _nextTextureSlot++; + _calls.Add(new GpuRecordedTextureRegistration(texture.Name, sampler.Description, slot)); + return new GpuTextureSlot(slot); + } + + public void ReleaseTextureSlot(GpuTextureSlot slot) + { + if (!slot.IsAssigned) + throw new ArgumentException("Cannot release an unassigned texture slot.", nameof(slot)); + + _freeTextureSlots.Push(slot.Index); + _calls.Add(new GpuRecordedTextureRelease(slot.Index)); + } + + public IGpuFrame BeginFrame() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_openFrame is not null) + throw new InvalidOperationException("The previous frame must end before another begins."); + + _ringCursor = 0; + long serial = ++_serial; + int slotIndex = (int)((serial - 1) % 2); + _calls.Add(new GpuRecordedFrameBegin(serial, slotIndex)); + OpenFrameCount++; + _openFrame = new RecordingGpuFrame(this, serial, slotIndex); + return _openFrame; + } + + public void QueueDeviceAction(Action action) + { + ArgumentNullException.ThrowIfNull(action); + _queuedActions.Add(action); + } + + public void ProcessDeviceActions() + { + Action[] pending = [.. _queuedActions]; + _queuedActions.Clear(); + foreach (Action action in pending) + action(); + } + + public byte[] CaptureBackbuffer(int width, int height) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height); + return new byte[checked(width * height * 4)]; + } + + public void WaitIdle() => ProcessDeviceActions(); + + public void Dispose() => _disposed = true; + + internal void Record(GpuRecordedCall call) => _calls.Add(call); + + internal GpuRingAllocation Allocate(int byteCount, GpuRingUsage usage) + { + ArgumentOutOfRangeException.ThrowIfNegative(byteCount); + + uint alignment = usage switch + { + GpuRingUsage.Storage => Capabilities.MinStorageBufferOffsetAlignment, + GpuRingUsage.Uniform => Capabilities.MinUniformBufferOffsetAlignment, + _ => 4u, + }; + + uint aligned = AlignUp(_ringCursor, alignment); + if (aligned + (uint)byteCount > (uint)_ring.Length) + { + throw new InvalidOperationException( + $"Ring allocation of {byteCount} bytes for {usage} exceeds the {_ring.Length}-byte test ring."); + } + + _ringCursor = aligned + (uint)byteCount; + _calls.Add(new GpuRecordedRingAllocation(usage, byteCount, aligned)); + return new GpuRingAllocation(RingBuffer, aligned, _ring.AsSpan((int)aligned, byteCount)); + } + + internal IGpuBuffer RingBuffer { get; } + + internal void CloseFrame(RecordingGpuFrame frame) + { + if (!ReferenceEquals(_openFrame, frame)) + return; + + _calls.Add(new GpuRecordedFrameEnd(frame.Serial)); + OpenFrameCount--; + _openFrame = null; + } + + private static uint AlignUp(uint value, uint alignment) => + alignment <= 1 ? value : (value + alignment - 1) / alignment * alignment; +} + +internal sealed class RecordingGpuFrame(RecordingGpuDevice device, long serial, int slotIndex) : IGpuFrame +{ + private bool _ended; + + public int SlotIndex { get; } = slotIndex; + + public long Serial { get; } = serial; + + public GpuRingAllocation AllocateRing(int byteCount, GpuRingUsage usage) => device.Allocate(byteCount, usage); + + public IGpuPassEncoder BeginPass(GpuPassDescription description) + { + ArgumentNullException.ThrowIfNull(description); + device.Record(new GpuRecordedPassBegin(description.Name, description.SampleCount)); + return new RecordingGpuPassEncoder(device, description); + } + + public void End() + { + if (_ended) + return; + + _ended = true; + device.CloseFrame(this); + } + + public void Dispose() => End(); +} + +internal sealed class RecordingGpuPassEncoder(RecordingGpuDevice device, GpuPassDescription pass) : IGpuPassEncoder +{ + private bool _closed; + + public GpuPassDescription Pass { get; } = pass; + + public void BindPipeline(IGpuPipeline pipeline) + { + ArgumentNullException.ThrowIfNull(pipeline); + 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)); + } + + public void BindUniformBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes) + { + ArgumentNullException.ThrowIfNull(buffer); + device.Record(new GpuRecordedUniformBind(binding, buffer.Name, offsetBytes, sizeBytes)); + } + + public void BindVertexBuffer(IGpuBuffer buffer, uint offsetBytes) + { + ArgumentNullException.ThrowIfNull(buffer); + device.Record(new GpuRecordedVertexBind(buffer.Name, offsetBytes)); + } + + public void BindIndexBuffer(IGpuBuffer buffer, uint offsetBytes, GpuIndexType indexType) + { + ArgumentNullException.ThrowIfNull(buffer); + device.Record(new GpuRecordedIndexBind(buffer.Name, offsetBytes, indexType)); + } + + public void SetPushConstants(in GpuPushConstants constants) => + 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 SetScissor(int x, int y, int width, int height) => + device.Record(new GpuRecordedScissor(x, y, width, height)); + + public void SetCullMode(GpuCullMode cullMode) => device.Record(new GpuRecordedCullMode(cullMode)); + + public void SetFrontFace(GpuFrontFace frontFace) => device.Record(new GpuRecordedFrontFace(frontFace)); + + public void SetDepthWrite(bool enabled) => 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 Draw(uint vertexCount, uint instanceCount, uint firstVertex, uint firstInstance) => + 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)); + } + + public IDisposable BeginTimerScope(string scopeName) => NullDisposable.Instance; + + public void Dispose() + { + if (_closed) + return; + + _closed = true; + device.Record(new GpuRecordedPassEnd(Pass.Name)); + } + + private sealed class NullDisposable : IDisposable + { + public static NullDisposable Instance { get; } = new(); + + public void Dispose() + { + } + } +} + +internal sealed class RecordingGpuBuffer(GpuBufferDescription description) : IGpuBuffer +{ + private readonly byte[] _storage = new byte[description.SizeBytes]; + + public string Name { get; } = description.Name; + + public long SizeBytes { get; } = description.SizeBytes; + + public GpuBufferUsage Usage { get; } = description.Usage; + + public GpuMemoryResidency Residency { get; } = description.Residency; + + public bool IsDisposed { get; private set; } + + public void Upload(long offsetBytes, ReadOnlySpan data) => + data.CopyTo(_storage.AsSpan((int)offsetBytes, data.Length)); + + public void CopyTo(IGpuBuffer destination, long sourceOffsetBytes, long destinationOffsetBytes, long byteCount) + { + ArgumentNullException.ThrowIfNull(destination); + if (destination is not RecordingGpuBuffer target) + throw new ArgumentException("Recording buffers can only copy to recording buffers.", nameof(destination)); + + _storage.AsSpan((int)sourceOffsetBytes, (int)byteCount) + .CopyTo(target._storage.AsSpan((int)destinationOffsetBytes, (int)byteCount)); + } + + public void Read(long offsetBytes, Span destination) => + _storage.AsSpan((int)offsetBytes, destination.Length).CopyTo(destination); + + public void Dispose() => IsDisposed = true; +} + +internal sealed class RecordingGpuTexture( + string name, + GpuTextureKind kind, + GpuTextureFormat format, + int width, + int height, + int layerCount, + int mipLevelCount) : IGpuTexture +{ + private readonly List<(int MipLevel, int Layer, int ByteCount)> _uploads = []; + + public string Name { get; } = name; + + public GpuTextureKind Kind { get; } = kind; + + public GpuTextureFormat Format { get; } = format; + + public int Width { get; } = width; + + public int Height { get; } = height; + + public int LayerCount { get; } = layerCount; + + public int MipLevelCount { get; } = mipLevelCount; + + public bool MipChainGenerated { get; private set; } + + public bool IsDisposed { get; private set; } + + public IReadOnlyList<(int MipLevel, int Layer, int ByteCount)> Uploads => _uploads; + + public void Upload(int mipLevel, int layer, ReadOnlySpan data) => + _uploads.Add((mipLevel, layer, data.Length)); + + public void GenerateMipChain() => MipChainGenerated = true; + + public void Dispose() => IsDisposed = true; +} + +internal sealed class RecordingGpuSampler(GpuSamplerDescription description) : IGpuSampler +{ + public GpuSamplerDescription Description { get; } = description; + + public bool IsDisposed { get; private set; } + + public void Dispose() => IsDisposed = true; +} + +internal sealed class RecordingGpuPipeline(GpuPipelineDescription description) : IGpuPipeline +{ + public GpuPipelineDescription Description { get; } = description; + + public bool IsDisposed { get; private set; } + + public void Dispose() => IsDisposed = true; +} + +internal sealed class RecordingGpuRenderTarget : IGpuRenderTarget +{ + public RecordingGpuRenderTarget(GpuRenderTargetDescription description) + { + Description = description; + ColorTexture = new RecordingGpuTexture( + $"{description.Name}-color", + GpuTextureKind.Texture2D, + description.ColorFormat, + description.Width, + description.Height, + layerCount: 1, + mipLevelCount: 1); + } + + public GpuRenderTargetDescription Description { get; } + + public IGpuTexture ColorTexture { get; } + + public bool IsDisposed { get; private set; } + + public void Dispose() => IsDisposed = true; +} + +internal sealed class RecordingGpuTimerPool : IGpuTimerPool +{ + public bool IsSupported => false; + + public bool TryResolve(string scopeName, out double milliseconds) + { + milliseconds = 0d; + return false; + } +} + +/// Convenience helpers so renderer tests read as assertions, not as list surgery. +internal static class RecordingGpuDeviceAssertions +{ + public static IEnumerable OfKind(this RecordingGpuDevice device) where T : GpuRecordedCall => + device.Calls.OfType(); + + public static Vector4 ClearColorOf(this GpuPassDescription pass) => pass.Color.ClearColor; +} diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDeviceTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDeviceTests.cs new file mode 100644 index 00000000..f184fe50 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDeviceTests.cs @@ -0,0 +1,221 @@ +using System.Numerics; +using AcDream.App.Rendering.Gpu; + +namespace AcDream.App.Tests.Rendering.Gpu; + +/// +/// Campaign V slice V0 — the test double every later renderer-port slice asserts +/// against. If the double misreports ordering or ring alignment, the migration +/// slices inherit false confidence, so it gets its own tests. +/// +public sealed class RecordingGpuDeviceTests +{ + [Fact] + public void FramePassAndDrawCallsAreRecordedInSubmissionOrder() + { + using RecordingGpuDevice device = new(); + IGpuPipeline pipeline = device.CreatePipeline(new GpuPipelineDescription + { + Name = "mesh-opaque", + Shaders = new GpuShaderSet("mesh_modern"), + VertexLayout = GpuVertexLayout.WorldMesh, + }); + IGpuBuffer indirect = device.CreateBuffer(new GpuBufferDescription( + "indirect", 4096, GpuBufferUsage.Indirect, GpuMemoryResidency.HostWritable)); + device.Clear(); + + using (IGpuFrame frame = device.BeginFrame()) + { + using (IGpuPassEncoder pass = frame.BeginPass( + GpuPassDescription.BackbufferClear("world", Vector4.Zero, sampleCount: 1))) + { + pass.BindPipeline(pipeline); + pass.SetCullMode(GpuCullMode.None); + pass.MultiDrawIndexedIndirect(indirect, offsetBytes: 0, drawCount: 12, strideBytes: 20); + } + + frame.End(); + } + + Assert.Collection( + device.Calls, + call => Assert.Equal(new GpuRecordedFrameBegin(1, 0), call), + call => Assert.Equal(new GpuRecordedPassBegin("world", 1), call), + call => Assert.Equal(new GpuRecordedPipelineBind("mesh-opaque"), call), + call => Assert.Equal(new GpuRecordedCullMode(GpuCullMode.None), call), + call => Assert.Equal(new GpuRecordedMultiDrawIndirect("indirect", 0, 12, 20), call), + call => Assert.Equal(new GpuRecordedPassEnd("world"), call), + call => Assert.Equal(new GpuRecordedFrameEnd(1), call)); + } + + [Fact] + public void RingAllocationsAreAlignedForTheirUsageAndReadableAfterWriting() + { + using RecordingGpuDevice device = new(); + uint storageAlignment = device.Capabilities.MinStorageBufferOffsetAlignment; + + using IGpuFrame frame = device.BeginFrame(); + + GpuRingAllocation first = frame.AllocateRing(12, GpuRingUsage.Indirect); + Assert.Equal(0u, first.OffsetBytes); + Assert.Equal(12, first.Data.Length); + + GpuRingAllocation second = frame.AllocateRing(64, GpuRingUsage.Storage); + Assert.Equal(0u, second.OffsetBytes % storageAlignment); + Assert.True(second.OffsetBytes >= 12); + + Span transforms = second.AsSpan(); + Assert.Equal(1, transforms.Length); + transforms[0] = Matrix4x4.CreateTranslation(1f, 2f, 3f); + + frame.End(); + + // The bytes a renderer writes are the bytes a driver would read; a test can + // therefore verify upload content without a GPU. + ReadOnlySpan ring = device.RingBytes; + Matrix4x4 written = System.Runtime.InteropServices.MemoryMarshal.Read( + ring.Slice((int)second.OffsetBytes, 64)); + Assert.Equal(Matrix4x4.CreateTranslation(1f, 2f, 3f), written); + } + + [Fact] + public void RingIsRewoundEachFrameSoPerFrameDataDoesNotAccumulate() + { + using RecordingGpuDevice device = new(); + + using (IGpuFrame first = device.BeginFrame()) + { + first.AllocateRing(256, GpuRingUsage.Storage); + first.End(); + } + + uint afterFirst = device.RingBytesAllocated; + + using (IGpuFrame second = device.BeginFrame()) + { + GpuRingAllocation allocation = second.AllocateRing(256, GpuRingUsage.Storage); + Assert.Equal(0u, allocation.OffsetBytes); + second.End(); + } + + Assert.Equal(afterFirst, device.RingBytesAllocated); + } + + [Fact] + public void OverlargeRingRequestThrowsRatherThanTruncating() + { + using RecordingGpuDevice device = new(ringCapacityBytes: 1024); + using IGpuFrame frame = device.BeginFrame(); + + // Silently shortening an allocation would corrupt the frame invisibly. + Assert.Throws(() => + { + frame.AllocateRing(4096, GpuRingUsage.Storage); + }); + } + + [Fact] + public void OverlappingFramesAreRejected() + { + using RecordingGpuDevice device = new(); + using IGpuFrame frame = device.BeginFrame(); + + Assert.Throws(device.BeginFrame); + } + + [Fact] + public void FrameSlotsAlternateAcrossTwoFramesInFlight() + { + using RecordingGpuDevice device = new(); + + int[] slots = new int[4]; + for (int i = 0; i < slots.Length; i++) + { + using IGpuFrame frame = device.BeginFrame(); + slots[i] = frame.SlotIndex; + frame.End(); + } + + Assert.Equal([0, 1, 0, 1], slots); + Assert.Equal(0, device.OpenFrameCount); + } + + [Fact] + public void ReleasedTextureSlotsAreRecycledRatherThanLeaked() + { + using RecordingGpuDevice device = new(); + IGpuSampler sampler = device.CreateSampler(GpuSamplerDescription.WorldRepeat); + IGpuTexture texture = device.CreateTexture(new GpuTextureDescription( + "wall", GpuTextureKind.Texture2DArray, GpuTextureFormat.Bc1Unorm, 64, 64, 4, 1)); + + int liveBefore = device.LiveTextureSlotCount; + GpuTextureSlot slot = device.RegisterTexture(texture, sampler); + Assert.True(slot.IsAssigned); + Assert.Equal(liveBefore + 1, device.LiveTextureSlotCount); + + device.ReleaseTextureSlot(slot); + Assert.Equal(liveBefore, device.LiveTextureSlotCount); + + GpuTextureSlot reused = device.RegisterTexture(texture, sampler); + Assert.Equal(slot.Index, reused.Index); + } + + [Fact] + public void ReleasingAnUnassignedSlotIsRejected() + { + using RecordingGpuDevice device = new(); + Assert.Throws(() => device.ReleaseTextureSlot(GpuTextureSlot.Unassigned)); + } + + [Fact] + public void DefaultTextureSlotIsRegisteredAndUsable() + { + using RecordingGpuDevice device = new(); + + // Renderers needing a fallback take this, rather than assuming slot 0 + // resolves to something sensible. + Assert.True(device.DefaultTextureSlot.IsAssigned); + } + + [Fact] + public void SamplersAreDeduplicatedByValue() + { + using RecordingGpuDevice device = new(); + + IGpuSampler first = device.CreateSampler(GpuSamplerDescription.WorldRepeat); + IGpuSampler second = device.CreateSampler(GpuSamplerDescription.WorldRepeat); + IGpuSampler other = device.CreateSampler(GpuSamplerDescription.UiNearest); + + Assert.Same(first, second); + Assert.NotSame(first, other); + } + + [Fact] + public void QueuedDeviceActionsRunOnlyWhenProcessed() + { + using RecordingGpuDevice device = new(); + int ran = 0; + + device.QueueDeviceAction(() => ran++); + Assert.Equal(0, ran); + + device.ProcessDeviceActions(); + Assert.Equal(1, ran); + + device.ProcessDeviceActions(); + Assert.Equal(1, ran); + } + + [Fact] + public void DisposingAFrameClosesItExactlyOnce() + { + using RecordingGpuDevice device = new(); + + IGpuFrame frame = device.BeginFrame(); + frame.End(); + frame.Dispose(); + + Assert.Single(device.OfKind()); + Assert.Equal(0, device.OpenFrameCount); + } +}