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
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue