acdream/src/AcDream.App/Rendering/Gpu/IGpuFrame.cs
Erik 621b16364b 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>
2026-07-27 14:26:26 +02:00

79 lines
3.4 KiB
C#

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();
}