feat(render): shader ABI v2 - AtmosphericFrame gains clock/wind blocks; caster pass binds it (Campaign VM VM6a)

AtmosphericFrame (set 3/binding 5) grows additively from 160 to 192 bytes:
two appended vec4 members, uAtmosphereClockWind and uAtmosphereWindAmplitude,
carry the foliage-wind clock/weather and amplitude inputs VM6b's shader
displacement will read. RenderPackShaderAbi renames the old constant to
AtmosphericFrameSizeBytesV1 (160), adds AtmosphericFrameSizeBytesV2 (192),
keeps AtmosphericFrameSizeBytes pointing at the current (v2) size, and adds
ShaderAbiVersion = 2. RenderPackSpirvValidator.ValidateAtmosphericFrame
accepts either the v1 (seven-member, 160-byte) or v2 (nine-member, 192-byte)
shape and rejects anything else naming both — this is why the frozen
external sample packs under samples/*/Shaders/*.spv, whose GLSL sources are
not in this tree, need no rebuild: a v1 shader bound to the 192-byte buffer
still reads correctly, since a bound range only needs to be >= the block's
own declared size.

DirectionalSunShadowRenderer's caster pass now binds AtmosphericFrame too
(both the multiview and per-cascade sites), through a new
AtmosphericFrameBufferBinding the graph owns and supplies via
DirectionalSunShadowRenderInput. AtmosphericPostProcessGraph.RenderDirectionalShadows
builds its own 192-byte ring allocation for this, separate from the world
receiver's frame block, because the caster pass runs before RenderPostProcess
constructs that block within the same frame. The four world caster pipeline
variants (opaque/cutout, base/multiview) are now allowed to declare binding
5 in the validator; terrain casters are untouched.

This commit is plumbing only: the two new members are always written but
never read by any shader yet (zero placeholders), so pack-on and pack-off
output are both pixel-identical to before. VM6b wires the real weather-driven
values and the shader-side displacement.

App hermetic filter: 5972/5974 (2 pre-existing failures unrelated to this
change, confirmed against the unmodified baseline). Core.Tests hermetic:
4697/4697. RenderPackValidator.Tests: 30/30. VulkanShaderManifestTests
(retail oracle set): 7/7, byte-identical.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-23 00:30:02 +02:00
parent a0157693ec
commit 0930c35d1d
17 changed files with 340 additions and 32 deletions

View file

@ -209,6 +209,15 @@ optional after a pass unconditionally declares them.
these graphics declarations. Additive enum/record support stays compatible; these graphics declarations. Additive enum/record support stays compatible;
a breaking contract requires a new render-pack API version and explicit a breaking contract requires a new render-pack API version and explicit
compatibility path. compatibility path.
- `RenderPackShaderAbi.ShaderAbiVersion` separately versions the numeric
SPIR-V interface (set/binding numbers and std140 block layouts) declarations
are validated against — distinct from `RenderPackApi`/`PluginApi`. Campaign
VM VM6 shipped v2: `AtmosphericFrame` (set 3, binding 5) grew additively
from 160 to 192 bytes (see `docs/render-packs/semantic-bindings-v1.md`'s
"ABI v2 (additive)" section). `RenderPackSpirvValidator` accepts both the
v1 and v2 shapes, so shader assets compiled before a version bump — the
external sample packs among them — never need a rebuild for an additive
change.
- Persisted identity is pack ID + pack version + preset ID, never list index. - Persisted identity is pack ID + pack version + preset ID, never list index.
User-authored setting strings are keyed by the same stable pack identity and User-authored setting strings are keyed by the same stable pack identity and
stable setting ID, never declaration or menu index. stable setting ID, never declaration or menu index.

View file

@ -87,7 +87,7 @@ checked-in shared render-pack GLSL includes are the byte-offset SSOT; authors
include those definitions rather than maintaining a private copy. The tables include those definitions rather than maintaining a private copy. The tables
below state the same values for review and tool diagnostics. below state the same values for review and tool diagnostics.
### `AtmosphericFrame` — set 3, binding 5, 160 bytes ### `AtmosphericFrame` — set 3, binding 5, 192 bytes (ABI v2; see below)
```glsl ```glsl
layout(std140, set = 3, binding = 5) uniform AtmosphericFrame { layout(std140, set = 3, binding = 5) uniform AtmosphericFrame {
@ -134,6 +134,38 @@ curve without an exact 0-degree point must make its first positive point zero.
from scene depth and the normalized viewport coordinates. Matrix convention from scene depth and the normalized viewport coordinates. Matrix convention
and depth range match the shared push-block `viewProjection`. and depth range match the shared push-block `viewProjection`.
### ABI v2 (additive) — Campaign VM VM6
`AtmosphericFrame` grew from 160 to 192 bytes by appending two members after
`uAtmosphereInverseViewProjection`. Nothing above this line moved or changed
meaning:
```glsl
layout(std140, set = 3, binding = 5) uniform AtmosphericFrame {
// ... the seven v1 members, unchanged ...
vec4 uAtmosphereClockWind; // @160: elapsed seconds, wind mean [0..1], wind gust [0..1], wind direction radians
vec4 uAtmosphereWindAmplitude; // @176: lean amplitude m, branch amplitude m, flutter amplitude m, max canopy height m
};
```
`uAtmosphereClockWind`/`uAtmosphereWindAmplitude` feed the shared
`foliage_wind.glsl` include, which `mesh_atmospheric.vert` and the four
`directional_shadow_world_*` (opaque/cutout, base and multiview) caster
vertex shaders call identically so a displaced leaf's shadow moves with it.
No other pass reads these members.
**Compatibility rule:** the host always allocates and binds the full 192-byte
v2 block (`RenderPackShaderAbi.AtmosphericFrameSizeBytes`), but a v1 shader —
one compiled before this campaign, declaring only the original seven members
— binds and reads correctly against it: a bound range only needs to be at
least as large as the block's declared size, so the shader simply never sees
the appended bytes. `RenderPackSpirvValidator.ValidateAtmosphericFrame`
accepts either the v1 shape (seven members, 160 bytes) or the v2 shape (nine
members, 192 bytes); any other member count is rejected naming both. This is
why the external sample packs under `samples/*/Shaders/*.spv` — whose GLSL
sources are not in this tree and are never recompiled — needed no rebuild for
this change.
### Colour space ### Colour space
The main-world colour target, `uAtmosphereSunColor` (sun-ray input), and any The main-world colour target, `uAtmosphereSunColor` (sun-ray input), and any

View file

@ -9,6 +9,25 @@ using DatReaderWriter.Enums;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
/// <summary>
/// Campaign VM VM6: the caster's own set 3/binding 5 AtmosphericFrame slice
/// (192 bytes, ABI v2 — see RenderPackShaderAbi.AtmosphericFrameSizeBytes),
/// owned and allocated by the graph exactly like the world receiver's frame
/// block, so the caster can read the identical foliage-wind clock/amplitude
/// inputs through the shared foliage_wind.glsl include. Unbound (the
/// default) when the caller has no atmospheric-frame data to offer — the
/// declared-pack graph does not build one, and the renderer simply skips
/// binding 5 for that caller rather than manufacturing a buffer nobody asked
/// for.
/// </summary>
internal readonly record struct AtmosphericFrameBufferBinding(
IGpuBuffer? Buffer,
uint OffsetBytes,
uint SizeBytes)
{
internal bool IsBound => Buffer is not null;
}
internal readonly record struct DirectionalSunShadowRenderInput( internal readonly record struct DirectionalSunShadowRenderInput(
DirectionalShadowEnvironmentInput Environment, DirectionalShadowEnvironmentInput Environment,
Matrix4x4 CameraView, Matrix4x4 CameraView,
@ -18,7 +37,8 @@ internal readonly record struct DirectionalSunShadowRenderInput(
float CasterDepthPaddingMeters = 48f, float CasterDepthPaddingMeters = 48f,
float ResidentMaximumReachMeters = float.PositiveInfinity, float ResidentMaximumReachMeters = float.PositiveInfinity,
bool MeasureGpuTimers = true, bool MeasureGpuTimers = true,
bool MeasureCpuStages = false); bool MeasureCpuStages = false,
AtmosphericFrameBufferBinding AtmosphericFrame = default);
internal readonly record struct DirectionalSunShadowCpuStageTicks( internal readonly record struct DirectionalSunShadowCpuStageTicks(
long EnvironmentGateTicks, long EnvironmentGateTicks,
@ -417,7 +437,8 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
0L, 0L,
0L, 0L,
0L), 0L),
transformChurn); transformChurn,
input.AtmosphericFrame);
} }
catch catch
{ {
@ -489,7 +510,8 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
bool measureGpuTimers = true, bool measureGpuTimers = true,
bool measureCpuStages = false, bool measureCpuStages = false,
DirectionalSunShadowCpuStageTicks cpuStages = default, DirectionalSunShadowCpuStageTicks cpuStages = default,
DirectionalShadowTransformChurnDiagnostics transformChurn = default) DirectionalShadowTransformChurnDiagnostics transformChurn = default,
AtmosphericFrameBufferBinding atmosphericFrame = default)
{ {
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(frame); ArgumentNullException.ThrowIfNull(frame);
@ -566,6 +588,21 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
uniformAllocation.Buffer, uniformAllocation.Buffer,
uniformAllocation.OffsetBytes, uniformAllocation.OffsetBytes,
DirectionalShadowUniforms.SizeInBytes); DirectionalShadowUniforms.SizeInBytes);
// Campaign VM VM6: the caster pass reads foliage-wind clock/
// amplitude inputs through the same shared AtmosphericFrame
// block the world receiver binds (set 3/binding 5), so a
// displaced leaf's shadow moves with it by construction. Bound
// only when the caller supplied one — declared (non-built-in)
// packs leave this unbound and their caster shaders, which never
// declare binding 5, are unaffected.
if (atmosphericFrame.IsBound)
{
encoder.BindUniformBuffer(
GpuBindingModel.UniformAtmosphericFrame,
atmosphericFrame.Buffer!,
atmosphericFrame.OffsetBytes,
atmosphericFrame.SizeBytes);
}
DrawTerrain(encoder, uploads, terrainDraws, terrainGeometry, 0, DrawTerrain(encoder, uploads, terrainDraws, terrainGeometry, 0,
_terrainMultiviewPipeline); _terrainMultiviewPipeline);
DrawWorld(encoder, uploads, worldDraws, worldGeometry, 0, DrawWorld(encoder, uploads, worldDraws, worldGeometry, 0,
@ -586,6 +623,16 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
uniformAllocation.Buffer, uniformAllocation.Buffer,
uniformAllocation.OffsetBytes, uniformAllocation.OffsetBytes,
DirectionalShadowUniforms.SizeInBytes); DirectionalShadowUniforms.SizeInBytes);
// Campaign VM VM6 — see the matching comment in the multiview
// branch above.
if (atmosphericFrame.IsBound)
{
encoder.BindUniformBuffer(
GpuBindingModel.UniformAtmosphericFrame,
atmosphericFrame.Buffer!,
atmosphericFrame.OffsetBytes,
atmosphericFrame.SizeBytes);
}
DrawTerrain(encoder, uploads, terrainDraws, terrainGeometry, cascadeIndex); DrawTerrain(encoder, uploads, terrainDraws, terrainGeometry, cascadeIndex);
DrawWorld(encoder, uploads, worldDraws, worldGeometry, cascadeIndex); DrawWorld(encoder, uploads, worldDraws, worldGeometry, cascadeIndex);

View file

@ -129,13 +129,18 @@ internal sealed class AtmosphericFrameInputState : IAtmosphericWorldFrameSink
} }
/// <summary> /// <summary>
/// Shader ABI SSOT for opt-in set 3 binding 5. Six std140 vec4 values followed by one /// Shader ABI SSOT for opt-in set 3 binding 5. ABI v2 (Campaign VM VM6):
/// mat4, 160 bytes. /// six std140 vec4 values, one mat4, then two more std140 vec4 values —
/// 192 bytes. The two appended members carry the foliage-wind clock/weather
/// and amplitude inputs <c>foliage_wind.glsl</c> reads; every earlier member
/// keeps its ABI v1 offset. See <see cref="RenderPackShaderAbi.AtmosphericFrameSizeBytesV1"/>
/// and <c>atmospheric_common.glsl</c> for the byte-level contract both
/// backends and the SPIR-V validator agree on.
/// </summary> /// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 4)] [StructLayout(LayoutKind.Sequential, Pack = 4)]
internal readonly struct AtmosphericFrameUniforms internal readonly struct AtmosphericFrameUniforms
{ {
internal const int SizeInBytes = 160; internal const int SizeInBytes = 192;
internal AtmosphericFrameUniforms( internal AtmosphericFrameUniforms(
Vector4 sunScreen, Vector4 sunScreen,
@ -144,7 +149,9 @@ internal readonly struct AtmosphericFrameUniforms
Vector4 weather, Vector4 weather,
Vector4 sunDirection, Vector4 sunDirection,
Vector4 policy, Vector4 policy,
Matrix4x4 inverseViewProjection) Matrix4x4 inverseViewProjection,
Vector4 clockWind,
Vector4 windAmplitude)
{ {
SunScreen = sunScreen; SunScreen = sunScreen;
SunColor = sunColor; SunColor = sunColor;
@ -153,6 +160,8 @@ internal readonly struct AtmosphericFrameUniforms
SunDirection = sunDirection; SunDirection = sunDirection;
Policy = policy; Policy = policy;
InverseViewProjection = inverseViewProjection; InverseViewProjection = inverseViewProjection;
ClockWind = clockWind;
WindAmplitude = windAmplitude;
} }
internal readonly Vector4 SunScreen; internal readonly Vector4 SunScreen;
@ -162,6 +171,9 @@ internal readonly struct AtmosphericFrameUniforms
internal readonly Vector4 SunDirection; internal readonly Vector4 SunDirection;
internal readonly Vector4 Policy; internal readonly Vector4 Policy;
internal readonly Matrix4x4 InverseViewProjection; internal readonly Matrix4x4 InverseViewProjection;
// Campaign VM VM6 ABI v2 additions — offsets 160/176.
internal readonly Vector4 ClockWind;
internal readonly Vector4 WindAmplitude;
} }
/// <summary> /// <summary>

View file

@ -323,6 +323,8 @@ internal sealed class AtmosphericPostProcessGraph :
* _shadowStrength, * _shadowStrength,
0f, 0f,
1f)); 1f));
AtmosphericFrameBufferBinding shadowAtmosphericFrame =
BuildShadowAtmosphericFrameBinding(frame);
var input = new DirectionalSunShadowRenderInput( var input = new DirectionalSunShadowRenderInput(
environment, environment,
world.Camera.Camera.View, world.Camera.Camera.View,
@ -333,7 +335,8 @@ internal sealed class AtmosphericPostProcessGraph :
MeasureGpuTimers: AtmosphericGpuTimerSampling.ShouldMeasure( MeasureGpuTimers: AtmosphericGpuTimerSampling.ShouldMeasure(
Preset.Semantic, Preset.Semantic,
frame.Serial), frame.Serial),
MeasureCpuStages: measureCpuStages); MeasureCpuStages: measureCpuStages,
AtmosphericFrame: shadowAtmosphericFrame);
long environmentFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L; long environmentFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L;
_lastShadowCasterCount = _shadowCasters.Stats.Accepted; _lastShadowCasterCount = _shadowCasters.Stats.Accepted;
_lastShadowClassificationCalls = _shadowCasters.Stats.TopologyRebuilt ? 1 : 0; _lastShadowClassificationCalls = _shadowCasters.Stats.TopologyRebuilt ? 1 : 0;
@ -368,6 +371,43 @@ internal sealed class AtmosphericPostProcessGraph :
return _lastShadowDiagnostics; return _lastShadowDiagnostics;
} }
/// <summary>
/// Campaign VM VM6: the caster pass runs before <see cref="RenderPostProcess"/>
/// builds the receiver's frame block (shadows render during the prepared-
/// world phase; post-process runs after), so it cannot share that
/// allocation. This graph owns a second, independent 192-byte ABI v2
/// ring slice for the caster instead — see the D2 binding sites in
/// <see cref="DirectionalSunShadowRenderer.RenderPrepared"/>. Only the
/// two appended VM6 members carry real content; the caster shaders never
/// read the other seven (sun/weather/policy/inverse-view-projection are
/// receiver-only concerns), so this method leaves them zeroed rather
/// than duplicating <see cref="RenderPostProcess"/>'s computation.
/// </summary>
private AtmosphericFrameBufferBinding BuildShadowAtmosphericFrameBinding(
IGpuFrame frame)
{
GpuRingAllocation allocation = frame.AllocateRing(
AtmosphericFrameUniforms.SizeInBytes,
GpuRingUsage.Uniform);
var uniforms = new AtmosphericFrameUniforms(
Vector4.Zero,
Vector4.Zero,
Vector4.Zero,
Vector4.Zero,
Vector4.Zero,
Vector4.Zero,
Matrix4x4.Identity,
// VM6a: ABI v2 plumbing only. VM6b fills the real weather-driven
// wind block here; no caster shader reads it yet.
Vector4.Zero,
Vector4.Zero);
MemoryMarshal.Write(allocation.Data, in uniforms);
return new AtmosphericFrameBufferBinding(
allocation.Buffer,
allocation.OffsetBytes,
(uint)AtmosphericFrameUniforms.SizeInBytes);
}
public IGpuRenderTarget PrepareWorldTarget( public IGpuRenderTarget PrepareWorldTarget(
int width, int width,
int height, int height,
@ -485,7 +525,13 @@ internal sealed class AtmosphericPostProcessGraph :
dayGroupPolicy, dayGroupPolicy,
shadowElevationPolicy, shadowElevationPolicy,
volumetricElevationPolicy), volumetricElevationPolicy),
inputs.InverseViewProjection); inputs.InverseViewProjection,
// Campaign VM VM6a: ABI v2 plumbing only — written here so the
// 192-byte block is always fully populated, but mesh_atmospheric.vert
// does not read these two members yet (VM6b wires the real
// weather-driven values and the shader read together).
Vector4.Zero,
Vector4.Zero);
GpuRingAllocation frameBlock; GpuRingAllocation frameBlock;
GpuRingAllocation settingsBlock; GpuRingAllocation settingsBlock;
GpuRingAllocation fusedSunPassBlock = default; GpuRingAllocation fusedSunPassBlock = default;

View file

@ -319,7 +319,14 @@ internal class DeclaredFullscreenRenderPackGraph :
dayGroupPolicy, dayGroupPolicy,
shadowElevationPolicy, shadowElevationPolicy,
volumetricElevationPolicy), volumetricElevationPolicy),
inputs.InverseViewProjection); inputs.InverseViewProjection,
// Campaign VM VM6 ABI v2 additions. Foliage wind is a built-in-
// pack feature (BuiltInAtmosphericRenderPack.Settings /
// AtmosphericPostProcessGraph) — declared (third-party) packs
// write zero here and their shaders, compiled before VM6, never
// declare binding 5's appended members.
Vector4.Zero,
Vector4.Zero);
GpuRingAllocation frameBlock = frame.AllocateRing(AtmosphericFrameUniforms.SizeInBytes, GpuRingUsage.Uniform); GpuRingAllocation frameBlock = frame.AllocateRing(AtmosphericFrameUniforms.SizeInBytes, GpuRingUsage.Uniform);
MemoryMarshal.Write(frameBlock.Data, in frameValues); MemoryMarshal.Write(frameBlock.Data, in frameValues);
GpuRingAllocation settingsBlock = frame.AllocateRing(PackSettingsUniforms.SizeInBytes, GpuRingUsage.Uniform); GpuRingAllocation settingsBlock = frame.AllocateRing(PackSettingsUniforms.SizeInBytes, GpuRingUsage.Uniform);

View file

@ -374,7 +374,11 @@ internal sealed class VolumetricShaftRenderer : IDisposable
RenderPackAtmospherePolicyEvaluation.VolumetricShaft( RenderPackAtmospherePolicyEvaluation.VolumetricShaft(
_atmospherePolicy.VolumetricShaftSunElevationResponse, _atmospherePolicy.VolumetricShaftSunElevationResponse,
inputs.SunElevationDegrees)), inputs.SunElevationDegrees)),
inputs.InverseViewProjection); inputs.InverseViewProjection,
// Campaign VM VM6 ABI v2 additions — the volumetric-shaft pass does
// not read foliage wind.
Vector4.Zero,
Vector4.Zero);
private VolumetricShaftDiagnostics Disabled(VolumetricShaftGateReason reason) => new( private VolumetricShaftDiagnostics Disabled(VolumetricShaftGateReason reason) => new(
reason, reason,

View file

@ -1,10 +1,23 @@
#ifndef ACDREAM_ATMOSPHERIC_COMMON_GLSL #ifndef ACDREAM_ATMOSPHERIC_COMMON_GLSL
#define ACDREAM_ATMOSPHERIC_COMMON_GLSL #define ACDREAM_ATMOSPHERIC_COMMON_GLSL
// Render-pack shader ABI v1. These declarations are the byte-level SSOT for // Render-pack shader ABI v2 (Campaign VM VM6, additive over v1). These
// AtmosphericFrameUniforms (set 3/binding 5, 160 bytes) and // declarations are the byte-level SSOT for AtmosphericFrameUniforms (set
// AtmosphericPackPassUniforms (set 3/binding 7). Binding 6 is intentionally // 3/binding 5) and AtmosphericPackPassUniforms (set 3/binding 7). Binding 6
// reserved for directional-shadow data in directional_shadow_common.glsl. // is intentionally reserved for directional-shadow data in
// directional_shadow_common.glsl.
//
// v1 was seven members / 160 bytes (everything through
// uAtmosphereInverseViewProjection). v2 appends two vec4 members at offsets
// 160/176 for a total of 192 bytes — foliage-wind clock/weather and
// amplitude inputs. The append is additive-only: a v1 module bound to the
// (now 192-byte) buffer reads only its declared 160-byte prefix, which is
// valid Vulkan (the bound range only needs to be >= the block's declared
// size), so shader assets compiled before VM6 — including the frozen
// external sample packs under samples/*/Shaders/*.spv, whose GLSL sources
// are not in this tree and are never recompiled — remain valid without a
// rebuild. RenderPackSpirvValidator.ValidateAtmosphericFrame accepts EITHER
// shape.
layout(std140, ACDREAM_PACK_UBO_SET binding = 5) uniform AtmosphericFrame { layout(std140, ACDREAM_PACK_UBO_SET binding = 5) uniform AtmosphericFrame {
vec4 uAtmosphereSunScreen; // 0: uv.xy, ray strength, elevation degrees vec4 uAtmosphereSunScreen; // 0: uv.xy, ray strength, elevation degrees
vec4 uAtmosphereSunColor; // 16: authored display-space rgb (retail has no linear pipeline), policy multiplier vec4 uAtmosphereSunColor; // 16: authored display-space rgb (retail has no linear pipeline), policy multiplier
@ -13,6 +26,11 @@ layout(std140, ACDREAM_PACK_UBO_SET binding = 5) uniform AtmosphericFrame {
vec4 uAtmosphereSunDirection; // 64: surface-to-sun xyz, authored brightness vec4 uAtmosphereSunDirection; // 64: surface-to-sun xyz, authored brightness
vec4 uAtmospherePolicy; // 80: day group, group factor, shadow/shaft elevation factors vec4 uAtmospherePolicy; // 80: day group, group factor, shadow/shaft elevation factors
mat4 uAtmosphereInverseViewProjection; // 96: screen/depth to world mat4 uAtmosphereInverseViewProjection; // 96: screen/depth to world
// Campaign VM VM6 (2026-08-22): ABI v2 additions. Read by
// foliage_wind.glsl only — every pre-VM6 pass leaves these declared but
// unread.
vec4 uAtmosphereClockWind; // 160: elapsed seconds, wind mean [0..1], wind gust [0..1], wind direction radians
vec4 uAtmosphereWindAmplitude; // 176: lean amplitude m, branch amplitude m, flutter amplitude m, max canopy height m
}; };
layout(std140, ACDREAM_PACK_UBO_SET binding = 7) uniform PackPass { layout(std140, ACDREAM_PACK_UBO_SET binding = 7) uniform PackPass {

View file

@ -12,7 +12,7 @@
}, },
{ {
"stage": "frag", "stage": "frag",
"sourceSha256": "de77256a5edde4f50c0d50544cb7e15e7d6b618636f2c529750b20e7a779cf65", "sourceSha256": "ece56dc1c369316d7623f04795d93938fcc54756734bc49db01311fa96ec2dbf",
"compiled": true "compiled": true
} }
] ]
@ -28,7 +28,7 @@
}, },
{ {
"stage": "frag", "stage": "frag",
"sourceSha256": "f8f21269fdf4d994844a747842d68bbda5afcddb5dccccdcec11563450edba52", "sourceSha256": "b13cd8712c6532fbe9c4661794ad9f424a27437b5f2fa14c0b76d38cf7e93523",
"compiled": true "compiled": true
} }
] ]
@ -44,7 +44,7 @@
}, },
{ {
"stage": "frag", "stage": "frag",
"sourceSha256": "dfc11d59f71d65efe650bcfa3434777cbcfe3358f5be38bf392f51b2bd2561b3", "sourceSha256": "d28b82fc7ee8ca18d6911683ce904d59ecee749e9fd4bcc5af58ac048911a0b4",
"compiled": true "compiled": true
} }
] ]
@ -60,7 +60,7 @@
}, },
{ {
"stage": "frag", "stage": "frag",
"sourceSha256": "45d02b356ada9fb15052290188d0de85e666de94f7bec7dc34c4e405b31401ef", "sourceSha256": "695e692cd5442be15d63a0b4f155abeaecf54674adae5f7cf25ef0bb892d18ee",
"compiled": true "compiled": true
} }
] ]
@ -76,7 +76,7 @@
}, },
{ {
"stage": "frag", "stage": "frag",
"sourceSha256": "9765a397328fe9a002a825ae806fcd1651645458e2909dc9ff96a1759c4690d9", "sourceSha256": "6ae2a078177b516c8ee138467559e9d4bf8838ea53941709176ce4f6fa3803a0",
"compiled": true "compiled": true
} }
] ]
@ -92,7 +92,7 @@
}, },
{ {
"stage": "frag", "stage": "frag",
"sourceSha256": "93aeef7ce7555c9c184ee8d7e419c207b2fbed4d877788f016faf19832816b0d", "sourceSha256": "739b397a270142a77c08e7edefde7b2f9992588452a9827b737a0512bbeab513",
"compiled": true "compiled": true
} }
] ]

View file

@ -7,9 +7,27 @@ namespace AcDream.Plugin.Abstractions.Rendering;
/// </summary> /// </summary>
public static class RenderPackShaderAbi public static class RenderPackShaderAbi
{ {
/// <summary>
/// Campaign VM VM6: the render-pack shader ABI version this host writes
/// and binds. v2 is additive over v1 — see <see cref="AtmosphericFrameSizeBytesV2"/>.
/// </summary>
public const int ShaderAbiVersion = 2;
public const int UniformDescriptorSet = 3; public const int UniformDescriptorSet = 3;
public const int AtmosphericFrameBinding = 5; public const int AtmosphericFrameBinding = 5;
public const int AtmosphericFrameSizeBytes = 160;
/// <summary>ABI v1 AtmosphericFrame size: seven vec4/mat4 members, 160 bytes.</summary>
public const int AtmosphericFrameSizeBytesV1 = 160;
/// <summary>
/// ABI v2 AtmosphericFrame size: v1's 160 bytes plus two appended vec4
/// members (foliage-wind clock/weather and amplitude), 192 bytes.
/// </summary>
public const int AtmosphericFrameSizeBytesV2 = 192;
/// <summary>What the host allocates and binds. Always the current ABI version.</summary>
public const int AtmosphericFrameSizeBytes = AtmosphericFrameSizeBytesV2;
public const int DirectionalShadowBinding = 6; public const int DirectionalShadowBinding = 6;
public const int DirectionalShadowSizeBytes = 336; public const int DirectionalShadowSizeBytes = 336;
public const int PackPassBinding = 7; public const int PackPassBinding = 7;

View file

@ -67,14 +67,20 @@ public static class RenderPackSpirvValidator
new([], [], false, false, true, false, false), new([], [], false, false, true, false, false),
RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster => RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster =>
new([], [], false, false, true, false, false), new([], [], false, false, true, false, false),
// Campaign VM VM6: these four world caster variants now bind
// AtmosphericFrame (set 3/binding 5) for foliage_wind.glsl's
// clock/wind inputs — the shadow must move with the leaf, and the
// shared include is what keeps caster and receiver from drifting
// apart. Terrain casters are untouched and keep AllowAtmosphericFrame
// false.
RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster => RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster =>
new([0u], [], false, false, true, false, false), new([0u], [], false, true, true, false, false),
RenderPipelineVariantSemantic.WorldOpaqueMultiviewDirectionalShadowCaster => RenderPipelineVariantSemantic.WorldOpaqueMultiviewDirectionalShadowCaster =>
new([0u], [], false, false, true, false, false), new([0u], [], false, true, true, false, false),
RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster => RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster =>
new([0u, 1u], [], true, false, true, false, false), new([0u, 1u], [], true, true, true, false, false),
RenderPipelineVariantSemantic.WorldAlphaCutoutMultiviewDirectionalShadowCaster => RenderPipelineVariantSemantic.WorldAlphaCutoutMultiviewDirectionalShadowCaster =>
new([0u, 1u], [], true, false, true, false, false), new([0u, 1u], [], true, true, true, false, false),
RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver => RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver =>
new([], [1u, 2u, 3u], true, false, true, false, true), new([], [1u, 2u, 3u], true, false, true, false, true),
RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver => RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver =>
@ -247,8 +253,17 @@ public static class RenderPackSpirvValidator
private static string? ValidateAtmosphericFrame(Module module, uint id, uint[] members) private static string? ValidateAtmosphericFrame(Module module, uint id, uint[] members)
{ {
if (members.Length != 7) // Campaign VM VM6: ABI v2 is additive over v1 — a module declares
return "AtmosphericFrame must contain exactly seven members and occupy 160 bytes"; // EITHER the seven-member/160-byte v1 shape (frozen external sample
// packs, and every pass this campaign did not touch) OR the
// nine-member/192-byte v2 shape (the world receiver + directional-
// shadow caster passes VM6 gave foliage-wind inputs). Any other
// member count is rejected naming both accepted layouts.
if (members.Length != 7 && members.Length != 9)
{
return "AtmosphericFrame must match ABI v1 (seven members, 160 bytes) or "
+ "ABI v2 (nine members, 192 bytes)";
}
for (int i = 0; i < 6; i++) for (int i = 0; i < 6; i++)
{ {
if (!module.IsFloatVector(members[i], 4) || module.MemberOffset(id, i) != (uint)(i * 16)) if (!module.IsFloatVector(members[i], 4) || module.MemberOffset(id, i) != (uint)(i * 16))
@ -259,6 +274,13 @@ public static class RenderPackSpirvValidator
|| module.MemberDecoration(id, 6, Decoration.ColMajor) is null || module.MemberDecoration(id, 6, Decoration.ColMajor) is null
|| module.MemberDecoration(id, 6, Decoration.MatrixStride) != 16) || module.MemberDecoration(id, 6, Decoration.MatrixStride) != 16)
return "AtmosphericFrame inverse-view-projection layout does not match ABI v1"; return "AtmosphericFrame inverse-view-projection layout does not match ABI v1";
if (members.Length == 7)
return null;
if (!module.IsFloatVector(members[7], 4) || module.MemberOffset(id, 7) != 160)
return "AtmosphericFrame clock/wind member does not match ABI v2";
if (!module.IsFloatVector(members[8], 4) || module.MemberOffset(id, 8) != 176)
return "AtmosphericFrame wind-amplitude member does not match ABI v2";
return null; return null;
} }

View file

@ -11,7 +11,7 @@ public sealed class AtmosphericShaderAbiTests
[Fact] [Fact]
public void HostStructsMatchTheCheckedInStd140AtmosphericAbi() public void HostStructsMatchTheCheckedInStd140AtmosphericAbi()
{ {
Assert.Equal(160, Marshal.SizeOf<AtmosphericFrameUniforms>()); Assert.Equal(192, Marshal.SizeOf<AtmosphericFrameUniforms>());
Assert.Equal(0, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.SunScreen))); Assert.Equal(0, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.SunScreen)));
Assert.Equal(16, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.SunColor))); Assert.Equal(16, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.SunColor)));
Assert.Equal(32, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.Viewport))); Assert.Equal(32, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.Viewport)));
@ -19,6 +19,14 @@ public sealed class AtmosphericShaderAbiTests
Assert.Equal(64, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.SunDirection))); Assert.Equal(64, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.SunDirection)));
Assert.Equal(80, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.Policy))); Assert.Equal(80, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.Policy)));
Assert.Equal(96, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.InverseViewProjection))); Assert.Equal(96, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.InverseViewProjection)));
// Campaign VM VM6: ABI v2 appends the foliage-wind clock/weather and
// amplitude members at 160/176, growing the block from v1's 160
// bytes to 192.
Assert.Equal(160, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.ClockWind)));
Assert.Equal(176, Offset<AtmosphericFrameUniforms>(nameof(AtmosphericFrameUniforms.WindAmplitude)));
Assert.Equal(160, RenderPackShaderAbi.AtmosphericFrameSizeBytesV1);
Assert.Equal(192, RenderPackShaderAbi.AtmosphericFrameSizeBytesV2);
Assert.Equal(2, RenderPackShaderAbi.ShaderAbiVersion);
Assert.Equal(64, Marshal.SizeOf<AtmosphericPackPassUniforms>()); Assert.Equal(64, Marshal.SizeOf<AtmosphericPackPassUniforms>());
Assert.Equal(0, Offset<AtmosphericPackPassUniforms>(nameof(AtmosphericPackPassUniforms.Params0))); Assert.Equal(0, Offset<AtmosphericPackPassUniforms>(nameof(AtmosphericPackPassUniforms.Params0)));
@ -71,6 +79,8 @@ public sealed class AtmosphericShaderAbiTests
"uAtmosphereSunDirection", "uAtmosphereSunDirection",
"uAtmospherePolicy", "uAtmospherePolicy",
"uAtmosphereInverseViewProjection", "uAtmosphereInverseViewProjection",
"uAtmosphereClockWind",
"uAtmosphereWindAmplitude",
"binding = 7", "binding = 7",
"uPackParams0", "uPackParams0",
"uPackParams1", "uPackParams1",

View file

@ -195,6 +195,82 @@ public sealed class RenderPackSpirvValidatorTests
Assert.Contains("AtmosphericFrame", result.Reason, StringComparison.Ordinal); Assert.Contains("AtmosphericFrame", result.Reason, StringComparison.Ordinal);
} }
// Campaign VM VM6: atmospheric_common.glsl's AtmosphericFrame block grew
// from ABI v1 (seven members, 160 bytes) to ABI v2 (nine members, 192
// bytes) — additive only. Every compiled shader under this tree that
// includes the header (atmospheric_sun_occlusion.frag among them) is now
// v2; the frozen external sample packs under samples/*/Shaders/*.spv
// have no GLSL source in this tree and are never recompiled, so they
// stay v1 forever. The validator must accept both shapes and reject
// anything else, naming both.
[Fact]
public void AtmosphericFrameAbiV2ModuleIsValid()
{
byte[] spirv = Shader("atmospheric_sun_occlusion.frag.spv");
RenderPackSpirvValidationResult result = RenderPackSpirvValidator.ValidatePassShader(
spirv,
RenderPackShaderStage.Fragment,
Pass(inputs:
[
RenderSemanticInput.SceneDepth,
RenderSemanticInput.SunScreenPosition,
RenderSemanticInput.Weather,
]));
Assert.True(result.Success, result.Reason);
}
[Fact]
public void AtmosphericFrameAbiV1ModuleIsStillValid()
{
byte[] v2 = Shader("atmospheric_sun_occlusion.frag.spv");
byte[] v1 = RemoveLastBlockMember(
RemoveLastBlockMember(
v2,
RenderPackShaderAbi.UniformDescriptorSet,
RenderPackShaderAbi.AtmosphericFrameBinding),
RenderPackShaderAbi.UniformDescriptorSet,
RenderPackShaderAbi.AtmosphericFrameBinding);
RenderPackSpirvValidationResult result = RenderPackSpirvValidator.ValidatePassShader(
v1,
RenderPackShaderStage.Fragment,
Pass(inputs:
[
RenderSemanticInput.SceneDepth,
RenderSemanticInput.SunScreenPosition,
RenderSemanticInput.Weather,
]));
Assert.True(result.Success, result.Reason);
}
[Fact]
public void AtmosphericFrameEightMemberModuleIsRejected()
{
byte[] v2 = Shader("atmospheric_sun_occlusion.frag.spv");
byte[] eightMembers = RemoveLastBlockMember(
v2,
RenderPackShaderAbi.UniformDescriptorSet,
RenderPackShaderAbi.AtmosphericFrameBinding);
RenderPackSpirvValidationResult result = RenderPackSpirvValidator.ValidatePassShader(
eightMembers,
RenderPackShaderStage.Fragment,
Pass(inputs:
[
RenderSemanticInput.SceneDepth,
RenderSemanticInput.SunScreenPosition,
RenderSemanticInput.Weather,
]));
Assert.False(result.Success);
Assert.Contains("ABI v1", result.Reason, StringComparison.Ordinal);
Assert.Contains("ABI v2", result.Reason, StringComparison.Ordinal);
}
[Fact] [Fact]
public void DeclaredStageAndMainEntryPointAreEnforced() public void DeclaredStageAndMainEntryPointAreEnforced()
{ {

View file

@ -7,11 +7,18 @@ namespace AcDream.RenderPackValidator.Tests;
public sealed class RenderPackValidatorCommandTests public sealed class RenderPackValidatorCommandTests
{ {
[Fact] [Fact]
public void PublicShaderAbiConstantsDescribeV1() public void PublicShaderAbiConstantsDescribeTheCurrentAbi()
{ {
Assert.Equal(3, RenderPackShaderAbi.UniformDescriptorSet); Assert.Equal(3, RenderPackShaderAbi.UniformDescriptorSet);
Assert.Equal(5, RenderPackShaderAbi.AtmosphericFrameBinding); Assert.Equal(5, RenderPackShaderAbi.AtmosphericFrameBinding);
Assert.Equal(160, RenderPackShaderAbi.AtmosphericFrameSizeBytes); // Campaign VM VM6: the host always allocates/binds the current (v2)
// size; a v1-shaped shader — e.g. the frozen external sample packs
// this project validates — still reads correctly against it (a bound
// range only needs to be >= the block's own declared size).
Assert.Equal(160, RenderPackShaderAbi.AtmosphericFrameSizeBytesV1);
Assert.Equal(192, RenderPackShaderAbi.AtmosphericFrameSizeBytesV2);
Assert.Equal(192, RenderPackShaderAbi.AtmosphericFrameSizeBytes);
Assert.Equal(2, RenderPackShaderAbi.ShaderAbiVersion);
Assert.Equal(6, RenderPackShaderAbi.DirectionalShadowBinding); Assert.Equal(6, RenderPackShaderAbi.DirectionalShadowBinding);
Assert.Equal(336, RenderPackShaderAbi.DirectionalShadowSizeBytes); Assert.Equal(336, RenderPackShaderAbi.DirectionalShadowSizeBytes);
Assert.Equal(7, RenderPackShaderAbi.PackPassBinding); Assert.Equal(7, RenderPackShaderAbi.PackPassBinding);