feat(render): Campaign V slice V0 — pin the Vulkan-shaped RHI contract
Campaign V migrates the renderer from OpenGL 4.3+extensions to a single
Vulkan 1.3 backend on Windows x64 and Linux x64, then deletes the GL path.
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 descriptor indexing is core, and
per-frame data can be written straight into mapped memory rather than
copied through BufferSubData.
V0 pins the contract every later slice codes against. Nothing consumes it
yet, so this commit changes no runtime behavior.
The seam is a minimal Vulkan-shaped RHI implemented FIRST on GL. That
ordering is the point: the twelve renderers then port one at a time under a
strict pixel gate on the still-shipping backend, so a divergence is
attributed to one slice instead of surfacing at a big-bang integration.
Duplicating renderers per backend was rejected because WbDrawDispatcher is
4,449 lines holding only ~62 GL call sites — the API surface is small and
the retail-fidelity CPU logic is large, and forking the latter is how subtle
regressions enter.
Contract highlights:
- GpuBindingModel pins set/binding numbers dual-legal for GL and Vulkan
GLSL. Storage bindings 0-8 keep today's shader numbering; UBOs move to
their own set, which resolves the binding=1 collision GL only tolerates
because it keeps SSBO and UBO tables separate.
- GpuRingAllocation is a ref struct replacing every per-frame
BufferSubData; the compiler forbids outliving the owning frame.
- GpuTextureSlot replaces bindless handles. Unassigned is a loud
uint.MaxValue sentinel rather than a silent resolve to slot 0 — the
failure mode behind the magenta 1x1 UI placeholder bug. Renderers
needing a fallback take the device's really-registered default slot.
- Renderers always speak GL winding/viewport conventions; the Vulkan
backend compensates with a negative viewport height in exactly one
mapping function.
Verified while writing the plan: acdream's cameras already build
[0,1]-NDC projections (PortalProjection.cs:12-13), which is Vulkan's
convention. No projection rework is needed and depth precision improves,
at the cost of shifted z-fight patterns — the one pre-approved divergence
class, registered per instance at V7.
Gate: Release build green; App suite 3,785 passed / 3 skipped (3,763
baseline plus 22 new contract tests). Note for later slices, recorded in
the plan: run the suite in Release. LandblockBuildOriginTests'
far-strip test asserts behavior that LandblockStreamer.cs:505 deliberately
turns into a loud Debug.Assert in Debug builds, so a Debug run shows one
pre-existing failure that is not a regression.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f6275f4501
commit
621b16364b
17 changed files with 2577 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
458
docs/plans/2026-07-27-vulkan-campaign.md
Normal file
458
docs/plans/2026-07-27-vulkan-campaign.md
Normal file
|
|
@ -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<long, List<Action>>` 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.
|
||||
129
src/AcDream.App/Rendering/Gpu/GpuBindingModel.cs
Normal file
129
src/AcDream.App/Rendering/Gpu/GpuBindingModel.cs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
namespace AcDream.App.Rendering.Gpu;
|
||||
|
||||
/// <summary>
|
||||
/// 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 (<see cref="StorageTextureTable"/>),
|
||||
/// 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
|
||||
/// <see cref="GpuTextureSlot"/> 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.
|
||||
/// </summary>
|
||||
internal static class GpuBindingModel
|
||||
{
|
||||
// ---- set 0: storage buffers (identical numbering to today's SSBO bindings) ----
|
||||
|
||||
/// <summary>Per-instance transforms. std430 <c>InstanceData { mat4 transform; }</c>.</summary>
|
||||
public const uint StorageInstances = 0;
|
||||
|
||||
/// <summary>Per-draw batch metadata. std430 <c>BatchData</c> (see <see cref="GpuBatchDataStrideBytes"/>).</summary>
|
||||
public const uint StorageBatches = 1;
|
||||
|
||||
/// <summary>Phase U.3 shared per-frame clip regions (<c>CellClip</c>, 144 B/slot). Slot 0 = no-clip.</summary>
|
||||
public const uint StorageClipRegions = 2;
|
||||
|
||||
/// <summary>Phase U.3 per-instance clip-slot index, parallel to <see cref="StorageInstances"/>.</summary>
|
||||
public const uint StorageClipSlots = 3;
|
||||
|
||||
/// <summary>A7 Fix B global point/spot light array.</summary>
|
||||
public const uint StorageGlobalLights = 4;
|
||||
|
||||
/// <summary>A7 Fix B per-instance light set: 8 indices into the global light array, -1 = unused.</summary>
|
||||
public const uint StorageInstanceLightSets = 5;
|
||||
|
||||
/// <summary>#142 per-instance indoor flag (1 = parented to an EnvCell, skip the sun).</summary>
|
||||
public const uint StorageInstanceIndoor = 6;
|
||||
|
||||
/// <summary>#188 per-instance opacity multiplier for TransparentPartHook fades.</summary>
|
||||
public const uint StorageInstanceAlpha = 7;
|
||||
|
||||
/// <summary>Retail SmartBox selection lighting: one vec2 (luminosity, diffuse) per instance.</summary>
|
||||
public const uint StorageInstanceSelectionLighting = 8;
|
||||
|
||||
/// <summary>
|
||||
/// GL-only emulation of the Vulkan texture table: a storage buffer of uvec2
|
||||
/// bindless handles indexed by <see cref="GpuTextureSlot.Index"/>. The Vulkan
|
||||
/// backend binds <see cref="TextureTableSet"/> instead and never uses this
|
||||
/// binding; it is deleted with the GL backend at slice V11.
|
||||
/// </summary>
|
||||
public const uint StorageTextureTable = 9;
|
||||
|
||||
/// <summary>One past the highest storage binding — the count both backends must support.</summary>
|
||||
public const uint StorageBindingCount = 10;
|
||||
|
||||
// ---- set 1: uniform buffers ----
|
||||
|
||||
/// <summary>
|
||||
/// SceneLighting std140 block. Keeps binding=1 so the existing shader source
|
||||
/// and <c>SceneLightingUboBinding</c> layout are untouched; the set index is
|
||||
/// what disambiguates it from <see cref="StorageBatches"/> under Vulkan.
|
||||
/// </summary>
|
||||
public const uint UniformSceneLighting = 1;
|
||||
|
||||
/// <summary>Set index carrying every uniform buffer.</summary>
|
||||
public const uint UniformSet = 1;
|
||||
|
||||
// ---- set 2: the global texture table ----
|
||||
|
||||
/// <summary>Set index of the sampled-texture descriptor array (Vulkan) / logical table (GL).</summary>
|
||||
public const uint TextureTableSet = 2;
|
||||
|
||||
/// <summary>Binding of the descriptor array within <see cref="TextureTableSet"/>.</summary>
|
||||
public const uint TextureTableBinding = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound on simultaneously registered textures. Vulkan requires
|
||||
/// <c>maxDescriptorSetUpdateAfterBindSampledImages</c> to reach this; the
|
||||
/// capability probe asserts it rather than discovering it at draw time.
|
||||
/// </summary>
|
||||
public const uint TextureTableCapacity = 16384;
|
||||
|
||||
// ---- push constants ----
|
||||
|
||||
/// <summary>
|
||||
/// Bytes actually written by <see cref="GpuPushConstants"/>. Vulkan guarantees
|
||||
/// at least <see cref="MaxPushConstantBytes"/>, so 32 bytes of headroom remain
|
||||
/// for later slices; any growth updates this constant and the shader block in
|
||||
/// the same commit.
|
||||
/// </summary>
|
||||
public const int PushConstantBytes = 96;
|
||||
|
||||
/// <summary>The Vulkan-guaranteed minimum push-constant budget. A hard ceiling for us.</summary>
|
||||
public const int MaxPushConstantBytes = 128;
|
||||
|
||||
// ---- shared layout facts the CPU writers and the shaders must agree on ----
|
||||
|
||||
/// <summary>
|
||||
/// std430 stride of <c>BatchData</c>. The bindless <c>uvec2 textureHandle</c>
|
||||
/// becomes <c>uint textureIndex</c> plus one pad word at slice V2, so the
|
||||
/// stride is unchanged and every existing CPU writer keeps its offsets.
|
||||
/// </summary>
|
||||
public const int GpuBatchDataStrideBytes = 16;
|
||||
|
||||
/// <summary>Clip planes per <c>CellClip</c> slot; also the required <c>gl_ClipDistance</c> size.</summary>
|
||||
public const int ClipPlanesPerSlot = 8;
|
||||
|
||||
/// <summary>std430 stride of one <c>CellClip</c> slot: 16 B header + 8 × vec4.</summary>
|
||||
public const int ClipRegionStrideBytes = 16 + (ClipPlanesPerSlot * 16);
|
||||
|
||||
/// <summary>Lights selected per object by retail's <c>minimize_object_lighting</c>.</summary>
|
||||
public const int MaxLightsPerObject = 8;
|
||||
}
|
||||
116
src/AcDream.App/Rendering/Gpu/GpuCapabilityRecord.cs
Normal file
116
src/AcDream.App/Rendering/Gpu/GpuCapabilityRecord.cs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
namespace AcDream.App.Rendering.Gpu;
|
||||
|
||||
/// <summary>
|
||||
/// Backend-neutral view of what the device can do. Both backends fill this from
|
||||
/// their own probe layer — <c>GraphicalCapabilityRecord</c> 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.
|
||||
/// </summary>
|
||||
internal sealed record GpuCapabilityRecord
|
||||
{
|
||||
public required GpuBackendKind Backend { get; init; }
|
||||
|
||||
/// <summary>Adapter name, e.g. <c>"AMD Radeon RX 9070 XT"</c>.</summary>
|
||||
public required string DeviceName { get; init; }
|
||||
|
||||
/// <summary>Driver identification string for diagnostics and bug reports.</summary>
|
||||
public required string DriverInfo { get; init; }
|
||||
|
||||
/// <summary>API version actually in use, e.g. <c>"OpenGL 4.6"</c> or <c>"Vulkan 1.3.280"</c>.</summary>
|
||||
public required string ApiVersion { get; init; }
|
||||
|
||||
/// <summary>Simultaneously registerable texture-table slots. Must reach <see cref="GpuBindingModel.TextureTableCapacity"/>.</summary>
|
||||
public required uint MaxTextureTableSlots { get; init; }
|
||||
|
||||
/// <summary>Storage-buffer bindings available. Must reach <see cref="GpuBindingModel.StorageBindingCount"/>.</summary>
|
||||
public required uint MaxStorageBufferBindings { get; init; }
|
||||
|
||||
/// <summary>Push-constant bytes available. Must reach <see cref="GpuBindingModel.PushConstantBytes"/>.</summary>
|
||||
public required uint MaxPushConstantBytes { get; init; }
|
||||
|
||||
/// <summary>Required alignment for a storage-buffer binding offset.</summary>
|
||||
public required uint MinStorageBufferOffsetAlignment { get; init; }
|
||||
|
||||
/// <summary>Required alignment for a uniform-buffer binding offset.</summary>
|
||||
public required uint MinUniformBufferOffsetAlignment { get; init; }
|
||||
|
||||
/// <summary>Clip distances usable by a shader. Phase U.3's per-cell clip gate needs 8.</summary>
|
||||
public required uint MaxClipDistances { get; init; }
|
||||
|
||||
/// <summary>Highest supported multisample count for the backbuffer.</summary>
|
||||
public required uint MaxSampleCount { get; init; }
|
||||
|
||||
/// <summary>Multi-draw-indirect. Mandatory — it is the entire draw architecture.</summary>
|
||||
public required bool SupportsMultiDrawIndirect { get; init; }
|
||||
|
||||
/// <summary>Shader draw parameters (<c>gl_DrawID</c>). Mandatory — batch lookup depends on it.</summary>
|
||||
public required bool SupportsDrawParameters { get; init; }
|
||||
|
||||
/// <summary>BC1/2/3 sampling. Mandatory — DAT surfaces upload as DXT without transcoding.</summary>
|
||||
public required bool SupportsTextureCompressionBc { get; init; }
|
||||
|
||||
/// <summary>GPU timestamps. Optional: absence degrades profiling, not rendering.</summary>
|
||||
public required bool SupportsTimestampQueries { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public required bool SupportsPersistentlyMappedRings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>NotSupportedException</c>
|
||||
/// and exit-code-4 contract the GL gate already publishes.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> SupportFailures
|
||||
{
|
||||
get
|
||||
{
|
||||
List<string> 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;
|
||||
}
|
||||
191
src/AcDream.App/Rendering/Gpu/GpuEnums.cs
Normal file
191
src/AcDream.App/Rendering/Gpu/GpuEnums.cs
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
namespace AcDream.App.Rendering.Gpu;
|
||||
|
||||
/// <summary>Which RHI backend is servicing the device.</summary>
|
||||
internal enum GpuBackendKind
|
||||
{
|
||||
/// <summary>Test double — records calls, owns no driver objects.</summary>
|
||||
Recording,
|
||||
|
||||
/// <summary>OpenGL 4.3 + bindless/MDI. Deleted at Campaign V slice V11.</summary>
|
||||
OpenGl,
|
||||
|
||||
/// <summary>Vulkan 1.3 core.</summary>
|
||||
Vulkan,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where a buffer's memory lives. The distinction is invisible on GL (the driver
|
||||
/// decides) but load-bearing on Vulkan: <see cref="HostWritable"/> is what lets
|
||||
/// per-frame data be written straight into mapped memory instead of copied
|
||||
/// through <c>BufferSubData</c>, which is Campaign V's single largest CPU win.
|
||||
/// </summary>
|
||||
internal enum GpuMemoryResidency
|
||||
{
|
||||
/// <summary>Device-local, written only through staged transfers. Mesh arenas, textures.</summary>
|
||||
DeviceLocal,
|
||||
|
||||
/// <summary>Persistently mapped and CPU-writable. Per-frame rings and staging.</summary>
|
||||
HostWritable,
|
||||
|
||||
/// <summary>Mapped and CPU-readable. Screenshot and diagnostic readback only.</summary>
|
||||
HostReadable,
|
||||
}
|
||||
|
||||
/// <summary>Which alignment and usage a per-frame ring allocation must satisfy.</summary>
|
||||
internal enum GpuRingUsage
|
||||
{
|
||||
Storage,
|
||||
Uniform,
|
||||
Indirect,
|
||||
Vertex,
|
||||
Index,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal enum GpuTextureFormat
|
||||
{
|
||||
Rgba8Unorm,
|
||||
R8Unorm,
|
||||
Bc1Unorm,
|
||||
Bc2Unorm,
|
||||
Bc3Unorm,
|
||||
|
||||
/// <summary>Colour attachment format for offscreen targets (paperdoll, appraisal).</summary>
|
||||
Rgba8UnormRenderTarget,
|
||||
|
||||
/// <summary>Combined depth+stencil attachment. #117's portal punch needs the stencil aspect.</summary>
|
||||
Depth24Stencil8,
|
||||
}
|
||||
|
||||
/// <summary>Texture shape. acdream uses 2D for UI art and 2D arrays for every world material.</summary>
|
||||
internal enum GpuTextureKind
|
||||
{
|
||||
Texture2D,
|
||||
Texture2DArray,
|
||||
}
|
||||
|
||||
internal enum GpuFilter
|
||||
{
|
||||
Nearest,
|
||||
Linear,
|
||||
}
|
||||
|
||||
internal enum GpuMipFilter
|
||||
{
|
||||
None,
|
||||
Nearest,
|
||||
Linear,
|
||||
}
|
||||
|
||||
internal enum GpuAddressMode
|
||||
{
|
||||
Repeat,
|
||||
ClampToEdge,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Colour blending. These are the only combinations the GL pass matrix actually
|
||||
/// sets, and each becomes one <c>VkPipeline</c> variant because core Vulkan 1.3
|
||||
/// does not make blend state dynamic.
|
||||
/// </summary>
|
||||
internal enum GpuBlendMode
|
||||
{
|
||||
/// <summary>Opaque: blending disabled.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>Straight alpha: <c>SrcAlpha, OneMinusSrcAlpha</c>.</summary>
|
||||
StraightAlpha,
|
||||
|
||||
/// <summary>Additive: <c>SrcAlpha, One</c>.</summary>
|
||||
Additive,
|
||||
}
|
||||
|
||||
internal enum GpuCompareOp
|
||||
{
|
||||
Never,
|
||||
Less,
|
||||
LessOrEqual,
|
||||
Equal,
|
||||
Greater,
|
||||
GreaterOrEqual,
|
||||
Always,
|
||||
}
|
||||
|
||||
internal enum GpuCullMode
|
||||
{
|
||||
None,
|
||||
Back,
|
||||
Front,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal enum GpuFrontFace
|
||||
{
|
||||
CounterClockwise,
|
||||
Clockwise,
|
||||
}
|
||||
|
||||
internal enum GpuPrimitiveTopology
|
||||
{
|
||||
TriangleList,
|
||||
LineList,
|
||||
}
|
||||
|
||||
internal enum GpuIndexType
|
||||
{
|
||||
UInt16,
|
||||
UInt32,
|
||||
}
|
||||
|
||||
/// <summary>What happens to an attachment's existing contents when a pass begins.</summary>
|
||||
internal enum GpuLoadOp
|
||||
{
|
||||
/// <summary>Contents are undefined on entry — the cheapest option, and the default for MSAA targets.</summary>
|
||||
DontCare,
|
||||
|
||||
/// <summary>Contents are cleared to the attachment's clear value.</summary>
|
||||
Clear,
|
||||
|
||||
/// <summary>Existing contents are preserved and readable.</summary>
|
||||
Load,
|
||||
}
|
||||
|
||||
/// <summary>What happens to an attachment's contents when a pass ends.</summary>
|
||||
internal enum GpuStoreOp
|
||||
{
|
||||
/// <summary>Contents are discarded. Correct for MSAA colour that is resolved, and for depth.</summary>
|
||||
DontCare,
|
||||
|
||||
/// <summary>Contents are written back to memory.</summary>
|
||||
Store,
|
||||
|
||||
/// <summary>Multisampled contents are resolved into the pass's resolve target and then discarded.</summary>
|
||||
Resolve,
|
||||
}
|
||||
80
src/AcDream.App/Rendering/Gpu/GpuPassDescription.cs
Normal file
80
src/AcDream.App/Rendering/Gpu/GpuPassDescription.cs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Rendering.Gpu;
|
||||
|
||||
/// <summary>
|
||||
/// The colour attachment for a pass.
|
||||
/// </summary>
|
||||
/// <param name="Target">
|
||||
/// 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
|
||||
/// <paramref name="Store"/> is <see cref="GpuStoreOp.Resolve"/>).
|
||||
/// </param>
|
||||
/// <param name="Load">What happens to existing contents on entry.</param>
|
||||
/// <param name="Store">What happens to contents on exit.</param>
|
||||
/// <param name="ClearColor">Clear value used when <paramref name="Load"/> is <see cref="GpuLoadOp.Clear"/>.</param>
|
||||
internal readonly record struct GpuColorAttachment(
|
||||
IGpuRenderTarget? Target,
|
||||
GpuLoadOp Load,
|
||||
GpuStoreOp Store,
|
||||
Vector4 ClearColor);
|
||||
|
||||
/// <summary>
|
||||
/// The depth/stencil attachment for a pass. Depth is transient in every acdream
|
||||
/// pass — nothing reads it after the frame — so <see cref="Store"/> is normally
|
||||
/// <see cref="GpuStoreOp.DontCare"/>, which lets Vulkan skip writing it back to
|
||||
/// memory entirely.
|
||||
/// </summary>
|
||||
/// <param name="Load">What happens to existing contents on entry.</param>
|
||||
/// <param name="Store">What happens to contents on exit.</param>
|
||||
/// <param name="ClearDepth">Depth clear value. acdream renders with NDC z in [0,1], so far = 1.</param>
|
||||
/// <param name="ClearStencil">Stencil clear value; #117's portal punch uses the stencil aspect.</param>
|
||||
internal readonly record struct GpuDepthAttachment(
|
||||
GpuLoadOp Load,
|
||||
GpuStoreOp Store,
|
||||
float ClearDepth,
|
||||
uint ClearStencil);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed record GpuPassDescription
|
||||
{
|
||||
/// <summary>Stable identifier, surfaced as a debug label in captures.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>The colour attachment. Required — acdream has no colour-less passes.</summary>
|
||||
public required GpuColorAttachment Color { get; init; }
|
||||
|
||||
/// <summary>Depth/stencil attachment, or null for 2-D passes that need no depth.</summary>
|
||||
public GpuDepthAttachment? Depth { get; init; }
|
||||
|
||||
/// <summary>Samples per pixel. Must equal <see cref="GpuPipelineDescription.SampleCount"/> of every pipeline bound inside.</summary>
|
||||
public int SampleCount { get; init; } = 1;
|
||||
|
||||
/// <summary>Clears colour and depth to the standard frame-start values against the backbuffer.</summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
113
src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs
Normal file
113
src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
using System.Collections.Immutable;
|
||||
|
||||
namespace AcDream.App.Rendering.Gpu;
|
||||
|
||||
/// <summary>Vertex attribute component layout. Only the formats acdream's meshes actually use.</summary>
|
||||
internal enum GpuVertexFormat
|
||||
{
|
||||
Float1,
|
||||
Float2,
|
||||
Float3,
|
||||
Float4,
|
||||
UByte4Normalized,
|
||||
}
|
||||
|
||||
/// <summary>One vertex attribute, matching a <c>layout(location = N) in</c> declaration.</summary>
|
||||
internal readonly record struct GpuVertexAttribute(
|
||||
uint Location,
|
||||
GpuVertexFormat Format,
|
||||
uint OffsetBytes);
|
||||
|
||||
/// <summary>Interleaved vertex layout for a single bound vertex buffer.</summary>
|
||||
internal sealed record GpuVertexLayout(uint StrideBytes, ImmutableArray<GpuVertexAttribute> Attributes)
|
||||
{
|
||||
/// <summary>
|
||||
/// The world mesh vertex shared by <c>mesh_modern</c>, EnvCells, and terrain:
|
||||
/// position, normal, texcoord — 32 bytes, matching the format
|
||||
/// <c>ObjectMeshManager</c> packs into <c>GlobalMeshBuffer</c>.
|
||||
/// </summary>
|
||||
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),
|
||||
]);
|
||||
|
||||
/// <summary>Empty layout for pipelines whose vertices come entirely from storage buffers.</summary>
|
||||
public static GpuVertexLayout None { get; } = new(0, []);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Names one GLSL shader pair. The backend resolves it: the GL backend loads
|
||||
/// <c>Rendering/Shaders/{Name}.vert</c> and <c>.frag</c> and compiles at startup;
|
||||
/// the Vulkan backend loads the committed <c>Rendering/Shaders/spv/{Name}.vert.spv</c>
|
||||
/// and <c>.frag.spv</c> produced by <c>tools/compile-shaders.ps1</c>. One source
|
||||
/// of truth (the GLSL), two consumption paths.
|
||||
/// </summary>
|
||||
internal readonly record struct GpuShaderSet(string Name);
|
||||
|
||||
/// <summary>Depth-buffer behaviour baked into a pipeline.</summary>
|
||||
/// <param name="Test">Whether depth testing is enabled at all.</param>
|
||||
/// <param name="Write">Default depth-write state; overridable per draw via dynamic state.</param>
|
||||
/// <param name="Compare">Comparison function when testing is enabled.</param>
|
||||
internal readonly record struct GpuDepthState(bool Test, bool Write, GpuCompareOp Compare)
|
||||
{
|
||||
/// <summary>Standard opaque geometry: test and write, nearer wins.</summary>
|
||||
public static GpuDepthState OpaqueDefault { get; } = new(Test: true, Write: true, GpuCompareOp.LessOrEqual);
|
||||
|
||||
/// <summary>Translucent geometry: test against existing depth but do not occlude later draws.</summary>
|
||||
public static GpuDepthState TranslucentDefault { get; } = new(Test: true, Write: false, GpuCompareOp.LessOrEqual);
|
||||
|
||||
/// <summary>Sky and 2-D overlays: depth is irrelevant.</summary>
|
||||
public static GpuDepthState Disabled { get; } = new(Test: false, Write: false, GpuCompareOp.Always);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Everything a draw needs beyond its buffers: the shader pair and all fixed
|
||||
/// state. Vulkan bakes this into one <c>VkPipeline</c> 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. <see cref="Name"/> is the identity used by debug
|
||||
/// tooling and by the backend's own pipeline cache key.
|
||||
/// </summary>
|
||||
internal sealed record GpuPipelineDescription
|
||||
{
|
||||
/// <summary>Stable identifier, e.g. <c>"mesh-opaque"</c>. Surfaced to RenderDoc and validation layers.</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>The GLSL pair this pipeline draws with.</summary>
|
||||
public required GpuShaderSet Shaders { get; init; }
|
||||
|
||||
/// <summary>Vertex input layout; <see cref="GpuVertexLayout.None"/> for buffer-fed geometry.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Default cull mode; overridable per draw via <see cref="IGpuPassEncoder.SetCullMode"/>.</summary>
|
||||
public GpuCullMode Cull { get; init; } = GpuCullMode.Back;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public GpuFrontFace FrontFace { get; init; } = GpuFrontFace.CounterClockwise;
|
||||
|
||||
/// <summary>
|
||||
/// Alpha-to-coverage for foliage. Only meaningful when the pass is
|
||||
/// multisampled; the backend ignores it at one sample.
|
||||
/// </summary>
|
||||
public bool AlphaToCoverage { get; init; }
|
||||
|
||||
/// <summary>Whether the pipeline writes colour at all. False for depth/stencil-only prepasses.</summary>
|
||||
public bool ColorWrite { get; init; } = true;
|
||||
|
||||
/// <summary>Sample count of the passes this pipeline is used in. Must match the pass.</summary>
|
||||
public int SampleCount { get; init; } = 1;
|
||||
}
|
||||
80
src/AcDream.App/Rendering/Gpu/GpuPushConstants.cs
Normal file
80
src/AcDream.App/Rendering/Gpu/GpuPushConstants.cs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
using System.Numerics;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AcDream.App.Rendering.Gpu;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>vkCmdPushConstants</c>.
|
||||
///
|
||||
/// Layout is asserted by <c>GpuContractTests</c>; changing it is a contract change
|
||||
/// that must land together with the matching edit to the shared GLSL preamble.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 4)]
|
||||
internal struct GpuPushConstants
|
||||
{
|
||||
/// <summary>
|
||||
/// GLSL <c>uViewProjection</c>. acdream's cameras build this with
|
||||
/// <c>Matrix4x4.CreatePerspectiveFieldOfView</c>, whose NDC z range is [0,1] —
|
||||
/// already Vulkan's convention (see <c>PortalProjection.cs</c>). No projection
|
||||
/// rework is needed for the migration, and Vulkan gains the half of the depth
|
||||
/// range GL was discarding.
|
||||
/// </summary>
|
||||
public Matrix4x4 ViewProjection;
|
||||
|
||||
/// <summary>
|
||||
/// GLSL <c>uDrawIDOffset</c>. 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 <c>gl_DrawID</c> resets
|
||||
/// identically per <c>vkCmdDrawIndexedIndirect</c>, so the pattern carries over
|
||||
/// unchanged.
|
||||
/// </summary>
|
||||
public int DrawIdOffset;
|
||||
|
||||
/// <summary>GLSL <c>uLightingMode</c>: 0 = object (plain Lambert + sun), 1 = EnvCell (half-Lambert wrap, no sun).</summary>
|
||||
public int LightingMode;
|
||||
|
||||
/// <summary>GLSL <c>uRenderPass</c>: 0 = opaque, 1 = translucent.</summary>
|
||||
public int RenderPass;
|
||||
|
||||
/// <summary>GLSL <c>uLightDebug</c>: #176 stripe-hunt isolation modes; 0 = off.</summary>
|
||||
public int LightDebug;
|
||||
|
||||
/// <summary>
|
||||
/// GLSL <c>uTextureIndexA</c>. Primary texture-table slot for pipelines whose
|
||||
/// texture is per-pass rather than per-batch — currently the terrain atlas.
|
||||
/// </summary>
|
||||
public uint TextureIndexA;
|
||||
|
||||
/// <summary>GLSL <c>uTextureIndexB</c>. Secondary per-pass slot — currently the terrain alpha-mask array.</summary>
|
||||
public uint TextureIndexB;
|
||||
|
||||
/// <summary>GLSL <c>uParamA</c>. Spare scalar; unclaimed at V0.</summary>
|
||||
public float ParamA;
|
||||
|
||||
/// <summary>GLSL <c>uParamB</c>. Spare scalar; unclaimed at V0.</summary>
|
||||
public float ParamB;
|
||||
|
||||
/// <summary>Neutral defaults: identity transform, opaque object lighting, no debug mode.</summary>
|
||||
public static GpuPushConstants Default => new()
|
||||
{
|
||||
ViewProjection = Matrix4x4.Identity,
|
||||
DrawIdOffset = 0,
|
||||
LightingMode = 0,
|
||||
RenderPass = 0,
|
||||
LightDebug = 0,
|
||||
TextureIndexA = 0,
|
||||
TextureIndexB = 0,
|
||||
ParamA = 0f,
|
||||
ParamB = 0f,
|
||||
};
|
||||
}
|
||||
117
src/AcDream.App/Rendering/Gpu/GpuResourceDescriptions.cs
Normal file
117
src/AcDream.App/Rendering/Gpu/GpuResourceDescriptions.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
namespace AcDream.App.Rendering.Gpu;
|
||||
|
||||
/// <summary>
|
||||
/// Creation parameters for a GPU buffer. <paramref name="Name"/> is not
|
||||
/// cosmetic: the Vulkan backend publishes it through <c>VK_EXT_debug_utils</c>
|
||||
/// so RenderDoc captures and validation-layer messages name our objects.
|
||||
/// </summary>
|
||||
/// <param name="Name">Stable identifier, e.g. <c>"mesh-arena-vertex"</c>.</param>
|
||||
/// <param name="SizeBytes">Allocation size. Growth is a create-copy-retire cycle, never a resize.</param>
|
||||
/// <param name="Usage">Every way the buffer will be consumed.</param>
|
||||
/// <param name="Residency">Where the memory lives and whether the CPU may write it directly.</param>
|
||||
internal readonly record struct GpuBufferDescription(
|
||||
string Name,
|
||||
long SizeBytes,
|
||||
GpuBufferUsage Usage,
|
||||
GpuMemoryResidency Residency);
|
||||
|
||||
/// <summary>Creation parameters for a sampled texture or a render-target image.</summary>
|
||||
/// <param name="Name">Stable identifier for debug tooling.</param>
|
||||
/// <param name="Kind">2D, or the 2D array every world material uses.</param>
|
||||
/// <param name="Format">Pixel format; BC formats are uploaded as compressed blocks.</param>
|
||||
/// <param name="Width">Width in texels of mip level 0.</param>
|
||||
/// <param name="Height">Height in texels of mip level 0.</param>
|
||||
/// <param name="LayerCount">Array layers; 1 for <see cref="GpuTextureKind.Texture2D"/>.</param>
|
||||
/// <param name="MipLevelCount">
|
||||
/// Levels to allocate. 1 disables mipping. The backend never silently generates
|
||||
/// mips: <see cref="IGpuTexture.GenerateMipChain"/> is an explicit call, because
|
||||
/// Vulkan cannot blit-generate compressed mips and must take a CPU-built chain.
|
||||
/// </param>
|
||||
internal readonly record struct GpuTextureDescription(
|
||||
string Name,
|
||||
GpuTextureKind Kind,
|
||||
GpuTextureFormat Format,
|
||||
int Width,
|
||||
int Height,
|
||||
int LayerCount,
|
||||
int MipLevelCount);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal readonly record struct GpuSamplerDescription(
|
||||
GpuFilter MinFilter,
|
||||
GpuFilter MagFilter,
|
||||
GpuMipFilter MipFilter,
|
||||
GpuAddressMode AddressU,
|
||||
GpuAddressMode AddressV,
|
||||
float MaxAnisotropy)
|
||||
{
|
||||
/// <summary>Trilinear repeat — the default for world materials.</summary>
|
||||
public static GpuSamplerDescription WorldRepeat { get; } = new(
|
||||
GpuFilter.Linear,
|
||||
GpuFilter.Linear,
|
||||
GpuMipFilter.Linear,
|
||||
GpuAddressMode.Repeat,
|
||||
GpuAddressMode.Repeat,
|
||||
MaxAnisotropy: 1f);
|
||||
|
||||
/// <summary>Trilinear clamped — atlas pages and anything whose edges must not wrap.</summary>
|
||||
public static GpuSamplerDescription WorldClamp { get; } = new(
|
||||
GpuFilter.Linear,
|
||||
GpuFilter.Linear,
|
||||
GpuMipFilter.Linear,
|
||||
GpuAddressMode.ClampToEdge,
|
||||
GpuAddressMode.ClampToEdge,
|
||||
MaxAnisotropy: 1f);
|
||||
|
||||
/// <summary>Unfiltered clamped — retail UI icons and the composited 32×32 item art.</summary>
|
||||
public static GpuSamplerDescription UiNearest { get; } = new(
|
||||
GpuFilter.Nearest,
|
||||
GpuFilter.Nearest,
|
||||
GpuMipFilter.None,
|
||||
GpuAddressMode.ClampToEdge,
|
||||
GpuAddressMode.ClampToEdge,
|
||||
MaxAnisotropy: 1f);
|
||||
}
|
||||
|
||||
/// <summary>An offscreen colour(+depth) bundle: paperdoll, creature appraisal, portal masking.</summary>
|
||||
/// <param name="Name">Stable identifier for debug tooling.</param>
|
||||
/// <param name="Width">Colour attachment width in pixels.</param>
|
||||
/// <param name="Height">Colour attachment height in pixels.</param>
|
||||
/// <param name="ColorFormat">Colour attachment format.</param>
|
||||
/// <param name="DepthFormat">Depth/stencil format, or null for a colour-only target.</param>
|
||||
/// <param name="SampleCount">1 for single-sampled. Offscreen targets stay single-sampled.</param>
|
||||
internal readonly record struct GpuRenderTargetDescription(
|
||||
string Name,
|
||||
int Width,
|
||||
int Height,
|
||||
GpuTextureFormat ColorFormat,
|
||||
GpuTextureFormat? DepthFormat,
|
||||
int SampleCount);
|
||||
|
||||
/// <summary>
|
||||
/// A slot in the device's global texture table — the backend-neutral replacement
|
||||
/// for a 64-bit <c>ARB_bindless_texture</c> handle. Renderers write
|
||||
/// <see cref="Index"/> into batch data; the shader indexes the descriptor array
|
||||
/// (Vulkan) or the uvec2 handle buffer (GL) with it.
|
||||
///
|
||||
/// <see cref="Unassigned"/> 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
|
||||
/// <see cref="IGpuDevice.DefaultTextureSlot"/>, which is a real registered texture.
|
||||
/// </summary>
|
||||
internal readonly record struct GpuTextureSlot(uint Index)
|
||||
{
|
||||
/// <summary>Sentinel for "no texture assigned". Must never reach a shader.</summary>
|
||||
public static GpuTextureSlot Unassigned { get; } = new(uint.MaxValue);
|
||||
|
||||
public bool IsAssigned => Index != uint.MaxValue;
|
||||
|
||||
public override string ToString() =>
|
||||
IsAssigned ? $"slot#{Index}" : "slot#unassigned";
|
||||
}
|
||||
109
src/AcDream.App/Rendering/Gpu/GpuResources.cs
Normal file
109
src/AcDream.App/Rendering/Gpu/GpuResources.cs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
namespace AcDream.App.Rendering.Gpu;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>GpuFrameFlightController</c> already enforces for GL names today.
|
||||
/// </summary>
|
||||
internal interface IGpuBuffer : IDisposable
|
||||
{
|
||||
string Name { get; }
|
||||
long SizeBytes { get; }
|
||||
GpuBufferUsage Usage { get; }
|
||||
GpuMemoryResidency Residency { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Writes <paramref name="data"/> at <paramref name="offsetBytes"/>. On a
|
||||
/// <see cref="GpuMemoryResidency.DeviceLocal"/> 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.
|
||||
/// </summary>
|
||||
void Upload(long offsetBytes, ReadOnlySpan<byte> data);
|
||||
|
||||
/// <summary>
|
||||
/// Device-side copy, used by the mesh arena's grow-and-copy migration so
|
||||
/// arena growth never round-trips through system memory.
|
||||
/// </summary>
|
||||
void CopyTo(IGpuBuffer destination, long sourceOffsetBytes, long destinationOffsetBytes, long byteCount);
|
||||
|
||||
/// <summary>
|
||||
/// Reads back into <paramref name="destination"/>. Only valid on
|
||||
/// <see cref="GpuMemoryResidency.HostReadable"/> buffers; diagnostics only.
|
||||
/// </summary>
|
||||
void Read(long offsetBytes, Span<byte> destination);
|
||||
}
|
||||
|
||||
/// <summary>A sampled texture or an attachment image.</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// Uploads one mip level of one array layer. <paramref name="data"/> is raw
|
||||
/// texels for uncompressed formats and raw blocks for BC formats.
|
||||
/// </summary>
|
||||
void Upload(int mipLevel, int layer, ReadOnlySpan<byte> data);
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>glGenerateMipmap</c>, 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
|
||||
/// <see cref="Upload"/> and this call throws — the GL path's reliance on
|
||||
/// driver-defined compressed-mip regeneration is the behaviour we are
|
||||
/// deliberately not carrying forward.
|
||||
/// </summary>
|
||||
void GenerateMipChain();
|
||||
}
|
||||
|
||||
/// <summary>Immutable sampler state. Owned and de-duplicated by the device.</summary>
|
||||
internal interface IGpuSampler : IDisposable
|
||||
{
|
||||
GpuSamplerDescription Description { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A compiled shader program plus every piece of fixed pipeline state it draws
|
||||
/// with. This is the type that replaces the imperative
|
||||
/// <c>Enable/BlendFunc/DepthMask/CullFace</c> 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
|
||||
/// (<see cref="IGpuPassEncoder.SetCullMode"/> and friends).
|
||||
/// </summary>
|
||||
internal interface IGpuPipeline : IDisposable
|
||||
{
|
||||
GpuPipelineDescription Description { get; }
|
||||
}
|
||||
|
||||
/// <summary>An offscreen render target whose colour attachment is sampleable once the pass ends.</summary>
|
||||
internal interface IGpuRenderTarget : IDisposable
|
||||
{
|
||||
GpuRenderTargetDescription Description { get; }
|
||||
|
||||
/// <summary>The colour attachment, for registering into the texture table or blitting into UI.</summary>
|
||||
IGpuTexture ColorTexture { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GPU-side timing. Backed by GL <c>TimeElapsed</c> queries or Vulkan timestamp
|
||||
/// queries; results are only readable once the issuing frame has retired, so
|
||||
/// <see cref="TryResolve"/> reports the most recent completed measurement rather
|
||||
/// than blocking.
|
||||
/// </summary>
|
||||
internal interface IGpuTimerPool
|
||||
{
|
||||
/// <summary>True when the backend can measure GPU time at all.</summary>
|
||||
bool IsSupported { get; }
|
||||
|
||||
/// <summary>Milliseconds measured for <paramref name="scopeName"/> in the most recent retired frame.</summary>
|
||||
bool TryResolve(string scopeName, out double milliseconds);
|
||||
}
|
||||
88
src/AcDream.App/Rendering/Gpu/IGpuDevice.cs
Normal file
88
src/AcDream.App/Rendering/Gpu/IGpuDevice.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
namespace AcDream.App.Rendering.Gpu;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>GL</c> handle.
|
||||
///
|
||||
/// Campaign V (see <c>docs/plans/2026-07-27-vulkan-campaign.md</c>) 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.
|
||||
/// </summary>
|
||||
internal interface IGpuDevice : IDisposable
|
||||
{
|
||||
GpuBackendKind Backend { get; }
|
||||
|
||||
GpuCapabilityRecord Capabilities { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Frame-flight-gated resource release. Resource disposal routes through here
|
||||
/// so nothing is freed while a submitted frame may still reference it.
|
||||
/// </summary>
|
||||
IGpuResourceRetirementQueue Retirement { get; }
|
||||
|
||||
/// <summary>GPU timing results from retired frames.</summary>
|
||||
IGpuTimerPool Timers { get; }
|
||||
|
||||
/// <summary>
|
||||
/// A registered 1×1 opaque-white texture. Renderers that need a defined
|
||||
/// fallback use this rather than assuming slot 0 means anything — an
|
||||
/// unregistered <see cref="GpuTextureSlot"/> is
|
||||
/// <see cref="GpuTextureSlot.Unassigned"/> and must never reach a shader.
|
||||
/// </summary>
|
||||
GpuTextureSlot DefaultTextureSlot { get; }
|
||||
|
||||
IGpuBuffer CreateBuffer(in GpuBufferDescription description);
|
||||
|
||||
IGpuTexture CreateTexture(in GpuTextureDescription description);
|
||||
|
||||
/// <summary>Creates or returns a cached sampler; sampler state is de-duplicated by value.</summary>
|
||||
IGpuSampler CreateSampler(in GpuSamplerDescription description);
|
||||
|
||||
/// <summary>
|
||||
/// Compiles and links a pipeline. Both backends build every pipeline during
|
||||
/// startup, so no frame ever pays a shader-compile or state-revalidation cost.
|
||||
/// </summary>
|
||||
IGpuPipeline CreatePipeline(GpuPipelineDescription description);
|
||||
|
||||
IGpuRenderTarget CreateRenderTarget(in GpuRenderTargetDescription description);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
GpuTextureSlot RegisterTexture(IGpuTexture texture, IGpuSampler sampler);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
void ReleaseTextureSlot(GpuTextureSlot slot);
|
||||
|
||||
/// <summary>Opens the next frame, waiting for its flight slot to retire first.</summary>
|
||||
IGpuFrame BeginFrame();
|
||||
|
||||
/// <summary>
|
||||
/// Defers <paramref name="action"/> to the render thread. Replaces
|
||||
/// <c>OpenGLGraphicsDevice.QueueGLAction</c>; loader threads use it to hand
|
||||
/// GPU work back to the thread that owns the context or queue.
|
||||
/// </summary>
|
||||
void QueueDeviceAction(Action action);
|
||||
|
||||
/// <summary>Runs queued device actions. Called once per frame from the render thread.</summary>
|
||||
void ProcessDeviceActions();
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
byte[] CaptureBackbuffer(int width, int height);
|
||||
|
||||
/// <summary>Blocks until all submitted work completes and every pending retirement has run.</summary>
|
||||
void WaitIdle();
|
||||
}
|
||||
79
src/AcDream.App/Rendering/Gpu/IGpuFrame.cs
Normal file
79
src/AcDream.App/Rendering/Gpu/IGpuFrame.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AcDream.App.Rendering.Gpu;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>BufferSubData</c> 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 <c>ref struct</c> on purpose: the memory is only valid until the frame
|
||||
/// that produced it retires, so the compiler prevents storing it in a field.
|
||||
/// </summary>
|
||||
internal readonly ref struct GpuRingAllocation
|
||||
{
|
||||
public GpuRingAllocation(IGpuBuffer buffer, uint offsetBytes, Span<byte> data)
|
||||
{
|
||||
Buffer = buffer;
|
||||
OffsetBytes = offsetBytes;
|
||||
Data = data;
|
||||
}
|
||||
|
||||
/// <summary>The ring buffer to bind. Backends may hand out many allocations from one buffer.</summary>
|
||||
public IGpuBuffer Buffer { get; }
|
||||
|
||||
/// <summary>Byte offset of this allocation, already aligned for its <see cref="GpuRingUsage"/>.</summary>
|
||||
public uint OffsetBytes { get; }
|
||||
|
||||
/// <summary>CPU-writable memory for this allocation. Valid until the owning frame retires.</summary>
|
||||
public Span<byte> Data { get; }
|
||||
|
||||
public bool IsEmpty => Data.IsEmpty;
|
||||
|
||||
/// <summary>Reinterprets the allocation as a typed span so callers write structs, not bytes.</summary>
|
||||
public Span<T> AsSpan<T>() where T : unmanaged => MemoryMarshal.Cast<byte, T>(Data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One frame's recording context. Obtained from <see cref="IGpuDevice.BeginFrame"/>
|
||||
/// and closed by <see cref="End"/>, 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
|
||||
/// <c>GpuFrameFlightController</c> already enforces with GL fences and which the
|
||||
/// Vulkan backend expresses with a single timeline semaphore.
|
||||
/// </summary>
|
||||
internal interface IGpuFrame : IDisposable
|
||||
{
|
||||
/// <summary>Frames-in-flight slot index this frame occupies.</summary>
|
||||
int SlotIndex { get; }
|
||||
|
||||
/// <summary>Monotonic frame serial. Matches the retirement-ledger key used for resource release.</summary>
|
||||
long Serial { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Reserves <paramref name="byteCount"/> bytes of CPU-writable ring memory,
|
||||
/// aligned as <paramref name="usage"/> requires. Throws if the request exceeds
|
||||
/// the ring's per-frame capacity — silently truncating a draw's data would
|
||||
/// corrupt the frame invisibly.
|
||||
/// </summary>
|
||||
GpuRingAllocation AllocateRing(int byteCount, GpuRingUsage usage);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
IGpuPassEncoder BeginPass(GpuPassDescription description);
|
||||
|
||||
/// <summary>
|
||||
/// Submits the frame and presents it. Idempotent with <see cref="IDisposable.Dispose"/>
|
||||
/// so a failed frame still closes its slot rather than stalling the ring.
|
||||
/// </summary>
|
||||
void End();
|
||||
}
|
||||
79
src/AcDream.App/Rendering/Gpu/IGpuPassEncoder.cs
Normal file
79
src/AcDream.App/Rendering/Gpu/IGpuPassEncoder.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
namespace AcDream.App.Rendering.Gpu;
|
||||
|
||||
/// <summary>
|
||||
/// Records draw work inside one <see cref="GpuPassDescription"/>. 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 <see cref="GpuPipelineDescription"/>. Only the
|
||||
/// state core Vulkan 1.3 makes dynamic is settable per draw.
|
||||
/// </summary>
|
||||
internal interface IGpuPassEncoder : IDisposable
|
||||
{
|
||||
/// <summary>The pass this encoder is recording into.</summary>
|
||||
GpuPassDescription Pass { get; }
|
||||
|
||||
/// <summary>Binds the shader program and all baked fixed state.</summary>
|
||||
void BindPipeline(IGpuPipeline pipeline);
|
||||
|
||||
/// <summary>
|
||||
/// Binds a storage buffer range to a <see cref="GpuBindingModel"/> storage
|
||||
/// binding. Ranges come straight from <see cref="GpuRingAllocation"/> for
|
||||
/// per-frame data, or from a long-lived buffer for persistent data.
|
||||
/// </summary>
|
||||
void BindStorageBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes);
|
||||
|
||||
/// <summary>Binds a uniform buffer range — currently only the SceneLighting block.</summary>
|
||||
void BindUniformBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes);
|
||||
|
||||
/// <summary>Binds the interleaved vertex source matching the pipeline's vertex layout.</summary>
|
||||
void BindVertexBuffer(IGpuBuffer buffer, uint offsetBytes);
|
||||
|
||||
/// <summary>Binds the index source.</summary>
|
||||
void BindIndexBuffer(IGpuBuffer buffer, uint offsetBytes, GpuIndexType indexType);
|
||||
|
||||
/// <summary>Writes the shared push-constant block. Survives pipeline changes within a pass.</summary>
|
||||
void SetPushConstants(in GpuPushConstants constants);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
void SetViewport(int x, int y, int width, int height);
|
||||
|
||||
/// <summary>Sets the scissor rectangle in the same convention as <see cref="SetViewport"/>.</summary>
|
||||
void SetScissor(int x, int y, int width, int height);
|
||||
|
||||
/// <summary>Dynamic cull override — how the world dispatcher draws double-sided geometry.</summary>
|
||||
void SetCullMode(GpuCullMode cullMode);
|
||||
|
||||
/// <summary>Dynamic winding override, in GL convention. The Vulkan backend applies its own inversion.</summary>
|
||||
void SetFrontFace(GpuFrontFace frontFace);
|
||||
|
||||
/// <summary>Dynamic depth-write override — how the translucent pass stops occluding later draws.</summary>
|
||||
void SetDepthWrite(bool enabled);
|
||||
|
||||
/// <summary>Draws indexed geometry directly, without an indirect buffer.</summary>
|
||||
void DrawIndexed(uint indexCount, uint instanceCount, uint firstIndex, int vertexOffset, uint firstInstance);
|
||||
|
||||
/// <summary>Draws non-indexed geometry — the retained UI's batched sprite/glyph quads.</summary>
|
||||
void Draw(uint vertexCount, uint instanceCount, uint firstVertex, uint firstInstance);
|
||||
|
||||
/// <summary>
|
||||
/// The production draw call: one submission covering <paramref name="drawCount"/>
|
||||
/// commands read from <paramref name="commands"/>. Each command's draw index is
|
||||
/// visible to the shader as <c>gl_DrawID</c>, offset by
|
||||
/// <see cref="GpuPushConstants.DrawIdOffset"/>.
|
||||
/// </summary>
|
||||
void MultiDrawIndexedIndirect(IGpuBuffer commands, uint offsetBytes, uint drawCount, uint strideBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Opens a GPU timing scope whose result becomes readable through
|
||||
/// <see cref="IGpuTimerPool.TryResolve"/> once this frame retires. Returns a
|
||||
/// no-op disposable when the backend cannot measure GPU time.
|
||||
/// </summary>
|
||||
IDisposable BeginTimerScope(string scopeName);
|
||||
}
|
||||
166
tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs
Normal file
166
tests/AcDream.App.Tests/Rendering/Gpu/GpuContractTests.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class GpuContractTests
|
||||
{
|
||||
[Fact]
|
||||
public void PushConstantBlockMatchesThePinnedLayout()
|
||||
{
|
||||
Assert.Equal(GpuBindingModel.PushConstantBytes, Unsafe.SizeOf<GpuPushConstants>());
|
||||
Assert.True(GpuBindingModel.PushConstantBytes <= GpuBindingModel.MaxPushConstantBytes);
|
||||
|
||||
Assert.Equal(0, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.ViewProjection)));
|
||||
Assert.Equal(64, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.DrawIdOffset)));
|
||||
Assert.Equal(68, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.LightingMode)));
|
||||
Assert.Equal(72, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.RenderPass)));
|
||||
Assert.Equal(76, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.LightDebug)));
|
||||
Assert.Equal(80, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.TextureIndexA)));
|
||||
Assert.Equal(84, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.TextureIndexB)));
|
||||
Assert.Equal(88, (int)Marshal.OffsetOf<GpuPushConstants>(nameof(GpuPushConstants.ParamA)));
|
||||
Assert.Equal(92, (int)Marshal.OffsetOf<GpuPushConstants>(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,
|
||||
};
|
||||
}
|
||||
530
tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs
Normal file
530
tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs
Normal file
|
|
@ -0,0 +1,530 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="IGpuDevice"/>
|
||||
/// during Campaign V.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="IGpuDevice"/> 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 <see cref="Calls"/> in submission order.
|
||||
/// </summary>
|
||||
internal sealed class RecordingGpuDevice : IGpuDevice
|
||||
{
|
||||
private const int DefaultRingCapacityBytes = 8 * 1024 * 1024;
|
||||
|
||||
private readonly List<GpuRecordedCall> _calls = [];
|
||||
private readonly List<Action> _queuedActions = [];
|
||||
private readonly Dictionary<GpuSamplerDescription, RecordingGpuSampler> _samplers = [];
|
||||
private readonly byte[] _ring;
|
||||
private readonly Stack<uint> _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));
|
||||
}
|
||||
|
||||
/// <summary>Every recorded call, in submission order.</summary>
|
||||
public IReadOnlyList<GpuRecordedCall> Calls => _calls;
|
||||
|
||||
/// <summary>Backing store for ring allocations, so tests can read what a renderer wrote.</summary>
|
||||
public ReadOnlySpan<byte> RingBytes => _ring;
|
||||
|
||||
/// <summary>Number of ring bytes handed out during the currently open (or most recent) frame.</summary>
|
||||
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<byte> 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<byte> 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<byte> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Convenience helpers so renderer tests read as assertions, not as list surgery.</summary>
|
||||
internal static class RecordingGpuDeviceAssertions
|
||||
{
|
||||
public static IEnumerable<T> OfKind<T>(this RecordingGpuDevice device) where T : GpuRecordedCall =>
|
||||
device.Calls.OfType<T>();
|
||||
|
||||
public static Vector4 ClearColorOf(this GpuPassDescription pass) => pass.Color.ClearColor;
|
||||
}
|
||||
221
tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDeviceTests.cs
Normal file
221
tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDeviceTests.cs
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<Matrix4x4> transforms = second.AsSpan<Matrix4x4>();
|
||||
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<byte> ring = device.RingBytes;
|
||||
Matrix4x4 written = System.Runtime.InteropServices.MemoryMarshal.Read<Matrix4x4>(
|
||||
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<InvalidOperationException>(() =>
|
||||
{
|
||||
frame.AllocateRing(4096, GpuRingUsage.Storage);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverlappingFramesAreRejected()
|
||||
{
|
||||
using RecordingGpuDevice device = new();
|
||||
using IGpuFrame frame = device.BeginFrame();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(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<ArgumentException>(() => 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<GpuRecordedFrameEnd>());
|
||||
Assert.Equal(0, device.OpenFrameCount);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue