feat(render): implement Campaign AR and terrain fidelity

This commit is contained in:
Erik 2026-08-22 13:13:29 +02:00
parent 99cf26e00c
commit 7a5f96ede5
368 changed files with 50611 additions and 950 deletions

View file

@ -0,0 +1,50 @@
namespace AcDream.Plugin.Abstractions.Rendering;
/// <summary>The public render-pack contract version.</summary>
public static class RenderPackApi
{
/// <summary>The newest contract this build implements.</summary>
public const int Current = 1;
/// <summary>The oldest contract this build can still consume.</summary>
public const int MinimumSupported = 1;
/// <summary>Whether a descriptor's contract version can be consumed.</summary>
public static bool IsSupported(int apiVersion) =>
apiVersion >= MinimumSupported && apiVersion <= Current;
}
/// <summary>
/// Optional plugin entry point for declarative graphics enhancements. The host
/// invokes this only in a graphical process; no-window hosts never expose a
/// registry or ask a render-pack entry point to register.
/// </summary>
public interface IRenderPackPlugin
{
/// <summary>Register every pack supplied by this plugin.</summary>
void Register(IRenderPackRegistry registry);
}
/// <summary>
/// Host-owned render-pack catalog. Registration is descriptive only: it must
/// not open assets or allocate GPU objects.
/// </summary>
public interface IRenderPackRegistry
{
/// <summary>
/// Register one immutable descriptor and its lazy asset source. Disposing
/// the returned handle withdraws the pack and every host reference to the
/// asset source.
/// </summary>
IDisposable Register(RenderPackDescriptor descriptor, IRenderPackAssets assets);
}
/// <summary>
/// Lazy, key-addressed assets for one pack. The renderer opens assets only
/// while validating an explicitly selected candidate.
/// </summary>
public interface IRenderPackAssets
{
/// <summary>Open a new readable stream for a descriptor-declared key.</summary>
Stream OpenRead(string assetKey);
}

View file

@ -0,0 +1,432 @@
namespace AcDream.Plugin.Abstractions.Rendering;
/// <summary>Prerequisite tier reached by a pack.</summary>
public enum RenderPackTier
{
Tier1 = 1,
Tier2 = 2,
Tier2Plus = 3,
}
/// <summary>
/// Renderer-owned facilities a pack may require or use opportunistically.
/// These are semantic capabilities, not Vulkan extension or feature names.
/// </summary>
public enum RenderCapability
{
MainWorldColorIntermediate,
FullscreenPasses,
SceneDepthSampling,
SceneNormalSampling,
AuthoredSunDirection,
AuthoredSunScreenPosition,
AuthoredWeather,
DirectionalShadowMaps,
OutdoorDirectionalShadowCasterReplay,
AnimatedCasterTransforms,
AlphaCutoutShadowCasters,
GpuTimestampQueries,
/// <summary>One layered directional-depth pass may address multiple cascade views.</summary>
MultiviewDirectionalShadowCascades,
/// <summary>One renderer-selected authored sun-or-moon shadow direction.</summary>
AuthoredCelestialDirectionalLight,
}
/// <summary>Fixed renderer-owned positions at which a declared pass may run.</summary>
public enum RenderPassHook
{
ShadowDepthBeforeWorld,
AtmosphereBeforeToneMap,
ToneMap,
AfterToneMapBeforePrivateViewports,
}
/// <summary>Immutable frame facts the renderer may bind for a pack.</summary>
public enum RenderSemanticInput
{
WorldColor,
SceneDepth,
SceneNormals,
SunDirection,
SunScreenPosition,
ActiveDayGroup,
Weather,
CameraMatrices,
ShadowCasterTransforms,
DirectionalShadowMaps,
FrameTime,
/// <summary>Selected surface-to-sun-or-moon direction for shadow work.</summary>
SelectedCelestialDirectionalLight,
}
/// <summary>Renderer-owned replay operations available to a declaration.</summary>
public enum RenderSceneReplaySemantic
{
OutdoorDirectionalShadowCasters,
}
/// <summary>Existing retained-scene classes eligible for a scene replay.</summary>
[Flags]
public enum RenderCasterClass
{
None = 0,
Terrain = 1 << 0,
OpaqueWorld = 1 << 1,
AlphaCutoutWorld = 1 << 2,
AnimatedOpaque = 1 << 3,
AnimatedAlphaCutout = 1 << 4,
}
/// <summary>Base renderer pipeline a pack may specialize.</summary>
public enum RenderPipelineBaseSemantic
{
Terrain,
WorldMesh,
EnvCell,
}
/// <summary>Material classifications accepted by a pipeline variant.</summary>
[Flags]
public enum RenderMaterialClass
{
None = 0,
Opaque = 1 << 0,
AlphaCutout = 1 << 1,
AnimatedOpaque = 1 << 2,
AnimatedAlphaCutout = 1 << 3,
}
/// <summary>Kind of renderer-owned intermediate resource.</summary>
public enum RenderResourceKind
{
Image2D,
Image2DArray,
Buffer,
}
/// <summary>Portable format families resolved by the renderer.</summary>
public enum RenderFormatClass
{
LdrColor,
HdrColor,
SingleChannel,
DirectionalDepth,
StructuredData,
}
/// <summary>How declared image dimensions are interpreted.</summary>
public enum RenderExtentMode
{
AbsolutePixels,
RelativeToMainWorld,
RelativeToOutput,
}
/// <summary>Permitted uses of a declared resource.</summary>
[Flags]
public enum RenderResourceUsage
{
None = 0,
Sampled = 1 << 0,
ColorAttachment = 1 << 1,
DepthAttachment = 1 << 2,
Storage = 1 << 3,
TransferSource = 1 << 4,
TransferDestination = 1 << 5,
}
/// <summary>Lifetime class used by the renderer's frame-flight allocator.</summary>
public enum RenderResourceLifetime
{
TransientPass,
FrameFlight,
ActivePack,
}
/// <summary>
/// Renderer-owned meaning of a declared resource. <see cref="Custom"/> is
/// available to ordinary declarative fullscreen graphs; the remaining values
/// let a pack request host executors without relying on magic resource IDs.
/// </summary>
public enum RenderResourceSemantic
{
Custom,
MainWorldHdr,
BloomPing,
BloomPong,
SunOcclusionMask,
SunRays,
DirectionalShadowDepth,
VolumetricShafts,
}
/// <summary>Shape of one image declaration.</summary>
/// <param name="Mode">Absolute pixels or a scale relative to a renderer surface.</param>
/// <param name="Width">Pixel width for absolute mode; horizontal scale otherwise.</param>
/// <param name="Height">Pixel height for absolute mode; vertical scale otherwise.</param>
/// <param name="Layers">Array layers; one for an ordinary 2-D image.</param>
public sealed record RenderExtentDeclaration(
RenderExtentMode Mode,
double Width,
double Height,
int Layers = 1);
/// <summary>One renderer-owned intermediate image or buffer.</summary>
public sealed record RenderResourceDeclaration(
string Id,
RenderResourceKind Kind,
RenderFormatClass Format,
RenderExtentDeclaration? Extent,
long SizeBytes,
RenderResourceUsage Usage,
RenderResourceLifetime Lifetime,
long EstimatedResidentBytes)
{
public RenderResourceSemantic Semantic { get; init; } = RenderResourceSemantic.Custom;
}
/// <summary>
/// Renderer-owned execution meaning of a pass. IDs remain pack-owned stable
/// identifiers; semantic execution never depends on a particular ID string.
/// </summary>
public enum RenderPassSemantic
{
CustomFullscreen,
DirectionalShadowDepth,
BloomDownsample,
BloomBlurHorizontal,
BloomBlurVertical,
SunOcclusion,
SunRays,
VolumetricShafts,
FilmicComposite,
}
/// <summary>One declarative full-screen, atmosphere, or tone-map pass.</summary>
public sealed record RenderPassDeclaration(
string Id,
RenderPassHook Hook,
string VertexShaderAsset,
string FragmentShaderAsset,
IReadOnlyList<RenderSemanticInput> SemanticInputs,
IReadOnlyList<string> ResourceReads,
IReadOnlyList<string> ResourceWrites)
{
public RenderPassSemantic Semantic { get; init; } = RenderPassSemantic.CustomFullscreen;
}
/// <summary>A renderer-owned replay of retained scene geometry.</summary>
public sealed record SceneReplayDeclaration(
string Id,
RenderSceneReplaySemantic Semantic,
RenderCasterClass CasterClasses,
int ViewCount);
/// <summary>A shader specialization of an existing renderer pipeline.</summary>
public sealed record PipelineVariantDeclaration(
string Id,
RenderPipelineBaseSemantic BaseSemantic,
string VertexShaderAsset,
string FragmentShaderAsset,
RenderMaterialClass CompatibleMaterials,
IReadOnlyList<RenderSemanticInput> SemanticInputs)
{
public RenderPipelineVariantSemantic Semantic { get; init; } =
RenderPipelineVariantSemantic.Custom;
}
/// <summary>Renderer-owned role of a fixed retained-scene pipeline variant.</summary>
public enum RenderPipelineVariantSemantic
{
Custom,
TerrainDirectionalShadowCaster,
WorldOpaqueDirectionalShadowCaster,
WorldAlphaCutoutDirectionalShadowCaster,
TerrainDirectionalShadowReceiver,
WorldDirectionalShadowReceiver,
TerrainMultiviewDirectionalShadowCaster,
WorldOpaqueMultiviewDirectionalShadowCaster,
WorldAlphaCutoutMultiviewDirectionalShadowCaster,
}
/// <summary>Per-preset replacement for one resource's size.</summary>
public sealed record RenderQualityResourceOverride(
string ResourceId,
RenderExtentDeclaration? Extent,
long SizeBytes,
long EstimatedResidentBytes);
/// <summary>Per-preset value for a declared user setting.</summary>
public sealed record RenderQualitySettingOverride(
string SettingId,
string Value);
/// <summary>One user-selectable, independently capability-gated preset.</summary>
public sealed record RenderQualityPreset(
string Id,
string DisplayName,
IReadOnlyList<RenderCapability> RequiredCapabilities,
IReadOnlyList<RenderQualityResourceOverride> ResourceOverrides,
IReadOnlyList<RenderQualitySettingOverride> SettingOverrides,
long MaxResidentGpuBytes,
double MaxIncrementalGpuMillisecondsP50,
double MaxIncrementalGpuMillisecondsP99,
double MaxIncrementalCpuMillisecondsP50,
double MaxIncrementalCpuMillisecondsP99,
bool AutoEligible = true)
{
public RenderQualitySemantic Semantic { get; init; } = RenderQualitySemantic.Custom;
/// <summary>
/// Optional renderer-owned execution optimizations whose shader ABI the
/// pack explicitly implements. The host never infers these from a pack ID.
/// </summary>
public RenderQualityExecutionHints ExecutionHints { get; init; } =
RenderQualityExecutionHints.None;
}
/// <summary>
/// Opt-in execution forms for renderer-owned atmospheric work. These hints
/// may fuse passes or compatible submissions; they do not remove declared
/// effects or caster classes from the final image.
/// </summary>
[Flags]
public enum RenderQualityExecutionHints
{
None = 0,
/// <summary>
/// The sun-rays shader accepts scene depth directly and filmic composite
/// evaluates the declared bloom extraction/filter while composing the
/// image. See the standard PackPass ABI flags.
/// </summary>
FusedAtmosphericPostProcess = 1 << 0,
/// <summary>
/// The three multiview caster variants select the exact cascade matrix with
/// the renderer-owned view index and render every declared Low cascade in
/// one layered depth pass.
/// </summary>
MultiviewDirectionalShadowCascades = 1 << 1,
}
/// <summary>
/// Optional host quality role. Pack-owned IDs remain persisted; this semantic
/// is used only when a pack opts into the host's automatic-quality controller.
/// </summary>
public enum RenderQualitySemantic
{
Custom,
Low,
Medium,
High,
Automatic,
}
/// <summary>Storage and presentation kind for a pack-defined setting.</summary>
public enum RenderSettingKind
{
Boolean,
Integer,
Float,
Choice,
}
/// <summary>A bounded, user-visible pack setting.</summary>
public sealed record RenderSettingDeclaration(
string Id,
string DisplayName,
RenderSettingKind Kind,
string DefaultValue,
double? Minimum,
double? Maximum,
double? Step,
IReadOnlyList<string> Choices)
{
public RenderSettingSemantic Semantic { get; init; } = RenderSettingSemantic.Custom;
}
/// <summary>
/// Optional host meaning for settings consumed by a renderer-owned atmospheric
/// executor. Ordinary pack settings use <see cref="Custom"/>.
/// </summary>
public enum RenderSettingSemantic
{
Custom,
BloomStrength,
FilmicStrength,
Exposure,
GradeSaturation,
GradeContrast,
VignetteStrength,
SunRayStrength,
DirectionalShadowStrength,
DirectionalShadowReachMetres,
DirectionalShadowPcfTaps,
VolumetricStrength,
VolumetricRayMarchSteps,
AutomaticQuality,
}
/// <summary>One point on a declared sun-elevation response curve.</summary>
public sealed record SunElevationResponsePoint(
double ElevationDegrees,
double Multiplier);
/// <summary>Explicit mapping from an authored AC day group to an effect multiplier.</summary>
public sealed record ActiveDayGroupMultiplier(
int ActiveDayGroup,
double Multiplier);
/// <summary>Visible authored-atmosphere interpretation owned by the pack.</summary>
public sealed record AtmospherePolicyDeclaration(
IReadOnlyList<SunElevationResponsePoint> SunElevationResponse,
IReadOnlyList<ActiveDayGroupMultiplier> ActiveDayGroupMultipliers)
{
/// <summary>
/// Optional selected-light elevation curve for directional shadows. The host
/// linearly interpolates adjacent points in sine-of-elevation space and
/// clamps beyond the endpoints.
/// The resolved multiplier must be zero at and below the authored
/// 0-degree horizon: every non-positive control point must be zero and,
/// when no exact 0-degree point is declared, the first positive control
/// point must also be zero.
/// A pack using the directional-shadow semantic must declare this curve.
/// </summary>
public IReadOnlyList<SunElevationResponsePoint> DirectionalShadowLightElevationResponse
{ get; init; } = [];
/// <summary>
/// Optional moving-sun strength curve for volumetric shafts. The host
/// smoothstep-interpolates adjacent points in elevation-degree space and
/// clamps beyond the endpoints.
/// A pack using the volumetric-shaft semantic must declare this curve.
/// </summary>
public IReadOnlyList<SunElevationResponsePoint> VolumetricShaftSunElevationResponse
{ get; init; } = [];
}
/// <summary>
/// Complete immutable declaration for one render pack. Packs describe what
/// they need; the renderer validates and owns every concrete resource, pass,
/// pipeline, barrier, and scene replay.
/// </summary>
public sealed record RenderPackDescriptor(
string Id,
string DisplayName,
Version PackVersion,
int PackApiVersion,
RenderPackTier HighestTier,
IReadOnlyList<RenderCapability> RequiredCapabilities,
IReadOnlyList<RenderCapability> OptionalCapabilities,
IReadOnlyList<RenderResourceDeclaration> Resources,
IReadOnlyList<RenderPassDeclaration> Passes,
IReadOnlyList<SceneReplayDeclaration> SceneReplays,
IReadOnlyList<PipelineVariantDeclaration> PipelineVariants,
IReadOnlyList<RenderQualityPreset> QualityPresets,
IReadOnlyList<RenderSettingDeclaration> Settings,
AtmospherePolicyDeclaration? AtmospherePolicy)
{
/// <summary>Short user-facing description shown beside compatibility and cost.</summary>
public string FeatureSummary { get; init; } = string.Empty;
}

View file

@ -0,0 +1,113 @@
using System.Globalization;
namespace AcDream.Plugin.Abstractions.Rendering;
/// <summary>
/// API-v1 grammar and scalar encoding for a declared render-pack setting.
/// Both authoring tools and the graphical host use this codec so a value
/// cannot validate one way and bind another way.
/// </summary>
public static class RenderPackSettingValueCodec
{
private const long ExactFloatIntegerLimit = 16_777_216L;
/// <summary>
/// Validate and encode one string value for set-1/binding-8. The encoded
/// value is zero on failure, matching the host block's fail-safe fill.
/// </summary>
public static bool TryEncode(
RenderSettingDeclaration setting,
string? value,
out float encoded)
{
ArgumentNullException.ThrowIfNull(setting);
encoded = 0f;
if (value is null)
return false;
switch (setting.Kind)
{
case RenderSettingKind.Boolean:
if (!bool.TryParse(value, out bool boolean))
return false;
encoded = boolean ? 1f : 0f;
return true;
case RenderSettingKind.Integer:
if (!long.TryParse(
value,
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out long integer)
|| integer is < -ExactFloatIntegerLimit or > ExactFloatIntegerLimit
|| !WithinBounds(integer, setting)
|| !AlignedToStep(integer, setting))
{
return false;
}
encoded = integer;
return true;
case RenderSettingKind.Float:
if (!double.TryParse(
value,
NumberStyles.Float,
CultureInfo.InvariantCulture,
out double floating)
|| !double.IsFinite(floating)
|| floating < -float.MaxValue
|| floating > float.MaxValue
|| !WithinBounds(floating, setting)
|| !AlignedToStep(floating, setting))
{
return false;
}
encoded = (float)floating;
return float.IsFinite(encoded);
case RenderSettingKind.Choice:
int choice = IndexOf(setting.Choices, value);
if (choice < 0)
return false;
encoded = choice;
return true;
default:
return false;
}
}
private static bool WithinBounds(
double value,
RenderSettingDeclaration setting) =>
(setting.Minimum is null || value >= setting.Minimum.Value)
&& (setting.Maximum is null || value <= setting.Maximum.Value);
private static bool AlignedToStep(
double value,
RenderSettingDeclaration setting)
{
if (setting.Step is not { } step)
return true;
if (!double.IsFinite(step) || step <= 0)
return false;
double origin = setting.Minimum ?? 0d;
double quotient = (value - origin) / step;
if (!double.IsFinite(quotient))
return false;
double tolerance = Math.Max(1e-7, Math.Abs(quotient) * 1e-7);
return Math.Abs(quotient - Math.Round(quotient)) <= tolerance;
}
private static int IndexOf(IReadOnlyList<string>? choices, string value)
{
if (choices is null)
return -1;
for (int i = 0; i < choices.Count; i++)
{
if (string.Equals(choices[i], value, StringComparison.Ordinal))
return i;
}
return -1;
}
}

View file

@ -0,0 +1,28 @@
namespace AcDream.Plugin.Abstractions.Rendering;
/// <summary>
/// Numeric SPIR-V interface contract for <see cref="RenderPackApi"/> v1.
/// Descriptor sets and resources remain host-owned; these constants expose
/// layout numbers only and are not Vulkan handles.
/// </summary>
public static class RenderPackShaderAbi
{
public const int UniformDescriptorSet = 3;
public const int AtmosphericFrameBinding = 5;
public const int AtmosphericFrameSizeBytes = 160;
public const int DirectionalShadowBinding = 6;
public const int DirectionalShadowSizeBytes = 336;
public const int PackPassBinding = 7;
public const int PackPassSizeBytes = 64;
public const int PackSettingsBinding = 8;
public const int PackSettingsSizeBytes = 256;
public const int PackSettingScalarCapacity = 64;
public const int SampledTextureDescriptorSet = 2;
public const int SampledTextureBinding = 0;
public const int SampledPassInputCapacity = 4;
public const int PushConstantSizeBytes = 96;
public const int MaximumShaderAssetBytes = 16 * 1024 * 1024;
}

View file

@ -0,0 +1,611 @@
using System.Buffers.Binary;
namespace AcDream.Plugin.Abstractions.Rendering;
/// <summary>The shader stage a render-pack declaration assigns to one SPIR-V asset.</summary>
public enum RenderPackShaderStage
{
Vertex,
Fragment,
}
/// <summary>Hardware-independent validation result for one declared SPIR-V module.</summary>
public readonly record struct RenderPackSpirvValidationResult(bool Success, string? Reason)
{
public static RenderPackSpirvValidationResult Valid() => new(true, null);
public static RenderPackSpirvValidationResult Invalid(string reason) => new(false, reason);
}
/// <summary>
/// BCL-only SPIR-V interface validator for render-pack API v1. This validates
/// the binary handed to Vulkan, not filenames or GLSL source. Descriptor and
/// push-constant handles remain host-owned.
/// </summary>
public static class RenderPackSpirvValidator
{
private const uint SpirvMagic = 0x0723_0203;
private const uint VertexExecutionModel = 0;
private const uint FragmentExecutionModel = 4;
public static RenderPackSpirvValidationResult ValidatePassShader(
ReadOnlySpan<byte> spirv,
RenderPackShaderStage stage,
RenderPassDeclaration pass)
{
ArgumentNullException.ThrowIfNull(pass);
bool declaresSampledInput = pass.ResourceReads.Count != 0
|| pass.SemanticInputs.Any(static semantic => semantic is
RenderSemanticInput.WorldColor
or RenderSemanticInput.SceneDepth
or RenderSemanticInput.SceneNormals
or RenderSemanticInput.DirectionalShadowMaps);
bool directionalDepth = pass.Semantic == RenderPassSemantic.DirectionalShadowDepth;
var access = new AllowedInterface(
StorageBindings: directionalDepth ? [0u] : [],
UniformBindings: [],
AllowSampledTable: declaresSampledInput,
AllowAtmosphericFrame: directionalDepth || UsesAtmosphericFrame(pass.SemanticInputs),
AllowDirectionalShadow: directionalDepth
|| pass.SemanticInputs.Contains(RenderSemanticInput.DirectionalShadowMaps)
|| pass.SemanticInputs.Contains(
RenderSemanticInput.SelectedCelestialDirectionalLight),
AllowPackPass: !directionalDepth,
AllowPackSettings: !directionalDepth);
return Validate(spirv, stage, access);
}
public static RenderPackSpirvValidationResult ValidatePipelineVariantShader(
ReadOnlySpan<byte> spirv,
RenderPackShaderStage stage,
PipelineVariantDeclaration variant)
{
ArgumentNullException.ThrowIfNull(variant);
AllowedInterface access = variant.Semantic switch
{
RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster =>
new([], [], false, false, true, false, false),
RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster =>
new([], [], false, false, true, false, false),
RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster =>
new([0u], [], false, false, true, false, false),
RenderPipelineVariantSemantic.WorldOpaqueMultiviewDirectionalShadowCaster =>
new([0u], [], false, false, true, false, false),
RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster =>
new([0u, 1u], [], true, false, true, false, false),
RenderPipelineVariantSemantic.WorldAlphaCutoutMultiviewDirectionalShadowCaster =>
new([0u, 1u], [], true, false, true, false, false),
RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver =>
new([], [1u, 2u, 3u], true, false, true, false, true),
RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver =>
new([0u, 1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u], [1u],
true, false, true, false, true),
_ => new(
[],
[],
variant.SemanticInputs.Any(static semantic => semantic is
RenderSemanticInput.WorldColor
or RenderSemanticInput.SceneDepth
or RenderSemanticInput.SceneNormals
or RenderSemanticInput.DirectionalShadowMaps),
UsesAtmosphericFrame(variant.SemanticInputs),
variant.SemanticInputs.Contains(RenderSemanticInput.DirectionalShadowMaps)
|| variant.SemanticInputs.Contains(
RenderSemanticInput.SelectedCelestialDirectionalLight),
false,
true),
};
return Validate(spirv, stage, access);
}
private static bool UsesAtmosphericFrame(IReadOnlyList<RenderSemanticInput> inputs) =>
inputs.Any(static semantic => semantic is
RenderSemanticInput.SunDirection
or RenderSemanticInput.SunScreenPosition
or RenderSemanticInput.ActiveDayGroup
or RenderSemanticInput.Weather
or RenderSemanticInput.CameraMatrices
or RenderSemanticInput.FrameTime);
private static RenderPackSpirvValidationResult Validate(
ReadOnlySpan<byte> bytes,
RenderPackShaderStage stage,
AllowedInterface access)
{
if (bytes.Length < 20 || (bytes.Length & 3) != 0)
return Invalid("the module is not a word-aligned SPIR-V binary");
uint[] words = new uint[bytes.Length / 4];
for (int i = 0; i < words.Length; i++)
words[i] = BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(i * 4, 4));
if (words[0] != SpirvMagic)
return Invalid("the module does not have the SPIR-V magic word");
Module module;
try
{
module = Module.Parse(words);
}
catch (InvalidDataException error)
{
return Invalid(error.Message);
}
uint expectedModel = stage == RenderPackShaderStage.Vertex
? VertexExecutionModel
: FragmentExecutionModel;
if (module.EntryPoints.Count != 1
|| module.EntryPoints[0].ExecutionModel != expectedModel
|| !string.Equals(module.EntryPoints[0].Name, "main", StringComparison.Ordinal))
{
return Invalid(
$"the declared {stage.ToString().ToLowerInvariant()} asset must expose exactly "
+ "entry point 'main' for that stage");
}
var descriptors = new HashSet<(uint Set, uint Binding)>();
int pushBlockCount = 0;
foreach (Variable variable in module.Variables)
{
if (variable.StorageClass == StorageClass.PushConstant)
{
if (++pushBlockCount > 1)
return Invalid("the module declares more than one push-constant block");
string? pushFailure = ValidatePushBlock(module, variable);
if (pushFailure is not null)
return Invalid(pushFailure);
continue;
}
if (variable.StorageClass is not StorageClass.UniformConstant
and not StorageClass.Uniform
and not StorageClass.StorageBuffer)
continue;
if (!module.DescriptorSets.TryGetValue(variable.Id, out uint set)
|| !module.Bindings.TryGetValue(variable.Id, out uint binding))
return Invalid($"descriptor %{variable.Id} does not declare both set and binding");
if (!descriptors.Add((set, binding)))
return Invalid($"descriptor set {set} binding {binding} is declared more than once");
if (set == RenderPackShaderAbi.SampledTextureDescriptorSet
&& binding == RenderPackShaderAbi.SampledTextureBinding)
{
if (!access.AllowSampledTable)
return Invalid("set 2 binding 0 is sampled without a declared semantic/resource input");
if (!module.IsGlobalSampledTextureTable(variable))
return Invalid("set 2 binding 0 must be one runtime array of combined 2-D-array samplers");
continue;
}
if (set == RenderPackShaderAbi.UniformDescriptorSet)
{
if (variable.StorageClass != StorageClass.Uniform)
return Invalid($"set 3 binding {binding} must be a uniform buffer");
bool allowed = binding switch
{
RenderPackShaderAbi.AtmosphericFrameBinding => access.AllowAtmosphericFrame,
RenderPackShaderAbi.DirectionalShadowBinding => access.AllowDirectionalShadow,
RenderPackShaderAbi.PackPassBinding => access.AllowPackPass,
RenderPackShaderAbi.PackSettingsBinding => access.AllowPackSettings,
_ => false,
};
if (!allowed)
return Invalid($"set 3 binding {binding} is not declared for this shader role");
string? blockFailure = ValidatePackBlock(module, variable, binding);
if (blockFailure is not null)
return Invalid(blockFailure);
continue;
}
if (set == 0 && variable.StorageClass == StorageClass.StorageBuffer)
{
if (!access.StorageBindings.Contains(binding))
return Invalid($"set 0 binding {binding} storage access is not allowed for this shader role");
if (!module.IsReadOnlyStorage(variable))
return Invalid($"set 0 binding {binding} is writable; render-pack storage writes are forbidden");
if (!module.IsSingleBlockDescriptor(variable))
return Invalid($"set 0 binding {binding} must be one storage-buffer descriptor");
continue;
}
if (set == 1 && variable.StorageClass == StorageClass.Uniform)
{
if (!access.UniformBindings.Contains(binding))
return Invalid($"set 1 binding {binding} aliases renderer state not allowed for this shader role");
if (!module.IsSingleBlockDescriptor(variable))
return Invalid($"set 1 binding {binding} must be one uniform-buffer descriptor");
continue;
}
return Invalid(
$"descriptor set {set} binding {binding} has no render-pack API v1 binding");
}
if (module.ContainsImageWrite)
return Invalid("storage image writes are forbidden by render-pack API v1");
return RenderPackSpirvValidationResult.Valid();
}
private static string? ValidatePackBlock(Module module, Variable variable, uint binding)
{
if (!module.TryPointeeStruct(variable, out uint structId, out uint[] members)
|| !module.Blocks.Contains(structId))
return $"set 3 binding {binding} must point to one std140 Block struct";
return binding switch
{
RenderPackShaderAbi.AtmosphericFrameBinding =>
ValidateAtmosphericFrame(module, structId, members),
RenderPackShaderAbi.DirectionalShadowBinding =>
ValidateDirectionalShadow(module, structId, members),
RenderPackShaderAbi.PackPassBinding =>
ValidateVec4Block(module, structId, members, 4, "PackPass"),
RenderPackShaderAbi.PackSettingsBinding =>
ValidatePackSettings(module, structId, members),
_ => $"set 3 binding {binding} is reserved",
};
}
private static string? ValidateAtmosphericFrame(Module module, uint id, uint[] members)
{
if (members.Length != 7)
return "AtmosphericFrame must contain exactly seven members and occupy 160 bytes";
for (int i = 0; i < 6; i++)
{
if (!module.IsFloatVector(members[i], 4) || module.MemberOffset(id, i) != (uint)(i * 16))
return "AtmosphericFrame member types/offsets do not match ABI v1";
}
if (!module.IsFloatMatrix(members[6], 4, 4)
|| module.MemberOffset(id, 6) != 96
|| module.MemberDecoration(id, 6, Decoration.ColMajor) is null
|| module.MemberDecoration(id, 6, Decoration.MatrixStride) != 16)
return "AtmosphericFrame inverse-view-projection layout does not match ABI v1";
return null;
}
private static string? ValidateDirectionalShadow(Module module, uint id, uint[] members)
{
if (members.Length != 6
|| !module.IsArray(members[0], 4, 64, static (m, t) => m.IsFloatMatrix(t, 4, 4))
|| module.MemberOffset(id, 0) != 0
|| module.MemberDecoration(id, 0, Decoration.ColMajor) is null
|| module.MemberDecoration(id, 0, Decoration.MatrixStride) != 16)
return "DirectionalShadow matrix array does not match the 336-byte ABI v1 layout";
for (int i = 1; i <= 3; i++)
{
if (!module.IsFloatVector(members[i], 4)
|| module.MemberOffset(id, i) != (uint)(240 + i * 16))
return "DirectionalShadow vec4 member types/offsets do not match ABI v1";
}
if (!module.IsUIntVector(members[4], 4) || module.MemberOffset(id, 4) != 304)
return "DirectionalShadow flags member does not match ABI v1";
if (!module.IsFloatVector(members[5], 4) || module.MemberOffset(id, 5) != 320)
{
return "DirectionalShadow selected-light direction/source member does not "
+ "match the 336-byte ABI v1 layout";
}
return null;
}
private static string? ValidateVec4Block(
Module module,
uint id,
uint[] members,
int count,
string name)
{
if (members.Length != count)
return $"{name} must contain {count} vec4 members";
for (int i = 0; i < count; i++)
{
if (!module.IsFloatVector(members[i], 4) || module.MemberOffset(id, i) != (uint)(i * 16))
return $"{name} member types/offsets do not match ABI v1";
}
return null;
}
private static string? ValidatePackSettings(Module module, uint id, uint[] members)
{
if (members.Length != 1
|| module.MemberOffset(id, 0) != 0
|| !module.IsArray(members[0], 16, 16, static (m, t) => m.IsFloatVector(t, 4)))
return "PackSettings must be one std140 vec4[16] block occupying 256 bytes";
return null;
}
private static string? ValidatePushBlock(Module module, Variable variable)
{
if (!module.TryPointeeStruct(variable, out uint id, out uint[] members)
|| !module.Blocks.Contains(id)
|| members.Length != 9)
return "the push-constant block must match the exact 96-byte retail layout";
uint[] offsets = [0, 64, 68, 72, 76, 80, 84, 88, 92];
for (int i = 0; i < offsets.Length; i++)
{
if (module.MemberOffset(id, i) != offsets[i])
return "the push-constant member offsets do not match the exact 96-byte retail layout";
}
if (!module.IsFloatMatrix(members[0], 4, 4)
|| module.MemberDecoration(id, 0, Decoration.ColMajor) is null
|| module.MemberDecoration(id, 0, Decoration.MatrixStride) != 16
|| !module.IsInt(members[1], signed: true)
|| !module.IsInt(members[2], signed: true)
|| !module.IsInt(members[3], signed: true)
|| !module.IsInt(members[4], signed: true)
|| !module.IsInt(members[5], signed: false)
|| !module.IsInt(members[6], signed: false)
|| !module.IsFloat(members[7])
|| !module.IsFloat(members[8]))
return "the push-constant member types do not match the exact 96-byte retail layout";
return null;
}
private static RenderPackSpirvValidationResult Invalid(string reason) =>
RenderPackSpirvValidationResult.Invalid(reason);
private sealed record AllowedInterface(
IReadOnlyList<uint> StorageBindings,
IReadOnlyList<uint> UniformBindings,
bool AllowSampledTable,
bool AllowAtmosphericFrame,
bool AllowDirectionalShadow,
bool AllowPackPass,
bool AllowPackSettings);
private enum StorageClass : uint
{
UniformConstant = 0,
Uniform = 2,
PushConstant = 9,
StorageBuffer = 12,
}
private enum Decoration : uint
{
Block = 2,
ColMajor = 5,
ArrayStride = 6,
MatrixStride = 7,
NonWritable = 24,
Binding = 33,
DescriptorSet = 34,
Offset = 35,
}
private readonly record struct EntryPoint(uint ExecutionModel, string Name);
private readonly record struct Variable(uint ResultType, uint Id, StorageClass StorageClass);
private sealed record TypeInstruction(uint Opcode, uint[] Operands);
private sealed class Module
{
private const uint OpEntryPoint = 15;
private const uint OpTypeInt = 21;
private const uint OpTypeFloat = 22;
private const uint OpTypeVector = 23;
private const uint OpTypeMatrix = 24;
private const uint OpTypeImage = 25;
private const uint OpTypeSampledImage = 27;
private const uint OpTypeArray = 28;
private const uint OpTypeRuntimeArray = 29;
private const uint OpTypeStruct = 30;
private const uint OpTypePointer = 32;
private const uint OpConstant = 43;
private const uint OpVariable = 59;
private const uint OpDecorate = 71;
private const uint OpMemberDecorate = 72;
private const uint OpImageWrite = 99;
internal List<EntryPoint> EntryPoints { get; } = [];
internal List<Variable> Variables { get; } = [];
internal Dictionary<uint, uint> DescriptorSets { get; } = [];
internal Dictionary<uint, uint> Bindings { get; } = [];
internal HashSet<uint> NonWritable { get; } = [];
internal HashSet<uint> Blocks { get; } = [];
internal bool ContainsImageWrite { get; private set; }
private Dictionary<uint, TypeInstruction> Types { get; } = [];
private Dictionary<uint, uint> Constants { get; } = [];
private Dictionary<(uint Id, Decoration Decoration), uint> Decorations { get; } = [];
private Dictionary<(uint Id, int Member, Decoration Decoration), uint> MemberDecorations { get; } = [];
internal static Module Parse(uint[] words)
{
var module = new Module();
int index = 5;
while (index < words.Length)
{
uint header = words[index];
int count = (int)(header >> 16);
uint opcode = header & 0xffff;
if (count <= 0 || index + count > words.Length)
throw new InvalidDataException($"malformed SPIR-V instruction at word {index}");
ReadOnlySpan<uint> instruction = words.AsSpan(index, count);
module.ReadInstruction(opcode, instruction);
index += count;
}
return module;
}
private void ReadInstruction(uint opcode, ReadOnlySpan<uint> words)
{
if (opcode == OpEntryPoint)
{
if (words.Length < 4)
throw new InvalidDataException("malformed SPIR-V OpEntryPoint");
EntryPoints.Add(new EntryPoint(words[1], ReadString(words[3..])));
}
else if (opcode is >= OpTypeInt and <= OpTypePointer)
{
if (words.Length < 2)
throw new InvalidDataException("malformed SPIR-V type instruction");
Types[words[1]] = new TypeInstruction(opcode, words[1..].ToArray());
}
else if (opcode == OpConstant && words.Length >= 4)
{
Constants[words[2]] = words[3];
}
else if (opcode == OpVariable)
{
if (words.Length < 4)
throw new InvalidDataException("malformed SPIR-V OpVariable");
Variables.Add(new Variable(words[1], words[2], (StorageClass)words[3]));
}
else if (opcode == OpDecorate)
{
if (words.Length < 3)
throw new InvalidDataException("malformed SPIR-V OpDecorate");
Decoration decoration = (Decoration)words[2];
uint value = words.Length >= 4 ? words[3] : 1;
Decorations[(words[1], decoration)] = value;
if (decoration == Decoration.DescriptorSet) DescriptorSets[words[1]] = value;
if (decoration == Decoration.Binding) Bindings[words[1]] = value;
if (decoration == Decoration.NonWritable) NonWritable.Add(words[1]);
if (decoration == Decoration.Block) Blocks.Add(words[1]);
}
else if (opcode == OpMemberDecorate)
{
if (words.Length < 4)
throw new InvalidDataException("malformed SPIR-V OpMemberDecorate");
Decoration decoration = (Decoration)words[3];
MemberDecorations[(words[1], checked((int)words[2]), decoration)] =
words.Length >= 5 ? words[4] : 1;
}
else if (opcode == OpImageWrite)
{
ContainsImageWrite = true;
}
}
private static string ReadString(ReadOnlySpan<uint> words)
{
var bytes = new List<byte>(words.Length * 4);
foreach (uint word in words)
{
for (int shift = 0; shift < 32; shift += 8)
{
byte value = (byte)(word >> shift);
if (value == 0)
return System.Text.Encoding.UTF8.GetString([.. bytes]);
bytes.Add(value);
}
}
throw new InvalidDataException("unterminated SPIR-V string");
}
internal bool IsSingleBlockDescriptor(Variable variable) =>
TryPointeeStruct(variable, out uint id, out _) && Blocks.Contains(id);
internal bool IsReadOnlyStorage(Variable variable)
{
if (NonWritable.Contains(variable.Id))
return true;
return TryPointeeStruct(variable, out uint id, out uint[] members)
&& members.Length != 0
&& Enumerable.Range(0, members.Length).All(member =>
MemberDecoration(id, member, Decoration.NonWritable) is not null);
}
internal bool TryPointeeStruct(Variable variable, out uint id, out uint[] members)
{
id = 0;
members = [];
if (!Types.TryGetValue(variable.ResultType, out TypeInstruction? pointer)
|| pointer.Opcode != OpTypePointer
|| pointer.Operands.Length < 3)
return false;
id = pointer.Operands[2];
if (!Types.TryGetValue(id, out TypeInstruction? structure)
|| structure.Opcode != OpTypeStruct)
return false;
members = structure.Operands[1..];
return true;
}
internal bool IsGlobalSampledTextureTable(Variable variable)
{
if (!TryPointee(variable.ResultType, out uint arrayId)
|| !Types.TryGetValue(arrayId, out TypeInstruction? array)
|| array.Opcode != OpTypeRuntimeArray
|| array.Operands.Length != 2
|| !Types.TryGetValue(array.Operands[1], out TypeInstruction? sampled)
|| sampled.Opcode != OpTypeSampledImage
|| sampled.Operands.Length != 2
|| !Types.TryGetValue(sampled.Operands[1], out TypeInstruction? image)
|| image.Opcode != OpTypeImage
|| image.Operands.Length < 8)
return false;
// Dim=2D (1), Arrayed=true, Sampled=image used with a sampler (1).
return image.Operands[2] == 1 && image.Operands[4] == 1 && image.Operands[6] == 1;
}
private bool TryPointee(uint pointerId, out uint pointee)
{
pointee = 0;
if (!Types.TryGetValue(pointerId, out TypeInstruction? pointer)
|| pointer.Opcode != OpTypePointer
|| pointer.Operands.Length < 3)
return false;
pointee = pointer.Operands[2];
return true;
}
internal uint? MemberOffset(uint id, int member) =>
MemberDecoration(id, member, Decoration.Offset);
internal uint? MemberDecoration(uint id, int member, Decoration decoration) =>
MemberDecorations.TryGetValue((id, member, decoration), out uint value) ? value : null;
internal bool IsFloat(uint id) => IsScalar(id, OpTypeFloat, 32, signed: null);
internal bool IsInt(uint id, bool signed) => IsScalar(id, OpTypeInt, 32, signed);
private bool IsScalar(uint id, uint opcode, uint width, bool? signed)
{
if (!Types.TryGetValue(id, out TypeInstruction? type)
|| type.Opcode != opcode
|| type.Operands.Length < 2
|| type.Operands[1] != width)
return false;
return signed is null
|| type.Operands.Length >= 3 && type.Operands[2] == (signed.Value ? 1u : 0u);
}
internal bool IsFloatVector(uint id, uint count) =>
IsVector(id, count, static (m, t) => m.IsFloat(t));
internal bool IsUIntVector(uint id, uint count) =>
IsVector(id, count, static (m, t) => m.IsInt(t, signed: false));
private bool IsVector(uint id, uint count, Func<Module, uint, bool> element)
{
return Types.TryGetValue(id, out TypeInstruction? vector)
&& vector.Opcode == OpTypeVector
&& vector.Operands.Length == 3
&& vector.Operands[2] == count
&& element(this, vector.Operands[1]);
}
internal bool IsFloatMatrix(uint id, uint rows, uint columns)
{
return Types.TryGetValue(id, out TypeInstruction? matrix)
&& matrix.Opcode == OpTypeMatrix
&& matrix.Operands.Length == 3
&& matrix.Operands[2] == columns
&& IsFloatVector(matrix.Operands[1], rows);
}
internal bool IsArray(
uint id,
uint length,
uint stride,
Func<Module, uint, bool> element)
{
return Types.TryGetValue(id, out TypeInstruction? array)
&& array.Opcode == OpTypeArray
&& array.Operands.Length == 3
&& Constants.TryGetValue(array.Operands[2], out uint actualLength)
&& actualLength == length
&& Decorations.TryGetValue((id, Decoration.ArrayStride), out uint actualStride)
&& actualStride == stride
&& element(this, array.Operands[1]);
}
}
}