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

@ -51,7 +51,9 @@ internal sealed record FrameRootDependencies(
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> Animations,
UpdateFrameClock UpdateClock,
GameFrameGraphSlot FrameGraphs,
Action<string> Log)
Action<string> Log,
AcDream.App.Rendering.Packs.DeferredRenderPackDiagnosticsSource?
RenderPackDiagnostics = null)
{
public RuntimeLocalPlayerMovementState PlayerController =>
Runtime.MovementOwner;
@ -234,6 +236,7 @@ internal sealed class FrameRootCompositionPhase
ref bool bindingsOwnedByScope)
{
FrameRootDependencies d = _dependencies;
bindings = new FrameRootRuntimeBindings();
WorldRenderFoundation foundation = world.Foundation;
// Campaign V slice V6h: the frame root's raw-GL render graph fork was
// deleted at slice V11. The graph is the clear pass, the private-
@ -285,6 +288,35 @@ internal sealed class FrameRootCompositionPhase
renderFrameLivePreparation);
Fault(FrameRootCompositionPoint.RenderResourcesCreated);
AcDream.App.Rendering.Packs.RenderPackController? renderPackController = null;
AcDream.App.Rendering.Packs.RenderPackSelectionBinding? renderPackSelection = null;
AcDream.App.Rendering.Packs.AtmosphericFrameInputState? atmosphericInputs = null;
if (settings.RenderPacks is { } renderPackCatalog)
{
renderPackController = new AcDream.App.Rendering.Packs.RenderPackController(
renderPackCatalog.Snapshot,
new AcDream.App.Rendering.Packs.AtmosphericRenderPackRuntimeFactory(
host.GpuDevice),
new AcDream.App.Rendering.Packs.RenderPackReceiverPipelineCoordinator(
foundation.Terrain!,
live.DrawDispatcher!),
AcDream.App.Rendering.Packs.ThreadPoolRenderPackPreparationScheduler.Instance,
renderPackCatalog);
bindings.Adopt("render-pack controller", renderPackController);
renderPackSelection = new AcDream.App.Rendering.Packs.RenderPackSelectionBinding(
d.Settings,
renderPackController,
d.Log);
bindings.Adopt("render-pack selection", renderPackSelection);
if (d.RenderPackDiagnostics is { } renderPackDiagnostics)
{
bindings.Adopt(
"render-pack diagnostics",
renderPackDiagnostics.BindOwned(renderPackController));
}
atmosphericInputs = new AcDream.App.Rendering.Packs.AtmosphericFrameInputState();
}
var renderWeatherFrame = new RenderWeatherFrameController(
d.WorldTime,
d.Weather);
@ -463,7 +495,8 @@ internal sealed class FrameRootCompositionPhase
worldScenePasses,
d.RenderRange,
worldSceneDiagnostics,
live.WorldAvailability);
live.WorldAvailability,
atmosphericInputs);
// The world renderer runs INSIDE the frame's one backbuffer pass,
// which this phase opens, publishes on the scope, and closes.
worldSceneRenderer =
@ -474,14 +507,70 @@ internal sealed class FrameRootCompositionPhase
(d.Graphics as VulkanGameWindowGraphics)?.WorldPassScopeCore
?? throw new InvalidOperationException(
"The Vulkan world phase requires the Vulkan graphics handle."),
worldSceneRenderer);
worldSceneRenderer,
renderPackController,
atmosphericInputs,
renderPackSelection is null
? null
: renderPackSelection.ApplyAtFrameBoundary,
live.RenderSceneShadow,
live.DrawDispatcher,
foundation.Terrain);
}
Fault(FrameRootCompositionPoint.WorldRendererCreated);
bindings = new FrameRootRuntimeBindings();
WorldLifecycleAutomationController? lifecycleAutomation = null;
if (interaction.RetainedUi?.Screenshots is { } screenshots
&& d.Options.AutomationArtifactDirectory is { } artifactDirectory)
{
AcDream.UI.Abstractions.Panels.Settings.RenderPackSelectionSettings?
automationLastEnhancedSelection = null;
(bool Succeeded, string Error) SaveAutomationRenderPackSelection(
AcDream.UI.Abstractions.Panels.Settings.RenderPackSelectionSettings selection)
{
d.Settings.SaveDisplay(d.Settings.Display with
{
RenderPack = selection,
});
return d.Settings.Display.RenderPack == selection
? (true, string.Empty)
: (false, $"render-pack selection '{selection.PresetId}' was not persisted");
}
(bool Succeeded, string Error) SelectAutomationRenderPack(string preset)
{
var selection = string.Equals(
preset,
"retail",
StringComparison.Ordinal)
? AcDream.UI.Abstractions.Panels.Settings
.RenderPackSelectionSettings.Retail
: new AcDream.UI.Abstractions.Panels.Settings
.RenderPackSelectionSettings(
AcDream.App.Rendering.Packs
.BuiltInAtmosphericRenderPack.Id,
"1.0.0",
preset);
return SaveAutomationRenderPackSelection(selection);
}
(bool Succeeded, string Error) DisableAutomationRenderPack()
{
var current = d.Settings.Display.RenderPack;
if (!current.IsRetail)
automationLastEnhancedSelection = current;
return SaveAutomationRenderPackSelection(
AcDream.UI.Abstractions.Panels.Settings
.RenderPackSelectionSettings.Retail);
}
(bool Succeeded, string Error) ReenableAutomationRenderPack()
{
return automationLastEnhancedSelection is { } selection
? SaveAutomationRenderPackSelection(selection)
: (false, "render-pack re-enable requires a prior enhanced selection");
}
var resourceSnapshots =
new WorldLifecycleResourceSnapshotSource(
live.WorldState,
@ -515,7 +604,86 @@ internal sealed class FrameRootCompositionPhase
resourceSnapshots.Capture,
screenshots,
artifactDirectory,
message => d.Log("[UI-PROBE] " + message));
message => d.Log("[UI-PROBE] " + message),
() => renderPackController?.MinimumPerformanceSampleCount ?? 0,
() =>
{
if (renderPackController is null)
{
return (
false,
"render-pack performance automation is unavailable");
}
bool reset = renderPackController.TryResetPerformanceEvidence(
out string error);
return (reset, error);
},
() => renderPackController?.Snapshot.State ==
AcDream.App.Rendering.Packs.RenderPackActivationState.FailedToRetail,
getRenderPackStatus: () =>
{
AcDream.App.Rendering.Packs.RenderPackActivationSnapshot snapshot =
renderPackController?.Snapshot
?? new AcDream.App.Rendering.Packs.RenderPackActivationSnapshot(
AcDream.App.Rendering.Packs.RenderPackActivationState.Retail,
AcDream.UI.Abstractions.Panels.Settings
.RenderPackSelectionSettings.Retail,
ActivePackDisplayName: null,
Reason: null,
ActivationGeneration: 0);
var state = snapshot.State switch
{
AcDream.App.Rendering.Packs.RenderPackActivationState.Retail =>
AcDream.App.UI.Testing
.RetailUiAutomationRenderPackState.Retail,
AcDream.App.Rendering.Packs.RenderPackActivationState.CandidatePending =>
AcDream.App.UI.Testing
.RetailUiAutomationRenderPackState.CandidatePending,
AcDream.App.Rendering.Packs.RenderPackActivationState.Active =>
AcDream.App.UI.Testing
.RetailUiAutomationRenderPackState.Active,
AcDream.App.Rendering.Packs.RenderPackActivationState.FailedToRetail =>
AcDream.App.UI.Testing
.RetailUiAutomationRenderPackState.FailedToRetail,
_ => throw new ArgumentOutOfRangeException(),
};
return new AcDream.App.UI.Testing
.RetailUiAutomationRenderPackStatus(
state,
snapshot.Selection.PackId,
snapshot.Selection.PresetId,
snapshot.ActivationGeneration,
snapshot.Reason);
},
selectRenderPack: SelectAutomationRenderPack,
disableRenderPack: DisableAutomationRenderPack,
reenableRenderPack: ReenableAutomationRenderPack,
getFramebufferSize: () =>
{
var size = d.Window.FramebufferSize;
return (size.X, size.Y);
},
resizeFramebuffer: (width, height) =>
{
if (d.Settings.Display.Fullscreen)
{
return (
false,
"automation framebuffer resize requires windowed mode");
}
string resolution = $"{width}x{height}";
d.Settings.SaveDisplay(d.Settings.Display with
{
Resolution = resolution,
});
return string.Equals(
d.Settings.Display.Resolution,
resolution,
StringComparison.Ordinal)
? (true, string.Empty)
: (false, $"framebuffer resize '{resolution}' was not persisted");
},
requestClientClose: d.Window.Close);
bindings.Adopt(
"world lifecycle automation owner",
lifecycleAutomation);

View file

@ -52,7 +52,10 @@ internal sealed record HostInputCameraDependencies(
LocalPlayerModeState LocalPlayerMode,
ChaseCameraInputState ChaseCameraInput,
PointerPositionState PointerPosition,
IRenderFrameDiagnosticLog RenderDiagnosticLog);
IRenderFrameDiagnosticLog RenderDiagnosticLog,
float? InitialOrbitDistanceMeters = null,
float? InitialOrbitYawDegrees = null,
float? InitialOrbitPitchDegrees = null);
/// <summary>
/// The construction seam every backend differs at. Campaign V slice V6h widened
@ -101,7 +104,10 @@ internal interface IHostInputCameraCompositionFactory
IKeyboardSource keyboard,
IMouseSource mouse,
KeyBindings bindings);
CameraController CreateCameraController();
CameraController CreateCameraController(
float? initialOrbitDistanceMeters,
float? initialOrbitYawDegrees,
float? initialOrbitPitchDegrees);
IFramebufferCameraTarget CreateCameraTarget(CameraController camera);
CameraPointerInputController CreateCameraPointerInput(
IReadOnlyList<IMouse> mice,
@ -311,7 +317,10 @@ internal sealed class HostInputCameraCompositionPhase :
Fault(HostInputCameraCompositionPoint.CameraInputBound);
}
CameraController camera = _factory.CreateCameraController();
CameraController camera = _factory.CreateCameraController(
_dependencies.InitialOrbitDistanceMeters,
_dependencies.InitialOrbitYawDegrees,
_dependencies.InitialOrbitPitchDegrees);
_publication.PublishCameraController(camera);
Fault(HostInputCameraCompositionPoint.CameraPublished);
_dependencies.FramebufferResize.BindCamera(

View file

@ -92,7 +92,10 @@ internal sealed record InteractionRetainedUiDependencies(
// needed. gmMapUI::Update @0x004a1eb0 reads GameTime::current_game_time
// every 5s — MapPageController owns that cadence, this just supplies the
// current reading.
Func<AcDream.Core.World.DerethDateTime.Calendar> CurrentCalendar)
Func<AcDream.Core.World.DerethDateTime.Calendar> CurrentCalendar,
AcDream.App.Rendering.Packs.RenderPackCatalogSource? RenderPackCatalog = null,
Func<AcDream.App.Rendering.Packs.RenderPackDiagnosticsSnapshot>?
RenderPackDiagnostics = null)
{
public RuntimeActionState Actions => Runtime.ActionOwner;
@ -643,7 +646,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
screenshots = new FrameScreenshotController(
d.BackbufferReader,
Path.Combine(artifactDirectory, "screenshots"),
ProbeLog);
ProbeLog,
d.RenderPackDiagnostics);
}
checkpoint(InteractionRetainedUiCompositionPoint.UiProbeCreated);
@ -949,7 +953,53 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
LoadDisplay: () => d.Settings.Display,
SaveDisplay: d.Settings.SaveDisplay,
LoadAudio: () => d.Settings.Audio,
SaveAudio: d.Settings.SaveAudio),
SaveAudio: d.Settings.SaveAudio,
LoadRenderPackChoices: d.RenderPackCatalog is null
? null
: () => d.RenderPackCatalog.Snapshot().Entries
.Select(entry =>
new ConfigOptionsPageController.RenderPackChoice(
entry.Descriptor.Id,
entry.Descriptor.DisplayName,
entry.Descriptor.PackVersion.ToString(),
entry.IsCompatible,
entry.IncompatibilityReason,
entry.Descriptor.QualityPresets
.Select(preset =>
{
entry.PresetIncompatibilityReasons.TryGetValue(
preset.Id,
out string? reason);
return new ConfigOptionsPageController.RenderPackPresetChoice(
preset.Id,
preset.DisplayName,
entry.IsCompatible && reason is null,
reason ?? entry.IncompatibilityReason)
{
SettingOverrides = preset.SettingOverrides,
MaxResidentGpuBytes = preset.MaxResidentGpuBytes,
MaxIncrementalGpuMillisecondsP50 =
preset.MaxIncrementalGpuMillisecondsP50,
MaxIncrementalGpuMillisecondsP99 =
preset.MaxIncrementalGpuMillisecondsP99,
MaxIncrementalCpuMillisecondsP50 =
preset.MaxIncrementalCpuMillisecondsP50,
MaxIncrementalCpuMillisecondsP99 =
preset.MaxIncrementalCpuMillisecondsP99,
};
})
.ToArray())
{
FeatureSummary = entry.Descriptor.FeatureSummary,
Settings = entry.Descriptor.Settings,
})
.ToArray(),
LoadRenderPackCatalogRevision: d.RenderPackCatalog is null
? null
: () => d.RenderPackCatalog.Revision,
LoadRenderPackFailureNotice: d.RenderPackDiagnostics is null
? null
: () => d.RenderPackDiagnostics().FailureReason),
// Campaign FA slice FA3: the social panel's own bindings —
// FA2's typed Fellowship/Allegiance snapshot readers off the
// GameRuntime views, plus J4.1's Friends/Squelch owners

View file

@ -792,6 +792,67 @@ internal sealed class DeferredWorldLifecycleAutomationRuntime
!_deactivated && _target?.IsWorldViewportVisible == true;
public int PortalMaterializationCount =>
!_deactivated ? _target?.PortalMaterializationCount ?? 0 : 0;
public int RenderPackPerformanceSampleCount =>
!_deactivated ? _target?.RenderPackPerformanceSampleCount ?? 0 : 0;
public bool RenderPackFailedToRetail =>
!_deactivated && _target?.RenderPackFailedToRetail == true;
public RetailUiAutomationRenderPackStatus RenderPackStatus =>
!_deactivated
? _target?.RenderPackStatus
?? RetailUiAutomationRenderPackStatus.Retail
: RetailUiAutomationRenderPackStatus.Retail;
public int FramebufferWidth =>
!_deactivated ? _target?.FramebufferWidth ?? 0 : 0;
public int FramebufferHeight =>
!_deactivated ? _target?.FramebufferHeight ?? 0 : 0;
public bool TrySelectRenderPack(string presetId, out string error)
{
if (!_deactivated && _target is { } target)
return target.TrySelectRenderPack(presetId, out error);
error = "world lifecycle automation is not bound";
return false;
}
public bool TryDisableRenderPack(out string error)
{
if (!_deactivated && _target is { } target)
return target.TryDisableRenderPack(out error);
error = "world lifecycle automation is not bound";
return false;
}
public bool TryReenableRenderPack(out string error)
{
if (!_deactivated && _target is { } target)
return target.TryReenableRenderPack(out error);
error = "world lifecycle automation is not bound";
return false;
}
public bool TryResizeFramebuffer(int width, int height, out string error)
{
if (!_deactivated && _target is { } target)
return target.TryResizeFramebuffer(width, height, out error);
error = "world lifecycle automation is not bound";
return false;
}
public bool TryResetRenderPackPerformance(out string error)
{
if (!_deactivated && _target is { } target)
return target.TryResetRenderPackPerformance(out error);
error = "world lifecycle automation is not bound";
return false;
}
public bool TryRequestClientClose(out string error)
{
if (!_deactivated && _target is { } target)
return target.TryRequestClientClose(out error);
error = "world lifecycle automation is not bound";
return false;
}
public IDisposable Bind(IRetailUiAutomationRuntime target)
{

View file

@ -74,7 +74,9 @@ internal sealed record LivePresentationDependencies(
DeferredRenderFrameDiagnosticsSource? DevFrameDiagnostics,
DeferredRenderFrameDiagnosticsSource UiFrameDiagnostics,
Action<string> Log,
Action<string>? Toast)
Action<string>? Toast,
AcDream.App.Rendering.Packs.IRenderPackDiagnosticsSnapshotSource?
RenderPackDiagnostics = null)
{
public SelectionState Selection => Runtime.ActionOwner.Selection;
@ -448,7 +450,8 @@ internal sealed class LivePresentationCompositionPhase
LiveRenderProjectionJournal? liveRenderProjections =
renderSceneShadow?.BindLiveRuntime(
liveEntities,
new GpuWorldRenderTraversalOrderSource(worldState));
new GpuWorldRenderTraversalOrderSource(worldState),
d.PlayerIdentity);
Fault(LivePresentationCompositionPoint.CanonicalRuntimeCreated);
bindings.Adopt(
@ -803,7 +806,9 @@ internal sealed class LivePresentationCompositionPhase
d.TranslucencyFades,
selectionScene,
d.RetailAlphaQueue,
alphaScratchBudgets.DispatcherBytes),
alphaScratchBudgets.DispatcherBytes,
foundation.TerrainAtlas?.BuildingDetailTexture ?? default,
() => d.Settings.DisplayPreview.BuildingDetailTextures),
static value => value.Dispose());
var selectionQuery = new WorldSelectionQuery(
liveEntities,
@ -1238,9 +1243,11 @@ internal sealed class LivePresentationCompositionPhase
?? throw new InvalidOperationException(
"The graphics backend must publish a world pass scope."),
foundation.MeshAdapter!.MeshManager!,
envCellFrustum),
envCellFrustum,
foundation.TerrainAtlas?.EnvironmentDetailTexture ?? default,
() => d.Settings.DisplayPreview.BuildingDetailTextures),
static value => value.Dispose());
// The three pipelines ARE its program, built at construction — the
// The four pipelines ARE its program, built at construction — the
// raw-GL arm's separate Initialize(Shader) step was deleted at V11.
Fault(LivePresentationCompositionPoint.EnvironmentCellsCreated);
@ -1484,7 +1491,8 @@ internal sealed class LivePresentationCompositionPhase
new SilkRenderFrameTitleSink(d.Window),
d.RenderDiagnosticLog,
d.Options.UiProbeDump,
resourceDiagnostics);
resourceDiagnostics,
d.RenderPackDiagnostics);
if (d.DevFrameDiagnostics is { } devFrameDiagnostics)
{
bindings.Adopt(

View file

@ -1,4 +1,8 @@
using AcDream.App.Settings;
using AcDream.App.Plugins;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Packs;
using AcDream.UI.Abstractions.Panels.Settings;
using Silk.NET.Input;
namespace AcDream.App.Composition;
@ -18,11 +22,22 @@ namespace AcDream.App.Composition;
/// keybinds.json (not retail's <c>.keymap</c> format — register row AP-202).
/// </summary>
internal sealed record SettingsDevToolsResult(
AcDream.UI.Abstractions.Settings.QualitySettings ResolvedQuality);
AcDream.UI.Abstractions.Settings.QualitySettings ResolvedQuality)
{
internal RenderPackCatalogSource? RenderPacks { get; init; }
internal RenderPackSelectionSettings RenderPackSelection { get; init; } =
RenderPackSelectionSettings.Retail;
}
internal sealed record SettingsDevToolsDependencies(
RuntimeSettingsController Settings,
IRuntimeSettingsStartupTarget StartupTarget);
IRuntimeSettingsStartupTarget StartupTarget)
{
internal BufferedRenderPackRegistry? RenderPacks { get; init; }
internal IGpuDevice? GpuDevice { get; init; }
}
/// <summary>
/// Production Phase 3: applies the resolved startup display/audio settings.
@ -51,6 +66,19 @@ internal sealed class SettingsDevToolsCompositionPhase :
ArgumentNullException.ThrowIfNull(content);
_dependencies.Settings.ApplyStartup(_dependencies.StartupTarget);
return new SettingsDevToolsResult(_dependencies.Settings.ResolvedQuality);
RenderPackCatalogSource? renderPacks = null;
if (_dependencies.RenderPacks is { } registry
&& _dependencies.GpuDevice is { } gpu)
{
renderPacks = new RenderPackCatalogSource(
registry,
RenderPackCapabilityResolver.Resolve(gpu.Capabilities));
}
return new SettingsDevToolsResult(_dependencies.Settings.ResolvedQuality)
{
RenderPacks = renderPacks,
RenderPackSelection = _dependencies.Settings.Display.RenderPack,
};
}
}

View file

@ -82,8 +82,23 @@ internal sealed class VulkanHostInputCameraCompositionFactory
KeyBindings bindings) =>
InputDispatcher.CreateDetached(keyboard, mouse, bindings);
public CameraController CreateCameraController() =>
new(new OrbitCamera(), new FlyCamera());
public CameraController CreateCameraController(
float? initialOrbitDistanceMeters,
float? initialOrbitYawDegrees,
float? initialOrbitPitchDegrees)
{
var orbit = new OrbitCamera();
if (initialOrbitDistanceMeters is { } distance)
orbit.Distance = distance;
if (initialOrbitYawDegrees is { } yaw)
orbit.Yaw = DegreesToRadians(yaw);
if (initialOrbitPitchDegrees is { } pitch)
orbit.Pitch = DegreesToRadians(pitch);
return new CameraController(orbit, new FlyCamera());
}
private static float DegreesToRadians(float degrees) =>
degrees * (MathF.PI / 180f);
public IFramebufferCameraTarget CreateCameraTarget(CameraController camera) =>
new CameraFramebufferTarget(camera);

View file

@ -78,7 +78,10 @@ internal interface IGameWindowWorldRenderPublication
internal interface IWorldRenderCompositionFactory
{
WorldRegionData LoadRegion(IDatReaderWriter dats);
void InitializeEnvironment(WorldEnvironmentController environment, Region region);
void InitializeEnvironment(
WorldEnvironmentController environment,
Region region,
IDatReaderWriter dats);
/// <summary>
/// Campaign V slice V6i-2: the terrain atlas built through
/// <see cref="AcDream.App.Rendering.Gpu.IGpuDevice"/>. The raw-GL arm this
@ -188,11 +191,13 @@ internal sealed class RetailWorldRenderCompositionFactory
public void InitializeEnvironment(
WorldEnvironmentController environment,
Region region)
Region region,
IDatReaderWriter dats)
{
ArgumentNullException.ThrowIfNull(environment);
ArgumentNullException.ThrowIfNull(region);
environment.Initialize(region);
ArgumentNullException.ThrowIfNull(dats);
environment.Initialize(region, dats);
}
public TerrainAtlas AcquireBackendNeutralTerrainAtlas(
@ -452,7 +457,10 @@ internal sealed class WorldRenderCompositionPhase
WorldRegionData region = _factory.LoadRegion(content.Dats);
Fault(WorldRenderCompositionPoint.RegionLoaded);
_factory.InitializeEnvironment(_dependencies.Environment, region.Region);
_factory.InitializeEnvironment(
_dependencies.Environment,
region.Region,
content.Dats);
Fault(WorldRenderCompositionPoint.EnvironmentInitialized);
// Campaign V slice V6i-2: the atlas builds through IGpuDevice on

View file

@ -1,6 +1,9 @@
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using System.Text.Json;
using AcDream.App.Rendering.Packs;
namespace AcDream.App.Diagnostics;
/// <summary>
@ -22,6 +25,7 @@ internal sealed class FrameScreenshotController
private readonly Func<int, int, byte[]> _readRgba;
private readonly string _directory;
private readonly Action<string> _log;
private readonly Func<RenderPackDiagnosticsSnapshot>? _renderPackMetadata;
private readonly Queue<string> _pending = new();
private readonly Dictionary<string, CaptureStatus> _status =
new(StringComparer.OrdinalIgnoreCase);
@ -29,13 +33,15 @@ internal sealed class FrameScreenshotController
internal FrameScreenshotController(
Func<int, int, byte[]> readRgba,
string directory,
Action<string>? log = null)
Action<string>? log = null,
Func<RenderPackDiagnosticsSnapshot>? renderPackMetadata = null)
{
_readRgba = readRgba ?? throw new ArgumentNullException(nameof(readRgba));
_directory = string.IsNullOrWhiteSpace(directory)
? throw new ArgumentException("A screenshot directory is required.", nameof(directory))
: Path.GetFullPath(directory);
_log = log ?? (_ => { });
_renderPackMetadata = renderPackMetadata;
}
public bool TryRequest(string name, out string error)
@ -85,6 +91,27 @@ internal sealed class FrameScreenshotController
string temporaryPath = path + ".tmp";
using (Image<Rgba32> image = Image.LoadPixelData<Rgba32>(flipped, width, height))
image.SaveAsPng(temporaryPath);
string? metadataPath = null;
string? temporaryMetadataPath = null;
if (_renderPackMetadata is not null)
{
metadataPath = Path.Combine(_directory, name + ".metadata.json");
temporaryMetadataPath = metadataPath + ".tmp";
var metadata = new FrameScreenshotMetadata(
SchemaVersion: 1,
Width: width,
Height: height,
RenderPack: _renderPackMetadata());
File.WriteAllBytes(
temporaryMetadataPath,
JsonSerializer.SerializeToUtf8Bytes(
metadata,
new JsonSerializerOptions { WriteIndented = true }));
}
if (metadataPath is not null && temporaryMetadataPath is not null)
File.Move(temporaryMetadataPath, metadataPath, overwrite: true);
File.Move(temporaryPath, path, overwrite: true);
_status[name] = new CaptureStatus(CaptureState.Complete);
@ -93,6 +120,9 @@ internal sealed class FrameScreenshotController
}
catch (Exception exception)
{
TryDelete(Path.Combine(_directory, name + ".png.tmp"));
TryDelete(Path.Combine(_directory, name + ".metadata.json.tmp"));
TryDelete(Path.Combine(_directory, name + ".metadata.json"));
string message = $"screenshot '{name}' failed: {exception.Message}";
_status[name] = new CaptureStatus(CaptureState.Failed, message);
_log($"[world-gate] screenshot-failed name={name} error={exception.Message}");
@ -100,6 +130,22 @@ internal sealed class FrameScreenshotController
}
}
private static void TryDelete(string path)
{
try
{
File.Delete(path);
}
catch (Exception error) when (error is IOException
or UnauthorizedAccessException
or ArgumentException
or NotSupportedException)
{
// Preserve the primary capture error. The next artifact directory
// teardown reports any file that could not be cleaned.
}
}
internal static byte[] FlipRows(byte[] pixels, int width, int height)
{
int stride = checked(width * 4);
@ -249,3 +295,9 @@ internal sealed class FrameScreenshotController
}
}
internal sealed record FrameScreenshotMetadata(
int SchemaVersion,
int Width,
int Height,
RenderPackDiagnosticsSnapshot RenderPack);

View file

@ -240,6 +240,22 @@ internal sealed class WorldLifecycleAutomationController :
private readonly Func<RuntimeWorldTransitOwnershipSnapshot>
_getTransitOwnership;
private readonly Func<int> _getPortalMaterializationCount;
private readonly Func<int> _getRenderPackPerformanceSampleCount;
private readonly Func<bool> _getRenderPackFailedToRetail;
private readonly Func<RetailUiAutomationRenderPackStatus>
_getRenderPackStatus;
private readonly Func<string, (bool Succeeded, string Error)>
_selectRenderPack;
private readonly Func<(bool Succeeded, string Error)>?
_disableRenderPack;
private readonly Func<(bool Succeeded, string Error)>?
_reenableRenderPack;
private readonly Func<(int Width, int Height)> _getFramebufferSize;
private readonly Func<int, int, (bool Succeeded, string Error)>
_resizeFramebuffer;
private readonly Func<(bool Succeeded, string Error)>
_resetRenderPackPerformance;
private readonly Action? _requestClientClose;
private readonly Func<RenderFrameOutcome, WorldLifecycleResourceSnapshot>
_captureResources;
private readonly FrameScreenshotController _screenshots;
@ -248,6 +264,7 @@ internal sealed class WorldLifecycleAutomationController :
private readonly object _requestOwner = new();
private readonly object _sync = new();
private readonly Queue<WorldLifecycleCheckpointRequest> _requests = [];
private string? _lastEnabledRenderPackPreset;
private int _sequence;
private bool _disposed;
@ -260,7 +277,17 @@ internal sealed class WorldLifecycleAutomationController :
Func<RenderFrameOutcome, WorldLifecycleResourceSnapshot> captureResources,
FrameScreenshotController screenshots,
string artifactDirectory,
Action<string>? log = null)
Action<string>? log = null,
Func<int>? getRenderPackPerformanceSampleCount = null,
Func<(bool Succeeded, string Error)>? resetRenderPackPerformance = null,
Func<bool>? getRenderPackFailedToRetail = null,
Func<RetailUiAutomationRenderPackStatus>? getRenderPackStatus = null,
Func<string, (bool Succeeded, string Error)>? selectRenderPack = null,
Func<(bool Succeeded, string Error)>? disableRenderPack = null,
Func<(bool Succeeded, string Error)>? reenableRenderPack = null,
Func<(int Width, int Height)>? getFramebufferSize = null,
Func<int, int, (bool Succeeded, string Error)>? resizeFramebuffer = null,
Action? requestClientClose = null)
{
_getReveal = getReveal ?? throw new ArgumentNullException(nameof(getReveal));
_getEnvironmentOwnership = getEnvironmentOwnership
@ -270,6 +297,21 @@ internal sealed class WorldLifecycleAutomationController :
?? throw new ArgumentNullException(nameof(getTransitOwnership));
_getPortalMaterializationCount = getPortalMaterializationCount
?? throw new ArgumentNullException(nameof(getPortalMaterializationCount));
_getRenderPackPerformanceSampleCount =
getRenderPackPerformanceSampleCount ?? (() => 0);
_getRenderPackFailedToRetail = getRenderPackFailedToRetail ?? (() => false);
_getRenderPackStatus = getRenderPackStatus
?? (() => RetailUiAutomationRenderPackStatus.Retail);
_selectRenderPack = selectRenderPack
?? (_ => (false, "render-pack selection automation is unavailable"));
_disableRenderPack = disableRenderPack;
_reenableRenderPack = reenableRenderPack;
_getFramebufferSize = getFramebufferSize ?? (() => (0, 0));
_resizeFramebuffer = resizeFramebuffer
?? ((_, _) => (false, "framebuffer resize automation is unavailable"));
_resetRenderPackPerformance = resetRenderPackPerformance
?? (() => (false, "render-pack performance automation is unavailable"));
_requestClientClose = requestClientClose;
_captureResources = captureResources ?? throw new ArgumentNullException(nameof(captureResources));
_screenshots = screenshots ?? throw new ArgumentNullException(nameof(screenshots));
_artifactDirectory = string.IsNullOrWhiteSpace(artifactDirectory)
@ -281,6 +323,113 @@ internal sealed class WorldLifecycleAutomationController :
public bool IsWorldReady => _getReveal().IsReady;
public bool IsWorldViewportVisible => _getReveal().WorldViewportObserved;
public int PortalMaterializationCount => _getPortalMaterializationCount();
public int RenderPackPerformanceSampleCount =>
_getRenderPackPerformanceSampleCount();
public bool RenderPackFailedToRetail => _getRenderPackFailedToRetail();
public RetailUiAutomationRenderPackStatus RenderPackStatus =>
_getRenderPackStatus();
public int FramebufferWidth => _getFramebufferSize().Width;
public int FramebufferHeight => _getFramebufferSize().Height;
public bool TrySelectRenderPack(string presetId, out string error)
{
ArgumentException.ThrowIfNullOrWhiteSpace(presetId);
string normalized = presetId.ToLowerInvariant();
if (normalized == "off")
normalized = "retail";
if (normalized is not ("retail" or "low" or "medium" or "high" or "auto"))
{
error = $"unknown render-pack preset '{presetId}'";
return false;
}
(bool succeeded, string selectionError) = _selectRenderPack(normalized);
if (succeeded && normalized != "retail")
_lastEnabledRenderPackPreset = normalized;
error = selectionError;
return succeeded;
}
public bool TryDisableRenderPack(out string error)
{
if (_disableRenderPack is not null)
{
(bool succeeded, string disableError) = _disableRenderPack();
error = disableError;
return succeeded;
}
RetailUiAutomationRenderPackStatus current = RenderPackStatus;
if (current.State == RetailUiAutomationRenderPackState.Active
&& !string.Equals(current.PackId, "retail", StringComparison.OrdinalIgnoreCase))
{
_lastEnabledRenderPackPreset = current.PresetId;
}
return TrySelectRenderPack("retail", out error);
}
public bool TryReenableRenderPack(out string error)
{
if (_reenableRenderPack is not null)
{
(bool succeeded, string reenableError) = _reenableRenderPack();
error = reenableError;
return succeeded;
}
if (string.IsNullOrWhiteSpace(_lastEnabledRenderPackPreset))
{
error = "render-pack re-enable requires a prior active enhanced selection";
return false;
}
return TrySelectRenderPack(_lastEnabledRenderPackPreset, out error);
}
public bool TryResizeFramebuffer(int width, int height, out string error)
{
if (width < 320 || height < 240 || width > 8192 || height > 8192)
{
error = "automation framebuffer size must be within 320x240 and 8192x8192";
return false;
}
(bool succeeded, string resizeError) = _resizeFramebuffer(width, height);
error = resizeError;
return succeeded;
}
public bool TryResetRenderPackPerformance(out string error)
{
// A terminal fallback owns no enhanced evidence. Treat reset as an
// idempotent no-op so a reset/wait/screenshot automation sequence can
// report the unavailable preset instead of stopping before capture.
if (RenderPackFailedToRetail)
{
error = string.Empty;
return true;
}
(bool succeeded, string resetError) = _resetRenderPackPerformance();
error = resetError;
return succeeded;
}
public bool TryRequestClientClose(out string error)
{
if (_requestClientClose is null)
{
error = "client-close automation is unavailable";
return false;
}
try
{
_requestClientClose();
error = string.Empty;
return true;
}
catch (Exception exception)
{
error = $"client-close automation failed: {exception.Message}";
return false;
}
}
public bool TryRequestCheckpoint(
string name,

View file

@ -0,0 +1,171 @@
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.App.Plugins;
/// <summary>
/// Pre-device render-pack discovery buffer. Registration only retains the
/// immutable declaration and lazy asset source; it deliberately never calls
/// <see cref="IRenderPackAssets.OpenRead"/> or touches the renderer. This lets
/// plugins register before the window/GPU exists without weakening the retail
/// no-op contract.
/// </summary>
internal sealed class BufferedRenderPackRegistry : IRenderPackRegistry, IDisposable
{
private readonly object _sync = new();
private readonly Dictionary<string, Registration> _registrations =
new(StringComparer.OrdinalIgnoreCase);
private long _revision;
private long _nextRegistrationId;
private bool _disposed;
/// <summary>
/// Monotonic catalog generation. Consumers use <see cref="Changed"/> to
/// invalidate their cached view and consume the new snapshot at a safe
/// frame/UI boundary; no renderer path polls the registry per frame.
/// </summary>
internal long Revision
{
get
{
lock (_sync)
return _revision;
}
}
internal event Action<long>? Changed;
internal IReadOnlyList<BufferedRenderPackRegistration> Snapshot()
{
lock (_sync)
{
ObjectDisposedException.ThrowIf(_disposed, this);
return _registrations.Values
.OrderBy(static value => value.Descriptor.Id, StringComparer.OrdinalIgnoreCase)
.Select(static value => new BufferedRenderPackRegistration(
value.Descriptor,
value.Assets,
value.RegistrationId))
.ToArray();
}
}
public IDisposable Register(
RenderPackDescriptor descriptor,
IRenderPackAssets assets)
{
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentNullException.ThrowIfNull(assets);
Registration registration;
long revision;
lock (_sync)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_registrations.ContainsKey(descriptor.Id))
{
throw new InvalidOperationException(
$"A render pack with id '{descriptor.Id}' is already registered.");
}
registration = new Registration(
this,
descriptor,
assets,
checked(++_nextRegistrationId));
_registrations.Add(descriptor.Id, registration);
revision = checked(++_revision);
}
PublishChanged(revision);
return registration;
}
public void Dispose()
{
Registration[] registrations;
long? revision = null;
lock (_sync)
{
if (_disposed)
return;
_disposed = true;
registrations = _registrations.Values.ToArray();
_registrations.Clear();
if (registrations.Length != 0)
revision = checked(++_revision);
}
foreach (Registration registration in registrations)
registration.WithdrawFromOwner();
if (revision is { } changedRevision)
PublishChanged(changedRevision);
}
private void Withdraw(Registration registration)
{
long? revision = null;
lock (_sync)
{
if (_registrations.TryGetValue(
registration.Descriptor.Id,
out Registration? active)
&& ReferenceEquals(active, registration))
{
_registrations.Remove(registration.Descriptor.Id);
revision = checked(++_revision);
}
}
if (revision is { } changedRevision)
PublishChanged(changedRevision);
}
private void PublishChanged(long revision)
{
Delegate[] subscribers = Changed?.GetInvocationList() ?? [];
foreach (Delegate subscriber in subscribers)
{
try { ((Action<long>)subscriber)(revision); }
catch
{
// Registration ownership must not be corrupted by a UI or
// controller observer. The next explicit snapshot still sees
// the authoritative revision and contents.
}
}
}
private sealed class Registration : IDisposable
{
private BufferedRenderPackRegistry? _owner;
internal Registration(
BufferedRenderPackRegistry owner,
RenderPackDescriptor descriptor,
IRenderPackAssets assets,
long registrationId)
{
_owner = owner;
Descriptor = descriptor;
Assets = assets;
RegistrationId = registrationId;
}
internal RenderPackDescriptor Descriptor { get; }
internal IRenderPackAssets Assets { get; }
internal long RegistrationId { get; }
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Withdraw(this);
internal void WithdrawFromOwner() =>
Interlocked.Exchange(ref _owner, null);
}
}
internal sealed record BufferedRenderPackRegistration(
RenderPackDescriptor Descriptor,
IRenderPackAssets Assets,
long RegistrationId);

View file

@ -1,6 +1,7 @@
using AcDream.Core.Plugins;
using AcDream.Platform;
using AcDream.Plugin.Abstractions;
using AcDream.Plugin.Abstractions.Rendering;
using AcDream.Runtime.Session;
namespace AcDream.App.Plugins;
@ -44,7 +45,8 @@ internal sealed class GraphicalPluginSession : IDisposable
IReadOnlyList<string>? allowList,
string sessionId,
IPluginHost host,
SessionStatusWriter statusWriter)
SessionStatusWriter statusWriter,
IRenderPackRegistry? renderPacks = null)
{
ArgumentNullException.ThrowIfNull(paths);
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
@ -53,7 +55,11 @@ internal sealed class GraphicalPluginSession : IDisposable
var plugins = new PluginSession(
host,
status => Report(statusWriter, sessionId, status));
status => Report(statusWriter, sessionId, status),
renderPacks,
renderPacks is null
? [PluginKind.Gameplay]
: [PluginKind.Gameplay, PluginKind.RenderPack]);
return new GraphicalPluginSession(
plugins,
[

View file

@ -149,6 +149,15 @@ if (runtimeOptions.DevTools)
var worldGameState = new AcDream.Core.Plugins.WorldGameState();
var worldEvents = new AcDream.Core.Plugins.WorldEvents();
var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry();
using var renderPackRegistry = new AcDream.App.Plugins.BufferedRenderPackRegistry();
using IDisposable atmosphericPackRegistration = renderPackRegistry.Register(
AcDream.App.Rendering.Packs.BuiltInAtmosphericRenderPack.Descriptor,
AcDream.App.Rendering.Packs.BuiltInAtmosphericRenderPack.CreateAssets(
Path.Combine(
AppContext.BaseDirectory,
"Rendering",
"Shaders",
"spv")));
// Constructed here and handed to both sides: GameWindow binds it to the live
// session's Runtime owners, the plugin host exposes it to plugins.
using var automation = new AcDream.App.Plugins.AppAutomationSurface();
@ -158,7 +167,8 @@ using var window = new GameWindow(
worldEvents,
uiRegistry,
graphicalPlatform,
automation);
automation,
renderPackRegistry);
var host = new AppPluginHost(
new SerilogAdapter(Log.Logger),
worldGameState,
@ -171,7 +181,8 @@ GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(
runtimeOptions.Plugins,
runtimeOptions.SessionId ?? "app",
host,
window.StatusWriter);
window.StatusWriter,
renderPackRegistry);
window.StartPluginHosting(pluginSession);
try

View file

@ -194,14 +194,14 @@ internal sealed class RhiCompositeTextureArrayBackend : ICompositeTextureArrayBa
}
/// <summary>
/// The GL backend reads <c>GL_MAX_ARRAY_TEXTURE_LAYERS</c>. The pinned
/// <see cref="Gpu.GpuCapabilityRecord"/> has no array-layer field and §3.3 is
/// frozen, so this reports Vulkan's guaranteed <c>maxImageArrayLayers</c>
/// minimum of 256. That is not a limitation in practice:
/// The selected Vulkan adapter's probed <c>maxImageArrayLayers</c>. This is
/// normally far above the cache's own bound:
/// <see cref="CompositeTextureArrayCache.MaximumLayersPerArray"/> caps every
/// array at 64, so the true device limit is never the binding constraint.
/// </summary>
public int MaximumArrayLayers => 256;
public int MaximumArrayLayers => checked((int)Math.Min(
_device.Capabilities.MaxImageArrayLayers,
(uint)int.MaxValue));
public CompositeTextureArrayResource Create(int width, int height, int capacity)
{

View file

@ -0,0 +1,286 @@
using System.Numerics;
namespace AcDream.App.Rendering;
internal readonly record struct DirectionalShadowCascadeFitInput(
Matrix4x4 CameraView,
Matrix4x4 CameraProjection,
Vector3 SurfaceToLightDirection,
DirectionalShadowQuality Quality,
float CameraNearMeters = 0.1f,
float PracticalSplitLambda = 0.65f,
float CasterDepthPaddingMeters = 48f,
float ResidentMaximumReachMeters = float.PositiveInfinity);
internal readonly record struct DirectionalShadowCascade(
int Index,
float SplitNearMeters,
float SplitFarMeters,
Matrix4x4 LightView,
Matrix4x4 LightProjection,
Matrix4x4 WorldToShadowClip,
Vector2 StabilizedLightSpaceCenter,
float HalfExtentMeters,
float TexelWorldSize,
float CasterDepthPaddingMeters,
DirectionalShadowWorldBias Bias);
/// <summary>
/// Pure camera-relative cascade fitting. It receives no scene/PView callback,
/// so fitting N cascades cannot trigger N CPU visibility traversals.
/// </summary>
internal static class DirectionalShadowCascadeFitter
{
private const float RadiusQuantizationMeters = 1f / 16f;
public static int Fit(
in DirectionalShadowCascadeFitInput input,
Span<DirectionalShadowCascade> destination)
{
Validate(in input, destination.Length);
if (!Matrix4x4.Invert(input.CameraView, out Matrix4x4 inverseView))
throw new ArgumentException("Camera view matrix is not invertible.", nameof(input));
if (!Matrix4x4.Invert(input.CameraProjection, out Matrix4x4 inverseProjection))
throw new ArgumentException("Camera projection matrix is not invertible.", nameof(input));
Vector3 lightDirection = Vector3.Normalize(input.SurfaceToLightDirection);
float maximumReach = MathF.Min(
input.Quality.MaximumReachMeters,
input.ResidentMaximumReachMeters);
if (maximumReach <= input.CameraNearMeters)
return 0;
float splitNear = input.CameraNearMeters;
Span<Vector3> corners = stackalloc Vector3[8];
for (int cascadeIndex = 0;
cascadeIndex < input.Quality.CascadeCount;
cascadeIndex++)
{
float splitFar = PracticalSplit(
input.CameraNearMeters,
maximumReach,
cascadeIndex + 1,
input.Quality.CascadeCount,
input.PracticalSplitLambda);
BuildFrustumSliceCorners(
inverseView,
inverseProjection,
splitNear,
splitFar,
corners);
destination[cascadeIndex] = FitCascade(
cascadeIndex,
splitNear,
splitFar,
corners,
lightDirection,
input.Quality.MapResolution,
input.CasterDepthPaddingMeters,
input.Quality.BiasPolicy);
splitNear = splitFar;
}
return input.Quality.CascadeCount;
}
internal static float PracticalSplit(
float nearMeters,
float farMeters,
int splitIndex,
int splitCount,
float lambda)
{
if (!float.IsFinite(nearMeters)
|| !float.IsFinite(farMeters)
|| nearMeters <= 0f
|| farMeters <= nearMeters)
{
throw new ArgumentOutOfRangeException(nameof(farMeters));
}
if (splitCount <= 0 || splitIndex <= 0 || splitIndex > splitCount)
throw new ArgumentOutOfRangeException(nameof(splitIndex));
if (!float.IsFinite(lambda) || lambda < 0f || lambda > 1f)
throw new ArgumentOutOfRangeException(nameof(lambda));
float fraction = (float)splitIndex / splitCount;
float logarithmic = nearMeters * MathF.Pow(farMeters / nearMeters, fraction);
float uniform = nearMeters + (farMeters - nearMeters) * fraction;
return lambda * logarithmic + (1f - lambda) * uniform;
}
private static DirectionalShadowCascade FitCascade(
int index,
float splitNear,
float splitFar,
ReadOnlySpan<Vector3> corners,
Vector3 surfaceToLight,
int mapResolution,
float depthPadding,
in DirectionalShadowBiasPolicy biasPolicy)
{
Vector3 center = Vector3.Zero;
for (int i = 0; i < corners.Length; i++)
center += corners[i];
center /= corners.Length;
float radius = 0f;
for (int i = 0; i < corners.Length; i++)
radius = MathF.Max(radius, Vector3.Distance(center, corners[i]));
radius = MathF.Ceiling(radius / RadiusQuantizationMeters)
* RadiusQuantizationMeters;
radius = MathF.Max(radius, RadiusQuantizationMeters);
Vector3 up = StableLightUp(surfaceToLight);
Matrix4x4 lightRotation = Matrix4x4.CreateLookAt(
Vector3.Zero,
-surfaceToLight,
up);
Vector3 lightCenter = Vector3.Transform(center, lightRotation);
float texelWorldSize = (2f * radius) / mapResolution;
float snappedX = SnapToTexel(lightCenter.X, texelWorldSize);
float snappedY = SnapToTexel(lightCenter.Y, texelWorldSize);
float minZ = float.PositiveInfinity;
float maxZ = float.NegativeInfinity;
for (int i = 0; i < corners.Length; i++)
{
float z = Vector3.Transform(corners[i], lightRotation).Z;
minZ = MathF.Min(minZ, z);
maxZ = MathF.Max(maxZ, z);
}
// Move the light eye toward the selected celestial source. The
// receiver slice then lies
// between depthPadding and span+depthPadding metres in front of it,
// while the far extension admits casters behind the slice as well.
float eyeAxis = maxZ + depthPadding;
Vector3 eye = surfaceToLight * eyeAxis;
Matrix4x4 lightView = Matrix4x4.CreateLookAt(
eye,
eye - surfaceToLight,
up);
float nearPlane = 0.1f;
float farPlane = MathF.Max(
nearPlane + 0.1f,
(maxZ - minZ) + 2f * depthPadding);
Matrix4x4 lightProjection = Matrix4x4.CreateOrthographicOffCenter(
snappedX - radius,
snappedX + radius,
snappedY - radius,
snappedY + radius,
nearPlane,
farPlane);
return new DirectionalShadowCascade(
index,
splitNear,
splitFar,
lightView,
lightProjection,
lightView * lightProjection,
new Vector2(snappedX, snappedY),
radius,
texelWorldSize,
depthPadding,
biasPolicy.Resolve(texelWorldSize));
}
private static void BuildFrustumSliceCorners(
Matrix4x4 inverseView,
Matrix4x4 inverseProjection,
float nearMeters,
float farMeters,
Span<Vector3> destination)
{
int cursor = 0;
for (int depthIndex = 0; depthIndex < 2; depthIndex++)
{
float distance = depthIndex == 0 ? nearMeters : farMeters;
for (int yIndex = 0; yIndex < 2; yIndex++)
{
float y = yIndex == 0 ? -1f : 1f;
for (int xIndex = 0; xIndex < 2; xIndex++)
{
float x = xIndex == 0 ? -1f : 1f;
Vector4 viewCorner = Vector4.Transform(
new Vector4(x, y, 1f, 1f),
inverseProjection);
if (MathF.Abs(viewCorner.W) <= 1e-6f)
throw new ArgumentException("Camera projection produced a corner at infinity.");
Vector3 view = new(
viewCorner.X / viewCorner.W,
viewCorner.Y / viewCorner.W,
viewCorner.Z / viewCorner.W);
float viewDepth = MathF.Abs(view.Z);
if (viewDepth <= 1e-6f)
throw new ArgumentException("Camera projection produced zero view depth.");
view *= distance / viewDepth;
destination[cursor++] = Vector3.Transform(view, inverseView);
}
}
}
}
private static float SnapToTexel(float value, float texelWorldSize) =>
MathF.Round(value / texelWorldSize, MidpointRounding.AwayFromZero)
* texelWorldSize;
/// <summary>
/// Uses Duff's numerically stable revision of Frisvad's orthonormal basis.
/// The selected celestial source occupies the accepted upper hemisphere,
/// where this basis varies continuously through the exact zenith. The old
/// 0.95 dot-product branch rotated the cascade basis abruptly, while
/// projected world-up merely moved that discontinuity to exact zenith.
/// </summary>
internal static Vector3 StableLightUp(Vector3 surfaceToLight)
{
surfaceToLight = Vector3.Normalize(surfaceToLight);
float sign = MathF.CopySign(1f, surfaceToLight.Z);
float a = -1f / (sign + surfaceToLight.Z);
float b = surfaceToLight.X * surfaceToLight.Y * a;
return Vector3.Normalize(new Vector3(
b,
sign + surfaceToLight.Y * surfaceToLight.Y * a,
-surfaceToLight.Y));
}
private static void Validate(
in DirectionalShadowCascadeFitInput input,
int destinationLength)
{
DirectionalShadowQuality quality = input.Quality;
if (quality.CascadeCount <= 0 || quality.CascadeCount > 4)
throw new ArgumentOutOfRangeException(nameof(input), "Cascade count must be in [1,4].");
if (destinationLength < quality.CascadeCount)
throw new ArgumentException("Destination cannot hold every configured cascade.");
if (quality.MapResolution <= 0
|| !float.IsFinite(quality.MaximumReachMeters)
|| quality.MaximumReachMeters <= input.CameraNearMeters)
{
throw new ArgumentOutOfRangeException(nameof(input), "Shadow quality dimensions are invalid.");
}
if (!float.IsFinite(input.CameraNearMeters) || input.CameraNearMeters <= 0f)
throw new ArgumentOutOfRangeException(nameof(input), "Camera near distance must be positive.");
if (float.IsNaN(input.ResidentMaximumReachMeters)
|| input.ResidentMaximumReachMeters < 0f)
{
throw new ArgumentOutOfRangeException(
nameof(input),
"Resident shadow reach must be nonnegative or positive infinity.");
}
if (!float.IsFinite(input.PracticalSplitLambda)
|| input.PracticalSplitLambda < 0f
|| input.PracticalSplitLambda > 1f)
{
throw new ArgumentOutOfRangeException(nameof(input), "Split lambda must be in [0,1].");
}
if (!float.IsFinite(input.CasterDepthPaddingMeters)
|| input.CasterDepthPaddingMeters <= 0f)
{
throw new ArgumentOutOfRangeException(nameof(input), "Caster depth padding must be positive.");
}
float lightLength = input.SurfaceToLightDirection.Length();
if (!float.IsFinite(lightLength) || lightLength <= 1e-6f)
throw new ArgumentOutOfRangeException(nameof(input), "Light direction must be finite and nonzero.");
}
}

View file

@ -0,0 +1,362 @@
using AcDream.App.Rendering.Packs;
using AcDream.Core.World;
namespace AcDream.App.Rendering;
/// <summary>
/// User-visible quality rows for Dereth's selected celestial directional shadows. Presets may
/// reduce count, resolution, reach, and filtering cost; they never remove a
/// headline caster class.
/// </summary>
internal enum DirectionalShadowPreset : byte
{
Low,
Medium,
High,
}
[Flags]
internal enum DirectionalShadowSemantics : ushort
{
None = 0,
Terrain = 1 << 0,
TreesAndOutdoorStatics = 1 << 1,
Buildings = 1 << 2,
Players = 1 << 3,
Monsters = 1 << 4,
AnimatedTransforms = 1 << 5,
AlphaCutoutCasters = 1 << 6,
Headline = Terrain
| TreesAndOutdoorStatics
| Buildings
| Players
| Monsters
| AnimatedTransforms
| AlphaCutoutCasters,
}
/// <summary>
/// Converts shadow-map texel footprint into receiver-side offsets expressed in
/// metres. No term is an NDC constant: the projection may change without
/// silently changing the amount of world geometry displaced.
/// </summary>
internal readonly record struct DirectionalShadowBiasPolicy(
float ConstantTexels,
float SlopeTexels,
float NormalTexels,
float MinimumMeters,
float MaximumMeters)
{
public DirectionalShadowWorldBias Resolve(float texelWorldSize)
{
if (!float.IsFinite(texelWorldSize) || texelWorldSize <= 0f)
throw new ArgumentOutOfRangeException(nameof(texelWorldSize));
if (!float.IsFinite(MinimumMeters)
|| !float.IsFinite(MaximumMeters)
|| MinimumMeters < 0f
|| MaximumMeters < MinimumMeters)
{
throw new InvalidOperationException(
"Directional-shadow bias bounds must be finite, non-negative, and ordered.");
}
var minimumMeters = MinimumMeters;
var maximumMeters = MaximumMeters;
return new DirectionalShadowWorldBias(
ConstantDepthMeters: Math.Clamp(
ConstantTexels * texelWorldSize,
minimumMeters,
maximumMeters),
SlopeDepthMeters: Math.Clamp(
SlopeTexels * texelWorldSize,
minimumMeters,
maximumMeters),
NormalOffsetMeters: Math.Clamp(
NormalTexels * texelWorldSize,
minimumMeters,
maximumMeters));
}
}
internal readonly record struct DirectionalShadowWorldBias(
float ConstantDepthMeters,
float SlopeDepthMeters,
float NormalOffsetMeters);
internal readonly record struct DirectionalShadowQuality(
DirectionalShadowPreset Preset,
int CascadeCount,
int MapResolution,
float MaximumReachMeters,
int PcfRadiusTexels,
long ApproximateDepthMapBytes,
double IncrementalGpuP50BudgetMilliseconds,
double IncrementalGpuP99BudgetMilliseconds,
double IncrementalCpuP50BudgetMilliseconds,
double IncrementalCpuP99BudgetMilliseconds,
long PackResidentGpuByteBudget,
DirectionalShadowSemantics Semantics,
DirectionalShadowBiasPolicy BiasPolicy)
{
private const long MiB = 1024L * 1024L;
public static DirectionalShadowQuality For(DirectionalShadowPreset preset) =>
preset switch
{
DirectionalShadowPreset.Low => Create(
preset,
cascades: 2,
// The physical integrated-GPU row funds Low's cheaper
// quarter-resolution separable post path by reducing only
// texel density. Both cascades and every semantic caster
// class remain present.
resolution: 768,
reachMeters: 72f,
pcfRadius: 0,
gpuP50: 2.0,
gpuP99: 3.0,
cpuP50: 0.15,
cpuP99: 0.50,
residentBudget: 64L * MiB,
bias: new DirectionalShadowBiasPolicy(
0.45f, 1.25f, 1.0f, 0.001f, 0.35f)),
DirectionalShadowPreset.Medium => Create(
preset,
cascades: 3,
resolution: 1536,
reachMeters: 144f,
pcfRadius: 1,
gpuP50: 3.25,
gpuP99: 4.50,
cpuP50: 0.25,
cpuP99: 0.75,
residentBudget: 128L * MiB,
bias: new DirectionalShadowBiasPolicy(
0.40f, 1.15f, 0.9f, 0.001f, 0.30f)),
DirectionalShadowPreset.High => Create(
preset,
cascades: 4,
resolution: 2048,
reachMeters: 240f,
pcfRadius: 2,
gpuP50: 4.50,
gpuP99: 6.00,
cpuP50: 0.35,
cpuP99: 1.00,
residentBudget: 256L * MiB,
bias: new DirectionalShadowBiasPolicy(
0.35f, 1.0f, 0.8f, 0.001f, 0.25f)),
_ => throw new ArgumentOutOfRangeException(nameof(preset), preset, null),
};
private static DirectionalShadowQuality Create(
DirectionalShadowPreset preset,
int cascades,
int resolution,
float reachMeters,
int pcfRadius,
double gpuP50,
double gpuP99,
double cpuP50,
double cpuP99,
long residentBudget,
DirectionalShadowBiasPolicy bias) =>
new(
preset,
cascades,
resolution,
reachMeters,
pcfRadius,
checked((long)cascades * resolution * resolution * sizeof(float)),
gpuP50,
gpuP99,
cpuP50,
cpuP99,
residentBudget,
DirectionalShadowSemantics.Headline,
bias);
}
internal enum DirectionalShadowGateReason : byte
{
Enabled,
PackDisabled,
PortalOrLoginCover,
Indoor,
NoVisibleCelestial,
SelectedLightBelowHorizon,
SelectedLightHasNoEnergy,
AtmosphereSuppressed,
ResidentWindowUnavailable,
}
/// <summary>
/// Visible policy owned by the selected atmospheric pack. AC continues to own
/// the sky and weather inputs; these values only map them to enhancement
/// strength and softness.
/// </summary>
internal readonly record struct DirectionalShadowAtmospherePolicy(
float MinimumLightElevationSin,
float FullStrengthLightElevationSin,
float ClearStrength,
float OvercastStrength,
float RainStrength,
float SnowStrength,
float StormStrength,
float ClearSoftness,
float OvercastSoftness,
float RainSoftness,
float SnowSoftness,
float StormSoftness)
{
public static DirectionalShadowAtmospherePolicy BuiltIn { get; } = new(
MinimumLightElevationSin: MathF.Sin(MathF.PI / 180f),
FullStrengthLightElevationSin: MathF.Sin(12f * MathF.PI / 180f),
ClearStrength: 1.0f,
OvercastStrength: 0.65f,
RainStrength: 0.45f,
SnowStrength: 0.60f,
StormStrength: 0.25f,
ClearSoftness: 1.0f,
OvercastSoftness: 1.5f,
RainSoftness: 1.8f,
SnowSoftness: 1.6f,
StormSoftness: 2.0f);
public float StrengthFor(WeatherKind weather) => weather switch
{
WeatherKind.Clear => ClearStrength,
WeatherKind.Overcast => OvercastStrength,
WeatherKind.Rain => RainStrength,
WeatherKind.Snow => SnowStrength,
WeatherKind.Storm => StormStrength,
_ => throw new ArgumentOutOfRangeException(nameof(weather), weather, null),
};
public float SoftnessFor(WeatherKind weather) => weather switch
{
WeatherKind.Clear => ClearSoftness,
WeatherKind.Overcast => OvercastSoftness,
WeatherKind.Rain => RainSoftness,
WeatherKind.Snow => SnowSoftness,
WeatherKind.Storm => StormSoftness,
_ => throw new ArgumentOutOfRangeException(nameof(weather), weather, null),
};
}
internal readonly record struct DirectionalShadowEnvironmentInput(
bool PackEnabled,
bool PortalOrLoginCoverVisible,
bool PlayerInsideCell,
AuthoredCelestialShadowSource Source,
AtmosphereSnapshot Atmosphere,
float ActiveDayGroupMultiplier = 1f);
internal readonly record struct DirectionalShadowEnvironmentState(
DirectionalShadowGateReason Reason,
System.Numerics.Vector3 SurfaceToLightDirection,
float LightElevationSin,
float Strength,
float SoftnessMultiplier,
AuthoredCelestialShadowSourceKind SourceKind =
AuthoredCelestialShadowSourceKind.None,
int SourceObjectIndex = -1,
uint SourceGfxObjId = 0u)
{
public bool ShouldRender => Reason is DirectionalShadowGateReason.Enabled;
}
internal static class DirectionalShadowEnvironmentGate
{
private const float MinimumDirectionalEnergy = 1e-5f;
public static DirectionalShadowEnvironmentState Evaluate(
in DirectionalShadowEnvironmentInput input,
in DirectionalShadowAtmospherePolicy policy)
{
if (!input.PackEnabled)
return Disabled(DirectionalShadowGateReason.PackDisabled);
if (input.PortalOrLoginCoverVisible)
return Disabled(DirectionalShadowGateReason.PortalOrLoginCover);
if (input.PlayerInsideCell)
return Disabled(DirectionalShadowGateReason.Indoor);
if (!input.Source.IsAvailable)
return Disabled(DirectionalShadowGateReason.NoVisibleCelestial);
System.Numerics.Vector3 surfaceToLight =
input.Source.SurfaceToLightDirection;
float elevation = input.Source.ElevationSin;
if (!float.IsFinite(elevation)
|| elevation <= policy.MinimumLightElevationSin)
{
return new DirectionalShadowEnvironmentState(
DirectionalShadowGateReason.SelectedLightBelowHorizon,
surfaceToLight,
elevation,
0f,
1f,
input.Source.Kind,
input.Source.ObjectIndex,
input.Source.GfxObjId);
}
float energy = input.Source.AuthoredEnergy;
if (!float.IsFinite(energy)
|| energy <= MinimumDirectionalEnergy)
{
return new DirectionalShadowEnvironmentState(
DirectionalShadowGateReason.SelectedLightHasNoEnergy,
surfaceToLight,
elevation,
0f,
1f,
input.Source.Kind,
input.Source.ObjectIndex,
input.Source.GfxObjId);
}
float elevationSpan = MathF.Max(
1e-5f,
policy.FullStrengthLightElevationSin - policy.MinimumLightElevationSin);
float elevationStrength = Math.Clamp(
(elevation - policy.MinimumLightElevationSin) / elevationSpan,
0f,
1f);
float weatherStrength = policy.StrengthFor(input.Atmosphere.Kind);
float atmosphereProgress = Math.Clamp(input.Atmosphere.Intensity, 0f, 1f);
float dayGroupStrength = Math.Clamp(input.ActiveDayGroupMultiplier, 0f, 1f);
float strength = elevationStrength
* Math.Clamp(energy, 0f, 1f)
* weatherStrength
* atmosphereProgress
* dayGroupStrength;
if (!float.IsFinite(strength) || strength <= 0f)
{
return new DirectionalShadowEnvironmentState(
DirectionalShadowGateReason.AtmosphereSuppressed,
surfaceToLight,
elevation,
0f,
policy.SoftnessFor(input.Atmosphere.Kind),
input.Source.Kind,
input.Source.ObjectIndex,
input.Source.GfxObjId);
}
return new DirectionalShadowEnvironmentState(
DirectionalShadowGateReason.Enabled,
surfaceToLight,
elevation,
Math.Clamp(strength, 0f, 1f),
MathF.Max(1f, policy.SoftnessFor(input.Atmosphere.Kind)),
input.Source.Kind,
input.Source.ObjectIndex,
input.Source.GfxObjId);
}
private static DirectionalShadowEnvironmentState Disabled(
DirectionalShadowGateReason reason) =>
new(reason, System.Numerics.Vector3.UnitZ, 0f, 0f, 1f);
}

View file

@ -0,0 +1,154 @@
using System.Numerics;
using AcDream.App.Rendering.Gpu;
namespace AcDream.App.Rendering;
/// <summary>
/// The ordinary, non-ref view of the directional-shadow allocation produced for
/// one frame. The serial prevents a ring slice from leaking into a later frame.
/// </summary>
internal readonly record struct DirectionalShadowFrameBinding(
long FrameSerial,
bool Enabled,
IGpuBuffer? Buffer,
uint OffsetBytes,
uint SizeBytes,
GpuTextureSlot TextureSlot,
int CascadeCount)
{
internal static DirectionalShadowFrameBinding Disabled => default;
internal bool IsValidFor(IGpuFrame frame) =>
Enabled
&& Buffer is not null
&& FrameSerial == frame.Serial
&& SizeBytes == DirectionalShadowUniforms.SizeInBytes
&& TextureSlot.IsAssigned
&& CascadeCount is >= 2 and <= 4;
}
/// <summary>
/// Receiver-side seam. A pack runtime may publish this source at a stable frame
/// boundary without exposing the producer's target or ref-struct allocation.
/// </summary>
internal interface IDirectionalShadowReceiverSource
{
DirectionalShadowPipelineShaders PipelineShaders { get; }
bool TryGetCurrentFrameBinding(
IGpuFrame frame,
out DirectionalShadowFrameBinding binding);
}
internal readonly record struct DirectionalShadowPipelineShaders(
GpuShaderSet TerrainCaster,
GpuShaderSet WorldOpaqueCaster,
GpuShaderSet WorldAlphaCutoutCaster,
GpuShaderSet TerrainReceiver,
GpuShaderSet WorldReceiver)
{
internal DirectionalShadowMultiviewPipelineShaders? MultiviewCasters { get; init; }
internal static DirectionalShadowPipelineShaders Local { get; } = new(
new GpuShaderSet("directional_shadow_terrain"),
new GpuShaderSet("directional_shadow_world_opaque"),
new GpuShaderSet("directional_shadow_world_cutout"),
new GpuShaderSet("terrain_atmospheric"),
new GpuShaderSet("mesh_atmospheric"))
{
MultiviewCasters = new DirectionalShadowMultiviewPipelineShaders(
new GpuShaderSet("directional_shadow_terrain_multiview"),
new GpuShaderSet("directional_shadow_world_opaque_multiview"),
new GpuShaderSet("directional_shadow_world_cutout_multiview")),
};
}
internal readonly record struct DirectionalShadowMultiviewPipelineShaders(
GpuShaderSet TerrainCaster,
GpuShaderSet WorldOpaqueCaster,
GpuShaderSet WorldAlphaCutoutCaster);
internal readonly record struct DirectionalShadowCascadeBlend(
int PrimaryCascade,
int SecondaryCascade,
float SecondaryWeight,
bool WithinShadowReach);
/// <summary>CPU mirror of receiver-only cascade and world-metre bias policy.</summary>
internal static class DirectionalShadowReceiverPolicy
{
internal const string AtmosphericWorldPassName = "atmospheric-world-hdr";
internal static bool ShouldSelectReceiverPipeline(
string passName,
bool sourcePresent,
bool bindingValid) =>
sourcePresent
&& bindingValid
&& string.Equals(
passName,
AtmosphericWorldPassName,
StringComparison.Ordinal);
internal static DirectionalShadowCascadeBlend SelectCascade(
float cameraDistanceMeters,
Vector4 splitFarMeters,
int cascadeCount,
float blendWidthMeters)
{
if (!float.IsFinite(cameraDistanceMeters) || cameraDistanceMeters < 0f)
throw new ArgumentOutOfRangeException(nameof(cameraDistanceMeters));
if (cascadeCount is < 2 or > 4)
throw new ArgumentOutOfRangeException(nameof(cascadeCount));
if (!float.IsFinite(blendWidthMeters) || blendWidthMeters < 0f)
throw new ArgumentOutOfRangeException(nameof(blendWidthMeters));
Span<float> splits = stackalloc float[4]
{
splitFarMeters.X,
splitFarMeters.Y,
splitFarMeters.Z,
splitFarMeters.W,
};
for (int i = 0; i < cascadeCount; i++)
{
if (!float.IsFinite(splits[i])
|| splits[i] <= 0f
|| (i > 0 && splits[i] < splits[i - 1]))
{
throw new ArgumentException(
"Directional-shadow split distances must be finite, positive, and monotonic.",
nameof(splitFarMeters));
}
}
int primary = 0;
while (primary < cascadeCount && cameraDistanceMeters > splits[primary])
primary++;
if (primary == cascadeCount)
return new DirectionalShadowCascadeBlend(cascadeCount - 1, cascadeCount - 1, 0f, false);
if (primary == cascadeCount - 1 || blendWidthMeters <= 0f)
return new DirectionalShadowCascadeBlend(primary, primary, 0f, true);
float blendStart = MathF.Max(0f, splits[primary] - blendWidthMeters);
float t = Math.Clamp(
(cameraDistanceMeters - blendStart) / MathF.Max(blendWidthMeters, 1e-6f),
0f,
1f);
float smooth = t * t * (3f - 2f * t);
return new DirectionalShadowCascadeBlend(primary, primary + 1, smooth, true);
}
internal static float ReceiverBiasMeters(
in DirectionalShadowWorldBias bias,
float normalDotSurfaceToLight) =>
bias.ConstantDepthMeters
+ bias.SlopeDepthMeters * (1f - Math.Clamp(normalDotSurfaceToLight, 0f, 1f));
internal static bool ShouldSample(
bool bindingEnabled,
bool indoor,
bool hasSelectedCelestialDirectionalLight) =>
bindingEnabled && !indoor && hasSelectedCelestialDirectionalLight;
}

View file

@ -0,0 +1,439 @@
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Wb;
namespace AcDream.App.Rendering;
internal readonly record struct DirectionalShadowTransformPublishStats(
bool TopologyUploaded,
int DynamicMatricesUpdated,
int DynamicRangesUpdated,
long BytesWritten,
int CurrentChangedMatrices = 0,
int PendingReplayMatrices = 0,
bool UsedFullDynamicFallback = false,
bool DenseDirectUpload = false,
bool DenseFlightReplay = false);
/// <summary>
/// Pack-owned transform storage indexed by the RHI frame-flight slot. A slot is
/// handed back only after its prior GPU submission retires, so stable topology
/// can keep every static matrix in mapped storage and update only the exact
/// dynamic indices already refreshed by <see cref="DirectionalShadowPreparedDraws"/>.
/// No animation, scene lookup, or pose derivation happens here.
/// </summary>
internal sealed class DirectionalShadowTransformBufferSet : IDisposable
{
private readonly IGpuDevice _device;
private SlotState[] _slots = [];
private ulong _denseTopologyBuildSequence;
private ulong _denseRevision = 1;
private bool _disposed;
internal DirectionalShadowTransformBufferSet(IGpuDevice device)
{
_device = device ?? throw new ArgumentNullException(nameof(device));
if (!device.Capabilities.SupportsPersistentlyMappedRings)
{
throw new NotSupportedException(
"Directional-shadow retained transforms require persistently mapped host-writable buffers.");
}
}
internal long RetainedGpuBytes
{
get
{
long total = 0;
for (int i = 0; i < _slots.Length; i++)
total = checked(total + (_slots[i].Buffer?.SizeBytes ?? 0L));
return total;
}
}
internal int BufferCount
{
get
{
int count = 0;
for (int i = 0; i < _slots.Length; i++)
{
if (_slots[i].Buffer is not null)
count++;
}
return count;
}
}
internal long RetainedScratchBytes
{
get
{
long bytes = checked((long)_slots.Length
* System.Runtime.CompilerServices.Unsafe.SizeOf<SlotState>());
for (int index = 0; index < _slots.Length; index++)
bytes = checked(bytes + (_slots[index].Pending?.RetainedBytes ?? 0L));
return bytes;
}
}
internal DirectionalShadowTransformPublishStats LastStats { get; private set; }
internal WorldTransformFrameSlice Publish(
IGpuFrame frame,
ulong topologyBuildSequence,
ReadOnlySpan<Matrix4x4> transforms,
ReadOnlySpan<int> dynamicTransformSlots)
{
return Publish(
frame,
topologyBuildSequence,
transforms,
dynamicTransformSlots,
dynamicTransformSlots,
denseRefresh: false);
}
internal WorldTransformFrameSlice Publish(
IGpuFrame frame,
ulong topologyBuildSequence,
ReadOnlySpan<Matrix4x4> transforms,
ReadOnlySpan<int> dynamicTransformSlots,
ReadOnlySpan<int> allDynamicTransformSlots)
{
return Publish(
frame,
topologyBuildSequence,
transforms,
dynamicTransformSlots,
allDynamicTransformSlots,
denseRefresh: false);
}
internal WorldTransformFrameSlice Publish(
IGpuFrame frame,
ulong topologyBuildSequence,
ReadOnlySpan<Matrix4x4> transforms,
ReadOnlySpan<int> dynamicTransformSlots,
ReadOnlySpan<int> allDynamicTransformSlots,
bool denseRefresh,
uint bindingSizeBytes = 0)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(frame);
if (topologyBuildSequence == 0)
throw new ArgumentOutOfRangeException(nameof(topologyBuildSequence));
uint requiredInstances = checked((uint)transforms.Length);
if (bindingSizeBytes == 0)
{
bindingSizeBytes = WorldTransformCapacityPolicy.ResolveBindingSizeBytes(
requiredInstances,
_device.Capabilities.MaxStorageBufferRangeBytes);
}
WorldTransformCapacityPolicy.ValidateBindingSizeBytes(
bindingSizeBytes,
requiredInstances,
_device.Capabilities.MaxStorageBufferRangeBytes);
if (!denseRefresh)
ValidateDynamicSlots(dynamicTransformSlots, transforms.Length);
ResetDenseRevisionForTopology(topologyBuildSequence);
if (denseRefresh)
{
ValidateDynamicSlots(allDynamicTransformSlots, transforms.Length);
if (_denseRevision == ulong.MaxValue)
{
throw new InvalidOperationException(
"Directional-shadow dense transform revision was exhausted.");
}
_denseRevision++;
for (int index = 0; index < _slots.Length; index++)
_slots[index].Pending?.Clear();
}
EnsureSlotCapacity(frame.SlotIndex);
ref SlotState slot = ref _slots[frame.SlotIndex];
bool matchingSlot = slot.Buffer is not null
&& slot.TopologyBuildSequence == topologyBuildSequence
&& slot.TransformCount == transforms.Length
&& slot.Buffer.SizeBytes >= bindingSizeBytes;
bool denseFlightReplay = matchingSlot
&& slot.ConsumedDenseRevision != _denseRevision;
int pendingReplayMatrices = matchingSlot
? slot.Pending?.Count ?? 0
: 0;
if (!denseRefresh)
{
MarkPendingChanges(
topologyBuildSequence,
transforms.Length,
dynamicTransformSlots);
}
int contentBytes = checked(transforms.Length * 64);
int allocationBytes = checked((int)bindingSizeBytes);
if (slot.Buffer is null
|| slot.TopologyBuildSequence != topologyBuildSequence
|| slot.TransformCount != transforms.Length
|| slot.Buffer.SizeBytes < allocationBytes)
{
IGpuBuffer? candidate = null;
try
{
candidate = _device.CreateBuffer(new GpuBufferDescription(
$"directional-shadow-transforms-slot-{frame.SlotIndex}-build-{topologyBuildSequence}",
allocationBytes,
GpuBufferUsage.Storage | GpuBufferUsage.TransferDestination,
GpuMemoryResidency.HostWritable));
if (!candidate.HostWritesAreCoherent)
{
throw new NotSupportedException(
"Directional-shadow retained transforms require coherent "
+ "host-writable Vulkan memory. The pack will fail safe "
+ "on this adapter rather than expose unflushed pose data.");
}
if (!transforms.IsEmpty)
{
candidate.Upload(0, MemoryMarshal.AsBytes(transforms));
frame.PublishHostStorageWrites(candidate);
}
}
catch
{
candidate?.Dispose();
throw;
}
IGpuBuffer? previous = slot.Buffer;
PendingTransformSet pending = slot.Pending
?? new PendingTransformSet(transforms.Length);
pending.EnsureCapacity(transforms.Length);
pending.Clear();
slot = new SlotState(
candidate,
topologyBuildSequence,
transforms.Length,
pending,
_denseRevision);
previous?.Dispose();
LastStats = new DirectionalShadowTransformPublishStats(
TopologyUploaded: true,
DynamicMatricesUpdated: 0,
DynamicRangesUpdated: 0,
BytesWritten: contentBytes,
CurrentChangedMatrices: dynamicTransformSlots.Length,
PendingReplayMatrices: 0,
DenseDirectUpload: denseRefresh);
}
else
{
PendingTransformSet pending = slot.Pending
?? throw new InvalidOperationException(
"A retained directional-shadow flight slot has no pending-change owner.");
bool directDenseUpload = denseRefresh || denseFlightReplay;
ReadOnlySpan<int> slotsToUpload = directDenseUpload
? allDynamicTransformSlots
: pending.GetSorted();
if (directDenseUpload && !denseRefresh)
ValidateDynamicSlots(allDynamicTransformSlots, transforms.Length);
int ranges = UploadDynamicRanges(
slot.Buffer,
transforms,
slotsToUpload,
out long bytesWritten);
if (ranges != 0)
frame.PublishHostStorageWrites(slot.Buffer);
LastStats = new DirectionalShadowTransformPublishStats(
TopologyUploaded: false,
DynamicMatricesUpdated: slotsToUpload.Length,
DynamicRangesUpdated: ranges,
BytesWritten: bytesWritten,
CurrentChangedMatrices: dynamicTransformSlots.Length,
PendingReplayMatrices: directDenseUpload ? 0 : pendingReplayMatrices,
DenseDirectUpload: denseRefresh,
DenseFlightReplay: denseFlightReplay && !denseRefresh);
pending.Clear();
slot = slot with { ConsumedDenseRevision = _denseRevision };
}
IGpuBuffer buffer = slot.Buffer
?? throw new InvalidOperationException(
"The retained directional-shadow transform buffer was not published.");
return new WorldTransformFrameSlice(
frame.Serial,
buffer,
BaseOffsetBytes: 0,
checked((uint)buffer.SizeBytes),
FirstInstance: 0,
checked((uint)transforms.Length));
}
private void ResetDenseRevisionForTopology(ulong topologyBuildSequence)
{
if (_denseTopologyBuildSequence == topologyBuildSequence)
return;
_denseTopologyBuildSequence = topologyBuildSequence;
_denseRevision = 1;
for (int index = 0; index < _slots.Length; index++)
_slots[index].Pending?.Clear();
}
private void MarkPendingChanges(
ulong topologyBuildSequence,
int transformCount,
ReadOnlySpan<int> dynamicTransformSlots)
{
if (dynamicTransformSlots.IsEmpty)
return;
for (int index = 0; index < _slots.Length; index++)
{
ref SlotState candidate = ref _slots[index];
if (candidate.Buffer is null
|| candidate.TopologyBuildSequence != topologyBuildSequence
|| candidate.TransformCount != transformCount)
{
continue;
}
PendingTransformSet pending = candidate.Pending
??= new PendingTransformSet(transformCount);
pending.EnsureCapacity(transformCount);
pending.Mark(dynamicTransformSlots);
}
}
private static int UploadDynamicRanges(
IGpuBuffer buffer,
ReadOnlySpan<Matrix4x4> transforms,
ReadOnlySpan<int> slots,
out long bytesWritten)
{
bytesWritten = 0;
int ranges = 0;
int cursor = 0;
while (cursor < slots.Length)
{
int start = slots[cursor];
int end = start + 1;
cursor++;
while (cursor < slots.Length && slots[cursor] == end)
{
end++;
cursor++;
}
ReadOnlySpan<Matrix4x4> values = transforms.Slice(start, end - start);
ReadOnlySpan<byte> bytes = MemoryMarshal.AsBytes(values);
buffer.Upload(checked((long)start * 64L), bytes);
bytesWritten = checked(bytesWritten + bytes.Length);
ranges++;
}
return ranges;
}
private static void ValidateDynamicSlots(
ReadOnlySpan<int> slots,
int transformCount)
{
int previous = -1;
for (int i = 0; i < slots.Length; i++)
{
int current = slots[i];
if ((uint)current >= (uint)transformCount)
{
throw new InvalidOperationException(
$"Dynamic shadow transform slot {current} is outside the "
+ $"{transformCount}-matrix retained product.");
}
if (current <= previous)
{
throw new InvalidOperationException(
"Dynamic shadow transform slots must be strictly increasing.");
}
previous = current;
}
}
private void EnsureSlotCapacity(int slotIndex)
{
ArgumentOutOfRangeException.ThrowIfNegative(slotIndex);
if (_slots.Length > slotIndex)
return;
int capacity = _slots.Length == 0 ? 2 : _slots.Length;
while (capacity <= slotIndex)
capacity = checked(capacity * 2);
Array.Resize(ref _slots, capacity);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
for (int i = 0; i < _slots.Length; i++)
{
_slots[i].Buffer?.Dispose();
_slots[i] = default;
}
LastStats = default;
_denseTopologyBuildSequence = 0;
_denseRevision = 0;
}
private record struct SlotState(
IGpuBuffer? Buffer,
ulong TopologyBuildSequence,
int TransformCount,
PendingTransformSet? Pending,
ulong ConsumedDenseRevision);
private sealed class PendingTransformSet
{
private int[] _slots;
private bool[] _marked;
internal PendingTransformSet(int capacity)
{
ArgumentOutOfRangeException.ThrowIfNegative(capacity);
_slots = new int[capacity];
_marked = new bool[capacity];
}
internal int Count { get; private set; }
internal long RetainedBytes => checked(
(long)_slots.Length * sizeof(int) + _marked.Length);
internal void EnsureCapacity(int capacity)
{
ArgumentOutOfRangeException.ThrowIfNegative(capacity);
if (_slots.Length >= capacity)
return;
Array.Resize(ref _slots, capacity);
Array.Resize(ref _marked, capacity);
}
internal void Mark(ReadOnlySpan<int> slots)
{
for (int index = 0; index < slots.Length; index++)
{
int slot = slots[index];
if (_marked[slot])
continue;
_marked[slot] = true;
_slots[Count++] = slot;
}
}
internal ReadOnlySpan<int> GetSorted()
{
Array.Sort(_slots, 0, Count);
return _slots.AsSpan(0, Count);
}
internal void Clear()
{
for (int index = 0; index < Count; index++)
_marked[_slots[index]] = false;
Count = 0;
}
}
}

View file

@ -0,0 +1,111 @@
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
namespace AcDream.App.Rendering;
/// <summary>
/// Shader ABI SSOT companion for opt-in set 3 binding 6. The matching GLSL block is
/// directional_shadow_common.glsl; both are pinned at 336 std140 bytes.
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal readonly struct DirectionalShadowUniforms
{
internal const int SizeInBytes = 336;
public readonly Matrix4x4 WorldToClip0;
public readonly Matrix4x4 WorldToClip1;
public readonly Matrix4x4 WorldToClip2;
public readonly Matrix4x4 WorldToClip3;
public readonly Vector4 SplitFarMeters;
public readonly Vector4 Control;
public readonly Vector4 BiasMeters;
public readonly UInt4 TextureAndFlags;
public readonly Vector4 LightDirectionAndSource;
internal DirectionalShadowUniforms(
Matrix4x4 worldToClip0,
Matrix4x4 worldToClip1,
Matrix4x4 worldToClip2,
Matrix4x4 worldToClip3,
Vector4 splitFarMeters,
Vector4 control,
Vector4 biasMeters,
UInt4 textureAndFlags,
Vector4 lightDirectionAndSource)
{
WorldToClip0 = worldToClip0;
WorldToClip1 = worldToClip1;
WorldToClip2 = worldToClip2;
WorldToClip3 = worldToClip3;
SplitFarMeters = splitFarMeters;
Control = control;
BiasMeters = biasMeters;
TextureAndFlags = textureAndFlags;
LightDirectionAndSource = lightDirectionAndSource;
}
internal static DirectionalShadowUniforms Create(
ReadOnlySpan<DirectionalShadowCascade> cascades,
in DirectionalShadowEnvironmentState environment,
in DirectionalShadowQuality quality,
GpuTextureSlot textureSlot)
{
if (cascades.Length != quality.CascadeCount)
throw new ArgumentException("The cascade span must match the selected quality.", nameof(cascades));
if (!textureSlot.IsAssigned)
throw new ArgumentException("The directional depth array requires an assigned texture slot.", nameof(textureSlot));
Matrix4x4 matrix0 = cascades[0].WorldToShadowClip;
Matrix4x4 matrix1 = cascades.Length > 1 ? cascades[1].WorldToShadowClip : Matrix4x4.Identity;
Matrix4x4 matrix2 = cascades.Length > 2 ? cascades[2].WorldToShadowClip : Matrix4x4.Identity;
Matrix4x4 matrix3 = cascades.Length > 3 ? cascades[3].WorldToShadowClip : Matrix4x4.Identity;
float split0 = cascades[0].SplitFarMeters;
float split1 = cascades.Length > 1 ? cascades[1].SplitFarMeters : quality.MaximumReachMeters;
float split2 = cascades.Length > 2 ? cascades[2].SplitFarMeters : quality.MaximumReachMeters;
float split3 = cascades.Length > 3 ? cascades[3].SplitFarMeters : quality.MaximumReachMeters;
// The pinned v1 receiver block carries one world-space bias triple.
// Publish the conservative outer-cascade values; the receiver derives
// each inner cascade's relative texel footprint from its projection
// matrix before applying this triple. That preserves the bias portion
// of the v1 ABI while the selected-light vec4 is appended at byte 320
// without applying the outer map's visibly excessive offset nearby.
DirectionalShadowWorldBias bias = cascades[^1].Bias;
float effectiveReachMeters = cascades[^1].SplitFarMeters;
return new DirectionalShadowUniforms(
matrix0,
matrix1,
matrix2,
matrix3,
new Vector4(split0, split1, split2, split3),
new Vector4(
environment.Strength,
environment.SoftnessMultiplier,
effectiveReachMeters,
MathF.Max(1f, effectiveReachMeters * 0.02f)),
new Vector4(
bias.ConstantDepthMeters,
bias.SlopeDepthMeters,
bias.NormalOffsetMeters,
cascades[0].CasterDepthPaddingMeters),
new UInt4(
textureSlot.Index,
checked((uint)quality.CascadeCount),
checked((uint)quality.MapResolution),
1u | (checked((uint)quality.PcfRadiusTexels) << 8)),
new Vector4(
environment.SurfaceToLightDirection,
checked((uint)environment.SourceKind)));
}
}
/// <summary>Four uints with the exact 16-byte std140 uvec4 representation.</summary>
[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal readonly struct UInt4(uint x, uint y, uint z, uint w)
{
public readonly uint X = x;
public readonly uint Y = y;
public readonly uint Z = z;
public readonly uint W = w;
}

View file

@ -0,0 +1,951 @@
using System.Diagnostics;
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Packs;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Wb;
using DatReaderWriter.Enums;
namespace AcDream.App.Rendering;
internal readonly record struct DirectionalSunShadowRenderInput(
DirectionalShadowEnvironmentInput Environment,
Matrix4x4 CameraView,
Matrix4x4 CameraProjection,
DirectionalShadowCasterFrame Casters,
float CameraNearMeters = 0.1f,
float CasterDepthPaddingMeters = 48f,
float ResidentMaximumReachMeters = float.PositiveInfinity,
bool MeasureGpuTimers = true,
bool MeasureCpuStages = false);
internal readonly record struct DirectionalSunShadowCpuStageTicks(
long EnvironmentGateTicks,
long PreparedDrawsAndTransformsTicks,
long FitAndUniformTicks,
long LayeredPassRecordingTicks,
long BookkeepingTicks);
internal readonly record struct DirectionalShadowTransformChurnDiagnostics(
int CopiedSceneChanges,
int UpdateTransformChanges,
int UpdateAppearanceChanges,
int DynamicSynchronizationChanges,
int ActiveAnimatedStaticChanges,
int LiveDynamicRootChanges,
int EquippedChildChanges,
int DedupedCasterSlots,
bool SceneJournalFullRefresh,
bool DensityBulkRefresh,
int BatchedProjectionCopyCalls,
int ChangedMatrixSlots,
int FlightCurrentChangedMatrices,
int FlightPendingReplayMatrices,
int FlightUploadedMatrices,
int FlightUploadRanges,
long FlightBytesWritten,
bool FlightFullDynamicFallback,
bool DenseDirectUpload,
bool DenseFlightReplay,
DirectionalShadowCasterClassDiagnostics CasterClasses = default);
internal readonly record struct DirectionalSunShadowDiagnostics(
DirectionalShadowGateReason GateReason,
float Strength,
int CascadeCount,
int DrawCalls,
int WorldOpaqueCommands,
int WorldAlphaCutoutCommands,
int TerrainCommands,
ulong WorldPreparationSequence,
ulong TerrainPreparationSequence,
double CpuMilliseconds,
double LastResolvedGpuMilliseconds,
bool HasResolvedGpuMeasurement,
long ResidentDepthBytes,
DirectionalSunShadowCpuStageTicks CpuStages = default,
DirectionalShadowTransformChurnDiagnostics TransformChurn = default,
AuthoredCelestialShadowSourceKind SourceKind =
AuthoredCelestialShadowSourceKind.None,
int SourceObjectIndex = -1,
uint SourceGfxObjId = 0u,
Vector3 SurfaceToLightDirection = default,
float LightElevationSin = 0f);
internal static class DirectionalShadowBatchFlags
{
internal const uint AlphaCutout = 1u << 0;
internal static uint Encode(DirectionalShadowCasterMaterial material) =>
material is DirectionalShadowCasterMaterial.AlphaCutout
? AlphaCutout
: 0u;
}
/// <summary>
/// Tier-2 producer only: fits selected celestial-light cascades and records
/// their depth maps.
/// It does not alter the retail world pass or sample shadows in receivers.
/// </summary>
internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverSource, IDisposable
{
internal const string TimerPrefix = "directional-shadow-cascade-";
internal const string MultiviewTimerName = "directional-shadow-multiview";
internal const uint LowMultiviewMask = 0b11;
private const int DrawCommandStride = 20;
private readonly IGpuDevice _device;
private readonly DirectionalShadowQuality _quality;
private readonly DirectionalShadowAtmospherePolicy _atmospherePolicy;
private readonly DirectionalShadowPipelineShaders _pipelineShaders;
private readonly bool _multiviewCascades;
private readonly IGpuDirectionalDepthTarget _target;
private readonly IGpuSampler _sampler;
private readonly GpuTextureSlot _textureSlot;
private readonly IGpuPipeline _terrainPipeline;
private readonly IGpuPipeline _worldOpaquePipeline;
private readonly IGpuPipeline _worldCutoutPipeline;
private readonly IGpuPipeline? _terrainMultiviewPipeline;
private readonly IGpuPipeline? _worldOpaqueMultiviewPipeline;
private readonly IGpuPipeline? _worldCutoutMultiviewPipeline;
private readonly DirectionalShadowTransformBufferSet _transformBuffers;
private readonly DirectionalShadowCascade[] _cascades = new DirectionalShadowCascade[4];
private DirectionalShadowBatchGpuData[] _batchScratch = [];
private IGpuBuffer? _worldBatchBuffer;
private IGpuBuffer? _worldCommandBuffer;
private IGpuBuffer? _terrainCommandBuffer;
private ulong _worldGpuBuildSequence;
private ulong _terrainGpuBuildSequence;
private DirectionalShadowFrameBinding _currentFrameBinding;
private bool _disposed;
internal DirectionalSunShadowRenderer(
IGpuDevice device,
DirectionalShadowPreset preset,
DirectionalShadowAtmospherePolicy? atmospherePolicy = null,
DirectionalShadowPipelineShaders? pipelineShaders = null,
bool multiviewCascades = false)
: this(
device,
DirectionalShadowQuality.For(preset),
atmospherePolicy,
pipelineShaders,
multiviewCascades)
{
}
internal DirectionalSunShadowRenderer(
IGpuDevice device,
DirectionalShadowQuality quality,
DirectionalShadowAtmospherePolicy? atmospherePolicy = null,
DirectionalShadowPipelineShaders? pipelineShaders = null,
bool multiviewCascades = false)
{
_device = device ?? throw new ArgumentNullException(nameof(device));
if (quality.CascadeCount is < 1 or > 4
|| quality.MapResolution <= 0
|| !float.IsFinite(quality.MaximumReachMeters)
|| quality.MaximumReachMeters <= 0f
|| quality.PcfRadiusTexels is < 0 or > 2)
{
throw new ArgumentOutOfRangeException(
nameof(quality),
"Directional-shadow quality must declare 1..4 cascades, a positive "
+ "resolution/reach, and a 0..2 PCF radius.");
}
_quality = quality;
_atmospherePolicy = atmospherePolicy ?? DirectionalShadowAtmospherePolicy.BuiltIn;
_pipelineShaders = pipelineShaders ?? DirectionalShadowPipelineShaders.Local;
_multiviewCascades = multiviewCascades;
if (multiviewCascades && quality.CascadeCount != 2)
throw new NotSupportedException("The multiview shadow hint requires exactly two Low cascades.");
if (multiviewCascades && !device.Capabilities.SupportsMultiview)
throw new NotSupportedException("The selected device does not support multiview shadow cascades.");
if (multiviewCascades && _pipelineShaders.MultiviewCasters is null)
throw new NotSupportedException("The pack did not declare all multiview shadow caster variants.");
IGpuDirectionalDepthTarget? target = null;
IGpuSampler? sampler = null;
GpuTextureSlot textureSlot = GpuTextureSlot.Unassigned;
IGpuPipeline? terrain = null;
IGpuPipeline? opaque = null;
IGpuPipeline? cutout = null;
IGpuPipeline? terrainMultiview = null;
IGpuPipeline? opaqueMultiview = null;
IGpuPipeline? cutoutMultiview = null;
DirectionalShadowTransformBufferSet? transformBuffers = null;
try
{
target = device.CreateDirectionalDepthTarget(
new GpuDirectionalDepthTargetDescription(
$"directional-shadow-{quality.Preset.ToString().ToLowerInvariant()}",
_quality.MapResolution,
_quality.CascadeCount));
sampler = device.CreateSampler(GpuSamplerDescription.ShadowNearestClamp);
textureSlot = device.RegisterTexture(target.DepthTexture, sampler);
terrain = CreatePipeline(
device,
"directional-shadow-terrain",
_pipelineShaders.TerrainCaster,
TerrainModernRenderer.TerrainVertexLayout,
GpuFrontFace.CounterClockwise);
opaque = CreatePipeline(
device,
"directional-shadow-world-opaque",
_pipelineShaders.WorldOpaqueCaster,
GpuVertexLayout.WorldMesh,
GpuFrontFace.Clockwise);
cutout = CreatePipeline(
device,
"directional-shadow-world-cutout",
_pipelineShaders.WorldAlphaCutoutCaster,
GpuVertexLayout.WorldMesh,
GpuFrontFace.Clockwise);
if (multiviewCascades)
{
DirectionalShadowMultiviewPipelineShaders shaders =
_pipelineShaders.MultiviewCasters!.Value;
terrainMultiview = CreatePipeline(device, "directional-shadow-terrain-multiview",
shaders.TerrainCaster, TerrainModernRenderer.TerrainVertexLayout,
GpuFrontFace.CounterClockwise, LowMultiviewMask);
opaqueMultiview = CreatePipeline(device, "directional-shadow-world-opaque-multiview",
shaders.WorldOpaqueCaster, GpuVertexLayout.WorldMesh,
GpuFrontFace.Clockwise, LowMultiviewMask);
cutoutMultiview = CreatePipeline(device, "directional-shadow-world-cutout-multiview",
shaders.WorldAlphaCutoutCaster, GpuVertexLayout.WorldMesh,
GpuFrontFace.Clockwise, LowMultiviewMask);
}
transformBuffers = new DirectionalShadowTransformBufferSet(device);
}
catch
{
transformBuffers?.Dispose();
cutoutMultiview?.Dispose();
opaqueMultiview?.Dispose();
terrainMultiview?.Dispose();
cutout?.Dispose();
opaque?.Dispose();
terrain?.Dispose();
if (textureSlot.IsAssigned)
device.ReleaseTextureSlot(textureSlot);
sampler?.Dispose();
target?.Dispose();
throw;
}
_target = target;
_sampler = sampler;
_textureSlot = textureSlot;
_terrainPipeline = terrain;
_worldOpaquePipeline = opaque;
_worldCutoutPipeline = cutout;
_terrainMultiviewPipeline = terrainMultiview;
_worldOpaqueMultiviewPipeline = opaqueMultiview;
_worldCutoutMultiviewPipeline = cutoutMultiview;
_transformBuffers = transformBuffers;
}
internal DirectionalShadowQuality Quality => _quality;
internal bool MultiviewCascadesEnabled => _multiviewCascades;
internal static string TimerName(int cascadeIndex) => cascadeIndex switch
{
0 => "directional-shadow-cascade-0",
1 => "directional-shadow-cascade-1",
2 => "directional-shadow-cascade-2",
3 => "directional-shadow-cascade-3",
_ => throw new ArgumentOutOfRangeException(nameof(cascadeIndex)),
};
internal IGpuTexture DepthTexture => _target.DepthTexture;
internal GpuTextureSlot TextureSlot => _textureSlot;
public DirectionalShadowPipelineShaders PipelineShaders => _pipelineShaders;
internal DirectionalShadowFrameBinding CurrentFrameBinding => _currentFrameBinding;
/// <summary>
/// Topology-only command metadata lives in pack-owned device-local buffers.
/// It is rebuilt transactionally when the retained CPU product changes and
/// is never copied through a per-frame ring on a stable scene.
/// </summary>
internal long RetainedCommandBufferBytes => checked(
(_worldBatchBuffer?.SizeBytes ?? 0L)
+ (_worldCommandBuffer?.SizeBytes ?? 0L)
+ (_terrainCommandBuffer?.SizeBytes ?? 0L));
internal int RetainedCommandBufferCount =>
(_worldBatchBuffer is null ? 0 : 1)
+ (_worldCommandBuffer is null ? 0 : 1)
+ (_terrainCommandBuffer is null ? 0 : 1);
internal long RetainedGpuBufferBytes => checked(
RetainedCommandBufferBytes + _transformBuffers.RetainedGpuBytes);
internal int RetainedGpuBufferCount => checked(
RetainedCommandBufferCount + _transformBuffers.BufferCount);
public bool TryGetCurrentFrameBinding(
IGpuFrame frame,
out DirectionalShadowFrameBinding binding)
{
ArgumentNullException.ThrowIfNull(frame);
binding = _currentFrameBinding;
return !_disposed && binding.IsValidFor(frame);
}
internal DirectionalSunShadowDiagnostics Render(
IGpuFrame frame,
in DirectionalSunShadowRenderInput input,
WbDrawDispatcher world,
TerrainModernRenderer terrain)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(frame);
_currentFrameBinding = DirectionalShadowFrameBinding.Disabled;
ArgumentNullException.ThrowIfNull(world);
ArgumentNullException.ThrowIfNull(terrain);
long cpuStageStarted = input.MeasureCpuStages ? Stopwatch.GetTimestamp() : 0L;
DirectionalShadowEnvironmentState environment =
DirectionalShadowEnvironmentGate.Evaluate(
input.Environment,
_atmospherePolicy);
long environmentGateTicks = input.MeasureCpuStages
? Stopwatch.GetTimestamp() - cpuStageStarted
: 0L;
if (!environment.ShouldRender)
return Disabled(
in environment,
new DirectionalSunShadowCpuStageTicks(
environmentGateTicks, 0L, 0L, 0L, 0L));
if (input.ResidentMaximumReachMeters <= input.CameraNearMeters)
{
environment = environment with
{
Reason = DirectionalShadowGateReason.ResidentWindowUnavailable,
};
return Disabled(
in environment,
new DirectionalSunShadowCpuStageTicks(
environmentGateTicks, 0L, 0L, 0L, 0L));
}
cpuStageStarted = input.MeasureCpuStages ? Stopwatch.GetTimestamp() : 0L;
DirectionalShadowPreparedDraws worldDraws =
world.PrepareDirectionalShadowDraws(input.Casters);
DirectionalShadowTerrainPreparedDraws terrainDraws =
terrain.PrepareDirectionalShadowDraws();
DirectionalShadowMeshGeometry? worldGeometry =
worldDraws.Commands.IsEmpty ? null : world.GetDirectionalShadowGeometry();
DirectionalShadowTerrainGeometry? terrainGeometry =
terrainDraws.Commands.IsEmpty ? null : terrain.GetDirectionalShadowGeometry();
uint transformBindingSizeBytes =
world.ResolveDirectionalShadowTransformBindingSize(
worldDraws.Transforms.Length,
// Stats counts every current WB source render batch before
// transparent/cutout shadow rejection. Ordinary WB submission
// publishes at most one matrix per source batch, making this a
// complete-frame upper bound available before shadow commands
// bind the one authoritative pose buffer.
worldDraws.Stats.SourceBatches);
WorldTransformFrameSlice retainedTransforms = _transformBuffers.Publish(
frame,
worldDraws.BuildSequence,
worldDraws.Transforms,
worldDraws.DynamicTransformSlots,
worldDraws.AllDynamicTransformSlots,
worldDraws.LastDynamicTransformRefreshWasDense,
transformBindingSizeBytes);
WorldTransformFrameSlice transforms =
world.BeginDirectionalShadowTransformFrame(
frame,
in retainedTransforms);
DirectionalShadowCasterBuildStats casterStats = input.Casters.Stats;
DirectionalShadowCasterClassDiagnostics casterClasses =
CompleteCasterClassDiagnostics(
in casterStats,
terrainDraws.Commands.Length);
DirectionalShadowTransformPublishStats publishStats =
_transformBuffers.LastStats;
var transformChurn = new DirectionalShadowTransformChurnDiagnostics(
casterStats.CopiedTransformChanges,
casterStats.UpdateTransformChanges,
casterStats.UpdateAppearanceChanges,
casterStats.DynamicSynchronizationChanges,
casterStats.ActiveAnimatedStaticChanges,
casterStats.LiveDynamicRootChanges,
casterStats.EquippedChildChanges,
casterStats.DedupedChangedCasterSlots,
casterStats.TransformJournalFullRefresh,
casterStats.DensityBulkRefresh,
casterStats.BatchedProjectionCopyCalls,
worldDraws.LastDynamicTransformRefreshCount,
publishStats.CurrentChangedMatrices,
publishStats.PendingReplayMatrices,
publishStats.DynamicMatricesUpdated,
publishStats.DynamicRangesUpdated,
publishStats.BytesWritten,
publishStats.UsedFullDynamicFallback,
publishStats.DenseDirectUpload,
publishStats.DenseFlightReplay,
casterClasses);
long preparedDrawsAndTransformsTicks = input.MeasureCpuStages
? Stopwatch.GetTimestamp() - cpuStageStarted
: 0L;
try
{
return RenderPrepared(
frame,
environment,
input.CameraView,
input.CameraProjection,
input.CameraNearMeters,
input.CasterDepthPaddingMeters,
worldDraws,
terrainDraws,
worldGeometry,
terrainGeometry,
transforms,
input.ResidentMaximumReachMeters,
input.MeasureGpuTimers,
input.MeasureCpuStages,
new DirectionalSunShadowCpuStageTicks(
environmentGateTicks,
preparedDrawsAndTransformsTicks,
0L,
0L,
0L),
transformChurn);
}
catch
{
world.CancelDirectionalShadowTransformFrame(frame);
throw;
}
}
internal static DirectionalShadowCasterClassDiagnostics
CompleteCasterClassDiagnostics(
in DirectionalShadowCasterBuildStats casterStats,
int terrainCommandCount)
{
ArgumentOutOfRangeException.ThrowIfNegative(terrainCommandCount);
return casterStats.CasterClasses with
{
TerrainCommands = terrainCommandCount,
};
}
internal DirectionalSunShadowDiagnostics RenderPrepared(
IGpuFrame frame,
in DirectionalShadowEnvironmentState environment,
Matrix4x4 cameraView,
Matrix4x4 cameraProjection,
float cameraNearMeters,
float casterDepthPaddingMeters,
DirectionalShadowPreparedDraws worldDraws,
DirectionalShadowTerrainPreparedDraws terrainDraws,
DirectionalShadowMeshGeometry? worldGeometry,
DirectionalShadowTerrainGeometry? terrainGeometry,
WorldTransformFrameSlice worldTransforms,
float residentMaximumReachMeters = float.PositiveInfinity,
bool measureGpuTimers = true,
bool measureCpuStages = false,
DirectionalSunShadowCpuStageTicks cpuStages = default,
DirectionalShadowTransformChurnDiagnostics transformChurn = default)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(frame);
_currentFrameBinding = DirectionalShadowFrameBinding.Disabled;
ArgumentNullException.ThrowIfNull(worldDraws);
ArgumentNullException.ThrowIfNull(terrainDraws);
if (!environment.ShouldRender)
return Disabled(in environment, cpuStages);
if (!worldDraws.Commands.IsEmpty && worldGeometry is null)
throw new ArgumentNullException(nameof(worldGeometry));
if (!terrainDraws.Commands.IsEmpty && terrainGeometry is null)
throw new ArgumentNullException(nameof(terrainGeometry));
if (!worldTransforms.IsValidFor(frame))
throw new ArgumentException(
"Shadow transforms must use this frame's shared N.5 allocation.",
nameof(worldTransforms));
long started = Stopwatch.GetTimestamp();
var fit = new DirectionalShadowCascadeFitInput(
cameraView,
cameraProjection,
environment.SurfaceToLightDirection,
_quality,
cameraNearMeters,
PracticalSplitLambda: 0.65f,
casterDepthPaddingMeters,
residentMaximumReachMeters);
int cascadeCount = DirectionalShadowCascadeFitter.Fit(
fit,
_cascades);
if (cascadeCount == 0)
{
DirectionalShadowEnvironmentState unavailable = environment with
{
Reason = DirectionalShadowGateReason.ResidentWindowUnavailable,
};
return Disabled(in unavailable, cpuStages);
}
ReadOnlySpan<DirectionalShadowCascade> cascades =
_cascades.AsSpan(0, cascadeCount);
DirectionalShadowUniforms uniforms = DirectionalShadowUniforms.Create(
cascades,
environment,
_quality,
_textureSlot);
GpuRingAllocation uniformAllocation = frame.AllocateRing(
DirectionalShadowUniforms.SizeInBytes,
GpuRingUsage.Uniform);
MemoryMarshal.Write(uniformAllocation.Data, in uniforms);
long fitAndUniformFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L;
PreparedGpuUploads uploads = PrepareGpuData(
worldTransforms,
worldDraws,
terrainDraws);
if (MultiviewCascadesEnabled)
{
using IGpuPassEncoder encoder = frame.BeginPass(
GpuPassDescription.DirectionalDepthMultiview(
"directional-shadow-multiview",
_target,
LowMultiviewMask));
using IDisposable? timer = measureGpuTimers
? encoder.BeginTimerScope(MultiviewTimerName)
: null;
encoder.BindUniformBuffer(
GpuBindingModel.UniformDirectionalShadow,
uniformAllocation.Buffer,
uniformAllocation.OffsetBytes,
DirectionalShadowUniforms.SizeInBytes);
DrawTerrain(encoder, uploads, terrainDraws, terrainGeometry, 0,
_terrainMultiviewPipeline);
DrawWorld(encoder, uploads, worldDraws, worldGeometry, 0,
_worldOpaqueMultiviewPipeline, _worldCutoutMultiviewPipeline);
}
else for (int cascadeIndex = 0; cascadeIndex < cascadeCount; cascadeIndex++)
{
using IGpuPassEncoder encoder = frame.BeginPass(
GpuPassDescription.DirectionalDepth(
$"directional-shadow-{cascadeIndex}",
_target,
cascadeIndex));
using IDisposable? timer = measureGpuTimers
? encoder.BeginTimerScope(TimerName(cascadeIndex))
: null;
encoder.BindUniformBuffer(
GpuBindingModel.UniformDirectionalShadow,
uniformAllocation.Buffer,
uniformAllocation.OffsetBytes,
DirectionalShadowUniforms.SizeInBytes);
DrawTerrain(encoder, uploads, terrainDraws, terrainGeometry, cascadeIndex);
DrawWorld(encoder, uploads, worldDraws, worldGeometry, cascadeIndex);
}
long passRecordingFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L;
_currentFrameBinding = new DirectionalShadowFrameBinding(
frame.Serial,
Enabled: true,
uniformAllocation.Buffer,
uniformAllocation.OffsetBytes,
DirectionalShadowUniforms.SizeInBytes,
_textureSlot,
cascadeCount);
(bool hasGpu, double gpuMilliseconds) = ResolveGpu(cascadeCount);
int drawsPerCascade = terrainDraws.Commands.IsEmpty ? 0 : 1;
drawsPerCascade = checked(
drawsPerCascade
+ (worldDraws.Commands.IsEmpty
? 0
: worldDraws.OpaqueRuns.Length + worldDraws.AlphaCutoutRuns.Length));
long finished = Stopwatch.GetTimestamp();
cpuStages = cpuStages with
{
FitAndUniformTicks = measureCpuStages
? fitAndUniformFinished - started
: 0L,
LayeredPassRecordingTicks = measureCpuStages
? passRecordingFinished - fitAndUniformFinished
: 0L,
BookkeepingTicks = measureCpuStages
? finished - passRecordingFinished
: 0L,
};
return new DirectionalSunShadowDiagnostics(
DirectionalShadowGateReason.Enabled,
environment.Strength,
cascadeCount,
checked((MultiviewCascadesEnabled ? 1 : cascadeCount) * drawsPerCascade),
worldDraws.OpaqueCommandCount,
worldDraws.AlphaCutoutCommandCount,
terrainDraws.Commands.Length,
worldDraws.BuildSequence,
terrainDraws.BuildSequence,
(finished - started) * 1000d / Stopwatch.Frequency,
gpuMilliseconds,
hasGpu,
_quality.ApproximateDepthMapBytes,
cpuStages,
transformChurn,
environment.SourceKind,
environment.SourceObjectIndex,
environment.SourceGfxObjId,
environment.SurfaceToLightDirection,
environment.LightElevationSin);
}
private PreparedGpuUploads PrepareGpuData(
in WorldTransformFrameSlice transforms,
DirectionalShadowPreparedDraws world,
DirectionalShadowTerrainPreparedDraws terrain)
{
if (_worldGpuBuildSequence != world.BuildSequence)
RebuildWorldGpuData(world);
if (_terrainGpuBuildSequence != terrain.BuildSequence)
RebuildTerrainGpuData(terrain);
return new PreparedGpuUploads(
transforms,
Slice(_worldBatchBuffer),
Slice(_worldCommandBuffer),
Slice(_terrainCommandBuffer));
}
private void RebuildWorldGpuData(DirectionalShadowPreparedDraws world)
{
IGpuBuffer? batches = null;
IGpuBuffer? commands = null;
try
{
if (!world.Commands.IsEmpty)
{
EnsureBatchCapacity(world.Batches.Length);
for (int i = 0; i < world.Batches.Length; i++)
{
DirectionalShadowPreparedBatch batch = world.Batches[i];
_batchScratch[i] = new DirectionalShadowBatchGpuData(
batch.TextureSlot.Index,
0u,
batch.TextureLayer,
DirectionalShadowBatchFlags.Encode(batch.Material));
}
ReadOnlySpan<byte> batchBytes = MemoryMarshal.AsBytes(
_batchScratch.AsSpan(0, world.Batches.Length));
ReadOnlySpan<byte> commandBytes = MemoryMarshal.AsBytes(
world.Commands);
batches = CreateRetainedBuffer(
$"directional-shadow-world-batches-{world.BuildSequence}",
batchBytes,
GpuBufferUsage.Storage);
commands = CreateRetainedBuffer(
$"directional-shadow-world-commands-{world.BuildSequence}",
commandBytes,
GpuBufferUsage.Indirect);
}
}
catch
{
commands?.Dispose();
batches?.Dispose();
throw;
}
IGpuBuffer? previousBatches = _worldBatchBuffer;
IGpuBuffer? previousCommands = _worldCommandBuffer;
_worldBatchBuffer = batches;
_worldCommandBuffer = commands;
_worldGpuBuildSequence = world.BuildSequence;
previousCommands?.Dispose();
previousBatches?.Dispose();
}
private void RebuildTerrainGpuData(DirectionalShadowTerrainPreparedDraws terrain)
{
IGpuBuffer? commands = null;
if (!terrain.Commands.IsEmpty)
{
commands = CreateRetainedBuffer(
$"directional-shadow-terrain-commands-{terrain.BuildSequence}",
MemoryMarshal.AsBytes(terrain.Commands),
GpuBufferUsage.Indirect);
}
IGpuBuffer? previous = _terrainCommandBuffer;
_terrainCommandBuffer = commands;
_terrainGpuBuildSequence = terrain.BuildSequence;
previous?.Dispose();
}
private IGpuBuffer CreateRetainedBuffer(
string name,
ReadOnlySpan<byte> contents,
GpuBufferUsage usage)
{
if (contents.IsEmpty)
throw new ArgumentException("Retained shadow buffers cannot be empty.", nameof(contents));
IGpuBuffer buffer = _device.CreateBuffer(new GpuBufferDescription(
name,
contents.Length,
usage | GpuBufferUsage.TransferDestination,
GpuMemoryResidency.DeviceLocal));
try
{
buffer.Upload(0, contents);
return buffer;
}
catch
{
buffer.Dispose();
throw;
}
}
private static RetainedGpuBufferSlice Slice(IGpuBuffer? buffer) =>
new(buffer, 0u, checked((uint)(buffer?.SizeBytes ?? 0L)));
private void DrawTerrain(
IGpuPassEncoder encoder,
in PreparedGpuUploads uploads,
DirectionalShadowTerrainPreparedDraws draws,
DirectionalShadowTerrainGeometry? geometry,
int cascadeIndex,
IGpuPipeline? pipeline = null)
{
if (draws.Commands.IsEmpty)
return;
DirectionalShadowTerrainGeometry actual = geometry!.Value;
encoder.BindPipeline(pipeline ?? _terrainPipeline);
encoder.BindVertexBuffer(0, actual.VertexBuffer, 0);
encoder.BindIndexBuffer(actual.IndexBuffer, 0, GpuIndexType.UInt32);
GpuPushConstants push = PushForCascade(cascadeIndex, 0);
encoder.SetPushConstants(in push);
encoder.MultiDrawIndexedIndirect(
uploads.TerrainCommands.RequireBuffer(),
uploads.TerrainCommands.OffsetBytes,
checked((uint)draws.Commands.Length),
DrawCommandStride);
}
private void DrawWorld(
IGpuPassEncoder encoder,
in PreparedGpuUploads uploads,
DirectionalShadowPreparedDraws draws,
DirectionalShadowMeshGeometry? geometry,
int cascadeIndex,
IGpuPipeline? opaquePipeline = null,
IGpuPipeline? cutoutPipeline = null)
{
if (draws.Commands.IsEmpty)
return;
DirectionalShadowMeshGeometry actual = geometry!.Value;
encoder.BindStorageBuffer(
GpuBindingModel.StorageInstances,
uploads.Transforms.Buffer,
uploads.Transforms.BaseOffsetBytes,
uploads.Transforms.BindingSizeBytes);
encoder.BindStorageBuffer(
GpuBindingModel.StorageBatches,
uploads.Batches.RequireBuffer(),
uploads.Batches.OffsetBytes,
uploads.Batches.SizeBytes);
DrawWorldRange(
encoder,
uploads.WorldCommands,
draws.OpaqueRuns,
cascadeIndex,
opaquePipeline ?? _worldOpaquePipeline,
actual);
DrawWorldRange(
encoder,
uploads.WorldCommands,
draws.AlphaCutoutRuns,
cascadeIndex,
cutoutPipeline ?? _worldCutoutPipeline,
actual);
}
private static void DrawWorldRange(
IGpuPassEncoder encoder,
in RetainedGpuBufferSlice commands,
ReadOnlySpan<DirectionalShadowPreparedRun> runs,
int cascadeIndex,
IGpuPipeline pipeline,
in DirectionalShadowMeshGeometry geometry)
{
if (runs.IsEmpty)
return;
encoder.BindPipeline(pipeline);
encoder.BindVertexBuffer(0, geometry.VertexBuffer, 0);
encoder.BindIndexBuffer(geometry.IndexBuffer, 0, GpuIndexType.UInt16);
for (int runIndex = 0; runIndex < runs.Length; runIndex++)
{
DirectionalShadowPreparedRun run = runs[runIndex];
ApplyCull(encoder, run.CullMode);
GpuPushConstants push = PushForCascade(cascadeIndex, run.StartCommand);
encoder.SetPushConstants(in push);
encoder.MultiDrawIndexedIndirect(
commands.RequireBuffer(),
commands.OffsetBytes + checked((uint)(run.StartCommand * DrawCommandStride)),
checked((uint)run.CommandCount),
DrawCommandStride);
}
}
private static GpuPushConstants PushForCascade(int cascadeIndex, int drawIdOffset)
{
GpuPushConstants push = GpuPushConstants.Default;
push.RenderPass = cascadeIndex;
push.DrawIdOffset = drawIdOffset;
return push;
}
private static void ApplyCull(IGpuPassEncoder encoder, CullMode mode)
{
encoder.SetFrontFace(GpuFrontFace.Clockwise);
encoder.SetCullMode(mode switch
{
CullMode.None => GpuCullMode.None,
CullMode.Clockwise => GpuCullMode.Front,
_ => GpuCullMode.Back,
});
}
private static IGpuPipeline CreatePipeline(
IGpuDevice device,
string name,
GpuShaderSet shaders,
GpuVertexLayout layout,
GpuFrontFace frontFace,
uint viewMask = 0) =>
device.CreatePipeline(new GpuPipelineDescription
{
Name = name,
Shaders = shaders,
VertexLayout = layout,
Topology = GpuPrimitiveTopology.TriangleList,
Blend = GpuBlendMode.None,
Depth = new GpuDepthState(true, true, GpuCompareOp.Less),
Cull = GpuCullMode.Back,
FrontFace = frontFace,
AlphaToCoverage = false,
ColorWrite = false,
HasColorAttachment = false,
AllowColorFormatVariants = false,
SampleCount = 1,
UsesRenderPackShaderAbi = true,
ViewMask = viewMask,
});
private (bool HasMeasurement, double Milliseconds) ResolveGpu(int cascadeCount)
{
if (MultiviewCascadesEnabled)
return _device.Timers.TryResolve(MultiviewTimerName, out double measured)
? (true, measured)
: (false, 0d);
double total = 0d;
for (int i = 0; i < cascadeCount; i++)
{
if (!_device.Timers.TryResolve(TimerName(i), out double milliseconds))
return (false, 0d);
total += milliseconds;
}
return (true, total);
}
private DirectionalSunShadowDiagnostics Disabled(
in DirectionalShadowEnvironmentState environment,
DirectionalSunShadowCpuStageTicks cpuStages = default) =>
new(
environment.Reason,
0f,
0,
0,
0,
0,
0,
0,
0,
0d,
0d,
false,
_quality.ApproximateDepthMapBytes,
cpuStages,
SourceKind: environment.SourceKind,
SourceObjectIndex: environment.SourceObjectIndex,
SourceGfxObjId: environment.SourceGfxObjId,
SurfaceToLightDirection: environment.SurfaceToLightDirection,
LightElevationSin: environment.LightElevationSin);
private void EnsureBatchCapacity(int required)
{
if (_batchScratch.Length >= required)
return;
int capacity = _batchScratch.Length == 0 ? 16 : _batchScratch.Length;
while (capacity < required)
capacity = checked(capacity * 2);
Array.Resize(ref _batchScratch, capacity);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_currentFrameBinding = DirectionalShadowFrameBinding.Disabled;
_worldCutoutPipeline.Dispose();
_worldCutoutMultiviewPipeline?.Dispose();
_worldOpaqueMultiviewPipeline?.Dispose();
_terrainMultiviewPipeline?.Dispose();
_worldOpaquePipeline.Dispose();
_terrainPipeline.Dispose();
_terrainCommandBuffer?.Dispose();
_worldCommandBuffer?.Dispose();
_worldBatchBuffer?.Dispose();
_transformBuffers.Dispose();
_device.ReleaseTextureSlot(_textureSlot);
_sampler.Dispose();
_target.Dispose();
}
[StructLayout(LayoutKind.Sequential, Pack = 4)]
private readonly record struct DirectionalShadowBatchGpuData(
uint TextureIndex,
uint Reserved,
uint TextureLayer,
uint Flags);
private readonly record struct RetainedGpuBufferSlice(
IGpuBuffer? Buffer,
uint OffsetBytes,
uint SizeBytes)
{
internal IGpuBuffer RequireBuffer() => Buffer
?? throw new InvalidOperationException(
"A non-empty directional-shadow draw has no retained GPU buffer.");
}
private readonly record struct PreparedGpuUploads(
WorldTransformFrameSlice transforms,
RetainedGpuBufferSlice batches,
RetainedGpuBufferSlice worldCommands,
RetainedGpuBufferSlice terrainCommands)
{
internal WorldTransformFrameSlice Transforms { get; } = transforms;
internal RetainedGpuBufferSlice Batches { get; } = batches;
internal RetainedGpuBufferSlice WorldCommands { get; } = worldCommands;
internal RetainedGpuBufferSlice TerrainCommands { get; } = terrainCommands;
}
}

View file

@ -35,6 +35,43 @@ public sealed class GameWindow :
System.Diagnostics.Stopwatch.GetTimestamp()
/ (double)System.Diagnostics.Stopwatch.Frequency;
internal static WindowOptions CreateStartupWindowOptions(
bool exactAutomationFramebuffer,
string persistedResolution,
bool useVSync)
{
WindowOptions defaults = WindowOptions.DefaultVulkan;
Vector2D<int> size = new(1280, 720);
WindowBorder border = defaults.WindowBorder;
if (exactAutomationFramebuffer)
{
if (!SilkRuntimeDisplayWindowTarget.TryParseResolution(
persistedResolution,
out int width,
out int height))
{
throw new InvalidOperationException(
"Exact automation framebuffer requires a valid persisted resolution.");
}
size = new Vector2D<int>(width, height);
border = WindowBorder.Hidden;
}
return defaults with
{
Size = size,
Title = "acdream — Vulkan",
VSync = useVSync,
WindowBorder = border,
// A desktop-sized borderless automation window must stay hidden,
// not iconified. Windows throttles/occludes an iconified GLFW
// surface, which prevents the performance gate from collecting a
// complete rolling sample window. Ordinary launches retain the
// Silk default visibility.
IsVisible = !exactAutomationFramebuffer,
};
}
private readonly AcDream.App.RuntimeOptions _options;
// Campaign LA slice LA1: no-op instance when --session-config didn't
// configure a statusFile (or the env-var launch path was used at all).
@ -57,6 +94,13 @@ public sealed class GameWindow :
// loop!" and would otherwise bury whatever exception actually wounded
// the loop). See docs/ISSUES.md #343.
private bool _renderLoopArmed;
// Silk may invoke Closing synchronously from IWindow.Close during Update,
// then still invoke Render once before its loop exits. Teardown cannot run
// from that Closing callback: it would dispose the scene while the cached
// render delegate is still eligible to execute. Latch the edge, skip that
// terminal render, and close the ownership graph after Run returns.
private bool _nativeCloseRequested;
private bool _nativeRunReturned;
private SilkWindowCallbackBinding? _windowCallbacks;
private GameWindowGraphics? _graphics;
// Campaign V slice V6h: borrowed, not owned — _graphics owns the context and
@ -414,6 +458,8 @@ public sealed class GameWindow :
private readonly AcDream.App.UI.RetailUiRuntimeLease _retailUiLease = new();
private InteractionUiLateBindings? _interactionUiLateBindings;
private readonly DeferredRenderFrameDiagnosticsSource _uiFrameDiagnostics = new();
private readonly AcDream.App.Rendering.Packs.DeferredRenderPackDiagnosticsSource
_renderPackDiagnostics = new();
private readonly AcDream.App.Combat.CombatAttackOperationsSlot
_combatAttackOperations = new();
private readonly AcDream.App.Combat.RuntimeCombatTargetOperationsSlot
@ -449,6 +495,7 @@ public sealed class GameWindow :
private AcDream.App.Rendering.ChargenPreviewController? _summaryPreviewController;
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
private readonly AcDream.App.Plugins.BufferedRenderPackRegistry? _renderPackRegistry;
private AcDream.App.Plugins.GraphicalPluginSession? _pluginSession;
// Campaign V slice V11 deleted the ImGui developer-tools frontend along
// with the OpenGL backend it required, so no host ever composes a
@ -636,7 +683,8 @@ public sealed class GameWindow :
WorldEvents worldEvents,
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry,
GraphicalHostPlatformServices platformServices,
AcDream.App.Plugins.AppAutomationSurface? automation = null)
AcDream.App.Plugins.AppAutomationSurface? automation = null,
AcDream.App.Plugins.BufferedRenderPackRegistry? renderPackRegistry = null)
{
_options = options ?? throw new System.ArgumentNullException(nameof(options));
_automation = automation;
@ -724,6 +772,7 @@ public sealed class GameWindow :
characterOptionValue: _runtime.CharacterOwner.Options.GetOptionBit);
_animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment();
_uiRegistry = uiRegistry;
_renderPackRegistry = renderPackRegistry;
_animatedEntities = new LiveEntityAnimationRuntimeView<LiveEntityAnimationState>(
_liveEntityRuntimeSlot);
// #184 Slice 2a: the extracted per-remote DR tick. Its stateful
@ -799,12 +848,10 @@ public sealed class GameWindow :
// attribute there — both are attachment properties the RHI device
// configures instead. The raw-GL window options this used to fork to
// were deleted at Campaign V slice V11.
var options = WindowOptions.DefaultVulkan with
{
Size = new Vector2D<int>(1280, 720),
Title = "acdream — Vulkan",
VSync = startupPacing.UseVSync,
};
WindowOptions options = CreateStartupWindowOptions(
_options.ExactAutomationFramebuffer,
startup.Display.Resolution,
startupPacing.UseVSync);
_startupPacing = startupPacing;
_startupQuality = startup.Quality;
@ -833,6 +880,8 @@ public sealed class GameWindow :
try
{
_window.Run();
_nativeRunReturned = true;
CompleteShutdown(releaseNativeWindow: false);
}
catch (Exception failure)
{
@ -1359,7 +1408,10 @@ public sealed class GameWindow :
_localPlayerMode,
_chaseCameraInput,
_pointerPosition,
_renderDiagnosticLog),
_renderDiagnosticLog,
_options.InitialOrbitDistanceMeters,
_options.InitialOrbitYawDegrees,
_options.InitialOrbitPitchDegrees),
this).Compose(platformResult),
(platformResult, hostInputCamera) =>
new ContentEffectsAudioCompositionPhase(
@ -1387,7 +1439,11 @@ public sealed class GameWindow :
new SilkRuntimeDisplayWindowTarget(_window!),
_displayFramePacing,
hostInputCamera.CameraController,
contentEffectsAudio.Audio?.Engine)))
contentEffectsAudio.Audio?.Engine))
{
RenderPacks = _renderPackRegistry,
GpuDevice = hostInputCamera.GpuDevice,
})
.Compose(platformResult, hostInputCamera, contentEffectsAudio),
(platformResult, contentEffectsAudio, settingsDevTools) =>
{
@ -1461,7 +1517,9 @@ public sealed class GameWindow :
Console.WriteLine,
hostInputCamera.GpuDevice,
hostInputCamera.GpuFrameLifetime,
() => WorldTime.CurrentCalendar),
() => WorldTime.CurrentCalendar,
settingsDevTools.RenderPacks,
_renderPackDiagnostics.CaptureDiagnostics),
_retailUiLease,
this).Compose(
platformResult,
@ -1519,7 +1577,8 @@ public sealed class GameWindow :
DevFrameDiagnostics: null,
_uiFrameDiagnostics,
Console.WriteLine,
compositionToast),
compositionToast,
_renderPackDiagnostics),
this).Compose(
platformResult,
hostInputCamera,
@ -1631,7 +1690,8 @@ public sealed class GameWindow :
_animatedEntities,
_updateFrameClock,
_frameGraphs,
Console.WriteLine),
Console.WriteLine,
_renderPackDiagnostics),
this).Compose(
platformResult,
hostInputCamera,
@ -1672,6 +1732,11 @@ public sealed class GameWindow :
// #343: see OnUpdate above — armed on entry, cleared on every normal
// exit path below, left stuck true if anything here throws.
_renderLoopArmed = true;
if (_nativeCloseRequested)
{
_renderLoopArmed = false;
return;
}
Vector2D<int> size = _window!.Size;
// Campaign V slice V6h: swapchain currency is the one piece of
// presentation the RHI contract deliberately leaves to the host (plan
@ -1735,12 +1800,23 @@ public sealed class GameWindow :
private void CompleteShutdown(bool releaseNativeWindow)
{
// IWindow.Close can raise Closing synchronously from Update and Silk
// can still issue one cached Render callback before Run returns. Keep
// Closing as the one narrow shutdown edge, but do not release frame
// owners until the native loop has actually returned. OnRender sees
// this latch and makes that terminal callback inert.
if (!releaseNativeWindow && !_nativeRunReturned)
{
_nativeCloseRequested = true;
return;
}
if (!_lifetime.HasShutdownRoots)
{
// Campaign LA slice LA1: capture BEFORE the shutdown roots run —
// by the time teardown completes, IsInWorld is always false
// regardless of whether a real session was ever connected.
// OnClosing() and Dispose() both funnel through this method;
// post-Run shutdown and Dispose() both funnel through this method;
// HasShutdownRoots's own guard means this fires exactly once,
// from whichever of the two reaches it first.
if (_runtime.Session.IsInWorld)
@ -1754,7 +1830,7 @@ public sealed class GameWindow :
if (report.Status == GameWindowLifetimeStatus.Complete)
{
// "exited" = terminal — only the true Dispose() call (not the
// OnClosing() native-window-close-request pass) represents the
// post-Run native-window-close-request pass) represents the
// process actually being done.
if (releaseNativeWindow)
ReportExited(report);

View file

@ -1,3 +1,5 @@
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.App.Rendering.Gpu;
/// <summary>
@ -60,16 +62,20 @@ internal static class GpuBindingModel
/// <summary>Retail SmartBox selection lighting: one vec2 (luminosity, diffuse) per instance.</summary>
public const uint StorageInstanceSelectionLighting = 8;
// Campaign V slice V11 deleted StorageTextureTable (binding 9): the GL-only
// emulation of the Vulkan texture table via a storage buffer of uvec2
// bindless handles indexed by GpuTextureSlot.Index. The Vulkan backend
// always bound TextureTableSet instead and never used this binding — every
// Vulkan descriptor set layout declared it anyway (seeded with a dummy
// buffer, like every other unused-by-a-given-shader binding), purely
// because it counted toward StorageBindingCount.
/// <summary>
/// #226 per-instance retail detail category. One uint parallel to
/// <see cref="StorageInstances"/>: 1 = building shell, 0 = every other
/// object. EnvCell detail uses its renderer-wide category and does not
/// inspect this field.
/// </summary>
public const uint StorageInstanceDetailCategory = 9;
// Campaign V slice V11 deleted the old GL-only StorageTextureTable from
// binding 9. #226 deliberately reclaims that vacant number for the detail
// category above; the Vulkan texture table remains set 2.
/// <summary>One past the highest storage binding — the count the backend must support.</summary>
public const uint StorageBindingCount = 9;
public const uint StorageBindingCount = 10;
// ---- set 1: uniform buffers ----
@ -106,9 +112,43 @@ internal static class GpuBindingModel
/// </summary>
public const uint UniformSkyParams = 4;
/// <summary>Set index carrying every uniform buffer.</summary>
/// <summary>
/// Immutable authored-atmosphere inputs for one enhanced world frame in
/// opt-in render-pack descriptor set 3.
/// The std140 ABI is four vec4 values: sunScreen, sunColor, viewport, and
/// weather. See AtmosphericFrameUniforms and atmospheric_common.glsl.
/// </summary>
public const uint UniformAtmosphericFrame = 5;
/// <summary>
/// Directional-shadow cascade matrices and sampling parameters. Reserved by
/// the shared pack ABI even when a Tier-1 graph leaves the dummy binding in
/// place, so Tier 2 never changes the common pipeline layout.
/// </summary>
public const uint UniformDirectionalShadow = 6;
/// <summary>
/// Per-fullscreen-pass values for enhancement graphs. The v1 std140 ABI is
/// four vec4 values named params0..params3; individual passes assign their
/// meanings without changing the descriptor layout.
/// </summary>
public const uint UniformPackPass = 7;
/// <summary>
/// Pack-declared settings in declaration order: sixteen std140 vec4 values
/// (64 scalar slots). Preset overrides are resolved before activation.
/// </summary>
public const uint UniformPackSettings = RenderPackShaderAbi.PackSettingsBinding;
/// <summary>Set index carrying retail uniform buffers.</summary>
public const uint UniformSet = 1;
/// <summary>
/// Opt-in render-pack uniform set. It is absent from retail layouts and is
/// created only while a render-pack pipeline is alive.
/// </summary>
public const uint RenderPackUniformSet = RenderPackShaderAbi.UniformDescriptorSet;
// ---- set 2: the global texture table ----
/// <summary>Set index of the sampled-texture descriptor array.</summary>

View file

@ -34,6 +34,13 @@ internal sealed record GpuCapabilityRecord
/// <summary>Required alignment for a storage-buffer binding offset.</summary>
public required uint MinStorageBufferOffsetAlignment { get; init; }
/// <summary>
/// Largest byte range one storage-buffer descriptor may expose. Vulkan
/// guarantees at least 128 MiB; optional render packs use the exact probed
/// value to size scene-dependent buffers instead of imposing a host ceiling.
/// </summary>
public uint MaxStorageBufferRangeBytes { get; init; } = 128u * 1024u * 1024u;
/// <summary>Required alignment for a uniform-buffer binding offset.</summary>
public required uint MinUniformBufferOffsetAlignment { get; init; }
@ -43,6 +50,20 @@ internal sealed record GpuCapabilityRecord
/// <summary>Highest supported multisample count for the backbuffer.</summary>
public required uint MaxSampleCount { get; init; }
/// <summary>Largest supported two-dimensional image edge from the selected adapter.</summary>
public required uint MaxImageDimension2D { get; init; }
/// <summary>Largest supported image-array layer count from the selected adapter.</summary>
public required uint MaxImageArrayLayers { get; init; }
/// <summary>
/// Total bytes in device-local heaps on the selected adapter. Render-pack
/// policy derives a deliberately bounded share from this value before any
/// optional image is allocated; zero means that no optional pack memory may
/// be assumed.
/// </summary>
public required ulong DeviceLocalMemoryBytes { get; init; }
/// <summary>Multi-draw-indirect. Mandatory — it is the entire draw architecture.</summary>
public required bool SupportsMultiDrawIndirect { get; init; }
@ -62,6 +83,27 @@ internal sealed record GpuCapabilityRecord
/// </summary>
public required bool SupportsPersistentlyMappedRings { get; init; }
/// <summary>
/// Whether RGBA16F images can be colour attachments, sampled, and linearly
/// filtered. Optional: absence disables HDR packs, never the retail client.
/// </summary>
public required bool SupportsRgba16FloatRenderTargets { get; init; }
/// <summary>
/// Highest usable sample count for an RGBA16F colour attachment that is
/// also a sampled resolve target. Zero means the format is unavailable.
/// </summary>
public required uint MaxRgba16FloatSampleCount { get; init; }
/// <summary>
/// Whether the selected combined depth/stencil format can also expose its
/// depth aspect as a sampled image. Optional: needed by screen-space packs.
/// </summary>
public required bool SupportsSampledDepth { get; init; }
/// <summary>Vulkan core multiview; optional and used only by packs that declare it.</summary>
public required bool SupportsMultiview { get; init; }
/// <summary>
/// Every mandatory capability this device fails to provide, phrased as
/// operator-facing sentences. Empty means the device can run acdream.

View file

@ -57,9 +57,9 @@ internal enum GpuRingUsage
}
/// <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.
/// Texture formats acdream uploads or renders. BC1/2/3 are the DXT1/3/5 DAT
/// surfaces uploaded verbatim; RGBA8 covers decoded and composited art; R8 is
/// the stb-baked font atlas; RGBA16F is reserved for opt-in HDR intermediates.
/// </summary>
internal enum GpuTextureFormat
{
@ -72,6 +72,12 @@ internal enum GpuTextureFormat
/// <summary>Colour attachment format for offscreen targets (paperdoll, appraisal).</summary>
Rgba8UnormRenderTarget,
/// <summary>
/// Half-float HDR colour attachment used only by opt-in enhancement graphs.
/// The retail/default graph remains on <see cref="Rgba8UnormRenderTarget"/>.
/// </summary>
Rgba16FloatRenderTarget,
/// <summary>Combined depth+stencil attachment. #117's portal punch needs the stencil aspect.</summary>
Depth24Stencil8,
}
@ -129,6 +135,14 @@ internal enum GpuBlendMode
/// `ParticleRenderer` needs it too (slice V4e).
/// </summary>
InverseAlpha,
/// <summary>
/// Retail building/EnvCell detail overlay:
/// <c>DstColor, OneMinusSrcAlpha</c>. This intentionally preserves the
/// retail client's measured brightening; it is not a conventional
/// modulate/roughening blend.
/// </summary>
RetailDetail,
}
internal enum GpuCompareOp

View file

@ -21,20 +21,24 @@ internal readonly record struct GpuColorAttachment(
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.
/// The depth/stencil attachment for a pass. Ordinary world/private-viewport
/// depth is transient, so <see cref="Store"/> is normally
/// <see cref="GpuStoreOp.DontCare"/>. Directional shadow layers instead name a
/// <see cref="DirectionalTarget"/> and use Store so receivers may sample them.
/// </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>
/// <param name="DirectionalTarget">Dedicated layered depth target, or null for the pass colour target/backbuffer depth.</param>
/// <param name="Layer">The cascade layer when <paramref name="DirectionalTarget"/> is present.</param>
internal readonly record struct GpuDepthAttachment(
GpuLoadOp Load,
GpuStoreOp Store,
float ClearDepth,
uint ClearStencil);
uint ClearStencil,
IGpuDirectionalDepthTarget? DirectionalTarget = null,
int Layer = 0);
/// <summary>
/// One rendering pass: a set of attachments, their load/store behaviour, and the
@ -57,15 +61,21 @@ 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>
/// <summary>The colour attachment. Ignored when <see cref="HasColorAttachment"/> is false.</summary>
public required GpuColorAttachment Color { get; init; }
/// <summary>False only for dedicated depth-only producers such as directional shadow maps.</summary>
public bool HasColorAttachment { get; init; } = true;
/// <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>Non-zero Vulkan multiview mask. Ordinary passes always leave this zero.</summary>
public uint ViewMask { get; init; }
/// <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()
{
@ -82,4 +92,43 @@ internal sealed record GpuPassDescription
ClearStencil: 0),
SampleCount = sampleCount,
};
/// <summary>Clears and stores one cascade layer of a directional-depth array.</summary>
public static GpuPassDescription DirectionalDepth(
string name,
IGpuDirectionalDepthTarget target,
int layer) => new()
{
Name = name,
Color = default,
HasColorAttachment = false,
Depth = new GpuDepthAttachment(
Load: GpuLoadOp.Clear,
Store: GpuStoreOp.Store,
ClearDepth: 1f,
ClearStencil: 0,
DirectionalTarget: target,
Layer: layer),
SampleCount = 1,
};
/// <summary>Clears and stores all contiguous cascade layers in one multiview pass.</summary>
public static GpuPassDescription DirectionalDepthMultiview(
string name,
IGpuDirectionalDepthTarget target,
uint viewMask) => new()
{
Name = name,
Color = default,
HasColorAttachment = false,
Depth = new GpuDepthAttachment(
GpuLoadOp.Clear,
GpuStoreOp.Store,
1f,
0,
target,
Layer: 0),
SampleCount = 1,
ViewMask = viewMask,
};
}

View file

@ -168,13 +168,39 @@ internal sealed record GpuVertexLayout(
}
/// <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.
/// Names one SPIR-V shader pair. Renderer-owned shaders resolve from the
/// committed shader directory. A selected render pack instead supplies an
/// immutable candidate-owned byte pair, so validation never turns into a
/// second host-path lookup or a private built-in shortcut.
/// </summary>
internal readonly record struct GpuShaderSet(string Name);
internal readonly record struct GpuShaderSet
{
internal GpuShaderSet(string name)
: this(name, ReadOnlyMemory<byte>.Empty, ReadOnlyMemory<byte>.Empty)
{
}
internal GpuShaderSet(
string name,
ReadOnlyMemory<byte> vertexSpirv,
ReadOnlyMemory<byte> fragmentSpirv)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
if (vertexSpirv.IsEmpty != fragmentSpirv.IsEmpty)
throw new ArgumentException("Both SPIR-V stages must be supplied together.");
Name = name;
VertexSpirv = vertexSpirv;
FragmentSpirv = fragmentSpirv;
}
internal string Name { get; }
internal ReadOnlyMemory<byte> VertexSpirv { get; }
internal ReadOnlyMemory<byte> FragmentSpirv { get; }
internal bool HasEmbeddedSpirv => !VertexSpirv.IsEmpty;
}
/// <summary>Depth-buffer behaviour baked into a pipeline.</summary>
/// <param name="Test">Whether depth testing is enabled at all.</param>
@ -241,6 +267,8 @@ internal readonly record struct GpuStencilState(
/// </summary>
internal sealed record GpuPipelineDescription
{
/// <summary>Non-zero only for a pipeline compiled for a matching multiview pass.</summary>
public uint ViewMask { get; init; }
/// <summary>Stable identifier, e.g. <c>"mesh-opaque"</c>. Surfaced to RenderDoc and validation layers.</summary>
public required string Name { get; init; }
@ -275,6 +303,12 @@ internal sealed record GpuPipelineDescription
/// <summary>Whether the pipeline writes colour at all. False for depth/stencil-only prepasses.</summary>
public bool ColorWrite { get; init; } = true;
/// <summary>
/// Whether the compatible dynamic-rendering pass carries a colour
/// attachment. False creates a true depth-only graphics pipeline.
/// </summary>
public bool HasColorAttachment { get; init; } = true;
/// <summary>
/// Whether this pipeline uses the stencil aspect at all.
///
@ -324,6 +358,21 @@ internal sealed record GpuPipelineDescription
/// </summary>
public GpuTextureFormat ColorFormat { get; init; } = GpuTextureFormat.Rgba8UnormRenderTarget;
/// <summary>
/// Whether an opt-in graph may prebuild this pipeline against an additional
/// colour-attachment format. World pipelines leave this enabled; dedicated
/// fullscreen pipelines already name their only format and disable it.
/// </summary>
public bool AllowColorFormatVariants { get; init; } = true;
/// <summary>
/// Opts this pipeline into render-pack shader ABI v1. Vulkan then uses the
/// lazy four-set pipeline layout whose set 3 contains bindings 5..8; retail
/// pipelines keep the authoritative three-set layout and create no pack
/// descriptors or layouts.
/// </summary>
public bool UsesRenderPackShaderAbi { get; init; }
/// <summary>Sample count of the passes this pipeline is used in. Must match the pass.</summary>
public int SampleCount { get; init; } = 1;
}

View file

@ -55,7 +55,9 @@ internal struct GpuPushConstants
/// </summary>
public uint TextureIndexA;
/// <summary>GLSL <c>uTextureIndexB</c>. Secondary per-pass slot — currently the terrain alpha-mask array.</summary>
/// <summary>GLSL <c>uTextureIndexB</c>. Secondary per-pass slot; terrain
/// uses it for the alpha-mask array, while shared-pose world/detail passes
/// carry the absolute transform-prefix instance count.</summary>
public uint TextureIndexB;
/// <summary>

View file

@ -76,22 +76,55 @@ internal readonly record struct GpuSamplerDescription(
GpuAddressMode.ClampToEdge,
GpuAddressMode.ClampToEdge,
MaxAnisotropy: 1f);
/// <summary>
/// Discrete nearest-clamp depth reads for manual PCF. Mip-nearest is
/// deliberate even though the shadow image has one level: it keeps this
/// pack-owned sampler distinct from the device's long-lived UI sampler.
/// </summary>
public static GpuSamplerDescription ShadowNearestClamp { get; } = new(
GpuFilter.Nearest,
GpuFilter.Nearest,
GpuMipFilter.Nearest,
GpuAddressMode.ClampToEdge,
GpuAddressMode.ClampToEdge,
MaxAnisotropy: 1f);
}
/// <summary>An offscreen colour(+depth) bundle: paperdoll, creature appraisal, portal masking.</summary>
/// <summary>An offscreen colour(+depth) bundle: paperdoll, creature appraisal, portal masking, or an enhancement intermediate.</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>
/// <param name="SampleCount">
/// Attachment sample count. Values above one use transient multisample
/// attachments and resolve into the single-sampled textures exposed by
/// <see cref="IGpuRenderTarget"/>.
/// </param>
/// <param name="SampleableDepth">
/// Whether the depth result must be exposed for later shader sampling. This is
/// opt-in so ordinary private viewports retain transient attachment-only depth.
/// </param>
internal readonly record struct GpuRenderTargetDescription(
string Name,
int Width,
int Height,
GpuTextureFormat ColorFormat,
GpuTextureFormat? DepthFormat,
int SampleCount);
int SampleCount,
bool SampleableDepth = false);
/// <summary>
/// A single-sampled, sampleable depth-array used by directional shadow maps.
/// Each cascade is rendered through its own 2-D layer attachment while the
/// complete array is registered once in the global texture table.
/// </summary>
internal readonly record struct GpuDirectionalDepthTargetDescription(
string Name,
int Resolution,
int LayerCount,
GpuTextureFormat DepthFormat = GpuTextureFormat.Depth24Stencil8);
/// <summary>
/// A slot in the device's global texture table — the backend-neutral replacement

View file

@ -13,6 +13,13 @@ internal interface IGpuBuffer : IDisposable
GpuBufferUsage Usage { get; }
GpuMemoryResidency Residency { get; }
/// <summary>
/// True when CPU writes through a mapped HostWritable allocation are made
/// available without an explicit non-coherent atom flush. Retained mapped
/// resources may require this and fail safe when a device cannot provide it.
/// </summary>
bool HostWritesAreCoherent { get; }
/// <summary>
/// Writes <paramref name="data"/> at <paramref name="offsetBytes"/>. On a
/// <see cref="GpuMemoryResidency.DeviceLocal"/> buffer this stages through a
@ -89,8 +96,32 @@ internal interface IGpuRenderTarget : IDisposable
{
GpuRenderTargetDescription Description { get; }
/// <summary>The colour attachment, for registering into the texture table or blitting into UI.</summary>
/// <summary>
/// The single-sampled colour result, for registering into the texture table
/// or blitting into UI. A multisampled target resolves into this texture;
/// callers never sample its transient multisample attachment directly.
/// </summary>
IGpuTexture ColorTexture { get; }
/// <summary>
/// The single-sampled depth result when
/// <see cref="GpuRenderTargetDescription.SampleableDepth"/> was requested;
/// otherwise null. Combined depth/stencil targets expose the depth aspect
/// only through the sampled view while retaining stencil for rendering.
/// </summary>
IGpuTexture? DepthTexture { get; }
}
/// <summary>
/// A sampleable directional-depth array. Layers are attachment-addressable by
/// <see cref="GpuDepthAttachment.Layer"/>; callers sample the full array through
/// <see cref="DepthTexture"/> after the producing passes end.
/// </summary>
internal interface IGpuDirectionalDepthTarget : IDisposable
{
GpuDirectionalDepthTargetDescription Description { get; }
IGpuTexture DepthTexture { get; }
}
/// <summary>
@ -106,4 +137,12 @@ internal interface IGpuTimerPool
/// <summary>Milliseconds measured for <paramref name="scopeName"/> in the most recent retired frame.</summary>
bool TryResolve(string scopeName, out double milliseconds);
/// <summary>
/// Consumes the newest retired measurement for <paramref name="scopeName"/>.
/// Distribution builders use this form so a GPU result is sampled exactly
/// once even when the render thread runs several frames before another
/// flight slot retires.
/// </summary>
bool TryTakeResolved(string scopeName, out double milliseconds);
}

View file

@ -4,6 +4,10 @@ namespace AcDream.App.Rendering.Gpu;
/// 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.
/// Resource creation/registration and retirement are safe for one asynchronous
/// off-side render-pack preparation worker while the render thread records the
/// active generation. Frame/pass recording and queued-device-action draining
/// remain render-thread-only.
///
/// 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
@ -49,6 +53,10 @@ internal interface IGpuDevice : IDisposable
IGpuRenderTarget CreateRenderTarget(in GpuRenderTargetDescription description);
/// <summary>Creates the dedicated 2-4 cascade sampleable depth array.</summary>
IGpuDirectionalDepthTarget CreateDirectionalDepthTarget(
in GpuDirectionalDepthTargetDescription 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

View file

@ -65,6 +65,14 @@ internal interface IGpuFrame : IDisposable
/// </summary>
GpuRingAllocation AllocateRing(int byteCount, GpuRingUsage usage);
/// <summary>
/// Publishes CPU writes made through a retained host-writable storage
/// buffer before a later pass reads them in a shader. Frame-ring writes use
/// the frame submission's existing visibility contract; this explicit seam
/// exists for pack-owned mapped buffers that persist across submissions.
/// </summary>
void PublishHostStorageWrites(IGpuBuffer buffer);
/// <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.

View file

@ -0,0 +1,13 @@
namespace AcDream.App.Rendering.Gpu;
/// <summary>
/// Device-owned lifetime for attachment-format variants of already-created
/// graphics pipelines. Vulkan dynamic rendering bakes the colour format into a
/// pipeline; an enhancement graph acquires its HDR format before recording any
/// enhanced pass and releases it when the pack retires. The clean retail path
/// never acquires a lease and therefore creates no HDR world variants.
/// </summary>
internal interface IGpuPipelineFormatVariantHost
{
IDisposable AcquirePipelineColorFormat(GpuTextureFormat format);
}

View file

@ -39,6 +39,9 @@ internal sealed record VulkanDeviceFeatureSupport
/// <summary><c>gl_DrawID</c>. Resets per indirect dispatch exactly as GL's does.</summary>
public required bool ShaderDrawParameters { get; init; }
/// <summary>Optional Vulkan 1.1 core multiview support for layered shadow cascades.</summary>
public required bool Multiview { get; init; }
// ---- 1.2 ----
/// <summary>One monotonic serial replaces the GL fence array; the retirement ledger keeps its keys.</summary>
@ -93,6 +96,7 @@ internal sealed record VulkanDeviceFeatureSupport
TextureCompressionBc = true,
SamplerAnisotropy = true,
ShaderDrawParameters = true,
Multiview = true,
TimelineSemaphore = true,
HostQueryReset = true,
RuntimeDescriptorArray = true,
@ -123,6 +127,7 @@ internal sealed record VulkanDeviceFeatureSupport
var n when Is(n, nameof(TextureCompressionBc)) => this with { TextureCompressionBc = false },
var n when Is(n, nameof(SamplerAnisotropy)) => this with { SamplerAnisotropy = false },
var n when Is(n, nameof(ShaderDrawParameters)) => this with { ShaderDrawParameters = false },
var n when Is(n, nameof(Multiview)) => this with { Multiview = false },
var n when Is(n, nameof(TimelineSemaphore)) => this with { TimelineSemaphore = false },
var n when Is(n, nameof(HostQueryReset)) => this with { HostQueryReset = false },
var n when Is(n, nameof(RuntimeDescriptorArray)) => this with { RuntimeDescriptorArray = false },
@ -170,6 +175,15 @@ internal sealed record VulkanDeviceLimitSupport
/// </summary>
public required uint MaxDescriptorSetStorageBuffersDynamic { get; init; }
/// <summary>Must reach every storage binding declared by descriptor set 0.</summary>
public required uint MaxDescriptorSetStorageBuffers { get; init; }
/// <summary>
/// Must reach every set-0 storage binding because the shared layout exposes
/// all of them to both the vertex and fragment stages.
/// </summary>
public required uint MaxPerStageDescriptorStorageBuffers { get; init; }
/// <summary>Must reach the number of dynamic uniform bindings set 1 declares.</summary>
public required uint MaxDescriptorSetUniformBuffersDynamic { get; init; }
@ -185,12 +199,25 @@ internal sealed record VulkanDeviceLimitSupport
/// <summary>Ring allocations must satisfy this; getting it wrong is a driver error on Vulkan.</summary>
public required uint MinStorageBufferOffsetAlignment { get; init; }
/// <summary>
/// Largest legal range in one storage-buffer descriptor. Vulkan 1.3
/// guarantees at least 128 MiB; enhanced scene buffers are bounded by the
/// actual adapter value rather than a renderer-authored constant.
/// </summary>
public required uint MaxStorageBufferRange { get; init; }
/// <summary>As above, for the SceneLighting uniform block.</summary>
public required uint MinUniformBufferOffsetAlignment { get; init; }
/// <summary>Largest 2D image edge; the terrain atlas and composite arrays are sized against it.</summary>
public required uint MaxImageDimension2D { get; init; }
/// <summary>Largest image-array layer count reported by the selected physical device.</summary>
public required uint MaxImageArrayLayers { get; init; }
/// <summary>Sum of device-local heap bytes reported by the selected physical device.</summary>
public required ulong DeviceLocalHeapBytes { get; init; }
/// <summary>Highest colour sample count the framebuffer supports, as a plain count (1/2/4/8...).</summary>
public required uint MaxColorSampleCount { get; init; }
@ -205,16 +232,22 @@ internal sealed record VulkanDeviceLimitSupport
MaxPushConstantsSize = GpuBindingModel.MaxPushConstantBytes,
MaxClipDistances = GpuBindingModel.ClipPlanesPerSlot,
MaxBoundDescriptorSets = 4,
// Vulkan's guaranteed minimums. That the layout fits inside them is the
// point of slice V6g's split — see VulkanPipelineLayouts.
// The dynamic counts use Vulkan's guaranteed minimums. Total/per-stage
// counts use acdream's shared-layout requirement, which the startup
// capability gate verifies on the real device.
MaxDescriptorSetStorageBuffersDynamic = 4,
MaxDescriptorSetStorageBuffers = GpuBindingModel.StorageBindingCount,
MaxPerStageDescriptorStorageBuffers = GpuBindingModel.StorageBindingCount,
MaxDescriptorSetUniformBuffersDynamic = 8,
MaxDescriptorSetUpdateAfterBindSampledImages = GpuBindingModel.TextureTableCapacity,
MaxPerStageDescriptorUpdateAfterBindSampledImages = GpuBindingModel.TextureTableCapacity,
TimestampComputeAndGraphics = true,
MinStorageBufferOffsetAlignment = 256,
MaxStorageBufferRange = 128u * 1024u * 1024u,
MinUniformBufferOffsetAlignment = 256,
MaxImageDimension2D = 16384,
MaxImageArrayLayers = 2048,
DeviceLocalHeapBytes = 8UL * 1024 * 1024 * 1024,
MaxColorSampleCount = 8,
};
}
@ -236,6 +269,24 @@ internal sealed record VulkanFormatSupport
/// <summary>The chosen depth+stencil format, or <see cref="Format.Undefined"/> when none is usable.</summary>
public required Format DepthStencilFormat { get; init; }
/// <summary>Whether the chosen combined depth/stencil format is sampleable through its depth aspect.</summary>
public required bool DepthStencilSampled { get; init; }
/// <summary>RGBA16F supports optimal-tiling colour-attachment writes.</summary>
public required bool Rgba16FloatColorAttachment { get; init; }
/// <summary>RGBA16F supports optimal-tiling sampled-image reads.</summary>
public required bool Rgba16FloatSampled { get; init; }
/// <summary>RGBA16F supports linear filtering, required by scaled bloom/ray passes.</summary>
public required bool Rgba16FloatLinearFilter { get; init; }
/// <summary>
/// Highest supported RGBA16F sample count for a colour-attachment image.
/// Zero means the format/usage combination is unavailable.
/// </summary>
public required uint MaxRgba16FloatSampleCount { get; init; }
/// <summary>BC1 (DXT1) sampled-image support with optimal tiling.</summary>
public required bool Bc1Sampled { get; init; }
@ -249,6 +300,11 @@ internal sealed record VulkanFormatSupport
{
SwapchainUnormFormat = true,
DepthStencilFormat = Format.D32SfloatS8Uint,
DepthStencilSampled = true,
Rgba16FloatColorAttachment = true,
Rgba16FloatSampled = true,
Rgba16FloatLinearFilter = true,
MaxRgba16FloatSampleCount = 8,
Bc1Sampled = true,
Bc2Sampled = true,
Bc3Sampled = true,
@ -368,23 +424,36 @@ internal sealed record VulkanCapabilityRecord(
Math.Min(
Limits.MaxDescriptorSetUpdateAfterBindSampledImages,
Limits.MaxPerStageDescriptorUpdateAfterBindSampledImages),
// Sets 0..2 give each binding its own namespace, so the storage
// bindings the model declares (nine, since Campaign V slice V11
// deleted the GL-only StorageTextureTable binding) are always all
// available once the set count requirement passes. There is no
// per-set binding-count limit in Vulkan below
// maxPerStageDescriptorStorageBuffers, which is far higher.
MaxStorageBufferBindings = GpuBindingModel.StorageBindingCount,
MaxStorageBufferBindings = Math.Min(
Limits.MaxDescriptorSetStorageBuffers,
Limits.MaxPerStageDescriptorStorageBuffers),
MaxPushConstantBytes = Limits.MaxPushConstantsSize,
MinStorageBufferOffsetAlignment = Limits.MinStorageBufferOffsetAlignment,
MaxStorageBufferRangeBytes = Limits.MaxStorageBufferRange,
MinUniformBufferOffsetAlignment = Limits.MinUniformBufferOffsetAlignment,
MaxClipDistances = Limits.MaxClipDistances,
MaxSampleCount = Limits.MaxColorSampleCount,
MaxImageDimension2D = Limits.MaxImageDimension2D,
MaxImageArrayLayers = Limits.MaxImageArrayLayers,
DeviceLocalMemoryBytes = Limits.DeviceLocalHeapBytes,
SupportsMultiDrawIndirect = Features.MultiDrawIndirect,
SupportsDrawParameters = Features.ShaderDrawParameters,
SupportsTextureCompressionBc = Features.TextureCompressionBc,
SupportsTimestampQueries = Limits.TimestampComputeAndGraphics,
SupportsMultiview = Features.Multiview,
SupportsPersistentlyMappedRings = true,
SupportsRgba16FloatRenderTargets =
Formats.Rgba16FloatColorAttachment
&& Formats.Rgba16FloatSampled
&& Formats.Rgba16FloatLinearFilter
&& Formats.MaxRgba16FloatSampleCount > 0,
MaxRgba16FloatSampleCount =
Formats.Rgba16FloatColorAttachment
&& Formats.Rgba16FloatSampled
&& Formats.Rgba16FloatLinearFilter
? Math.Min(Formats.MaxRgba16FloatSampleCount, Limits.MaxColorSampleCount)
: 0u,
SupportsSampledDepth = Formats.DepthStencilSampled,
};
}
@ -478,6 +547,18 @@ internal static class VulkanCapabilityRequirements
$"set 0 declares {VulkanPipelineLayouts.DynamicStorageBindingCount} dynamic storage bindings " +
$"(Vulkan guarantees 4); this device provides {limits.MaxDescriptorSetStorageBuffersDynamic}.");
}
if (limits.MaxDescriptorSetStorageBuffers < GpuBindingModel.StorageBindingCount)
{
failures.Add(
$"set 0 declares {GpuBindingModel.StorageBindingCount} total storage bindings; " +
$"this device provides {limits.MaxDescriptorSetStorageBuffers} per set.");
}
if (limits.MaxPerStageDescriptorStorageBuffers < GpuBindingModel.StorageBindingCount)
{
failures.Add(
$"set 0 exposes {GpuBindingModel.StorageBindingCount} storage bindings to each shader stage; " +
$"this device provides {limits.MaxPerStageDescriptorStorageBuffers} per stage.");
}
if (limits.MaxDescriptorSetUniformBuffersDynamic < VulkanFrameBindings.DynamicUniformBindingCount)
{
failures.Add(

View file

@ -1,6 +1,10 @@
using System.Diagnostics;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Packs;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Wb;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.World;
@ -68,8 +72,13 @@ internal sealed class VulkanRenderFrameClearPhase : IRenderFrameClearPhase
Math.Clamp(atmosphere.FogColor.Z, 0f, 1f),
1f);
var foundation = new RenderFrameFoundation(
portalViewportVisible,
sky,
atmosphere);
_clear.ClearColor = clear;
return new RenderFrameFoundation(portalViewportVisible, sky, atmosphere);
_clear.Foundation = foundation;
return foundation;
}
}
@ -108,19 +117,39 @@ internal sealed class VulkanWorldScenePhase : IWorldSceneFramePhase
private readonly Func<int> _sampleCount;
private readonly VulkanWorldPassScope _scope;
private readonly IWorldSceneFramePhase _world;
private readonly RenderPackController? _renderPacks;
private readonly AtmosphericFrameInputState? _atmosphere;
private readonly Func<RenderPackActivationExtent, RenderPackActivationSnapshot>?
_applyRenderPackBoundary;
private readonly RenderSceneShadowRuntime? _renderScene;
private readonly WbDrawDispatcher? _worldMeshes;
private readonly TerrainModernRenderer? _terrain;
public VulkanWorldScenePhase(
ICurrentGpuFrameSource frames,
VulkanBackbufferClearState clear,
Func<int> sampleCount,
VulkanWorldPassScope scope,
IWorldSceneFramePhase world)
IWorldSceneFramePhase world,
RenderPackController? renderPacks = null,
AtmosphericFrameInputState? atmosphere = null,
Func<RenderPackActivationExtent, RenderPackActivationSnapshot>?
applyRenderPackBoundary = null,
RenderSceneShadowRuntime? renderScene = null,
WbDrawDispatcher? worldMeshes = null,
TerrainModernRenderer? terrain = null)
{
_frames = frames ?? throw new ArgumentNullException(nameof(frames));
_clear = clear ?? throw new ArgumentNullException(nameof(clear));
_sampleCount = sampleCount ?? throw new ArgumentNullException(nameof(sampleCount));
_scope = scope ?? throw new ArgumentNullException(nameof(scope));
_world = world ?? throw new ArgumentNullException(nameof(world));
_renderPacks = renderPacks;
_atmosphere = atmosphere;
_applyRenderPackBoundary = applyRenderPackBoundary;
_renderScene = renderScene;
_worldMeshes = worldMeshes;
_terrain = terrain;
}
public WorldRenderFrameOutcome Render(RenderFrameInput input)
@ -129,6 +158,264 @@ internal sealed class VulkanWorldScenePhase : IWorldSceneFramePhase
?? throw new InvalidOperationException(
"The Vulkan world phase requires an open IGpuFrame (see GpuDeviceFrameLifetime).");
int samples = _sampleCount();
if (_renderPacks is not null)
{
var extent = new RenderPackActivationExtent(
input.ViewportWidth,
input.ViewportHeight,
samples);
_ = _applyRenderPackBoundary is not null
? _applyRenderPackBoundary(extent)
: _renderPacks.ApplyAtFrameBoundary(extent);
}
if (_renderPacks?.ActiveRuntime is { } active)
{
if (active is IDefaultWorldPathRenderPackRuntime)
return RenderRetail(frame, input);
if (active is not IAtmosphericWorldGraphRuntime graph
|| _atmosphere is null)
{
_renderPacks.OnRuntimeFailure(
"The selected pack has no compatible production world graph.");
return RenderRetail(frame, input);
}
IAtmosphericCpuStageProfileRuntime? cpuStageProfile =
graph as IAtmosphericCpuStageProfileRuntime;
bool profileCpuStages = cpuStageProfile?.ShouldProfileCpuFrame(frame.Serial) == true;
long packCpuTicks = 0;
long targetPreparationTicks = 0;
IGpuRenderTarget target;
long packStarted = Stopwatch.GetTimestamp();
try
{
target = graph.PrepareWorldTarget(
input.ViewportWidth,
input.ViewportHeight,
samples);
}
catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error))
{
_renderPacks.OnRuntimeFailure(
"Atmospheric target creation failed: "
+ error.GetBaseException().Message);
return RenderRetail(frame, input);
}
finally
{
long elapsed = Stopwatch.GetTimestamp() - packStarted;
packCpuTicks += elapsed;
if (profileCpuStages)
targetPreparationTicks = elapsed;
}
_atmosphere.BeginFrame(in input, _clear.Foundation);
PreparedWorldSceneFrame? prepared = null;
if (graph is IDirectionalShadowWorldGraphRuntime directional)
{
if (_world is not IPreparedWorldSceneFramePhase preparedWorld
|| _renderScene is null
|| _worldMeshes is null
|| _terrain is null)
{
_renderPacks.OnRuntimeFailure(
"The selected directional-shadow pack has no compatible world preparation seam.");
return RenderRetail(frame, input);
}
PreparedWorldSceneFrame value;
try
{
value = preparedWorld.PrepareEnhanced(input);
}
catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error))
{
_renderPacks.OnRuntimeFailure(
"Atmospheric world preparation failed: "
+ error.GetBaseException().Message);
return RenderRetail(frame, input);
}
prepared = value;
if (value.ShouldRender)
{
packStarted = Stopwatch.GetTimestamp();
try
{
RenderSceneQuery scene = _renderScene.Query;
RenderFrameFoundation preparedFoundation = value.Foundation;
WorldRenderFrame preparedWorldFrame = value.World;
directional.RenderDirectionalShadows(
frame,
in preparedFoundation,
in preparedWorldFrame,
value.ActiveDayGroup,
in scene,
_worldMeshes,
_terrain);
}
catch (Exception error) when (VulkanRenderFailurePolicy.IsFatal(error))
{
preparedWorld.CancelPreparedEnhanced(in value);
throw;
}
catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error))
{
preparedWorld.CancelPreparedEnhanced(in value);
// DirectionalShadowRenderer may have completed its depth
// pass and published the pack-owned retained transform
// prefix before a later graph check fails (notably the
// scene-dependent retained-VRAM ceiling). Cancel the
// dispatcher's borrowed same-frame slice before
// OnRuntimeFailure disposes the pack and its buffers;
// RenderRetail below must allocate its ordinary N.5
// transforms from the frame ring, never append to that
// retired prefix.
_worldMeshes.CancelDirectionalShadowTransformFrame(frame);
_renderPacks.OnRuntimeFailure(
"Directional shadow rendering failed: "
+ error.GetBaseException().Message);
return RenderRetail(frame, input);
}
finally
{
packCpuTicks += Stopwatch.GetTimestamp() - packStarted;
}
}
}
WorldRenderFrameOutcome outcome;
long receiverCpuTicks = 0;
try
{
using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription
{
Name = "atmospheric-world-hdr",
Color = new GpuColorAttachment(
target,
GpuLoadOp.Clear,
samples > 1 ? GpuStoreOp.Resolve : GpuStoreOp.Store,
_clear.ClearColor),
Depth = new GpuDepthAttachment(
GpuLoadOp.Clear,
GpuStoreOp.Store,
1f,
0),
SampleCount = samples,
});
using IDisposable publication = prepared is { ShouldRender: true }
? _scope.PublishPrepared(encoder)
: _scope.Publish(encoder);
if (prepared is { } value)
{
using IDisposable receiverTimer = encoder.BeginTimerScope(
RenderPackPerformanceScopeNames.EnhancedWorldReceiver);
long receiverStarted = Stopwatch.GetTimestamp();
try
{
outcome = ((IPreparedWorldSceneFramePhase)_world)
.RenderPreparedEnhanced(input, in value);
}
finally
{
receiverCpuTicks += Stopwatch.GetTimestamp() - receiverStarted;
}
}
else
{
outcome = _world.Render(input);
}
}
catch (Exception error) when (VulkanRenderFailurePolicy.IsFatal(error))
{
if (prepared is { } value
&& _world is IPreparedWorldSceneFramePhase preparedWorld)
{
preparedWorld.CancelPreparedEnhanced(in value);
}
_worldMeshes?.CancelDirectionalShadowTransformFrame(frame);
throw;
}
catch (Exception error)
{
if (prepared is { } value
&& _world is IPreparedWorldSceneFramePhase preparedWorld)
{
preparedWorld.CancelPreparedEnhanced(in value);
}
_worldMeshes?.CancelDirectionalShadowTransformFrame(frame);
// The HDR pass may already contain receiver commands, so it
// cannot be replayed through retail in this frame. Quarantine
// the pack, return an empty outcome for this one aborted frame,
// and let the next frame use the unchanged default renderer.
_renderPacks?.OnRuntimeFailure(
"Atmospheric world rendering failed: "
+ error.GetBaseException().Message);
return default;
}
try
{
AtmosphericFrameInputs atmospheric = _atmosphere.Snapshot();
packStarted = Stopwatch.GetTimestamp();
graph.RenderPostProcess(frame, in atmospheric);
packCpuTicks += Stopwatch.GetTimestamp() - packStarted;
var observation = new RenderPackFramePerformanceObservation(
PackAddedCpuMilliseconds: packCpuTicks * 1000d / Stopwatch.Frequency,
StableFrameBoundary: outcome.NormalWorldDrawn,
input.ViewportWidth,
input.ViewportHeight,
samples,
AbsoluteEnhancedWorldReceiverCpuMilliseconds:
receiverCpuTicks * 1000d / Stopwatch.Frequency);
bool observationSucceeded = false;
long observeStarted = profileCpuStages ? Stopwatch.GetTimestamp() : 0L;
try
{
_renderPacks.ObserveActiveFrame(in observation);
observationSucceeded = true;
}
catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error))
{
_renderPacks.OnRuntimeFailure(
"Atmospheric performance observation failed: "
+ error.GetBaseException().Message);
}
long observeBookkeepingTicks = profileCpuStages
? Stopwatch.GetTimestamp() - observeStarted
: 0L;
if (observationSucceeded && profileCpuStages)
{
cpuStageProfile!.CompleteCpuProfile(
frame.Serial,
targetPreparationTicks,
packCpuTicks,
observeBookkeepingTicks,
outcome.NormalWorldDrawn);
}
return outcome;
}
catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error))
{
// The canonical world transaction has already completed and
// cannot legally be replayed. Keep its outcome, quarantine the
// pack, and let the next frame use the unchanged default path.
_renderPacks.OnRuntimeFailure(
"Atmospheric post-processing failed: "
+ error.GetBaseException().Message);
return outcome;
}
}
return RenderRetail(frame, input);
}
private WorldRenderFrameOutcome RenderRetail(
IGpuFrame frame,
RenderFrameInput input)
{
// This is the exact pre-pack pass/resource/pipeline path. Keep the branch
// whole so Retail selection does not create, touch, or query any pack
// object after ApplyAtFrameBoundary reports no active runtime.
int samples = _sampleCount();
using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription
{
@ -167,6 +454,8 @@ internal sealed class VulkanWorldScenePhase : IWorldSceneFramePhase
internal sealed class VulkanBackbufferClearState
{
internal System.Numerics.Vector4 ClearColor { get; set; } = new(0f, 0f, 0f, 1f);
internal RenderFrameFoundation Foundation { get; set; }
}
/// <summary>

View file

@ -12,6 +12,7 @@ internal readonly unsafe struct VulkanAllocation(
ulong offsetBytes,
ulong sizeBytes,
uint memoryTypeIndex,
MemoryPropertyFlags memoryProperties,
VulkanMemoryRange range,
void* mapped)
{
@ -19,6 +20,7 @@ internal readonly unsafe struct VulkanAllocation(
internal ulong OffsetBytes { get; } = offsetBytes;
internal ulong SizeBytes { get; } = sizeBytes;
internal uint MemoryTypeIndex { get; } = memoryTypeIndex;
internal MemoryPropertyFlags MemoryProperties { get; } = memoryProperties;
internal VulkanMemoryRange Range { get; } = range;
/// <summary>First mapped byte of this allocation, or null on device-local memory.</summary>
@ -64,6 +66,7 @@ internal sealed unsafe class VulkanDeviceMemoryAllocator : IDisposable
private readonly MemoryPropertyFlags[] _memoryTypeProperties;
private readonly ulong _blockSizeBytes;
private readonly ulong _dedicatedThresholdBytes;
private readonly object _sync = new();
private readonly Dictionary<uint, VulkanMemoryTypePool> _pools = [];
private readonly Dictionary<(uint TypeIndex, int BlockIndex), BlockMemory> _blockMemory = [];
@ -110,9 +113,11 @@ internal sealed unsafe class VulkanDeviceMemoryAllocator : IDisposable
GpuMemoryResidency residency,
string ownerName)
{
ObjectDisposedException.ThrowIf(_disposed, this);
lock (_sync)
{
ObjectDisposedException.ThrowIf(_disposed, this);
uint typeIndex = VulkanMemoryTypeSelection.Choose(
uint typeIndex = VulkanMemoryTypeSelection.Choose(
_memoryTypeProperties,
requirements.MemoryTypeBits,
residency)
@ -121,64 +126,69 @@ internal sealed unsafe class VulkanDeviceMemoryAllocator : IDisposable
$"Allowed type bits 0x{requirements.MemoryTypeBits:X8}; the device exposes " +
$"{_memoryTypeProperties.Length} memory types.");
if (!_pools.TryGetValue(typeIndex, out VulkanMemoryTypePool? pool))
{
pool = new VulkanMemoryTypePool(typeIndex, _blockSizeBytes, _dedicatedThresholdBytes);
_pools.Add(typeIndex, pool);
if (!_pools.TryGetValue(typeIndex, out VulkanMemoryTypePool? pool))
{
pool = new VulkanMemoryTypePool(typeIndex, _blockSizeBytes, _dedicatedThresholdBytes);
_pools.Add(typeIndex, pool);
}
ulong size = requirements.Size;
ulong alignment = Math.Max(requirements.Alignment, 1);
if (!pool.TryAllocate(size, alignment, out VulkanMemoryRange range))
{
bool dedicated = pool.IsDedicatedSize(size);
ulong capacity = Math.Max(pool.BlockCapacityFor(size), size);
int blockIndex = pool.AddBlock(capacity, dedicated);
CreateBlockMemory(typeIndex, blockIndex, capacity, ownerName);
range = dedicated
? pool.AllocateWholeBlock(blockIndex, size)
: pool.TryAllocate(size, alignment, out VulkanMemoryRange placed)
? placed
: throw new InvalidOperationException(
$"A freshly created {capacity}-byte block could not satisfy a {size}-byte " +
$"allocation at alignment {alignment} for '{ownerName}'.");
}
BlockMemory block = _blockMemory[(typeIndex, range.BlockIndex)];
AllocatedBytes += range.SizeBytes;
void* mapped = block.Mapped == 0
? null
: (void*)(block.Mapped + (nint)range.OffsetBytes);
return new VulkanAllocation(
block.Memory,
range.OffsetBytes,
range.SizeBytes,
typeIndex,
_memoryTypeProperties[(int)typeIndex],
range,
mapped);
}
ulong size = requirements.Size;
ulong alignment = Math.Max(requirements.Alignment, 1);
if (!pool.TryAllocate(size, alignment, out VulkanMemoryRange range))
{
bool dedicated = pool.IsDedicatedSize(size);
ulong capacity = Math.Max(pool.BlockCapacityFor(size), size);
int blockIndex = pool.AddBlock(capacity, dedicated);
CreateBlockMemory(typeIndex, blockIndex, capacity, ownerName);
range = dedicated
? pool.AllocateWholeBlock(blockIndex, size)
: pool.TryAllocate(size, alignment, out VulkanMemoryRange placed)
? placed
: throw new InvalidOperationException(
$"A freshly created {capacity}-byte block could not satisfy a {size}-byte " +
$"allocation at alignment {alignment} for '{ownerName}'.");
}
BlockMemory block = _blockMemory[(typeIndex, range.BlockIndex)];
AllocatedBytes += range.SizeBytes;
void* mapped = block.Mapped == 0
? null
: (void*)(block.Mapped + (nint)range.OffsetBytes);
return new VulkanAllocation(
block.Memory,
range.OffsetBytes,
range.SizeBytes,
typeIndex,
range,
mapped);
}
/// <summary>Returns an allocation's bytes to its pool, freeing the block when a dedicated one empties.</summary>
internal void Free(in VulkanAllocation allocation)
{
if (_disposed || allocation.SizeBytes == 0)
return;
if (!_pools.TryGetValue(allocation.MemoryTypeIndex, out VulkanMemoryTypePool? pool))
return;
lock (_sync)
{
if (_disposed || allocation.SizeBytes == 0)
return;
if (!_pools.TryGetValue(allocation.MemoryTypeIndex, out VulkanMemoryTypePool? pool))
return;
AllocatedBytes -= Math.Min(AllocatedBytes, allocation.Range.SizeBytes);
if (!pool.Free(allocation.Range))
return;
AllocatedBytes -= Math.Min(AllocatedBytes, allocation.Range.SizeBytes);
if (!pool.Free(allocation.Range))
return;
var key = (allocation.MemoryTypeIndex, allocation.Range.BlockIndex);
if (!_blockMemory.Remove(key, out BlockMemory block))
return;
var key = (allocation.MemoryTypeIndex, allocation.Range.BlockIndex);
if (!_blockMemory.Remove(key, out BlockMemory block))
return;
if (block.Mapped != 0)
_vk.UnmapMemory(_device, block.Memory);
_vk.FreeMemory(_device, block.Memory, null);
CommittedBytes -= Math.Min(CommittedBytes, block.CapacityBytes);
if (block.Mapped != 0)
_vk.UnmapMemory(_device, block.Memory);
_vk.FreeMemory(_device, block.Memory, null);
CommittedBytes -= Math.Min(CommittedBytes, block.CapacityBytes);
}
}
private void CreateBlockMemory(uint typeIndex, int blockIndex, ulong capacityBytes, string ownerName)
@ -216,27 +226,35 @@ internal sealed unsafe class VulkanDeviceMemoryAllocator : IDisposable
}
/// <summary>Human-readable accounting for the diagnostics report and for teardown assertions.</summary>
internal string Describe() =>
$"{DeviceMemoryObjectCount} device-memory object(s), " +
$"{CommittedBytes / (1024 * 1024)} MiB committed, " +
$"{AllocatedBytes / (1024 * 1024)} MiB allocated";
internal string Describe()
{
lock (_sync)
{
return $"{DeviceMemoryObjectCount} device-memory object(s), "
+ $"{CommittedBytes / (1024 * 1024)} MiB committed, "
+ $"{AllocatedBytes / (1024 * 1024)} MiB allocated";
}
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
foreach (BlockMemory block in _blockMemory.Values)
lock (_sync)
{
if (block.Mapped != 0)
_vk.UnmapMemory(_device, block.Memory);
_vk.FreeMemory(_device, block.Memory, null);
}
if (_disposed)
return;
_disposed = true;
_blockMemory.Clear();
_pools.Clear();
AllocatedBytes = 0;
CommittedBytes = 0;
foreach (BlockMemory block in _blockMemory.Values)
{
if (block.Mapped != 0)
_vk.UnmapMemory(_device, block.Memory);
_vk.FreeMemory(_device, block.Memory, null);
}
_blockMemory.Clear();
_pools.Clear();
AllocatedBytes = 0;
CommittedBytes = 0;
}
}
}

View file

@ -0,0 +1,157 @@
using Silk.NET.Vulkan;
namespace AcDream.App.Rendering.Gpu.Vk;
internal readonly record struct VulkanDirectionalMultiviewRange(uint BaseLayer, uint LayerCount);
internal static class VulkanDirectionalMultiviewContract
{
internal static VulkanDirectionalMultiviewRange Resolve(uint viewMask, int targetLayerCount)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(targetLayerCount);
if (targetLayerCount > 31)
throw new ArgumentOutOfRangeException(nameof(targetLayerCount));
uint expected = (1u << targetLayerCount) - 1u;
if (viewMask != expected)
throw new NotSupportedException("Directional multiview must cover every contiguous target layer.");
return new VulkanDirectionalMultiviewRange(0u, (uint)targetLayerCount);
}
}
/// <summary>
/// One sampleable depth array plus a 2-D attachment view for every cascade.
/// Layout is tracked per layer because cascades are produced in distinct
/// dynamic-rendering passes and become shader-readable independently.
/// </summary>
internal sealed unsafe class VulkanDirectionalDepthTarget : IGpuDirectionalDepthTarget
{
private readonly Silk.NET.Vulkan.Vk _vk;
private readonly Device _device;
private readonly IGpuResourceRetirementQueue _retirement;
private readonly ImageView[] _layerViews;
private readonly ImageLayout[] _layerLayouts;
private bool _disposed;
internal VulkanDirectionalDepthTarget(
Silk.NET.Vulkan.Vk vk,
Device device,
VulkanDeviceMemoryAllocator allocator,
VulkanUploadQueue uploads,
IGpuResourceRetirementQueue retirement,
VulkanDebugNames debugNames,
in GpuDirectionalDepthTargetDescription description,
Format depthStencilFormat)
{
_vk = vk ?? throw new ArgumentNullException(nameof(vk));
_device = device;
_retirement = retirement ?? throw new ArgumentNullException(nameof(retirement));
Description = description;
var textureDescription = new GpuTextureDescription(
description.Name,
GpuTextureKind.Texture2DArray,
description.DepthFormat,
description.Resolution,
description.Resolution,
description.LayerCount,
MipLevelCount: 1);
Texture = new VulkanGpuTexture(
vk,
device,
allocator,
uploads,
retirement,
debugNames,
textureDescription,
sampleCount: 1,
renderTarget: true,
sampleable: true,
formatOverride: depthStencilFormat);
_layerViews = new ImageView[description.LayerCount];
_layerLayouts = new ImageLayout[description.LayerCount];
try
{
for (int layer = 0; layer < _layerViews.Length; layer++)
{
var create = new ImageViewCreateInfo
{
SType = StructureType.ImageViewCreateInfo,
Image = Texture.Image,
ViewType = ImageViewType.Type2D,
Format = Texture.VkFormat,
SubresourceRange = new ImageSubresourceRange
{
AspectMask = ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit,
BaseMipLevel = 0,
LevelCount = 1,
BaseArrayLayer = (uint)layer,
LayerCount = 1,
},
};
VulkanInterop.Check(
vk.CreateImageView(device, &create, null, out ImageView view),
$"vkCreateImageView ('{description.Name}', layer {layer})");
_layerViews[layer] = view;
debugNames.NameImageView(view, $"{description.Name}-layer-{layer}");
}
}
catch
{
foreach (ImageView view in _layerViews)
{
if (view.Handle != 0)
vk.DestroyImageView(device, view, null);
}
Texture.Dispose();
throw;
}
}
public GpuDirectionalDepthTargetDescription Description { get; }
public IGpuTexture DepthTexture => Texture;
internal VulkanGpuTexture Texture { get; }
internal ImageView ViewAt(int layer)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentOutOfRangeException.ThrowIfNegative(layer);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(layer, _layerViews.Length);
return _layerViews[layer];
}
internal ImageView MultiviewView(uint viewMask)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_ = VulkanDirectionalMultiviewContract.Resolve(viewMask, Description.LayerCount);
return Texture.View;
}
internal int LayerCountForViewMask(uint viewMask)
{
return checked((int)VulkanDirectionalMultiviewContract.Resolve(
viewMask,
Description.LayerCount).LayerCount);
}
internal ImageLayout LayoutAt(int layer) => _layerLayouts[layer];
internal void MarkLayout(int layer, ImageLayout layout) => _layerLayouts[layer] = layout;
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
ImageView[] views = [.. _layerViews];
_retirement.Retire(() =>
{
foreach (ImageView view in views)
_vk.DestroyImageView(_device, view, null);
});
Texture.Dispose();
}
}

View file

@ -0,0 +1,34 @@
namespace AcDream.App.Rendering.Gpu.Vk;
/// <summary>
/// Per-pass descriptor-bind state. Vulkan descriptor bindings survive pipeline
/// changes and remain valid until their layout or dynamic offsets change, so a
/// draw can omit an identical second <c>vkCmdBindDescriptorSets</c> command.
/// A new pass receives a fresh state and therefore always binds before its
/// first draw.
/// </summary>
internal struct VulkanDrawBindingState
{
private ulong _pipelineLayout;
private int _packGeneration;
private bool _hasBinding;
private bool _dirty;
internal readonly bool RequiresBind(
ulong pipelineLayout,
int packGeneration) =>
!_hasBinding
|| _dirty
|| _pipelineLayout != pipelineLayout
|| _packGeneration != packGeneration;
internal void MarkDirty() => _dirty = true;
internal void MarkBound(ulong pipelineLayout, int packGeneration)
{
_pipelineLayout = pipelineLayout;
_packGeneration = packGeneration;
_hasBinding = true;
_dirty = false;
}
}

View file

@ -26,10 +26,9 @@ namespace AcDream.App.Rendering.Gpu.Vk;
///
/// <para><b>Every binding is always bound, whether a renderer uses it or
/// not.</b> Bindings a shader does not declare still need a live descriptor, so
/// unused ones point at a shared dummy range. That is what lets there be ONE
/// descriptor set layout and one pipeline layout rather than a permutation per
/// renderer — plan §4.4's requirement, and the thing that makes switching
/// pipelines mid-pass free.</para>
/// unused ones point at a shared dummy range. Retail keeps its one common
/// layout; opt-in render packs add exactly one compatible set rather than
/// changing these sets or creating renderer permutations.</para>
///
/// <para><b>Slice V6i: one set pair per renderer scope.</b> There is no longer a
/// single (set 0, set 1) pair per flight slot; there is an arena of them, and
@ -45,8 +44,16 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
private readonly Device _device;
private readonly VulkanPipelineLayouts.Created _layouts;
private readonly VulkanBindingScopeArena _arena;
private readonly uint _maxStorageBufferRangeBytes;
private readonly List<DescriptorPool> _pools = [];
private readonly List<(DescriptorSet Storage, DescriptorSet Uniform)> _sets = [];
private readonly ulong[] _packBuffers = new ulong[VulkanPipelineLayouts.PackUniformBindingCount];
private readonly uint[] _packOffsets = new uint[VulkanPipelineLayouts.PackUniformBindingCount];
private readonly uint[] _packRanges = new uint[VulkanPipelineLayouts.PackUniformBindingCount];
private readonly Dictionary<PackBindingKey, int> _packSlotsByState = [];
private readonly List<DescriptorSet> _packSets = [];
private int _packLiveCount;
private int _packGeneration = -1;
private bool _disposed;
@ -76,8 +83,7 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
}
/// <summary>
/// Bindings 0..4 of set 1. Slice V6i-2 raised this from 4 when the layout
/// gained binding 4 (sky params); binding 0 remains unused and is counted
/// Bindings 0..4 of retail set 1. Binding 0 remains unused and is counted
/// only so the bookkeeping arrays stay index-aligned with the binding number.
/// Which of them the layout DECLARES is
/// <see cref="VulkanPipelineLayouts.IsDeclaredUniformBinding"/>.
@ -87,45 +93,46 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
/// <summary>
/// How many of set 1's bindings the layout actually declares, all dynamic.
/// Asserted against <c>maxDescriptorSetUniformBuffersDynamic</c> by the
/// capability gate; Vulkan guarantees 8, so this is comfortable.
/// capability gate; Vulkan guarantees exactly the four bindings declared.
/// </summary>
internal static uint DynamicUniformBindingCount { get; } =
(uint)VulkanPipelineLayouts.DeclaredUniformBindings.Length;
/// <summary>
/// Widest range any single binding may address. Dynamic descriptors take a
/// static range at write time and slide it with an offset, so this bounds
/// how much of the ring one binding can see at once.
/// </summary>
internal const uint MaxBindingRangeBytes = 4 * 1024 * 1024;
internal VulkanFrameBindings(
Silk.NET.Vulkan.Vk vk,
Device device,
VulkanPipelineLayouts.Created layouts,
VulkanGpuBuffer ring,
VulkanGpuBuffer dummy)
VulkanGpuBuffer dummy,
uint maxStorageBufferRangeBytes)
{
_vk = vk ?? throw new ArgumentNullException(nameof(vk));
_device = device;
_layouts = layouts ?? throw new ArgumentNullException(nameof(layouts));
ArgumentNullException.ThrowIfNull(ring);
ArgumentNullException.ThrowIfNull(dummy);
ArgumentOutOfRangeException.ThrowIfLessThan(maxStorageBufferRangeBytes, 16u);
Ring = ring;
Dummy = dummy;
_maxStorageBufferRangeBytes = maxStorageBufferRangeBytes;
_arena = new VulkanBindingScopeArena(
(int)GpuBindingModel.StorageBindingCount,
UniformBindingCount,
VulkanPipelineLayouts.IsDynamicStorageBinding);
uint dummyStorageRange = (uint)Math.Min(dummy.SizeBytes, MaxBindingRangeBytes);
uint dummyStorageRange = (uint)Math.Min(dummy.SizeBytes, _maxStorageBufferRangeBytes);
for (uint binding = 0; binding < GpuBindingModel.StorageBindingCount; binding++)
_arena.SeedStorage(binding, dummy.Handle.Handle, offsetBytes: 0, dummyStorageRange);
uint dummyUniformRange = (uint)Math.Min(dummy.SizeBytes, 65536);
for (uint binding = 0; binding < UniformBindingCount; binding++)
_arena.SeedUniform(binding, dummy.Handle.Handle, dummyUniformRange);
for (int binding = 0; binding < _packBuffers.Length; binding++)
{
_packBuffers[binding] = dummy.Handle.Handle;
_packRanges[binding] = dummyUniformRange;
}
}
internal VulkanGpuBuffer Ring { get; }
@ -144,7 +151,12 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
/// previous submission has retired before <c>BeginFrame</c> returns, which is
/// the same guarantee that lets the ring rewind.
/// </summary>
internal void BeginFrame() => _arena.BeginFrame();
internal void BeginFrame()
{
_arena.BeginFrame();
_packSlotsByState.Clear();
_packLiveCount = 0;
}
internal void SetStorage(uint binding, VulkanGpuBuffer buffer, uint offsetBytes, uint sizeBytes)
{
@ -159,16 +171,33 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
internal void SetUniform(uint binding, VulkanGpuBuffer buffer, uint offsetBytes, uint sizeBytes)
{
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(binding, (uint)UniformBindingCount);
_arena.SetUniform(
binding,
buffer.Handle.Handle,
offsetBytes,
Math.Min(ClampRange(buffer, sizeBytes, offsetBytes: 0), 65536));
if (binding < UniformBindingCount)
{
_arena.SetUniform(
binding,
buffer.Handle.Handle,
offsetBytes,
Math.Min(ClampRange(buffer, sizeBytes, offsetBytes: 0), 65536));
return;
}
ArgumentOutOfRangeException.ThrowIfLessThan(binding, GpuBindingModel.UniformAtmosphericFrame);
ArgumentOutOfRangeException.ThrowIfGreaterThan(binding, GpuBindingModel.UniformPackSettings);
int packBinding = (int)(binding - GpuBindingModel.UniformAtmosphericFrame);
_packBuffers[packBinding] = buffer.Handle.Handle;
_packOffsets[packBinding] = offsetBytes;
_packRanges[packBinding] = Math.Min(ClampRange(buffer, sizeBytes, offsetBytes: 0), 65536);
}
/// <summary>Binds all three sets with the current dynamic offsets.</summary>
internal void Bind(CommandBuffer commands, VulkanGpuDevice device)
/// <summary>
/// Binds retail sets 0..2. A flagged pipeline additionally supplies its
/// live pack state, which lazily materialises and binds set 3.
/// </summary>
internal void Bind(
CommandBuffer commands,
VulkanGpuDevice device,
PipelineLayout pipelineLayout,
VulkanPipelineLayouts.Created.PackState? packState = null)
{
(int index, int slot, bool needsWrite) = _arena.Resolve();
if (slot < 0)
@ -199,12 +228,71 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
_vk.CmdBindDescriptorSets(
commands,
PipelineBindPoint.Graphics,
device.Layouts.PipelineLayout,
pipelineLayout,
0,
3,
sets,
(uint)dynamicCount,
offsets);
if (packState is not null)
BindPackSet(commands, pipelineLayout, packState);
}
private void BindPackSet(
CommandBuffer commands,
PipelineLayout pipelineLayout,
VulkanPipelineLayouts.Created.PackState state)
{
if (_packGeneration != state.Generation)
{
_packGeneration = state.Generation;
_packSets.Clear();
_packSlotsByState.Clear();
_packLiveCount = 0;
}
PackBindingKey key = CurrentPackKey();
if (!_packSlotsByState.TryGetValue(key, out int slot))
{
slot = _packLiveCount++;
_packSlotsByState.Add(key, slot);
if (slot == _packSets.Count)
_packSets.Add(state.AllocateDescriptorSet());
WritePackSet(_packSets[slot]);
}
DescriptorSet set = _packSets[slot];
uint* offsets = stackalloc uint[(int)VulkanPipelineLayouts.PackUniformBindingCount];
for (int i = 0; i < _packOffsets.Length; i++)
offsets[i] = _packOffsets[i];
_vk.CmdBindDescriptorSets(
commands,
PipelineBindPoint.Graphics,
pipelineLayout,
GpuBindingModel.RenderPackUniformSet,
1,
&set,
VulkanPipelineLayouts.PackUniformBindingCount,
offsets);
}
private PackBindingKey CurrentPackKey() => new(
_packBuffers[0], _packRanges[0],
_packBuffers[1], _packRanges[1],
_packBuffers[2], _packRanges[2],
_packBuffers[3], _packRanges[3]);
private void WritePackSet(DescriptorSet set)
{
for (int i = 0; i < _packBuffers.Length; i++)
{
WriteUniform(
set,
GpuBindingModel.UniformAtmosphericFrame + (uint)i,
new Silk.NET.Vulkan.Buffer(_packBuffers[i]),
_packRanges[i]);
}
}
private void WritePair((DescriptorSet Storage, DescriptorSet Uniform) pair)
@ -273,7 +361,7 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
return pool;
}
private static uint ClampRange(VulkanGpuBuffer buffer, uint requested, uint offsetBytes)
private uint ClampRange(VulkanGpuBuffer buffer, uint requested, uint offsetBytes)
{
long remaining = buffer.SizeBytes - offsetBytes;
if (remaining <= 0)
@ -285,7 +373,7 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
"A descriptor range of zero is not representable in Vulkan.");
}
uint available = (uint)Math.Min(remaining, MaxBindingRangeBytes);
uint available = (uint)Math.Min(remaining, _maxStorageBufferRangeBytes);
return requested == 0 ? available : Math.Min(Math.Max(requested, 16), available);
}
@ -370,5 +458,17 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
_pools.Clear();
_sets.Clear();
_packSlotsByState.Clear();
_packSets.Clear();
}
private readonly record struct PackBindingKey(
ulong Buffer0,
uint Range0,
ulong Buffer1,
uint Range1,
ulong Buffer2,
uint Range2,
ulong Buffer3,
uint Range3);
}

View file

@ -81,6 +81,7 @@ internal sealed class VulkanFrameFlightController : IGpuResourceRetirementQueue,
private readonly IVulkanTimelineApi _timeline;
private readonly SortedDictionary<long, List<Action>> _retirements = [];
private readonly object _sync = new();
private long _openSerial;
private long _submittedSerial;
@ -99,15 +100,43 @@ internal sealed class VulkanFrameFlightController : IGpuResourceRetirementQueue,
internal int SlotCount { get; }
/// <summary>Serial of the frame currently being recorded, or 0 when none is open.</summary>
internal long OpenSerial => _openSerial;
internal long OpenSerial
{
get
{
lock (_sync)
return _openSerial;
}
}
/// <summary>Highest serial handed to <see cref="EndFrame"/>.</summary>
internal long SubmittedSerial => _submittedSerial;
internal long SubmittedSerial
{
get
{
lock (_sync)
return _submittedSerial;
}
}
/// <summary>Flight slot index of the currently open frame.</summary>
internal int CurrentSlot => SlotIndexOf(_openSerial);
internal int CurrentSlot
{
get
{
lock (_sync)
return SlotIndexOf(_openSerial);
}
}
internal int PendingRetirementCount => _retirements.Sum(entry => entry.Value.Count);
internal int PendingRetirementCount
{
get
{
lock (_sync)
return _retirements.Sum(entry => entry.Value.Count);
}
}
/// <summary>Maps a frame serial onto its flight slot. Serials are 1-based.</summary>
internal int SlotIndexOf(long serial) =>
@ -120,30 +149,36 @@ internal sealed class VulkanFrameFlightController : IGpuResourceRetirementQueue,
/// </summary>
internal long BeginFrame()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_openSerial != 0)
lock (_sync)
{
throw new InvalidOperationException(
$"Frame {_openSerial} is still open; call EndFrame before beginning another.");
ObjectDisposedException.ThrowIf(_disposed, this);
if (_openSerial != 0)
{
throw new InvalidOperationException(
$"Frame {_openSerial} is still open; call EndFrame before beginning another.");
}
long serial = _submittedSerial + 1;
long mustComplete = serial - SlotCount;
if (mustComplete > 0)
_timeline.Wait((ulong)mustComplete);
_openSerial = serial;
RunRetirements();
return serial;
}
long serial = _submittedSerial + 1;
long mustComplete = serial - SlotCount;
if (mustComplete > 0)
_timeline.Wait((ulong)mustComplete);
_openSerial = serial;
RunRetirements();
return serial;
}
/// <summary>Records that the open frame has been submitted with its serial as the timeline signal value.</summary>
internal void EndFrame()
{
if (_openSerial == 0)
return;
_submittedSerial = _openSerial;
_openSerial = 0;
lock (_sync)
{
if (_openSerial == 0)
return;
_submittedSerial = _openSerial;
_openSerial = 0;
}
}
/// <summary>
@ -154,68 +189,83 @@ internal sealed class VulkanFrameFlightController : IGpuResourceRetirementQueue,
public void Retire(Action release)
{
ArgumentNullException.ThrowIfNull(release);
if (_disposed)
lock (_sync)
{
// Teardown already drained the ledger; running immediately is the
// only way this release ever happens, and by then the device is idle.
release();
return;
}
if (_disposed)
{
// Teardown already drained the ledger; running immediately is the
// only way this release ever happens, and by then the device is idle.
release();
return;
}
long key = _openSerial != 0 ? _openSerial : _submittedSerial + 1;
if (!_retirements.TryGetValue(key, out List<Action>? actions))
{
actions = [];
_retirements.Add(key, actions);
}
long key = _openSerial != 0 ? _openSerial : _submittedSerial + 1;
if (!_retirements.TryGetValue(key, out List<Action>? actions))
{
actions = [];
_retirements.Add(key, actions);
}
actions.Add(release);
actions.Add(release);
}
}
/// <summary>Runs every retirement whose frame the GPU has completed.</summary>
internal void RunRetirements()
{
if (_retirements.Count == 0)
return;
var completed = (long)_timeline.CurrentValue;
while (_retirements.Count > 0)
lock (_sync)
{
KeyValuePair<long, List<Action>> first = _retirements.First();
if (first.Key > completed)
break;
if (_retirements.Count == 0)
return;
_retirements.Remove(first.Key);
foreach (Action release in first.Value)
release();
var completed = (long)_timeline.CurrentValue;
while (_retirements.Count > 0)
{
KeyValuePair<long, List<Action>> first = _retirements.First();
if (first.Key > completed)
break;
_retirements.Remove(first.Key);
foreach (Action release in first.Value)
release();
}
}
}
/// <summary>Blocks until every submitted frame has completed, then drains the whole ledger.</summary>
internal void WaitForSubmittedWork()
{
if (_submittedSerial > 0)
_timeline.Wait((ulong)_submittedSerial);
DrainAll();
lock (_sync)
{
if (_submittedSerial > 0)
_timeline.Wait((ulong)_submittedSerial);
DrainAll();
}
}
/// <summary>Runs every pending retirement regardless of serial. Only legal when the device is idle.</summary>
internal void DrainAll()
{
while (_retirements.Count > 0)
lock (_sync)
{
KeyValuePair<long, List<Action>> first = _retirements.First();
_retirements.Remove(first.Key);
foreach (Action release in first.Value)
release();
while (_retirements.Count > 0)
{
KeyValuePair<long, List<Action>> first = _retirements.First();
_retirements.Remove(first.Key);
foreach (Action release in first.Value)
release();
}
}
}
public void Dispose()
{
if (_disposed)
return;
DrainAll();
_disposed = true;
lock (_sync)
{
if (_disposed)
return;
DrainAll();
_disposed = true;
}
}
}

View file

@ -91,6 +91,8 @@ internal sealed unsafe class VulkanGpuBuffer : IGpuBuffer
public long SizeBytes { get; }
public GpuBufferUsage Usage { get; }
public GpuMemoryResidency Residency { get; }
public bool HostWritesAreCoherent =>
_allocation.MemoryProperties.HasFlag(MemoryPropertyFlags.HostCoherentBit);
internal Buffer Handle { get; }

View file

@ -26,6 +26,9 @@ internal sealed unsafe partial class VulkanGpuDevice
private readonly Dictionary<GpuSamplerDescription, VulkanGpuSampler> _samplers = [];
private readonly Dictionary<string, (ShaderModule Vertex, ShaderModule Fragment)> _shaderModules = [];
private readonly HashSet<VulkanGpuPipeline> _pipelines = [];
private readonly Dictionary<GpuTextureFormat, int> _pipelineFormatLeaseCounts = [];
private readonly object _resourceCreationSync = new();
private string _shaderSpirvDirectory = string.Empty;
private float _maxSamplerAnisotropy = 1f;
@ -127,7 +130,8 @@ internal sealed unsafe partial class VulkanGpuDevice
_device,
_layouts,
_ringBuffers[slot],
_bindingDummy);
_bindingDummy,
Capabilities.MaxStorageBufferRangeBytes);
}
}
@ -228,6 +232,8 @@ internal sealed unsafe partial class VulkanGpuDevice
}
_shaderModules.Clear();
_pipelines.Clear();
_pipelineFormatLeaseCounts.Clear();
foreach (VulkanGpuSampler sampler in _samplers.Values)
sampler.Dispose();
@ -290,6 +296,18 @@ internal sealed unsafe partial class VulkanGpuDevice
public IGpuTexture CreateTexture(in GpuTextureDescription description)
{
ThrowIfDisposed();
if (description.Format == GpuTextureFormat.Rgba16FloatRenderTarget
&& !Capabilities.SupportsRgba16FloatRenderTargets)
{
throw new NotSupportedException(
"RGBA16F colour-attachment, sampling, and linear filtering are unavailable.");
}
if (description.Format == GpuTextureFormat.Depth24Stencil8
&& !Capabilities.SupportsSampledDepth)
{
throw new NotSupportedException(
"The selected combined depth/stencil format cannot expose a sampled depth aspect.");
}
return new VulkanGpuTexture(
_vk,
_device,
@ -303,9 +321,16 @@ internal sealed unsafe partial class VulkanGpuDevice
}
public IGpuSampler CreateSampler(in GpuSamplerDescription description)
{
lock (_resourceCreationSync)
return CreateSamplerLocked(in description);
}
private IGpuSampler CreateSamplerLocked(in GpuSamplerDescription description)
{
ThrowIfDisposed();
if (_samplers.TryGetValue(description, out VulkanGpuSampler? existing))
if (_samplers.TryGetValue(description, out VulkanGpuSampler? existing)
&& !existing.IsDisposed)
return existing;
var created = new VulkanGpuSampler(
@ -315,13 +340,39 @@ internal sealed unsafe partial class VulkanGpuDevice
_debugNames,
description,
_maxSamplerAnisotropy);
_samplers.Add(description, created);
_samplers[description] = created;
return created;
}
public IGpuRenderTarget CreateRenderTarget(in GpuRenderTargetDescription description)
{
ThrowIfDisposed();
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.SampleCount);
if ((uint)description.SampleCount > Capabilities.MaxSampleCount)
{
throw new NotSupportedException(
$"The device supports at most {Capabilities.MaxSampleCount} colour/depth samples; "
+ $"'{description.Name}' requested {description.SampleCount}.");
}
if (description.ColorFormat == GpuTextureFormat.Rgba16FloatRenderTarget)
{
if (!Capabilities.SupportsRgba16FloatRenderTargets)
{
throw new NotSupportedException(
"RGBA16F colour-attachment, sampling, and linear filtering are required by this render target.");
}
if ((uint)description.SampleCount > Capabilities.MaxRgba16FloatSampleCount)
{
throw new NotSupportedException(
$"RGBA16F supports at most {Capabilities.MaxRgba16FloatSampleCount} samples on this device; "
+ $"'{description.Name}' requested {description.SampleCount}.");
}
}
if (description.SampleableDepth && !Capabilities.SupportsSampledDepth)
{
throw new NotSupportedException(
"The selected combined depth/stencil format cannot expose a sampled depth aspect.");
}
return new VulkanGpuRenderTarget(
_vk,
_device,
@ -333,6 +384,39 @@ internal sealed unsafe partial class VulkanGpuDevice
DepthStencilFormat);
}
public IGpuDirectionalDepthTarget CreateDirectionalDepthTarget(
in GpuDirectionalDepthTargetDescription description)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(description.Name);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.Resolution);
if (description.LayerCount is < 2 or > 4)
{
throw new ArgumentOutOfRangeException(
nameof(description),
description.LayerCount,
"Directional depth targets require 2-4 cascade layers.");
}
if (description.DepthFormat != GpuTextureFormat.Depth24Stencil8)
{
throw new ArgumentException(
"Directional depth targets currently require Depth24Stencil8.",
nameof(description));
}
if (!Capabilities.SupportsSampledDepth)
throw new NotSupportedException("Sampled depth is unavailable on this device.");
return new VulkanDirectionalDepthTarget(
_vk,
_device,
_allocator,
_uploads,
_flights,
_debugNames,
description,
DepthStencilFormat);
}
public GpuTextureSlot RegisterTexture(IGpuTexture texture, IGpuSampler sampler)
{
ThrowIfDisposed();
@ -342,6 +426,12 @@ internal sealed unsafe partial class VulkanGpuDevice
throw new ArgumentException("The Vulkan backend can only register a Vulkan texture.", nameof(texture));
if (sampler is not VulkanGpuSampler vulkanSampler)
throw new ArgumentException("The Vulkan backend can only register a Vulkan sampler.", nameof(sampler));
if (!vulkanTexture.IsSampleable || vulkanTexture.SampledView.Handle == 0)
{
throw new ArgumentException(
$"Texture '{vulkanTexture.Name}' is an attachment-only image and has no sampled view.",
nameof(texture));
}
// Campaign V slice V6k made this a loud refusal, and V6l is the slice
// that serves it. A render-target image is viewed as
@ -352,7 +442,10 @@ internal sealed unsafe partial class VulkanGpuDevice
// layered view over the same image for exactly this, and every texture
// that is not an attachment has always had one; SampledView is that view
// in both cases, so the question disappears rather than being answered.
return TextureTable.Register(vulkanTexture.SampledView, vulkanSampler.Handle);
return TextureTable.Register(
vulkanTexture.SampledView,
vulkanSampler.Handle,
vulkanTexture.SampledLayout);
}
public void ReleaseTextureSlot(GpuTextureSlot slot)
@ -375,11 +468,20 @@ internal sealed unsafe partial class VulkanGpuDevice
/// frame ever pays a shader compile or a driver state revalidation.
/// </summary>
public IGpuPipeline CreatePipeline(GpuPipelineDescription description)
{
lock (_resourceCreationSync)
return CreatePipelineLocked(description);
}
private IGpuPipeline CreatePipelineLocked(GpuPipelineDescription description)
{
ThrowIfDisposed();
ArgumentNullException.ThrowIfNull(description);
if (description.ViewMask != 0 && !Capabilities.SupportsMultiview)
throw new NotSupportedException("The selected Vulkan device does not support multiview pipelines.");
(ShaderModule vertex, ShaderModule fragment) = LoadShaderModules(description.Shaders.Name);
(ShaderModule vertex, ShaderModule fragment, bool ownsModules) =
LoadShaderModules(description.Shaders);
// Slice V6d: the pipeline names the format it renders into, rather than
// every pipeline being hard-coded to one. Rgba8UnormRenderTarget — the
// default — still maps to the swapchain's format; see
@ -387,29 +489,164 @@ internal sealed unsafe partial class VulkanGpuDevice
// offscreen targets adopt the swapchain's format rather than the other
// way round.
Format colorFormat = VulkanTextureFormatMapping.FormatOf(description.ColorFormat);
return new VulkanGpuPipeline(
_vk,
_device,
_flights,
_debugNames,
Layouts.PipelineLayout,
_pipelineCache?.Handle ?? default,
vertex,
fragment,
description,
colorFormat,
DepthStencilFormat);
VulkanGpuPipeline pipeline;
VulkanPipelineLayouts.Created.PackLayoutLease? packLease = null;
try
{
packLease = description.UsesRenderPackShaderAbi
? Layouts.AcquirePackLayout()
: null;
pipeline = new VulkanGpuPipeline(
_vk,
_device,
_flights,
_debugNames,
Layouts,
packLease,
packLease?.PipelineLayout ?? Layouts.PipelineLayout,
_pipelineCache?.Handle ?? default,
vertex,
fragment,
ownsModules,
description,
colorFormat,
DepthStencilFormat);
}
catch
{
packLease?.Dispose();
if (ownsModules)
{
_vk.DestroyShaderModule(_device, fragment, null);
_vk.DestroyShaderModule(_device, vertex, null);
}
throw;
}
try
{
foreach (GpuTextureFormat format in _pipelineFormatLeaseCounts.Keys)
pipeline.AddColorFormatVariant(format);
_pipelines.Add(pipeline);
return pipeline;
}
catch
{
pipeline.Dispose();
throw;
}
}
private (ShaderModule Vertex, ShaderModule Fragment) LoadShaderModules(string name)
public IDisposable AcquirePipelineColorFormat(GpuTextureFormat format)
{
lock (_resourceCreationSync)
return AcquirePipelineColorFormatLocked(format);
}
private IDisposable AcquirePipelineColorFormatLocked(GpuTextureFormat format)
{
ThrowIfDisposed();
if (!VulkanTextureFormatMapping.IsRenderTarget(format)
|| VulkanTextureFormatMapping.IsDepthStencil(format))
{
throw new ArgumentException(
$"{format} is not a colour render-target format.",
nameof(format));
}
if (format == GpuTextureFormat.Rgba16FloatRenderTarget
&& !Capabilities.SupportsRgba16FloatRenderTargets)
{
throw new NotSupportedException(
"RGBA16F colour-attachment, sampling, and linear filtering are unavailable.");
}
_pipelines.RemoveWhere(static pipeline => pipeline.IsDisposed);
if (!_pipelineFormatLeaseCounts.TryGetValue(format, out int count))
{
var added = new List<VulkanGpuPipeline>(_pipelines.Count);
try
{
foreach (VulkanGpuPipeline pipeline in _pipelines)
{
if (pipeline.AddColorFormatVariant(format))
added.Add(pipeline);
}
}
catch
{
foreach (VulkanGpuPipeline pipeline in added)
pipeline.RemoveColorFormatVariant(format);
throw;
}
_pipelineFormatLeaseCounts.Add(format, 1);
}
else
{
_pipelineFormatLeaseCounts[format] = checked(count + 1);
}
return new PipelineColorFormatLease(this, format);
}
private void ReleasePipelineColorFormat(GpuTextureFormat format)
{
lock (_resourceCreationSync)
{
if (_disposed || !_pipelineFormatLeaseCounts.TryGetValue(format, out int count))
return;
if (count > 1)
{
_pipelineFormatLeaseCounts[format] = count - 1;
return;
}
_pipelineFormatLeaseCounts.Remove(format);
_pipelines.RemoveWhere(static pipeline => pipeline.IsDisposed);
foreach (VulkanGpuPipeline pipeline in _pipelines)
pipeline.RemoveColorFormatVariant(format);
}
}
private sealed class PipelineColorFormatLease(
VulkanGpuDevice device,
GpuTextureFormat format) : IDisposable
{
private VulkanGpuDevice? _device = device;
public void Dispose() =>
Interlocked.Exchange(ref _device, null)?.ReleasePipelineColorFormat(format);
}
private (ShaderModule Vertex, ShaderModule Fragment, bool OwnsModules) LoadShaderModules(
in GpuShaderSet shaders)
{
if (shaders.HasEmbeddedSpirv)
{
ShaderModule embeddedVertex = CreateShaderModule(
shaders.Name,
"vert",
shaders.VertexSpirv.Span);
try
{
return (
embeddedVertex,
CreateShaderModule(shaders.Name, "frag", shaders.FragmentSpirv.Span),
true);
}
catch
{
_vk.DestroyShaderModule(_device, embeddedVertex, null);
throw;
}
}
string name = shaders.Name;
if (_shaderModules.TryGetValue(name, out (ShaderModule Vertex, ShaderModule Fragment) existing))
return existing;
return (existing.Vertex, existing.Fragment, false);
ShaderModule vertex = CreateShaderModule(name, "vert");
ShaderModule fragment = CreateShaderModule(name, "frag");
_shaderModules[name] = (vertex, fragment);
return (vertex, fragment);
return (vertex, fragment, false);
}
private ShaderModule CreateShaderModule(string name, string stage)
@ -424,9 +661,19 @@ internal sealed unsafe partial class VulkanGpuDevice
path);
}
byte[] code = File.ReadAllBytes(path);
if (code.Length % 4 != 0)
throw new InvalidDataException($"'{path}' is {code.Length} bytes, which is not a whole number of SPIR-V words.");
return CreateShaderModule(name, stage, File.ReadAllBytes(path));
}
private ShaderModule CreateShaderModule(
string name,
string stage,
ReadOnlySpan<byte> code)
{
if (code.Length < 4 || code.Length % 4 != 0)
throw new InvalidDataException(
$"'{name}.{stage}' is {code.Length} bytes, which is not valid word-aligned SPIR-V.");
if (System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(code) != 0x07230203u)
throw new InvalidDataException($"'{name}.{stage}' has no SPIR-V header.");
fixed (byte* first = code)
{
@ -505,12 +752,50 @@ internal sealed unsafe partial class VulkanGpuDevice
uint width;
uint height;
ImageView colorView;
ImageView colorView = default;
ImageView resolveView = default;
ImageView depthView = default;
bool backbuffer = description.Color.Target is null;
ImageView depthResolveView = default;
bool hasColorAttachment = description.HasColorAttachment;
bool backbuffer = hasColorAttachment && description.Color.Target is null;
uint viewMask = description.ViewMask;
GpuTextureFormat passColorFormat = GpuTextureFormat.Rgba8UnormRenderTarget;
if (backbuffer)
if (!hasColorAttachment)
{
if (description.SampleCount != 1)
throw new InvalidOperationException("Directional depth passes are single-sampled.");
if (description.Depth is not { DirectionalTarget: VulkanDirectionalDepthTarget target } depth)
{
throw new ArgumentException(
"A colour-less pass requires a Vulkan directional-depth target.",
nameof(description));
}
if (depth.Store != GpuStoreOp.Store)
throw new InvalidOperationException("Directional depth must be stored for later sampling.");
if (depth.Layer < 0 || depth.Layer >= target.Description.LayerCount)
throw new ArgumentOutOfRangeException(nameof(description), "Directional depth layer is outside the target.");
width = (uint)target.Description.Resolution;
height = (uint)target.Description.Resolution;
if (viewMask != 0)
{
if (!Capabilities.SupportsMultiview)
throw new NotSupportedException("The selected Vulkan device does not support multiview.");
depthView = target.MultiviewView(viewMask);
TransitionDirectionalDepthForRendering(
commands,
target,
baseLayer: 0,
layerCount: target.LayerCountForViewMask(viewMask));
}
else
{
depthView = target.ViewAt(depth.Layer);
TransitionDirectionalDepthForRendering(commands, target, depth.Layer, 1);
}
}
else if (backbuffer)
{
if (_backbuffer is null || _acquiredImageIndex is not { } imageIndex)
{
@ -545,52 +830,106 @@ internal sealed unsafe partial class VulkanGpuDevice
{
if (description.Color.Target is not VulkanGpuRenderTarget target)
throw new ArgumentException("The Vulkan backend can only render into a Vulkan render target.");
if (target.Description.SampleCount != description.SampleCount)
{
throw new InvalidOperationException(
$"Pass '{description.Name}' declares {description.SampleCount} samples but target "
+ $"'{target.Description.Name}' was created for {target.Description.SampleCount}.");
}
width = (uint)target.Description.Width;
height = (uint)target.Description.Height;
colorView = target.Color.View;
passColorFormat = target.Description.ColorFormat;
colorView = target.ColorAttachment.View;
if (target.ColorResolve is { } colorResolve)
{
if (description.Color.Load == GpuLoadOp.Load)
{
throw new InvalidOperationException(
$"Multisampled target '{target.Description.Name}' cannot Load a prior resolved image; "
+ "its transient multisample attachment has no preserved contents.");
}
if (description.Color.Store != GpuStoreOp.Resolve)
{
throw new InvalidOperationException(
$"Multisampled target '{target.Description.Name}' must use Store=Resolve so its "
+ "single-sampled ColorTexture receives this pass.");
}
resolveView = colorResolve.View;
}
else if (description.Color.Store == GpuStoreOp.Resolve)
{
throw new InvalidOperationException(
$"Single-sampled target '{target.Description.Name}' cannot use Store=Resolve.");
}
TransitionRenderTargetForRendering(commands, target);
if (description.Depth is not null && target.Depth is { } depth)
if (description.Depth is not null && target.DepthAttachment is { } depth)
{
depthView = depth.View;
if (target.Description.SampleCount > 1
&& description.Depth.Value.Load == GpuLoadOp.Load)
{
throw new InvalidOperationException(
$"Multisampled depth target '{target.Description.Name}' cannot Load transient depth.");
}
if (target.Description.SampleableDepth
&& description.Depth.Value.Store != GpuStoreOp.Store)
{
throw new InvalidOperationException(
$"Sampleable depth on '{target.Description.Name}' requires Store=Store.");
}
if (target.DepthResolve is { } depthResolve)
{
depthResolveView = depthResolve.View;
}
}
}
Vector4 clear = description.Color.ClearColor;
var colorAttachment = new RenderingAttachmentInfo
RenderingAttachmentInfo colorAttachment = default;
if (hasColorAttachment)
{
SType = StructureType.RenderingAttachmentInfo,
ImageView = colorView,
ImageLayout = ImageLayout.ColorAttachmentOptimal,
LoadOp = VulkanViewportMapping.ToVulkan(description.Color.Load),
StoreOp = description.Color.Store == GpuStoreOp.Resolve
? AttachmentStoreOp.DontCare
: VulkanViewportMapping.ToVulkan(description.Color.Store),
ClearValue = new ClearValue
Vector4 clear = description.Color.ClearColor;
colorAttachment = new RenderingAttachmentInfo
{
Color = new ClearColorValue
SType = StructureType.RenderingAttachmentInfo,
ImageView = colorView,
ImageLayout = ImageLayout.ColorAttachmentOptimal,
LoadOp = VulkanViewportMapping.ToVulkan(description.Color.Load),
StoreOp = description.Color.Store == GpuStoreOp.Resolve
? AttachmentStoreOp.DontCare
: VulkanViewportMapping.ToVulkan(description.Color.Store),
ClearValue = new ClearValue
{
Float32_0 = clear.X,
Float32_1 = clear.Y,
Float32_2 = clear.Z,
Float32_3 = clear.W,
Color = new ClearColorValue
{
Float32_0 = clear.X,
Float32_1 = clear.Y,
Float32_2 = clear.Z,
Float32_3 = clear.W,
},
},
},
};
if (resolveView.Handle != 0)
{
colorAttachment.ResolveMode = ResolveModeFlags.AverageBit;
colorAttachment.ResolveImageView = resolveView;
colorAttachment.ResolveImageLayout = ImageLayout.ColorAttachmentOptimal;
};
if (resolveView.Handle != 0)
{
colorAttachment.ResolveMode = ResolveModeFlags.AverageBit;
colorAttachment.ResolveImageView = resolveView;
colorAttachment.ResolveImageLayout = ImageLayout.ColorAttachmentOptimal;
}
}
RenderingAttachmentInfo depthAttachment = default;
RenderingAttachmentInfo stencilAttachment = default;
if (description.Depth is { } depthDescription && depthView.Handle != 0)
{
bool resolveDepth = depthResolveView.Handle != 0;
depthAttachment = new RenderingAttachmentInfo
{
SType = StructureType.RenderingAttachmentInfo,
ImageView = depthView,
ImageLayout = ImageLayout.DepthStencilAttachmentOptimal,
LoadOp = VulkanViewportMapping.ToVulkan(depthDescription.Load),
StoreOp = VulkanViewportMapping.ToVulkan(depthDescription.Store),
StoreOp = resolveDepth
? AttachmentStoreOp.DontCare
: VulkanViewportMapping.ToVulkan(depthDescription.Store),
ClearValue = new ClearValue
{
DepthStencil = new ClearDepthStencilValue(
@ -598,6 +937,19 @@ internal sealed unsafe partial class VulkanGpuDevice
depthDescription.ClearStencil),
},
};
stencilAttachment = depthAttachment;
if (resolveDepth)
{
// SAMPLE_ZERO is guaranteed for both depth and stencil by the
// Vulkan 1.3 depth/stencil-resolve contract. Resolving both
// aspects avoids depending on independentResolveNone.
depthAttachment.ResolveMode = ResolveModeFlags.SampleZeroBit;
depthAttachment.ResolveImageView = depthResolveView;
depthAttachment.ResolveImageLayout = ImageLayout.DepthStencilAttachmentOptimal;
stencilAttachment.ResolveMode = ResolveModeFlags.SampleZeroBit;
stencilAttachment.ResolveImageView = depthResolveView;
stencilAttachment.ResolveImageLayout = ImageLayout.DepthStencilAttachmentOptimal;
}
}
var rendering = new RenderingInfo
@ -605,13 +957,14 @@ internal sealed unsafe partial class VulkanGpuDevice
SType = StructureType.RenderingInfo,
RenderArea = new Rect2D(new Offset2D(0, 0), new Extent2D(width, height)),
LayerCount = 1,
ColorAttachmentCount = 1,
PColorAttachments = &colorAttachment,
ViewMask = viewMask,
ColorAttachmentCount = hasColorAttachment ? 1u : 0u,
PColorAttachments = hasColorAttachment ? &colorAttachment : null,
PDepthAttachment = depthAttachment.SType == StructureType.RenderingAttachmentInfo
? &depthAttachment
: null,
PStencilAttachment = depthAttachment.SType == StructureType.RenderingAttachmentInfo
? &depthAttachment
PStencilAttachment = stencilAttachment.SType == StructureType.RenderingAttachmentInfo
? &stencilAttachment
: null,
};
_vk.CmdBeginRendering(commands, &rendering);
@ -625,7 +978,9 @@ internal sealed unsafe partial class VulkanGpuDevice
description,
width,
height,
hasDepthAttachment: depthView.Handle != 0);
hasDepthAttachment: depthView.Handle != 0,
hasColorAttachment,
colorFormat: passColorFormat);
_openPass = encoder;
return encoder;
}
@ -640,7 +995,29 @@ internal sealed unsafe partial class VulkanGpuDevice
_debugNames.EndLabel(commands);
if (!_openPassIsBackbuffer && encoder.Pass.Color.Target is VulkanGpuRenderTarget target)
TransitionRenderTargetForSampling(commands, target);
{
TransitionRenderTargetForSampling(
commands,
target,
colorStored: encoder.Pass.Color.Store != GpuStoreOp.DontCare,
depthStored: encoder.Pass.Depth?.Store == GpuStoreOp.Store);
}
else if (encoder.Pass.Depth is
{ DirectionalTarget: VulkanDirectionalDepthTarget directionalTarget } depth)
{
if (encoder.Pass.ViewMask != 0)
{
TransitionDirectionalDepthForSampling(
commands,
directionalTarget,
0,
directionalTarget.LayerCountForViewMask(encoder.Pass.ViewMask));
}
else
{
TransitionDirectionalDepthForSampling(commands, directionalTarget, depth.Layer, 1);
}
}
_openPass = null;
}
@ -766,19 +1143,35 @@ internal sealed unsafe partial class VulkanGpuDevice
private void TransitionRenderTargetForRendering(CommandBuffer commands, VulkanGpuRenderTarget target)
{
VulkanGpuTexture colorAttachment = target.ColorAttachment;
TransitionImage(
commands,
target.Color.Image,
colorAttachment.Image,
ImageAspectFlags.ColorBit,
target.Color.CurrentLayout,
colorAttachment.CurrentLayout,
ImageLayout.ColorAttachmentOptimal,
PipelineStageFlags2.AllCommandsBit,
AccessFlags2.None,
PipelineStageFlags2.ColorAttachmentOutputBit,
AccessFlags2.ColorAttachmentWriteBit);
target.Color.MarkLayout(ImageLayout.ColorAttachmentOptimal);
colorAttachment.MarkLayout(ImageLayout.ColorAttachmentOptimal);
if (target.Depth is { } depth)
if (target.ColorResolve is { } colorResolve)
{
TransitionImage(
commands,
colorResolve.Image,
ImageAspectFlags.ColorBit,
colorResolve.CurrentLayout,
ImageLayout.ColorAttachmentOptimal,
PipelineStageFlags2.AllCommandsBit,
AccessFlags2.None,
PipelineStageFlags2.ColorAttachmentOutputBit,
AccessFlags2.ColorAttachmentWriteBit);
colorResolve.MarkLayout(ImageLayout.ColorAttachmentOptimal);
}
if (target.DepthAttachment is { } depth)
{
TransitionImage(
commands,
@ -788,25 +1181,157 @@ internal sealed unsafe partial class VulkanGpuDevice
ImageLayout.DepthStencilAttachmentOptimal,
PipelineStageFlags2.AllCommandsBit,
AccessFlags2.None,
PipelineStageFlags2.EarlyFragmentTestsBit,
PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit,
AccessFlags2.DepthStencilAttachmentWriteBit);
depth.MarkLayout(ImageLayout.DepthStencilAttachmentOptimal);
}
if (target.DepthResolve is { } depthResolve)
{
TransitionImage(
commands,
depthResolve.Image,
ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit,
depthResolve.CurrentLayout,
ImageLayout.DepthStencilAttachmentOptimal,
PipelineStageFlags2.AllCommandsBit,
AccessFlags2.None,
PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit,
AccessFlags2.DepthStencilAttachmentWriteBit);
depthResolve.MarkLayout(ImageLayout.DepthStencilAttachmentOptimal);
}
}
private void TransitionRenderTargetForSampling(CommandBuffer commands, VulkanGpuRenderTarget target)
/// <summary>
/// Makes retained mapped-storage writes visible to vertex-shader SSBO
/// reads. The buffer belongs to the current flight slot, whose prior use has
/// retired before the host write; this barrier supplies the in-submission
/// HOST_WRITE to SHADER_READ dependency before the shadow pass consumes it.
/// </summary>
internal void PublishHostStorageWrites(
VulkanGpuFrame frame,
IGpuBuffer buffer)
{
ThrowIfDisposed();
ArgumentNullException.ThrowIfNull(frame);
ArgumentNullException.ThrowIfNull(buffer);
if (!ReferenceEquals(_openFrame, frame))
throw new InvalidOperationException("Host writes require the open Vulkan frame.");
if (_openPass is not null)
{
throw new InvalidOperationException(
"Retained host writes must be published before opening a rendering pass.");
}
if (buffer is not VulkanGpuBuffer vkBuffer
|| buffer.Residency != GpuMemoryResidency.HostWritable
|| !buffer.Usage.HasFlag(GpuBufferUsage.Storage))
{
throw new ArgumentException(
"Published host writes require a Vulkan host-writable storage buffer.",
nameof(buffer));
}
CommandBuffer commands = _commandBuffers[frame.SlotIndex];
BufferMemoryBarrier2 barrier = VulkanHostStorageVisibility.Create(
vkBuffer.Handle,
checked((ulong)vkBuffer.SizeBytes));
var dependency = new DependencyInfo
{
SType = StructureType.DependencyInfo,
BufferMemoryBarrierCount = 1,
PBufferMemoryBarriers = &barrier,
};
_vk.CmdPipelineBarrier2(commands, &dependency);
}
private void TransitionRenderTargetForSampling(
CommandBuffer commands,
VulkanGpuRenderTarget target,
bool colorStored,
bool depthStored)
{
if (colorStored)
{
VulkanGpuTexture color = target.ColorResult;
TransitionImage(
commands,
color.Image,
ImageAspectFlags.ColorBit,
color.CurrentLayout,
ImageLayout.ShaderReadOnlyOptimal,
PipelineStageFlags2.ColorAttachmentOutputBit,
AccessFlags2.ColorAttachmentWriteBit,
PipelineStageFlags2.FragmentShaderBit,
AccessFlags2.ShaderReadBit);
color.MarkLayout(ImageLayout.ShaderReadOnlyOptimal);
}
if (depthStored && target.Description.SampleableDepth && target.DepthResult is { } depth)
{
TransitionImage(
commands,
depth.Image,
ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit,
depth.CurrentLayout,
ImageLayout.DepthStencilReadOnlyOptimal,
PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit,
AccessFlags2.DepthStencilAttachmentWriteBit,
PipelineStageFlags2.FragmentShaderBit,
AccessFlags2.ShaderReadBit);
depth.MarkLayout(ImageLayout.DepthStencilReadOnlyOptimal);
}
}
private void TransitionDirectionalDepthForRendering(
CommandBuffer commands,
VulkanDirectionalDepthTarget target,
int baseLayer,
int layerCount)
{
const PipelineStageFlags2 DepthStages =
PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit;
ImageLayout oldLayout = target.LayoutAt(baseLayer);
for (int i = 1; i < layerCount; i++)
{
if (target.LayoutAt(baseLayer + i) != oldLayout)
throw new InvalidOperationException("Multiview directional layers must share one layout.");
}
TransitionImage(
commands,
target.Texture.Image,
ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit,
oldLayout,
ImageLayout.DepthStencilAttachmentOptimal,
oldLayout == ImageLayout.Undefined ? PipelineStageFlags2.TopOfPipeBit : PipelineStageFlags2.FragmentShaderBit,
oldLayout == ImageLayout.Undefined ? AccessFlags2.None : AccessFlags2.ShaderReadBit,
DepthStages,
AccessFlags2.DepthStencilAttachmentWriteBit,
baseArrayLayer: (uint)baseLayer,
layerCount: (uint)layerCount);
for (int i = 0; i < layerCount; i++)
target.MarkLayout(baseLayer + i, ImageLayout.DepthStencilAttachmentOptimal);
}
private void TransitionDirectionalDepthForSampling(
CommandBuffer commands,
VulkanDirectionalDepthTarget target,
int baseLayer,
int layerCount)
{
TransitionImage(
commands,
target.Color.Image,
ImageAspectFlags.ColorBit,
ImageLayout.ColorAttachmentOptimal,
ImageLayout.ShaderReadOnlyOptimal,
PipelineStageFlags2.ColorAttachmentOutputBit,
AccessFlags2.ColorAttachmentWriteBit,
target.Texture.Image,
ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit,
target.LayoutAt(baseLayer),
ImageLayout.DepthStencilReadOnlyOptimal,
PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit,
AccessFlags2.DepthStencilAttachmentWriteBit,
PipelineStageFlags2.FragmentShaderBit,
AccessFlags2.ShaderReadBit);
target.Color.MarkLayout(ImageLayout.ShaderReadOnlyOptimal);
AccessFlags2.ShaderReadBit,
baseArrayLayer: (uint)baseLayer,
layerCount: (uint)layerCount);
for (int i = 0; i < layerCount; i++)
target.MarkLayout(baseLayer + i, ImageLayout.DepthStencilReadOnlyOptimal);
}
private void TransitionImage(
@ -818,7 +1343,9 @@ internal sealed unsafe partial class VulkanGpuDevice
PipelineStageFlags2 sourceStage,
AccessFlags2 sourceAccess,
PipelineStageFlags2 destinationStage,
AccessFlags2 destinationAccess)
AccessFlags2 destinationAccess,
uint baseArrayLayer = 0,
uint layerCount = Silk.NET.Vulkan.Vk.RemainingArrayLayers)
{
var barrier = new ImageMemoryBarrier2
{
@ -837,8 +1364,8 @@ internal sealed unsafe partial class VulkanGpuDevice
AspectMask = aspect,
BaseMipLevel = 0,
LevelCount = Silk.NET.Vulkan.Vk.RemainingMipLevels,
BaseArrayLayer = 0,
LayerCount = Silk.NET.Vulkan.Vk.RemainingArrayLayers,
BaseArrayLayer = baseArrayLayer,
LayerCount = layerCount,
},
};
var dependency = new DependencyInfo

View file

@ -67,7 +67,7 @@ internal interface IVulkanBackbuffer
/// signalling both the per-image render-complete semaphore and the timeline at
/// this frame's serial, present.</para>
/// </summary>
internal sealed unsafe partial class VulkanGpuDevice : IGpuDevice
internal sealed unsafe partial class VulkanGpuDevice : IGpuDevice, IGpuPipelineFormatVariantHost
{
/// <summary>Per-flight-slot ring capacity, matching the GL backend's 16 MiB.</summary>
internal const int DefaultRingCapacityBytesPerSlot = 16 * 1024 * 1024;
@ -150,17 +150,34 @@ internal sealed unsafe partial class VulkanGpuDevice : IGpuDevice
MaxStorageBufferBindings = GpuBindingModel.StorageBindingCount,
MaxPushConstantBytes = limits.MaxPushConstantsSize,
MinStorageBufferOffsetAlignment = Math.Max(limits.MinStorageBufferOffsetAlignment, 1),
MaxStorageBufferRangeBytes = limits.MaxStorageBufferRange,
MinUniformBufferOffsetAlignment = Math.Max(limits.MinUniformBufferOffsetAlignment, 1),
MaxClipDistances = limits.MaxClipDistances,
MaxSampleCount = limits.MaxColorSampleCount,
MaxImageDimension2D = limits.MaxImageDimension2D,
MaxImageArrayLayers = limits.MaxImageArrayLayers,
DeviceLocalMemoryBytes = limits.DeviceLocalHeapBytes,
SupportsMultiDrawIndirect = features.MultiDrawIndirect,
SupportsDrawParameters = features.ShaderDrawParameters,
SupportsTextureCompressionBc =
features.TextureCompressionBc && formats.Bc1Sampled && formats.Bc2Sampled && formats.Bc3Sampled,
SupportsTimestampQueries = limits.TimestampComputeAndGraphics,
SupportsMultiview = features.Multiview,
// The one capability that is true here and false on GL, and the
// mechanism behind the campaign's CPU-cost target.
SupportsPersistentlyMappedRings = true,
SupportsRgba16FloatRenderTargets =
formats.Rgba16FloatColorAttachment
&& formats.Rgba16FloatSampled
&& formats.Rgba16FloatLinearFilter
&& formats.MaxRgba16FloatSampleCount > 0,
MaxRgba16FloatSampleCount =
formats.Rgba16FloatColorAttachment
&& formats.Rgba16FloatSampled
&& formats.Rgba16FloatLinearFilter
? Math.Min(formats.MaxRgba16FloatSampleCount, limits.MaxColorSampleCount)
: 0u,
SupportsSampledDepth = formats.DepthStencilSampled,
};
var timelineType = new SemaphoreTypeCreateInfo

View file

@ -34,6 +34,9 @@ internal sealed class VulkanGpuFrame : IGpuFrame
public GpuRingAllocation AllocateRing(int byteCount, GpuRingUsage usage) =>
_device.AllocateRing(SlotIndex, byteCount, usage);
public void PublishHostStorageWrites(IGpuBuffer buffer) =>
_device.PublishHostStorageWrites(this, buffer);
public IGpuPassEncoder BeginPass(GpuPassDescription description)
{
ArgumentNullException.ThrowIfNull(description);

View file

@ -17,10 +17,9 @@ namespace AcDream.App.Rendering.Gpu.Vk;
/// <para><b>Storage and uniform bindings go through a dynamic descriptor
/// set.</b> The contract lets a renderer bind an arbitrary buffer range per
/// draw, and ring allocations mean that range moves every frame. Rather than
/// writing descriptors mid-frame, set 0 and set 1 are allocated per flight slot
/// writing descriptors mid-frame, retail sets 0 and 1 are allocated per flight slot
/// with DYNAMIC descriptor types and the per-draw offset is supplied at bind
/// time — which is what keeps the campaign's "zero descriptor writes per frame"
/// property true for buffers as well as for textures.</para>
/// time. Opt-in set 3 uses the same rule from a separately owned lazy pool.</para>
/// </summary>
internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
{
@ -31,6 +30,8 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
private readonly uint _attachmentWidth;
private readonly uint _attachmentHeight;
private readonly bool _hasDepthAttachment;
private readonly bool _hasColorAttachment;
private readonly GpuTextureFormat _colorFormat;
/// <summary>
/// Extent of the attachments <c>vkCmdBeginRendering</c> was handed. Campaign V
@ -47,6 +48,7 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
internal bool HasDepthAttachment => _hasDepthAttachment;
private VulkanGpuPipeline? _pipeline;
private VulkanDrawBindingState _drawBindingState;
private bool _closed;
internal VulkanGpuPassEncoder(
@ -57,7 +59,9 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
GpuPassDescription pass,
uint attachmentWidth,
uint attachmentHeight,
bool hasDepthAttachment)
bool hasDepthAttachment,
bool hasColorAttachment,
GpuTextureFormat colorFormat)
{
_device = device;
_frame = frame;
@ -70,6 +74,8 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
// the attachments exist gets none, and the pipeline variant has to agree
// with the command buffer rather than with the intent.
_hasDepthAttachment = hasDepthAttachment;
_hasColorAttachment = hasColorAttachment;
_colorFormat = colorFormat;
Pass = pass;
// A pass always starts with the whole attachment drawable. GL's
@ -80,23 +86,15 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
SetViewport(0, 0, (int)attachmentWidth, (int)attachmentHeight);
SetScissor(0, 0, (int)attachmentWidth, (int)attachmentHeight);
// Campaign V slice V6h: and for the same reason, the descriptor sets.
// Campaign V slice V6h requires every pass to be self-contained rather
// than inheriting descriptor state from an earlier renderer. The first
// draw now establishes that state through FlushBindings. Deferring it
// until a draw exists avoids recording an unused initial binding and
// lets later draws reuse an identical binding safely.
//
// Before this, sets 0/1/2 were bound only as a side effect of
// BindStorageBuffer/BindUniformBuffer, so a pass whose pipeline reads the
// texture table but binds no buffer — every retained-UI and debug-line
// pass, because their per-draw data travels in push constants and a
// vertex buffer — issued vkCmdDraw with set 2 unbound. That is
// VUID-vkCmdDraw-None-08600 and, on the RX 9070 XT, an immediate
// ErrorDeviceLost at submit.
//
// It went unseen through V6cV6g because the bring-up host always drew
// VulkanRhiScene first: its storage binds left all three sets bound in
// the same command buffer, so the UI pass that followed inherited them.
// The composition host has no 3-D scene, so its UI pass is the first
// thing in the buffer and inherits nothing. Binding here makes a pass
// self-contained rather than dependent on what preceded it in the frame.
_bindings.Bind(_commands, _device);
// FlushBindings is called by every draw verb, including passes such as
// retained UI and debug lines that bind no buffers themselves. Thus set
// 2 is still guaranteed before vkCmdDraw and VUID 08600 stays closed.
}
public GpuPassDescription Pass { get; }
@ -107,17 +105,27 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
ThrowIfClosed();
if (pipeline is not VulkanGpuPipeline vulkanPipeline)
throw new ArgumentException("The Vulkan backend can only bind a Vulkan pipeline.", nameof(pipeline));
if (vulkanPipeline.Description.HasColorAttachment != _hasColorAttachment)
{
throw new InvalidOperationException(
$"Pipeline '{vulkanPipeline.Description.Name}' colour-attachment intent does not match pass '{Pass.Name}'.");
}
if (vulkanPipeline.Description.ViewMask != Pass.ViewMask)
{
throw new InvalidOperationException(
$"Pipeline '{vulkanPipeline.Description.Name}' view mask does not match pass '{Pass.Name}'.");
}
_pipeline = vulkanPipeline;
_device.Api.CmdBindPipeline(
_commands,
PipelineBindPoint.Graphics,
vulkanPipeline.HandleFor(_hasDepthAttachment));
vulkanPipeline.HandleFor(_hasDepthAttachment, _colorFormat));
// Every pipeline shares one layout, so the descriptor sets and push
// constants bound earlier in the pass survive this call. That is the
// whole reason for the shared layout, and it is why a bucketed world
// pass can change pipeline per bucket for free.
// Retail and pack pipelines share sets 0..2 and the same 96-byte push
// range, but a pack pipeline has one additional set. Bind against the
// exact layout used to create the active pipeline so set 3 can never
// leak onto the authoritative retail path.
_device.CmdBindPipelineDefaults(_commands, vulkanPipeline.Description);
}
@ -125,12 +133,14 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
{
ThrowIfClosed();
_bindings.SetStorage(binding, RequireBuffer(buffer), offsetBytes, sizeBytes);
_drawBindingState.MarkDirty();
}
public void BindUniformBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes)
{
ThrowIfClosed();
_bindings.SetUniform(binding, RequireBuffer(buffer), offsetBytes, sizeBytes);
_drawBindingState.MarkDirty();
}
/// <summary>
@ -149,10 +159,23 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
/// what the arena was designed to produce.</para>
///
/// <para>Legal because descriptor-set binding is independent of pipeline
/// binding when the layouts are compatible, and acdream has ONE pipeline
/// layout by design (§4.4).</para>
/// binding when the layouts are compatible. Retail and pack layouts share
/// identical sets 0..2; the active pipeline supplies the optional set 3.</para>
/// </summary>
private void FlushBindings() => _bindings.Bind(_commands, _device);
private void FlushBindings()
{
VulkanGpuPipeline pipeline = RequirePipeline();
ulong pipelineLayout = pipeline.PipelineLayout.Handle;
int packGeneration = pipeline.PackState?.Generation ?? 0;
if (!_drawBindingState.RequiresBind(pipelineLayout, packGeneration))
return;
_bindings.Bind(
_commands,
_device,
pipeline.PipelineLayout,
pipeline.PackState);
_drawBindingState.MarkBound(pipelineLayout, packGeneration);
}
/// <summary>
/// A scoped clear inside the live render-pass instance — retail's interior
@ -194,7 +217,7 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
{
_device.Api.CmdPushConstants(
_commands,
_device.Layouts.PipelineLayout,
_pipeline?.PipelineLayout ?? _device.Layouts.PipelineLayout,
ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
0,
(uint)GpuBindingModel.PushConstantBytes,
@ -313,10 +336,11 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
return vulkanBuffer;
}
private void RequirePipeline()
private VulkanGpuPipeline RequirePipeline()
{
if (_pipeline is null)
throw new InvalidOperationException("BindPipeline must be called before drawing.");
return _pipeline;
}
private void ThrowIfClosed() => ObjectDisposedException.ThrowIf(_closed, this);

View file

@ -43,8 +43,18 @@ internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline
private readonly Silk.NET.Vulkan.Vk _vk;
private readonly Device _device;
private readonly IGpuResourceRetirementQueue _retirement;
private readonly VulkanDebugNames _debugNames;
private readonly VulkanPipelineLayouts.Created _layouts;
private readonly VulkanPipelineLayouts.Created.PackLayoutLease? _packLayoutLease;
private readonly PipelineLayout _layout;
private readonly PipelineCache _cache;
private readonly ShaderModule _vertexModule;
private readonly ShaderModule _fragmentModule;
private readonly bool _ownsShaderModules;
private readonly Format _depthStencilFormat;
private readonly Pipeline _withDepthAttachment;
private readonly Pipeline _withoutDepthAttachment;
private readonly Dictionary<GpuTextureFormat, VulkanGpuPipeline> _colorVariants = [];
private bool _disposed;
internal VulkanGpuPipeline(
@ -52,10 +62,13 @@ internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline
Device device,
IGpuResourceRetirementQueue retirement,
VulkanDebugNames debugNames,
VulkanPipelineLayouts.Created layouts,
VulkanPipelineLayouts.Created.PackLayoutLease? packLayoutLease,
PipelineLayout layout,
PipelineCache cache,
ShaderModule vertexModule,
ShaderModule fragmentModule,
bool ownsShaderModules,
GpuPipelineDescription description,
Format colorFormat,
Format depthStencilFormat)
@ -63,6 +76,15 @@ internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline
_vk = vk ?? throw new ArgumentNullException(nameof(vk));
_device = device;
_retirement = retirement ?? throw new ArgumentNullException(nameof(retirement));
_debugNames = debugNames ?? throw new ArgumentNullException(nameof(debugNames));
_layouts = layouts ?? throw new ArgumentNullException(nameof(layouts));
_packLayoutLease = packLayoutLease;
_layout = layout;
_cache = cache;
_vertexModule = vertexModule;
_fragmentModule = fragmentModule;
_ownsShaderModules = ownsShaderModules;
_depthStencilFormat = depthStencilFormat;
Description = description ?? throw new ArgumentNullException(nameof(description));
nint entryPoint = SilkMarshal.StringToPtr("main");
@ -211,8 +233,8 @@ internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline
{
SType = StructureType.PipelineColorBlendStateCreateInfo,
LogicOpEnable = false,
AttachmentCount = 1,
PAttachments = &attachment,
AttachmentCount = description.HasColorAttachment ? 1u : 0u,
PAttachments = description.HasColorAttachment ? &attachment : null,
};
DynamicState* dynamicStates = stackalloc DynamicState[9];
@ -246,8 +268,9 @@ internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline
var rendering = new PipelineRenderingCreateInfo
{
SType = StructureType.PipelineRenderingCreateInfo,
ColorAttachmentCount = 1,
PColorAttachmentFormats = &color,
ViewMask = description.ViewMask,
ColorAttachmentCount = description.HasColorAttachment ? 1u : 0u,
PColorAttachmentFormats = description.HasColorAttachment ? &color : null,
DepthAttachmentFormat = depthStencilFormat,
StencilAttachmentFormat = depthStencilFormat,
};
@ -312,17 +335,107 @@ internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline
internal Pipeline HandleFor(bool passHasDepthAttachment) =>
passHasDepthAttachment ? _withDepthAttachment : _withoutDepthAttachment;
/// <summary>
/// Selects the prebuilt attachment-format variant required by the live pass.
/// Missing variants fail before a draw can record undefined Vulkan usage.
/// </summary>
internal Pipeline HandleFor(
bool passHasDepthAttachment,
GpuTextureFormat colorFormat)
{
if (colorFormat == Description.ColorFormat)
return HandleFor(passHasDepthAttachment);
if (_colorVariants.TryGetValue(colorFormat, out VulkanGpuPipeline? variant))
return variant.HandleFor(passHasDepthAttachment);
throw new InvalidOperationException(
$"Pipeline '{Description.Name}' has no prebuilt {colorFormat} attachment variant.");
}
internal bool IsDisposed => _disposed;
/// <summary>The exact layout this pipeline was created against.</summary>
internal PipelineLayout PipelineLayout => _layout;
/// <summary>Non-null only for a pipeline flagged for render-pack ABI v1.</summary>
internal VulkanPipelineLayouts.Created.PackState? PackState => _packLayoutLease?.State;
internal bool AddColorFormatVariant(GpuTextureFormat format)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!Description.HasColorAttachment
|| !Description.AllowColorFormatVariants
|| format == Description.ColorFormat
|| _colorVariants.ContainsKey(format))
return false;
var variantDescription = Description with
{
Name = $"{Description.Name}-{format.ToString().ToLowerInvariant()}",
ColorFormat = format,
AllowColorFormatVariants = false,
};
VulkanPipelineLayouts.Created.PackLayoutLease? packLease =
Description.UsesRenderPackShaderAbi ? _layouts.AcquirePackLayout() : null;
VulkanGpuPipeline variant;
try
{
variant = new VulkanGpuPipeline(
_vk,
_device,
_retirement,
_debugNames,
_layouts,
packLease,
packLease?.PipelineLayout ?? _layouts.PipelineLayout,
_cache,
_vertexModule,
_fragmentModule,
ownsShaderModules: false,
variantDescription,
VulkanTextureFormatMapping.FormatOf(format),
_depthStencilFormat);
}
catch
{
packLease?.Dispose();
throw;
}
_colorVariants.Add(format, variant);
return true;
}
internal void RemoveColorFormatVariant(GpuTextureFormat format)
{
if (_colorVariants.Remove(format, out VulkanGpuPipeline? variant))
variant.Dispose();
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
foreach (VulkanGpuPipeline variant in _colorVariants.Values)
variant.Dispose();
_colorVariants.Clear();
Pipeline withDepth = _withDepthAttachment;
Pipeline withoutDepth = _withoutDepthAttachment;
ShaderModule vertex = _vertexModule;
ShaderModule fragment = _fragmentModule;
bool destroyModules = _ownsShaderModules;
VulkanPipelineLayouts.Created.PackLayoutLease? packLease = _packLayoutLease;
_retirement.Retire(() =>
{
_vk.DestroyPipeline(_device, withDepth, null);
_vk.DestroyPipeline(_device, withoutDepth, null);
if (destroyModules)
{
_vk.DestroyShaderModule(_device, fragment, null);
_vk.DestroyShaderModule(_device, vertex, null);
}
// The optional set-3 and four-set layout cannot be destroyed until
// every pipeline that names them has actually retired.
packLease?.Dispose();
});
}
}

View file

@ -7,11 +7,10 @@ namespace AcDream.App.Rendering.Gpu.Vk;
/// offscreen colour(+depth) bundle behind the paperdoll, the creature-appraisal
/// viewport and the portal mask.
///
/// <para>Offscreen targets stay single-sampled, matching the contract. Their
/// colour image carries <c>SAMPLED</c> as well as <c>COLOR_ATTACHMENT</c> usage
/// so it can be registered into the texture table and drawn by the retained UI
/// the moment its pass ends — which is the whole reason these exist rather than
/// rendering those views onto the backbuffer.</para>
/// <para>The textures exposed through <see cref="IGpuRenderTarget"/> are always
/// single-sampled. When the requested attachment sample count is greater than
/// one, separate transient multisample attachments resolve into those textures;
/// the global table never receives an illegal multisampled view.</para>
///
/// <para>Slice V6l made both halves of that sentence true. The colour image now
/// carries a second, LAYERED view for the table to sample (see
@ -22,7 +21,9 @@ namespace AcDream.App.Rendering.Gpu.Vk;
internal sealed class VulkanGpuRenderTarget : IGpuRenderTarget
{
private readonly VulkanGpuTexture _color;
private readonly VulkanGpuTexture? _multisampleColor;
private readonly VulkanGpuTexture? _depth;
private readonly VulkanGpuTexture? _multisampleDepth;
private bool _disposed;
internal VulkanGpuRenderTarget(
@ -38,8 +39,18 @@ internal sealed class VulkanGpuRenderTarget : IGpuRenderTarget
ArgumentException.ThrowIfNullOrWhiteSpace(description.Name);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.Width);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.Height);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.SampleCount);
if (description.SampleableDepth && description.DepthFormat is null)
{
throw new ArgumentException(
"SampleableDepth requires a depth format.",
nameof(description));
}
Description = description;
// The public colour texture is the single-sampled result even when the
// pass itself is multisampled. Post-process and retained-UI consumers
// always register this image, never the transient attachment below.
_color = new VulkanGpuTexture(
vk,
device,
@ -55,11 +66,36 @@ internal sealed class VulkanGpuRenderTarget : IGpuRenderTarget
description.Height,
LayerCount: 1,
MipLevelCount: 1),
Math.Max(1, description.SampleCount),
renderTarget: true);
sampleCount: 1,
renderTarget: true,
sampleable: true);
if (description.SampleCount > 1)
{
_multisampleColor = new VulkanGpuTexture(
vk,
device,
allocator,
uploads,
retirement,
debugNames,
new GpuTextureDescription(
$"{description.Name}-color-msaa",
GpuTextureKind.Texture2D,
description.ColorFormat,
description.Width,
description.Height,
LayerCount: 1,
MipLevelCount: 1),
description.SampleCount,
renderTarget: true,
sampleable: false);
}
if (description.DepthFormat is { } depthFormat)
{
int retainedDepthSamples =
description.SampleableDepth ? 1 : description.SampleCount;
_depth = new VulkanGpuTexture(
vk,
device,
@ -75,14 +111,38 @@ internal sealed class VulkanGpuRenderTarget : IGpuRenderTarget
description.Height,
LayerCount: 1,
MipLevelCount: 1),
Math.Max(1, description.SampleCount),
retainedDepthSamples,
renderTarget: true,
sampleable: description.SampleableDepth,
// Slice V6l: the DEVICE's combined depth/stencil format, not the
// contract enum's literal one. Every pipeline bakes one
// depth/stencil format under dynamic rendering and the same
// pipelines draw in both the backbuffer pass and this one, so a
// second format here would make one of the two undefined.
formatOverride: deviceDepthStencilFormat);
if (description.SampleableDepth && description.SampleCount > 1)
{
_multisampleDepth = new VulkanGpuTexture(
vk,
device,
allocator,
uploads,
retirement,
debugNames,
new GpuTextureDescription(
$"{description.Name}-depth-msaa",
GpuTextureKind.Texture2D,
depthFormat,
description.Width,
description.Height,
LayerCount: 1,
MipLevelCount: 1),
description.SampleCount,
renderTarget: true,
sampleable: false,
formatOverride: deviceDepthStencilFormat);
}
}
}
@ -90,16 +150,33 @@ internal sealed class VulkanGpuRenderTarget : IGpuRenderTarget
public IGpuTexture ColorTexture => _color;
internal VulkanGpuTexture Color => _color;
public IGpuTexture? DepthTexture => Description.SampleableDepth ? _depth : null;
internal VulkanGpuTexture? Depth => _depth;
/// <summary>The image written as the pass's colour attachment.</summary>
internal VulkanGpuTexture ColorAttachment => _multisampleColor ?? _color;
/// <summary>The single-sampled resolve destination, or null at one sample.</summary>
internal VulkanGpuTexture? ColorResolve => _multisampleColor is null ? null : _color;
/// <summary>The image written as the pass's depth/stencil attachment.</summary>
internal VulkanGpuTexture? DepthAttachment => _multisampleDepth ?? _depth;
/// <summary>The sampleable depth resolve destination, or null when no resolve is required.</summary>
internal VulkanGpuTexture? DepthResolve =>
Description.SampleableDepth && _multisampleDepth is not null ? _depth : null;
internal VulkanGpuTexture ColorResult => _color;
internal VulkanGpuTexture? DepthResult => _depth;
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_multisampleDepth?.Dispose();
_depth?.Dispose();
_multisampleColor?.Dispose();
_color.Dispose();
}
}

View file

@ -41,6 +41,7 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture
in GpuTextureDescription description,
int sampleCount = 1,
bool renderTarget = false,
bool sampleable = true,
// Fully qualified: in a parameter-default expression the simple name
// `Format` binds to this type's own GpuTextureFormat property first.
Format formatOverride = Silk.NET.Vulkan.Format.Undefined)
@ -55,6 +56,13 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.Height);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.LayerCount);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.MipLevelCount);
if (sampleCount > 1 && sampleable)
{
throw new ArgumentException(
"A multisampled image cannot be registered in acdream's single-sampled texture table; "
+ "create a separate single-sampled resolve image.",
nameof(sampleable));
}
Name = description.Name;
Kind = description.Kind;
@ -64,6 +72,7 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture
LayerCount = description.LayerCount;
MipLevelCount = description.MipLevelCount;
SampleCount = sampleCount;
IsSampleable = sampleable;
// Slice V6l: an offscreen target's DEPTH attachment takes the format the
// device already chose for the backbuffer, because a pipeline bakes one
// depth/stencil format and draws in both kinds of pass. The contract's
@ -77,7 +86,9 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture
bool depthStencil = VulkanTextureFormatMapping.IsDepthStencil(description.Format);
ImageUsageFlags usage = depthStencil
? ImageUsageFlags.DepthStencilAttachmentBit
: ImageUsageFlags.SampledBit | ImageUsageFlags.TransferDstBit | ImageUsageFlags.TransferSrcBit;
: ImageUsageFlags.TransferDstBit | ImageUsageFlags.TransferSrcBit;
if (sampleable)
usage |= ImageUsageFlags.SampledBit;
if (renderTarget && !depthStencil)
usage |= ImageUsageFlags.ColorAttachmentBit;
if (sampleCount > 1)
@ -142,9 +153,9 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture
_vk.CreateImageView(_device, &viewCreate, null, out ImageView view),
$"vkCreateImageView ('{description.Name}')");
View = view;
SampledView = view;
SampledView = renderTarget && !sampleable ? default : view;
// Campaign V slice V6l: a colour render target needs TWO views.
// Campaign V slice V6l: a sampleable render target needs TWO views.
//
// An ATTACHMENT view must be VK_IMAGE_VIEW_TYPE_2D, and the global
// texture table's descriptor array is declared sampler2DArray, so the
@ -155,9 +166,14 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture
// fix: one image, one allocation, two ways of looking at it. Legal
// without any creation flag — a 2D_ARRAY view over an imageType-2D
// image with arrayLayers >= 1 is exactly what the spec permits.
if (renderTarget && !depthStencil)
if (renderTarget && sampleable)
{
viewCreate.ViewType = VulkanTextureFormatMapping.SampledViewTypeOf(description.Kind);
// Combined depth/stencil remains one attachment for #117, but
// sampling exposes only depth. A sampled view containing the
// stencil aspect is invalid for sampler2DArray.
if (depthStencil)
viewCreate.SubresourceRange.AspectMask = ImageAspectFlags.DepthBit;
VulkanInterop.Check(
_vk.CreateImageView(_device, &viewCreate, null, out ImageView sampled),
$"vkCreateImageView ('{description.Name}', sampled)");
@ -184,6 +200,7 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture
public int MipLevelCount { get; }
internal int SampleCount { get; }
internal bool IsSampleable { get; }
internal Image Image { get; }
/// <summary>The view a pass names as an attachment, and the only view a non-attachment has.</summary>
@ -196,6 +213,10 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture
/// is <c>sampler2DArray</c> (slice V6l, plan §5.5.7).
/// </summary>
internal ImageView SampledView { get; }
internal ImageLayout SampledLayout =>
VulkanTextureFormatMapping.IsDepthStencil(Format)
? ImageLayout.DepthStencilReadOnlyOptimal
: ImageLayout.ShaderReadOnlyOptimal;
internal Format VkFormat { get; }
internal ImageAspectFlags Aspect { get; }
@ -264,7 +285,7 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture
VulkanAllocation allocation = _allocation;
_retirement.Retire(() =>
{
if (sampledView.Handle != view.Handle)
if (sampledView.Handle != 0 && sampledView.Handle != view.Handle)
_vk.DestroyImageView(_device, sampledView, null);
_vk.DestroyImageView(_device, view, null);
_vk.DestroyImage(_device, image, null);
@ -337,6 +358,8 @@ internal sealed unsafe class VulkanGpuSampler : IGpuSampler
internal Sampler Handle { get; }
internal bool IsDisposed => _disposed;
public void Dispose()
{
if (_disposed)

View file

@ -176,7 +176,7 @@ internal sealed unsafe class VulkanGpuTimerPool : IGpuTimerPool, IDisposable
/// <see cref="TryResolve"/> deliberately reports the last known value
/// forever — right for a diagnostic readout, wrong for a percentile.
/// </summary>
internal bool TryTakeResolved(string scopeName, out double milliseconds)
public bool TryTakeResolved(string scopeName, out double milliseconds)
{
if (!_resolved.TryGetValue(scopeName, out milliseconds))
return false;

View file

@ -214,6 +214,13 @@ internal sealed unsafe class VulkanGraphicsContext : IDisposable
_physicalDevice = handles[choice.Device.Index];
// The logical-device feature chain consumes this exact probe result.
// Keep the probe owned by the selected physical device and publish it
// before VulkanLogicalDeviceFactory.Create: probing later leaves the
// production Acquire path with no safe value from which to decide
// whether the optional Vulkan 1.1 multiview feature may be enabled.
_features = VulkanPhysicalDeviceInspector.ReadFeatures(vk, _physicalDevice);
IReadOnlyList<VulkanQueueFamilyCandidate> queueFamilies =
VulkanPhysicalDeviceInspector.ReadQueueFamilies(
vk,
@ -233,7 +240,8 @@ internal sealed unsafe class VulkanGraphicsContext : IDisposable
vk,
_physicalDevice,
families,
requireSwapchain: true);
requireSwapchain: true,
availableFeatures: _features);
_device = created.Device;
_graphicsQueue = created.GraphicsQueue;
_presentQueue = created.PresentQueue;
@ -287,7 +295,6 @@ internal sealed unsafe class VulkanGraphicsContext : IDisposable
_graphicsQueue,
families.GraphicsFamily);
_features = VulkanPhysicalDeviceInspector.ReadFeatures(vk, _physicalDevice);
_limits = VulkanPhysicalDeviceInspector.ReadLimits(vk, _physicalDevice);
_formats = VulkanPhysicalDeviceInspector.ReadFormats(
vk,

View file

@ -0,0 +1,30 @@
using Silk.NET.Vulkan;
using Buffer = Silk.NET.Vulkan.Buffer;
namespace AcDream.App.Rendering.Gpu.Vk;
/// <summary>
/// Exact sync2 dependency for CPU writes into a retained mapped SSBO before
/// shadow vertex shaders read it. Kept pure so driverless contract tests can
/// assert the stage/access/ownership/range tuple Vulkan receives.
/// </summary>
internal static class VulkanHostStorageVisibility
{
internal static BufferMemoryBarrier2 Create(Buffer buffer, ulong sizeBytes)
{
ArgumentOutOfRangeException.ThrowIfZero(sizeBytes);
return new BufferMemoryBarrier2
{
SType = StructureType.BufferMemoryBarrier2,
SrcStageMask = PipelineStageFlags2.HostBit,
SrcAccessMask = AccessFlags2.HostWriteBit,
DstStageMask = PipelineStageFlags2.VertexShaderBit,
DstAccessMask = AccessFlags2.ShaderReadBit,
SrcQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored,
Buffer = buffer,
Offset = 0,
Size = sizeBytes,
};
}
}

View file

@ -314,6 +314,7 @@ internal static unsafe class VulkanPhysicalDeviceInspector
TextureCompressionBc = core.TextureCompressionBC,
SamplerAnisotropy = core.SamplerAnisotropy,
ShaderDrawParameters = vulkan11.ShaderDrawParameters,
Multiview = vulkan11.Multiview,
TimelineSemaphore = vulkan12.TimelineSemaphore,
HostQueryReset = vulkan12.HostQueryReset,
RuntimeDescriptorArray = vulkan12.RuntimeDescriptorArray,
@ -363,6 +364,8 @@ internal static unsafe class VulkanPhysicalDeviceInspector
MaxClipDistances = limits.MaxClipDistances,
MaxBoundDescriptorSets = limits.MaxBoundDescriptorSets,
MaxDescriptorSetStorageBuffersDynamic = limits.MaxDescriptorSetStorageBuffersDynamic,
MaxDescriptorSetStorageBuffers = limits.MaxDescriptorSetStorageBuffers,
MaxPerStageDescriptorStorageBuffers = limits.MaxPerStageDescriptorStorageBuffers,
MaxDescriptorSetUniformBuffersDynamic = limits.MaxDescriptorSetUniformBuffersDynamic,
MaxDescriptorSetUpdateAfterBindSampledImages =
indexing.MaxDescriptorSetUpdateAfterBindSampledImages,
@ -370,8 +373,11 @@ internal static unsafe class VulkanPhysicalDeviceInspector
indexing.MaxPerStageDescriptorUpdateAfterBindSampledImages,
TimestampComputeAndGraphics = limits.TimestampComputeAndGraphics,
MinStorageBufferOffsetAlignment = (uint)limits.MinStorageBufferOffsetAlignment,
MaxStorageBufferRange = limits.MaxStorageBufferRange,
MinUniformBufferOffsetAlignment = (uint)limits.MinUniformBufferOffsetAlignment,
MaxImageDimension2D = limits.MaxImageDimension2D,
MaxImageArrayLayers = limits.MaxImageArrayLayers,
DeviceLocalHeapBytes = LargestDeviceLocalHeap(vk, device),
MaxColorSampleCount = HighestSampleCount(
limits.FramebufferColorSampleCounts & limits.FramebufferDepthSampleCounts),
};
@ -398,10 +404,28 @@ internal static unsafe class VulkanPhysicalDeviceInspector
{
ArgumentNullException.ThrowIfNull(vk);
Format depthStencil = ChooseDepthStencilFormat(vk, device);
FormatProperties rgba16;
vk.GetPhysicalDeviceFormatProperties(device, Format.R16G16B16A16Sfloat, &rgba16);
FormatFeatureFlags rgba16Features = rgba16.OptimalTilingFeatures;
return new VulkanFormatSupport
{
SwapchainUnormFormat = surfaceOffersUnorm,
DepthStencilFormat = ChooseDepthStencilFormat(vk, device),
DepthStencilFormat = depthStencil,
DepthStencilSampled =
depthStencil != Format.Undefined
&& SupportsOptimalSampling(vk, device, depthStencil),
Rgba16FloatColorAttachment =
rgba16Features.HasFlag(FormatFeatureFlags.ColorAttachmentBit),
Rgba16FloatSampled =
rgba16Features.HasFlag(FormatFeatureFlags.SampledImageBit),
Rgba16FloatLinearFilter =
rgba16Features.HasFlag(FormatFeatureFlags.SampledImageFilterLinearBit),
MaxRgba16FloatSampleCount = ReadOptimalColorSampleCount(
vk,
device,
Format.R16G16B16A16Sfloat),
Bc1Sampled = SupportsOptimalSampling(vk, device, Format.BC1RgbaUnormBlock),
Bc2Sampled = SupportsOptimalSampling(vk, device, Format.BC2UnormBlock),
Bc3Sampled = SupportsOptimalSampling(vk, device, Format.BC3UnormBlock),
@ -436,6 +460,32 @@ internal static unsafe class VulkanPhysicalDeviceInspector
return properties.OptimalTilingFeatures.HasFlag(FormatFeatureFlags.SampledImageBit);
}
/// <summary>
/// Query the sample-count mask for an optimal-tiling colour attachment.
/// Physical-device framebuffer limits are only an upper bound; the concrete
/// RGBA16F format may support fewer samples. Sampling is checked separately
/// because the multisample image is transient and only its one-sample
/// resolve target carries <c>SAMPLED</c> usage.
/// </summary>
internal static uint ReadOptimalColorSampleCount(
Silk.NET.Vulkan.Vk vk,
PhysicalDevice device,
Format format)
{
ImageFormatProperties properties;
Result result = vk.GetPhysicalDeviceImageFormatProperties(
device,
format,
ImageType.Type2D,
ImageTiling.Optimal,
ImageUsageFlags.ColorAttachmentBit,
ImageCreateFlags.None,
&properties);
return result == Result.Success
? HighestSampleCount(properties.SampleCounts)
: 0u;
}
/// <summary>
/// Enumerate queue families, reporting present support only when a surface
/// is supplied. The headless probe passes <c>null</c> and takes the
@ -517,10 +567,12 @@ internal sealed unsafe class VulkanLogicalDeviceFactory
Silk.NET.Vulkan.Vk vk,
PhysicalDevice physicalDevice,
VulkanQueueFamilyChoice families,
bool requireSwapchain)
bool requireSwapchain,
VulkanDeviceFeatureSupport availableFeatures)
{
ArgumentNullException.ThrowIfNull(vk);
ArgumentNullException.ThrowIfNull(families);
ArgumentNullException.ThrowIfNull(availableFeatures);
IReadOnlyList<string> available =
VulkanInterop.EnumerateDeviceExtensions(vk, physicalDevice);
@ -577,6 +629,9 @@ internal sealed unsafe class VulkanLogicalDeviceFactory
SType = StructureType.PhysicalDeviceVulkan11Features,
PNext = &vulkan12,
ShaderDrawParameters = true,
// Core 1.1 optional feature: enable it iff the physical-device
// Features2 chain reported it. No extension path is attempted.
Multiview = availableFeatures.Multiview,
};
var core = new PhysicalDeviceFeatures
{

View file

@ -3,8 +3,8 @@ using Silk.NET.Vulkan;
namespace AcDream.App.Rendering.Gpu.Vk;
/// <summary>
/// Campaign V, plan §3.4 and §4.4: the three descriptor set layouts and the ONE
/// pipeline layout every acdream pipeline shares.
/// Campaign V, plan §3.4 and §4.4: retail's three descriptor set layouts and
/// shared pipeline layout, plus the strictly opt-in render-pack extension.
///
/// <para>Extracted from slice V5's active capability probe at V6b so the probe
/// and the live backend build the same objects from the same code. The probe's
@ -12,33 +12,110 @@ namespace AcDream.App.Rendering.Gpu.Vk;
/// device; a second, similar-looking definition would quietly destroy that
/// property the first time one of them changed.</para>
///
/// <para><b>One pipeline layout is a decision, not an economy.</b> Because every
/// pipeline shares it, switching pipelines mid-pass does not invalidate bound
/// descriptor sets or push constants — which is what lets the world dispatcher
/// bind the texture table once per frame and then change pipeline per bucket.
/// The single 96-byte push-constant block exists for the same reason.</para>
/// <para><b>Retail remains authoritative.</b> Its layout is exactly sets 0, 1,
/// and 2 with the original 96-byte push block. A flagged render-pack pipeline
/// lazily acquires a compatible four-set layout whose additional set 3 owns
/// bindings 5..8. The pack objects are reference-counted through pipeline
/// retirement, so selecting retail creates no Vulkan pack object at all.</para>
/// </summary>
internal static unsafe class VulkanPipelineLayouts
{
/// <summary>The three sets plus the shared layout, owned together and destroyed together.</summary>
internal sealed class Created(
DescriptorSetLayout storage,
DescriptorSetLayout uniform,
DescriptorSetLayout textureTable,
PipelineLayout pipelineLayout) : IDisposable
/// <summary>The retail layouts and the lazy lifetime of the optional pack layout.</summary>
internal sealed class Created : IDisposable
{
private readonly Silk.NET.Vulkan.Vk _vk;
private readonly Device _device;
private readonly object _packLock = new();
private PackState? _pack;
private int _packReferences;
private int _nextPackGeneration;
private bool _disposed;
internal DescriptorSetLayout Storage { get; } = storage;
internal DescriptorSetLayout Uniform { get; } = uniform;
internal DescriptorSetLayout TextureTable { get; } = textureTable;
internal PipelineLayout PipelineLayout { get; } = pipelineLayout;
internal Created(
Silk.NET.Vulkan.Vk vk,
Device device,
DescriptorSetLayout storage,
DescriptorSetLayout uniform,
DescriptorSetLayout textureTable,
PipelineLayout pipelineLayout)
{
_vk = vk;
_device = device;
Storage = storage;
Uniform = uniform;
TextureTable = textureTable;
PipelineLayout = pipelineLayout;
}
internal DescriptorSetLayout Storage { get; }
internal DescriptorSetLayout Uniform { get; }
internal DescriptorSetLayout TextureTable { get; }
internal PipelineLayout PipelineLayout { get; }
/// <summary>
/// Acquires the opt-in layout. The first acquisition creates set 3 and
/// its compatible four-set pipeline layout; the last retired pipeline
/// destroys them together with every descriptor pool allocated from it.
/// </summary>
internal PackLayoutLease AcquirePackLayout()
{
lock (_packLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_pack ??= CreatePackState(++_nextPackGeneration);
_packReferences++;
return new PackLayoutLease(this, _pack);
}
}
private PackState CreatePackState(int generation)
{
DescriptorSetLayout packUniform = default;
try
{
packUniform = CreatePackUniformSetLayout(_vk, _device);
PipelineLayout packPipeline = CreatePackPipelineLayout(
_vk,
_device,
Storage,
Uniform,
TextureTable,
packUniform);
return new PackState(_vk, _device, generation, packUniform, packPipeline);
}
catch
{
if (packUniform.Handle != 0)
_vk.DestroyDescriptorSetLayout(_device, packUniform, null);
throw;
}
}
private void ReleasePackLayout(PackState state)
{
lock (_packLock)
{
if (_pack != state || _packReferences <= 0)
return;
_packReferences--;
if (_packReferences != 0)
return;
_pack = null;
state.Destroy();
}
}
internal void Destroy(Silk.NET.Vulkan.Vk vk, Device device)
{
if (_disposed)
return;
_disposed = true;
lock (_packLock)
{
_pack?.Destroy();
_pack = null;
_packReferences = 0;
}
if (PipelineLayout.Handle != 0)
vk.DestroyPipelineLayout(device, PipelineLayout, null);
if (TextureTable.Handle != 0)
@ -51,6 +128,114 @@ internal static unsafe class VulkanPipelineLayouts
/// <summary>Destruction needs the device, so <see cref="Destroy"/> is the real disposer.</summary>
public void Dispose() => _disposed = true;
internal sealed class PackLayoutLease : IDisposable
{
private Created? _owner;
internal PackLayoutLease(Created owner, PackState state)
{
_owner = owner;
State = state;
}
internal PackState State { get; }
internal PipelineLayout PipelineLayout => State.PipelineLayout;
public void Dispose()
{
Created? owner = Interlocked.Exchange(ref _owner, null);
owner?.ReleasePackLayout(State);
}
}
/// <summary>
/// One generation of the pack layout and all descriptor pools allocated
/// against it. Keeping the pools here prevents a stale per-flight set
/// from outliving the descriptor-set layout it was allocated from.
/// </summary>
internal sealed unsafe class PackState
{
private const int SetsPerPool = 32;
private readonly Silk.NET.Vulkan.Vk _vk;
private readonly Device _device;
private readonly List<DescriptorPool> _pools = [];
private int _setCount;
private bool _destroyed;
internal PackState(
Silk.NET.Vulkan.Vk vk,
Device device,
int generation,
DescriptorSetLayout descriptorSetLayout,
PipelineLayout pipelineLayout)
{
_vk = vk;
_device = device;
Generation = generation;
DescriptorSetLayout = descriptorSetLayout;
PipelineLayout = pipelineLayout;
}
internal int Generation { get; }
internal DescriptorSetLayout DescriptorSetLayout { get; }
internal PipelineLayout PipelineLayout { get; }
internal DescriptorSet AllocateDescriptorSet()
{
ObjectDisposedException.ThrowIf(_destroyed, this);
if (_setCount % SetsPerPool == 0)
_pools.Add(CreatePool());
DescriptorSetLayout layout = DescriptorSetLayout;
var allocate = new DescriptorSetAllocateInfo
{
SType = StructureType.DescriptorSetAllocateInfo,
DescriptorPool = _pools[^1],
DescriptorSetCount = 1,
PSetLayouts = &layout,
};
VulkanInterop.Check(
_vk.AllocateDescriptorSets(_device, &allocate, out DescriptorSet set),
"vkAllocateDescriptorSets (render-pack set 3)");
_setCount++;
return set;
}
private DescriptorPool CreatePool()
{
var size = new DescriptorPoolSize
{
Type = DescriptorType.UniformBufferDynamic,
DescriptorCount = PackUniformBindingCount * SetsPerPool,
};
var create = new DescriptorPoolCreateInfo
{
SType = StructureType.DescriptorPoolCreateInfo,
MaxSets = SetsPerPool,
PoolSizeCount = 1,
PPoolSizes = &size,
};
VulkanInterop.Check(
_vk.CreateDescriptorPool(_device, &create, null, out DescriptorPool pool),
"vkCreateDescriptorPool (render-pack set 3)");
return pool;
}
internal void Destroy()
{
if (_destroyed)
return;
_destroyed = true;
foreach (DescriptorPool pool in _pools)
_vk.DestroyDescriptorPool(_device, pool, null);
_pools.Clear();
if (PipelineLayout.Handle != 0)
_vk.DestroyPipelineLayout(_device, PipelineLayout, null);
if (DescriptorSetLayout.Handle != 0)
_vk.DestroyDescriptorSetLayout(_device, DescriptorSetLayout, null);
}
}
}
/// <summary>Creates all four objects, cleaning up whatever succeeded if a later one fails.</summary>
@ -67,7 +252,7 @@ internal static unsafe class VulkanPipelineLayouts
uniform = CreateUniformSetLayout(vk, device);
table = CreateTextureTableSetLayout(vk, device);
PipelineLayout layout = CreatePipelineLayout(vk, device, storage, uniform, table);
return new Created(storage, uniform, table, layout);
return new Created(vk, device, storage, uniform, table, layout);
}
catch
{
@ -105,11 +290,10 @@ internal static unsafe class VulkanPipelineLayouts
/// at all can fail this layout. That matters for slice V9's lavapipe row and
/// for whatever Linux driver the deferred physical row eventually uses.</para>
///
/// <para><b>Binding 9 is the clearest case.</b> The texture table is the
/// GL-only <c>uvec2</c> handle-buffer emulation; the Vulkan backend binds set
/// 2 instead and never touches binding 9 at all, so a dynamic descriptor for
/// it would be a device resource spent on a binding that is provably never
/// bound.</para>
/// <para><b>Binding 9 is a plain descriptor.</b> #226 uploads one
/// per-instance detail-category array at a stable ring range for each
/// submission. It does not need to be re-pointed between draw calls, so a
/// scarce dynamic descriptor buys it nothing.</para>
///
/// <para><b>What to do if V4c disagrees.</b> Bindings 6, 7 and 8 are
/// per-instance arrays grouped here with the frame-global tables because
@ -153,8 +337,8 @@ internal static unsafe class VulkanPipelineLayouts
/// <summary>
/// Set 0 — the <see cref="GpuBindingModel.StorageBindingCount"/> storage
/// bindings <see cref="GpuBindingModel"/> pins (nine, since Campaign V
/// slice V11 deleted the GL-only <c>StorageTextureTable</c> binding), split
/// bindings <see cref="GpuBindingModel"/> pins (ten after #226 reclaimed
/// binding 9 from the deleted GL-only texture table), split
/// between dynamic and plain by <see cref="IsDynamicStorageBinding"/>.
/// </summary>
internal static DescriptorSetLayout CreateStorageSetLayout(Silk.NET.Vulkan.Vk vk, Device device)
@ -199,6 +383,13 @@ internal static unsafe class VulkanPipelineLayouts
/// </summary>
internal const uint UniformTerrainClip = 2;
/// <summary>Bindings 5..8 in opt-in set 3.</summary>
internal const uint PackUniformBindingCount = 4;
internal static bool IsDeclaredPackUniformBinding(uint binding) =>
binding >= GpuBindingModel.UniformAtmosphericFrame
&& binding <= GpuBindingModel.UniformPackSettings;
/// <summary>
/// Which of set 1's bindings the layout declares — the uniform-side twin of
/// <see cref="IsDynamicStorageBinding"/>, and for the same reason: the
@ -247,12 +438,9 @@ internal static unsafe class VulkanPipelineLayouts
}
/// <summary>
/// Set 1 — the SceneLighting, terrain-clip, terrain-tiling and sky-params
/// uniform blocks. All four are dynamic: each is fed from the per-frame ring,
/// so its offset moves every frame and a dynamic descriptor is exactly what
/// spares the write. Four is half Vulkan's guaranteed
/// <c>maxDescriptorSetUniformBuffersDynamic</c> of 8, and the capability gate
/// asserts it.
/// Set 1 — only retail frame blocks. All four are dynamic: each is fed from
/// the per-frame ring, so its offset moves every frame and a dynamic
/// descriptor is exactly what spares the write.
/// </summary>
internal static DescriptorSetLayout CreateUniformSetLayout(Silk.NET.Vulkan.Vk vk, Device device)
{
@ -281,6 +469,38 @@ internal static unsafe class VulkanPipelineLayouts
return layout;
}
/// <summary>
/// Opt-in set 3 — sparse dynamic uniform bindings 5..8 from render-pack ABI
/// v1. It is deliberately not created by <see cref="Create"/>.
/// </summary>
internal static DescriptorSetLayout CreatePackUniformSetLayout(
Silk.NET.Vulkan.Vk vk,
Device device)
{
DescriptorSetLayoutBinding* bindings = stackalloc DescriptorSetLayoutBinding[(int)PackUniformBindingCount];
for (uint i = 0; i < PackUniformBindingCount; i++)
{
bindings[i] = new DescriptorSetLayoutBinding
{
Binding = GpuBindingModel.UniformAtmosphericFrame + i,
DescriptorType = DescriptorType.UniformBufferDynamic,
DescriptorCount = 1,
StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
};
}
var create = new DescriptorSetLayoutCreateInfo
{
SType = StructureType.DescriptorSetLayoutCreateInfo,
BindingCount = PackUniformBindingCount,
PBindings = bindings,
};
VulkanInterop.Check(
vk.CreateDescriptorSetLayout(device, &create, null, out DescriptorSetLayout layout),
"vkCreateDescriptorSetLayout (set 3, render-pack uniforms)");
return layout;
}
/// <summary>
/// Set 2 — the production texture table exactly as §4.4 specifies it: one
/// combined-image-sampler binding of
@ -323,7 +543,7 @@ internal static unsafe class VulkanPipelineLayouts
}
/// <summary>
/// One shared pipeline layout: three sets plus the single 96-byte
/// Retail's shared pipeline layout: three sets plus the single 96-byte
/// push-constant block. Creating it proves <c>maxBoundDescriptorSets</c> and
/// <c>maxPushConstantsSize</c> for real rather than by reading a limit.
/// </summary>
@ -358,4 +578,42 @@ internal static unsafe class VulkanPipelineLayouts
"vkCreatePipelineLayout");
return layout;
}
/// <summary>
/// Render-pack layout. Sets 0..2 are byte-for-byte the retail layouts; set 3
/// adds only the pack uniform ABI, and the push range remains 96 bytes.
/// </summary>
internal static PipelineLayout CreatePackPipelineLayout(
Silk.NET.Vulkan.Vk vk,
Device device,
DescriptorSetLayout storage,
DescriptorSetLayout uniform,
DescriptorSetLayout table,
DescriptorSetLayout packUniform)
{
DescriptorSetLayout* sets = stackalloc DescriptorSetLayout[4];
sets[0] = storage;
sets[1] = uniform;
sets[2] = table;
sets[3] = packUniform;
var pushConstants = new PushConstantRange
{
StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
Offset = 0,
Size = GpuBindingModel.PushConstantBytes,
};
var create = new PipelineLayoutCreateInfo
{
SType = StructureType.PipelineLayoutCreateInfo,
SetLayoutCount = 4,
PSetLayouts = sets,
PushConstantRangeCount = 1,
PPushConstantRanges = &pushConstants,
};
VulkanInterop.Check(
vk.CreatePipelineLayout(device, &create, null, out PipelineLayout layout),
"vkCreatePipelineLayout (render-pack ABI)");
return layout;
}
}

View file

@ -0,0 +1,43 @@
using Silk.NET.Vulkan;
namespace AcDream.App.Rendering.Gpu.Vk;
/// <summary>
/// Separates process/device-terminal Vulkan failures from faults contributed by
/// an optional render pack. Only the latter may be quarantined to the default
/// renderer; a lost device or exhausted host/device memory cannot be made safe
/// by changing render graphs.
/// </summary>
internal static class VulkanRenderFailurePolicy
{
internal static bool IsFatal(Exception error)
{
ArgumentNullException.ThrowIfNull(error);
if (error is AggregateException aggregate)
{
foreach (Exception inner in aggregate.Flatten().InnerExceptions)
{
if (IsFatal(inner))
return true;
}
}
for (Exception? current = error; current is not null; current = current.InnerException)
{
if (current is OutOfMemoryException)
return true;
if (current is VulkanCallException vulkan && IsFatal(vulkan.Result))
return true;
}
return false;
}
private static bool IsFatal(Result result) => result is
Result.ErrorDeviceLost
or Result.ErrorOutOfHostMemory
or Result.ErrorOutOfDeviceMemory
// The swapchain policy already treats a lost surface as terminal. It
// cannot be repaired by falling back from a pack to the default graph.
or Result.ErrorSurfaceLostKhr;
}

View file

@ -6,14 +6,11 @@ namespace AcDream.App.Rendering.Gpu.Vk;
/// Campaign V slice V6b, plan §4.3: <see cref="GpuTextureFormat"/> to
/// <see cref="Format"/>, and the byte arithmetic each format implies.
///
/// <para>Every format here is UNORM, and that is a finding rather than a
/// default. The V3 audit (plan §4.10) established that acdream has no sRGB
/// anywhere: not on upload, not in a shader, not at the framebuffer. The plan
/// previously specified an sRGB swapchain "matching the GL FramebufferSrgb
/// contract" — a contract that does not exist. Shipping an sRGB format would
/// have applied an unwanted encode to already-display-space values, brightening
/// every frame, and it would have passed silently until the V7 differential.
/// </para>
/// <para>The retail path's formats are UNORM, and that is a finding rather than
/// a default. The V3 audit (plan §4.10) established that acdream has no sRGB
/// anywhere: not on upload, not in a shader, not at the framebuffer. The one
/// float format in this mapping is an opt-in HDR intermediate; it does not alter
/// the retail swapchain or texture decode convention.</para>
/// </summary>
internal static class VulkanTextureFormatMapping
{
@ -54,6 +51,7 @@ internal static class VulkanTextureFormatMapping
// Deliberately the same 32-bit UNORM order as the swapchain rather than
// literal RGBA — see CanonicalColorAttachmentFormat.
GpuTextureFormat.Rgba8UnormRenderTarget => CanonicalColorAttachmentFormat,
GpuTextureFormat.Rgba16FloatRenderTarget => Format.R16G16B16A16Sfloat,
GpuTextureFormat.Depth24Stencil8 => Format.D24UnormS8Uint,
_ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unknown texture format."),
};
@ -62,12 +60,15 @@ internal static class VulkanTextureFormatMapping
format == GpuTextureFormat.Depth24Stencil8;
internal static bool IsRenderTarget(GpuTextureFormat format) =>
format is GpuTextureFormat.Rgba8UnormRenderTarget or GpuTextureFormat.Depth24Stencil8;
format is GpuTextureFormat.Rgba8UnormRenderTarget
or GpuTextureFormat.Rgba16FloatRenderTarget
or GpuTextureFormat.Depth24Stencil8;
/// <summary>Bytes one texel occupies. Only meaningful for uncompressed formats.</summary>
internal static int BytesPerTexel(GpuTextureFormat format) => format switch
{
GpuTextureFormat.Rgba8Unorm or GpuTextureFormat.Rgba8UnormRenderTarget => 4,
GpuTextureFormat.Rgba16FloatRenderTarget => 8,
GpuTextureFormat.R8Unorm => 1,
GpuTextureFormat.Depth24Stencil8 => 4,
_ => throw new ArgumentOutOfRangeException(nameof(format), format, "A block-compressed format has no texel size."),

View file

@ -110,9 +110,11 @@ internal sealed unsafe class VulkanTextureTable : IDisposable
private readonly VulkanTextureSlotAllocator _slots;
private readonly DescriptorPool _pool;
private readonly DescriptorSet _set;
private readonly object _sync = new();
private ImageView _defaultView;
private Sampler _defaultSampler;
private ImageLayout _defaultLayout = ImageLayout.ShaderReadOnlyOptimal;
private bool _disposed;
internal VulkanTextureTable(
@ -171,27 +173,51 @@ internal sealed unsafe class VulkanTextureTable : IDisposable
internal DescriptorSet Set => _set;
internal int LiveSlotCount => _slots.LiveCount;
internal int LiveSlotCount
{
get
{
lock (_sync)
return _slots.LiveCount;
}
}
internal uint HighWater => _slots.HighWater;
internal uint HighWater
{
get
{
lock (_sync)
return _slots.HighWater;
}
}
/// <summary>
/// Records the (view, sampler) pair written into a slot when it is scrubbed.
/// Supplied after the default texture exists, which is necessarily after the
/// table itself.
/// </summary>
internal void SetScrubTarget(ImageView view, Sampler sampler)
internal void SetScrubTarget(
ImageView view,
Sampler sampler,
ImageLayout layout = ImageLayout.ShaderReadOnlyOptimal)
{
_defaultView = view;
_defaultSampler = sampler;
_defaultLayout = layout;
}
internal GpuTextureSlot Register(ImageView view, Sampler sampler)
internal GpuTextureSlot Register(
ImageView view,
Sampler sampler,
ImageLayout layout = ImageLayout.ShaderReadOnlyOptimal)
{
ObjectDisposedException.ThrowIf(_disposed, this);
uint slot = _slots.Allocate();
Write(slot, view, sampler);
return new GpuTextureSlot(slot);
lock (_sync)
{
ObjectDisposedException.ThrowIf(_disposed, this);
uint slot = _slots.Allocate();
Write(slot, view, sampler, layout);
return new GpuTextureSlot(slot);
}
}
/// <summary>
@ -201,22 +227,33 @@ internal sealed unsafe class VulkanTextureTable : IDisposable
/// </summary>
internal void ReleaseNow(GpuTextureSlot slot)
{
if (_disposed || !slot.IsAssigned)
return;
if (_defaultView.Handle != 0 && _defaultSampler.Handle != 0)
Write(slot.Index, _defaultView, _defaultSampler);
_slots.Release(slot.Index);
lock (_sync)
{
if (_disposed || !slot.IsAssigned)
return;
if (_defaultView.Handle != 0 && _defaultSampler.Handle != 0)
Write(slot.Index, _defaultView, _defaultSampler, _defaultLayout);
_slots.Release(slot.Index);
}
}
internal bool IsLive(GpuTextureSlot slot) => slot.IsAssigned && _slots.IsLive(slot.Index);
internal bool IsLive(GpuTextureSlot slot)
{
lock (_sync)
return slot.IsAssigned && _slots.IsLive(slot.Index);
}
private void Write(uint slot, ImageView view, Sampler sampler)
private void Write(
uint slot,
ImageView view,
Sampler sampler,
ImageLayout layout)
{
var info = new DescriptorImageInfo
{
ImageView = view,
Sampler = sampler,
ImageLayout = ImageLayout.ShaderReadOnlyOptimal,
ImageLayout = layout,
};
var write = new WriteDescriptorSet
{
@ -233,10 +270,13 @@ internal sealed unsafe class VulkanTextureTable : IDisposable
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
if (_pool.Handle != 0)
_vk.DestroyDescriptorPool(_device, _pool, null);
lock (_sync)
{
if (_disposed)
return;
_disposed = true;
if (_pool.Handle != 0)
_vk.DestroyDescriptorPool(_device, _pool, null);
}
}
}

View file

@ -175,6 +175,9 @@ internal static class VulkanViewportMapping
GpuBlendMode.Additive => (BlendFactor.SrcAlpha, BlendFactor.One),
// Retail's third mode, found at slice V4c in WbDrawDispatcher.ApplyRetailBlend.
GpuBlendMode.InverseAlpha => (BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha),
// ACRender::SetDetailSurfaceInternal with DrawBuilding/DrawEnvCell's
// category state: D3DBLEND_DESTCOLOR + D3DBLEND_INVSRCALPHA.
GpuBlendMode.RetailDetail => (BlendFactor.DstColor, BlendFactor.OneMinusSrcAlpha),
GpuBlendMode.None => (BlendFactor.One, BlendFactor.Zero),
_ => throw new ArgumentOutOfRangeException(nameof(blend), blend, "Unknown blend mode."),
};

View file

@ -52,7 +52,24 @@ internal sealed unsafe class VulkanWorldPassScope : IWorldPassScope
/// per frame, so a nested publication could only mean two phases believe they
/// own the frame.
/// </summary>
public IDisposable Publish(IGpuPassEncoder encoder)
public IDisposable Publish(IGpuPassEncoder encoder) =>
PublishCore(encoder, preservePreparedSections: false);
/// <summary>
/// Publishes a world pass after <see cref="WorldSceneRenderer.PrepareEnhanced"/>
/// has already built the frame. That preparation writes the authoritative
/// scene-lighting ring section before the shadow passes can run; clearing it
/// here would make every HDR receiver bind the zero fallback. The preceding
/// publication's disposal (or construction for the first frame) already
/// established an empty section set, so this preserves only values prepared
/// for the current GPU frame.
/// </summary>
internal IDisposable PublishPrepared(IGpuPassEncoder encoder) =>
PublishCore(encoder, preservePreparedSections: true);
private IDisposable PublishCore(
IGpuPassEncoder encoder,
bool preservePreparedSections)
{
ArgumentNullException.ThrowIfNull(encoder);
if (_encoder is not null)
@ -62,7 +79,8 @@ internal sealed unsafe class VulkanWorldPassScope : IWorldPassScope
}
_encoder = encoder;
Sections.Reset();
if (!preservePreparedSections)
Sections.Reset();
return _publication;
}

View file

@ -0,0 +1,228 @@
namespace AcDream.App.Rendering.Packs;
using AcDream.Plugin.Abstractions.Rendering;
internal enum AtmosphericQualityLevel : byte
{
Low,
Medium,
High,
}
internal readonly record struct AtmosphericQualityMeasurement(
double InclusivePackGpuMillisecondsP99,
double IncrementalCpuMillisecondsP99,
long ResidentGpuBytes,
bool StableFrameBoundary);
internal readonly record struct AtmosphericAutoQualitySnapshot(
AtmosphericQualityLevel Current,
int ConsecutiveOverBudgetFrames,
int ConsecutiveHeadroomFrames,
int CooldownFramesRemaining,
long ChangeGeneration,
bool SafeFallbackToRetailRequested);
internal readonly record struct AtmosphericQualityBudget(
double GpuMillisecondsP99,
double CpuMillisecondsP99,
long ResidentGpuBytes)
{
internal static AtmosphericQualityBudget FromPreset(RenderQualityPreset preset) => new(
preset.MaxIncrementalGpuMillisecondsP99,
preset.MaxIncrementalCpuMillisecondsP99,
preset.MaxResidentGpuBytes);
}
/// <summary>
/// Long-hysteresis automatic quality policy. It changes only resolution,
/// cascade count/reach, and post-process sampling through one stable preset
/// swap. If even Low remains over its declared budget, it requests an atomic
/// whole-pack fallback to retail instead of silently dropping caster classes.
/// </summary>
internal sealed class AtmosphericAutoQualityController
{
internal const int DowngradeHysteresisFrames = 180;
internal const int UpgradeHysteresisFrames = 900;
internal const int ChangeCooldownFrames = 300;
private AtmosphericQualityLevel _current;
private readonly AtmosphericQualityLevel _minimum;
private readonly AtmosphericQualityLevel _maximum;
private readonly AtmosphericQualityBudget[] _budgets;
private int _overBudget;
private int _headroom;
private int _cooldown;
private long _generation;
private bool _safeFallbackToRetailRequested;
internal AtmosphericAutoQualityController(
AtmosphericQualityLevel initial = AtmosphericQualityLevel.Medium,
AtmosphericQualityLevel minimum = AtmosphericQualityLevel.Low,
AtmosphericQualityLevel maximum = AtmosphericQualityLevel.High)
: this(DefaultBudgets(), initial, minimum, maximum)
{
}
internal AtmosphericAutoQualityController(
IReadOnlyList<AtmosphericQualityBudget> budgets,
AtmosphericQualityLevel initial = AtmosphericQualityLevel.Medium,
AtmosphericQualityLevel minimum = AtmosphericQualityLevel.Low,
AtmosphericQualityLevel maximum = AtmosphericQualityLevel.High)
{
ArgumentNullException.ThrowIfNull(budgets);
if (budgets.Count != 3)
throw new ArgumentException("Auto quality requires Low, Medium, and High budgets.", nameof(budgets));
if (minimum > initial || initial > maximum)
throw new ArgumentOutOfRangeException(nameof(initial));
_budgets = budgets.ToArray();
foreach (AtmosphericQualityBudget budget in _budgets)
{
if (!double.IsFinite(budget.GpuMillisecondsP99)
|| budget.GpuMillisecondsP99 < 0d
|| !double.IsFinite(budget.CpuMillisecondsP99)
|| budget.CpuMillisecondsP99 < 0d
|| budget.ResidentGpuBytes < 0)
{
throw new ArgumentOutOfRangeException(
nameof(budgets),
"Automatic-quality budgets must be finite and non-negative.");
}
}
_minimum = minimum;
_maximum = maximum;
_current = initial;
}
internal AtmosphericAutoQualitySnapshot Snapshot => new(
_current,
_overBudget,
_headroom,
_cooldown,
_generation,
_safeFallbackToRetailRequested);
internal AtmosphericQualityBudget CurrentBudget => _budgets[(int)_current];
internal AtmosphericAutoQualitySnapshot Observe(
in AtmosphericQualityMeasurement measurement)
{
Validate(in measurement);
if (!measurement.StableFrameBoundary)
return Snapshot;
if (_safeFallbackToRetailRequested)
return Snapshot;
if (_cooldown > 0)
{
_cooldown--;
_overBudget = 0;
_headroom = 0;
return Snapshot;
}
AtmosphericQualityBudget budget = _budgets[(int)_current];
bool over = measurement.InclusivePackGpuMillisecondsP99
> budget.GpuMillisecondsP99
|| measurement.IncrementalCpuMillisecondsP99
> budget.CpuMillisecondsP99
|| measurement.ResidentGpuBytes > budget.ResidentGpuBytes;
if (over)
{
_overBudget++;
_headroom = 0;
if (_overBudget >= DowngradeHysteresisFrames)
{
if (_current != _minimum)
Change((AtmosphericQualityLevel)((int)_current - 1));
else
RequestSafeFallback();
}
return Snapshot;
}
_overBudget = 0;
if (_current == _maximum)
{
_headroom = 0;
return Snapshot;
}
AtmosphericQualityLevel next =
(AtmosphericQualityLevel)((int)_current + 1);
AtmosphericQualityBudget nextBudget = _budgets[(int)next];
bool hasHeadroom = measurement.InclusivePackGpuMillisecondsP99
<= nextBudget.GpuMillisecondsP99 * 0.70
&& measurement.IncrementalCpuMillisecondsP99
<= nextBudget.CpuMillisecondsP99 * 0.70
&& measurement.ResidentGpuBytes
<= (long)(nextBudget.ResidentGpuBytes * 0.70);
if (!hasHeadroom)
{
_headroom = 0;
return Snapshot;
}
_headroom++;
if (_headroom >= UpgradeHysteresisFrames)
Change(next);
return Snapshot;
}
internal void Reset(AtmosphericQualityLevel level)
{
_current = level;
_overBudget = 0;
_headroom = 0;
_cooldown = 0;
_safeFallbackToRetailRequested = false;
_generation = checked(_generation + 1);
}
private void Change(AtmosphericQualityLevel value)
{
_current = value;
_overBudget = 0;
_headroom = 0;
_cooldown = ChangeCooldownFrames;
_generation = checked(_generation + 1);
}
private void RequestSafeFallback()
{
_overBudget = DowngradeHysteresisFrames;
_headroom = 0;
_cooldown = 0;
_safeFallbackToRetailRequested = true;
_generation = checked(_generation + 1);
}
private static AtmosphericQualityBudget[] DefaultBudgets() =>
[
From(DirectionalShadowPreset.Low),
From(DirectionalShadowPreset.Medium),
From(DirectionalShadowPreset.High),
];
private static AtmosphericQualityBudget From(DirectionalShadowPreset preset)
{
DirectionalShadowQuality quality = DirectionalShadowQuality.For(preset);
return new AtmosphericQualityBudget(
quality.IncrementalGpuP99BudgetMilliseconds,
quality.IncrementalCpuP99BudgetMilliseconds,
quality.PackResidentGpuByteBudget);
}
private static void Validate(in AtmosphericQualityMeasurement value)
{
if (!double.IsFinite(value.InclusivePackGpuMillisecondsP99)
|| value.InclusivePackGpuMillisecondsP99 < 0
|| !double.IsFinite(value.IncrementalCpuMillisecondsP99)
|| value.IncrementalCpuMillisecondsP99 < 0
|| value.ResidentGpuBytes < 0)
{
throw new ArgumentOutOfRangeException(
nameof(value),
"Atmospheric quality measurements must be finite and non-negative.");
}
}
}

View file

@ -0,0 +1,153 @@
using System.Diagnostics;
using AcDream.App.Diagnostics;
namespace AcDream.App.Rendering.Packs;
internal readonly record struct AtmosphericCpuStageFrame(
long FrameSerial,
long ShadowCasterBuildTicks,
long ShadowEnvironmentTicks,
long ShadowPreparedDrawsAndTransformsTicks,
long ShadowFitAndUniformTicks,
long ShadowLayeredPassRecordingTicks,
long ShadowBookkeepingTicks,
long PostSetupAndOtherTicks,
long PostSunRaysTicks,
long PostFilmicTicks);
internal readonly record struct RenderPackCpuStageDiagnostics(
string Stage,
int SampleCount,
double CpuMillisecondsP50,
double CpuMillisecondsP95,
double CpuMillisecondsP99);
/// <summary>
/// Temporary Low-only structural profiler for the incremental CPU budget. It
/// samples the same one-in-four frames as Low GPU timestamps, keeping fewer
/// than half of the ordinary performance window instrumented while retaining
/// enough observations for a short physical run. Every hot-path buffer is
/// fixed at construction and observation is allocation-free.
/// </summary>
internal sealed class AtmosphericCpuStageProfiler
{
internal const int SampleIntervalFrames = AtmosphericGpuTimerSampling.LowIntervalFrames;
private static readonly string[] StageNames =
[
"target-preparation",
"shadow-caster-build",
"shadow-environment",
"shadow-prepared-draws-and-transforms",
"shadow-fit-and-uniform",
"shadow-layered-pass-recording",
"shadow-bookkeeping",
"post-setup-and-other",
"post-sun-rays",
"post-filmic",
"performance-observe-bookkeeping",
"measured-pack-total",
"measured-pack-unattributed",
];
private readonly FrameStatsBuffer[] _microseconds;
internal AtmosphericCpuStageProfiler(int capacity = RenderPackPerformanceWindow.DefaultCapacity)
{
_microseconds = new FrameStatsBuffer[StageNames.Length];
for (int i = 0; i < _microseconds.Length; i++)
_microseconds[i] = new FrameStatsBuffer(capacity);
}
internal static bool ShouldMeasure(long frameSerial)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(frameSerial);
return frameSerial % SampleIntervalFrames == 0;
}
internal void Observe(
in AtmosphericCpuStageFrame frame,
long targetPreparationTicks,
long measuredPackTotalTicks,
long observeBookkeepingTicks)
{
if (frame.FrameSerial <= 0)
throw new ArgumentOutOfRangeException(nameof(frame));
ArgumentOutOfRangeException.ThrowIfNegative(targetPreparationTicks);
ArgumentOutOfRangeException.ThrowIfNegative(measuredPackTotalTicks);
ArgumentOutOfRangeException.ThrowIfNegative(observeBookkeepingTicks);
long attributedTicks = checked(
targetPreparationTicks
+ frame.ShadowCasterBuildTicks
+ frame.ShadowEnvironmentTicks
+ frame.ShadowPreparedDrawsAndTransformsTicks
+ frame.ShadowFitAndUniformTicks
+ frame.ShadowLayeredPassRecordingTicks
+ frame.ShadowBookkeepingTicks
+ frame.PostSetupAndOtherTicks
+ frame.PostSunRaysTicks
+ frame.PostFilmicTicks);
long unattributedTicks = Math.Max(0L, measuredPackTotalTicks - attributedTicks);
Push(0, targetPreparationTicks);
Push(1, frame.ShadowCasterBuildTicks);
Push(2, frame.ShadowEnvironmentTicks);
Push(3, frame.ShadowPreparedDrawsAndTransformsTicks);
Push(4, frame.ShadowFitAndUniformTicks);
Push(5, frame.ShadowLayeredPassRecordingTicks);
Push(6, frame.ShadowBookkeepingTicks);
Push(7, frame.PostSetupAndOtherTicks);
Push(8, frame.PostSunRaysTicks);
Push(9, frame.PostFilmicTicks);
Push(10, observeBookkeepingTicks);
Push(11, measuredPackTotalTicks);
Push(12, unattributedTicks);
}
internal IReadOnlyList<RenderPackCpuStageDiagnostics> Snapshot()
{
var result = new RenderPackCpuStageDiagnostics[StageNames.Length];
for (int i = 0; i < result.Length; i++)
{
FrameStatsBuffer samples = _microseconds[i];
result[i] = new RenderPackCpuStageDiagnostics(
StageNames[i],
samples.Count,
samples.Percentile(0.50) / 1000d,
samples.Percentile(0.95) / 1000d,
samples.Percentile(0.99) / 1000d);
}
return result;
}
internal void Reset()
{
for (int i = 0; i < _microseconds.Length; i++)
_microseconds[i].Reset();
}
private void Push(int stage, long ticks)
{
long microseconds = checked((long)Math.Round(
ticks * 1_000_000d / Stopwatch.Frequency,
MidpointRounding.AwayFromZero));
_microseconds[stage].Push(microseconds);
}
}
/// <summary>
/// Optional production-frame seam. Only Low's built-in graph implements it;
/// retail, Medium, High, and declared graphs never enter the profiling path.
/// </summary>
internal interface IAtmosphericCpuStageProfileRuntime
{
bool ShouldProfileCpuFrame(long frameSerial);
void CompleteCpuProfile(
long frameSerial,
long targetPreparationTicks,
long measuredPackTotalTicks,
long observeBookkeepingTicks,
bool stableFrameBoundary);
}

View file

@ -0,0 +1,229 @@
using System.Numerics;
using System.Runtime.InteropServices;
using System.Runtime.CompilerServices;
using AcDream.Core.World;
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.App.Rendering.Packs;
/// <summary>
/// Immutable authored atmosphere and exact camera projection captured from the
/// normal world frame. This value owns no gameplay or renderer objects and is
/// valid after the wrapped world renderer returns.
/// </summary>
internal readonly record struct AtmosphericFrameInputs(
Vector2 SunScreenUv,
bool SunIsOnScreen,
float SunElevationDegrees,
Vector3 SunColor,
Vector3 SunDirection,
float SunDirectionalBrightness,
Matrix4x4 InverseViewProjection,
int ActiveDayGroup,
WeatherKind Weather,
float WeatherIntensity,
double DeltaSeconds,
int ViewportWidth,
int ViewportHeight,
bool IsOutdoor);
internal interface IAtmosphericWorldFrameSink
{
void Publish(
in RenderFrameFoundation foundation,
in WorldRenderFrame world,
int activeDayGroup);
}
/// <summary>
/// One-frame handoff between <see cref="WorldSceneRenderer"/>, which owns the
/// canonical camera build, and the post graph. Reset happens before the world
/// pass so an intentionally skipped world can never reuse a prior camera.
/// </summary>
internal sealed class AtmosphericFrameInputState : IAtmosphericWorldFrameSink
{
private RenderFrameInput _host;
private RenderFrameFoundation _foundation;
private AtmosphericFrameInputs _current;
private bool _published;
internal void BeginFrame(
in RenderFrameInput host,
in RenderFrameFoundation foundation)
{
_host = host;
_foundation = foundation;
_current = default;
_published = false;
}
public void Publish(
in RenderFrameFoundation foundation,
in WorldRenderFrame world,
int activeDayGroup)
{
Vector3 direction = SkyStateProvider.SunDirectionFromKeyframe(foundation.Sky);
Vector3 sunPoint = world.Camera.Position + (direction * 10_000f);
Vector4 clip = Vector4.Transform(
new Vector4(sunPoint, 1f),
world.Camera.ViewProjection);
bool finite = float.IsFinite(clip.X)
&& float.IsFinite(clip.Y)
&& float.IsFinite(clip.W)
&& clip.W > 1e-5f;
Vector2 uv = finite
? new Vector2(
(clip.X / clip.W * 0.5f) + 0.5f,
0.5f - (clip.Y / clip.W * 0.5f))
: new Vector2(-1f, -1f);
bool onScreen = finite
&& uv.X >= 0f && uv.X <= 1f
&& uv.Y >= 0f && uv.Y <= 1f;
Matrix4x4 inverseViewProjection = Matrix4x4.Invert(
world.Camera.ViewProjection,
out Matrix4x4 inverse)
? inverse
: Matrix4x4.Identity;
_current = new AtmosphericFrameInputs(
uv,
onScreen,
foundation.Sky.SunPitchDeg,
foundation.Sky.SunColor,
direction,
foundation.Sky.DirBright,
inverseViewProjection,
activeDayGroup,
foundation.Atmosphere.Kind,
Math.Clamp(foundation.Atmosphere.Intensity, 0f, 1f),
_host.DeltaSeconds,
_host.ViewportWidth,
_host.ViewportHeight,
IsOutdoor: world.Roots.RenderSky && !world.Roots.CameraInsideCell);
_published = true;
}
internal AtmosphericFrameInputs Snapshot()
{
if (_published)
return _current;
// A portal/login frame deliberately skipped the normal world. Preserve
// its authored colour inputs but suppress every directional effect.
return new AtmosphericFrameInputs(
new Vector2(-1f, -1f),
SunIsOnScreen: false,
_foundation.Sky.SunPitchDeg,
_foundation.Sky.SunColor,
SkyStateProvider.SunDirectionFromKeyframe(_foundation.Sky),
_foundation.Sky.DirBright,
Matrix4x4.Identity,
-1,
_foundation.Atmosphere.Kind,
Math.Clamp(_foundation.Atmosphere.Intensity, 0f, 1f),
_host.DeltaSeconds,
_host.ViewportWidth,
_host.ViewportHeight,
IsOutdoor: false);
}
}
/// <summary>
/// Shader ABI SSOT for opt-in set 3 binding 5. Six std140 vec4 values followed by one
/// mat4, 160 bytes.
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal readonly struct AtmosphericFrameUniforms
{
internal const int SizeInBytes = 160;
internal AtmosphericFrameUniforms(
Vector4 sunScreen,
Vector4 sunColor,
Vector4 viewport,
Vector4 weather,
Vector4 sunDirection,
Vector4 policy,
Matrix4x4 inverseViewProjection)
{
SunScreen = sunScreen;
SunColor = sunColor;
Viewport = viewport;
Weather = weather;
SunDirection = sunDirection;
Policy = policy;
InverseViewProjection = inverseViewProjection;
}
internal readonly Vector4 SunScreen;
internal readonly Vector4 SunColor;
internal readonly Vector4 Viewport;
internal readonly Vector4 Weather;
internal readonly Vector4 SunDirection;
internal readonly Vector4 Policy;
internal readonly Matrix4x4 InverseViewProjection;
}
/// <summary>
/// Shader ABI SSOT for opt-in set 3 binding 7. Passes assign meanings to four std140
/// vec4 values without changing the shared descriptor layout.
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal readonly struct AtmosphericPackPassUniforms
{
internal const int SizeInBytes = 64;
internal AtmosphericPackPassUniforms(
Vector4 params0,
Vector4 params1,
Vector4 params2,
Vector4 params3)
{
Params0 = params0;
Params1 = params1;
Params2 = params2;
Params3 = params3;
}
internal readonly Vector4 Params0;
internal readonly Vector4 Params1;
internal readonly Vector4 Params2;
internal readonly Vector4 Params3;
internal static AtmosphericPackPassUniforms From(Vector4 params0) =>
new(params0, Vector4.Zero, Vector4.Zero, Vector4.Zero);
}
/// <summary>
/// Shader ABI SSOT for opt-in set 3 binding 8. API v1 exposes 64 scalar values in
/// descriptor declaration order, physically grouped as sixteen std140 vec4s.
/// </summary>
[InlineArray(RenderPackShaderAbi.PackSettingScalarCapacity)]
internal struct PackSettingsUniforms
{
internal const int SizeInBytes = RenderPackShaderAbi.PackSettingsSizeBytes;
private float _element0;
internal static PackSettingsUniforms Create(
RenderPackDescriptor descriptor,
RenderQualityPreset preset,
IReadOnlyDictionary<string, string>? userSettingOverrides = null)
{
var result = new PackSettingsUniforms();
int count = Math.Min(
descriptor.Settings.Count,
RenderPackShaderAbi.PackSettingScalarCapacity);
for (int i = 0; i < count; i++)
{
RenderSettingDeclaration setting = descriptor.Settings[i];
string value = RenderPackSettingResolution.Resolve(
setting,
preset,
userSettingOverrides);
result[i] = RenderPackSettingValueCodec.TryEncode(setting, value, out float encoded)
? encoded
: 0f;
}
return result;
}
}

View file

@ -0,0 +1,24 @@
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.App.Rendering.Packs;
/// <summary>
/// Detailed per-pass GPU timestamps are diagnostic commands, not visual work.
/// Low samples one complete frame in four so its tight median CPU budget is not
/// dominated by instrumentation; sampled frames still include every receiver,
/// shadow, and post-process scope and therefore preserve the inclusive GPU
/// measurement contract. Medium and High retain continuous measurement.
/// </summary>
internal static class AtmosphericGpuTimerSampling
{
internal const int LowIntervalFrames = 4;
internal static bool ShouldMeasure(
RenderQualitySemantic quality,
long frameSerial)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(frameSerial);
return quality is not RenderQualitySemantic.Low
|| frameSerial % LowIntervalFrames == 0;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,183 @@
using System.Numerics;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Packs;
/// <summary>
/// Pack-only identity for the one celestial direction selected to cast the
/// current directional shadow map. Retail exposes one authored directional
/// colour/energy channel; moon meshes contribute direction only.
/// </summary>
internal enum AuthoredCelestialShadowSourceKind : uint
{
None = 0,
Sun = 1,
DominantMoon = 2,
SecondaryMoon = 3,
}
internal readonly record struct AuthoredCelestialShadowSource(
AuthoredCelestialShadowSourceKind Kind,
int ObjectIndex,
uint GfxObjId,
Vector3 SurfaceToLightDirection,
float ElevationSin,
float AuthoredEnergy)
{
internal static AuthoredCelestialShadowSource None(float authoredEnergy = 0f) =>
new(
AuthoredCelestialShadowSourceKind.None,
-1,
0u,
Vector3.UnitZ,
0f,
Math.Clamp(authoredEnergy, 0f, 1f));
internal bool IsAvailable =>
Kind is not AuthoredCelestialShadowSourceKind.None;
}
/// <summary>
/// Resolves the visible Dereth sun/moons from retail DAT sky objects and uses
/// the identical transform as <c>SkyRenderer</c>. This is an opt-in render-pack
/// enhancement; it never changes retail SceneLighting or world state.
/// </summary>
internal static class AuthoredCelestialShadowSourceResolver
{
internal const uint SunGfxObjId = 0x01001348u;
internal const uint DominantMoonGfxObjId = 0x01001F6Au;
internal const uint SecondaryMoonGfxObjId = 0x01001F67u;
internal static AuthoredCelestialShadowSource Resolve(
DayGroupData? dayGroup,
float dayFraction,
in SkyKeyframe sky)
{
float energy = Math.Clamp(
MathF.Max(sky.SunColor.X, MathF.Max(sky.SunColor.Y, sky.SunColor.Z)),
0f,
1f);
if (dayGroup is null || !float.IsFinite(dayFraction))
return AuthoredCelestialShadowSource.None(energy);
if (TryResolve(
dayGroup,
dayFraction,
SunGfxObjId,
AuthoredCelestialShadowSourceKind.Sun,
energy,
out var source)
|| TryResolve(
dayGroup,
dayFraction,
DominantMoonGfxObjId,
AuthoredCelestialShadowSourceKind.DominantMoon,
energy,
out source)
|| TryResolve(
dayGroup,
dayFraction,
SecondaryMoonGfxObjId,
AuthoredCelestialShadowSourceKind.SecondaryMoon,
energy,
out source))
{
return source;
}
return AuthoredCelestialShadowSource.None(energy);
}
private static bool TryResolve(
DayGroupData dayGroup,
float dayFraction,
uint roleGfxObjId,
AuthoredCelestialShadowSourceKind kind,
float energy,
out AuthoredCelestialShadowSource source)
{
for (int index = 0; index < dayGroup.SkyObjects.Count; index++)
{
SkyObjectData skyObject = dayGroup.SkyObjects[index];
if (skyObject.GfxObjId != roleGfxObjId
|| !skyObject.IsVisible(dayFraction))
{
continue;
}
SkyObjectReplaceData? replace = ActiveReplace(
dayGroup,
dayFraction,
checked((uint)index));
if (replace is not null && replace.Transparent >= 1f - 1e-5f)
continue;
uint effectiveGfxObjId = replace is { GfxObjId: not 0u }
? replace.GfxObjId
: skyObject.GfxObjId;
Vector3 anchor = replace is { GfxObjId: not 0u }
? replace.AuthoredSortCenter
: skyObject.AuthoredSortCenter;
if (!IsFiniteDirection(anchor))
continue;
float headingRadians = (replace?.Rotate ?? 0f) * (MathF.PI / 180f);
float rotationRadians = skyObject.CurrentAngle(dayFraction)
* (MathF.PI / 180f);
Matrix4x4 model = Matrix4x4.CreateRotationZ(-headingRadians)
* Matrix4x4.CreateRotationY(-rotationRadians);
Vector3 transformed = Vector3.TransformNormal(anchor, model);
float length = transformed.Length();
if (!float.IsFinite(length) || length <= 1e-5f)
continue;
Vector3 direction = transformed / length;
if (!IsFiniteDirection(direction) || direction.Z <= 0f)
continue;
source = new AuthoredCelestialShadowSource(
kind,
index,
effectiveGfxObjId,
direction,
direction.Z,
energy);
return true;
}
source = default;
return false;
}
private static SkyObjectReplaceData? ActiveReplace(
DayGroupData dayGroup,
float dayFraction,
uint objectIndex)
{
if (dayGroup.SkyTimes.Count == 0)
return null;
DatSkyKeyframeData active = dayGroup.SkyTimes[^1];
for (int i = 0; i < dayGroup.SkyTimes.Count; i++)
{
if (dayGroup.SkyTimes[i].Keyframe.Begin <= dayFraction)
active = dayGroup.SkyTimes[i];
else
break;
}
SkyObjectReplaceData? result = null;
foreach (SkyObjectReplaceData replace in active.Replaces)
{
if (replace.ObjectIndex == objectIndex)
result = replace;
}
return result;
}
private static bool IsFiniteDirection(Vector3 value) =>
float.IsFinite(value.X)
&& float.IsFinite(value.Y)
&& float.IsFinite(value.Z)
&& value.LengthSquared() > 1e-10f;
}

View file

@ -0,0 +1,519 @@
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.App.Rendering.Packs;
/// <summary>
/// The built-in Atmospheric Rendering pack is expressed through the same
/// public declaration consumed by third-party packs. Renderer implementation
/// code resolves public enum semantics and never recognizes this pack's IDs,
/// so the built-in receives no private capability or lifecycle shortcut.
/// </summary>
internal static class BuiltInAtmosphericRenderPack
{
internal const string Id = "acdream.atmospheric";
internal static RenderPackDescriptor Descriptor { get; } = new RenderPackDescriptor(
Id,
"Atmospheric Rendering",
new Version(1, 0, 0),
RenderPackApi.Current,
RenderPackTier.Tier2Plus,
[
RenderCapability.MainWorldColorIntermediate,
RenderCapability.FullscreenPasses,
RenderCapability.SceneDepthSampling,
RenderCapability.AuthoredSunDirection,
RenderCapability.AuthoredSunScreenPosition,
RenderCapability.AuthoredWeather,
RenderCapability.DirectionalShadowMaps,
RenderCapability.OutdoorDirectionalShadowCasterReplay,
RenderCapability.AnimatedCasterTransforms,
RenderCapability.AlphaCutoutShadowCasters,
RenderCapability.AuthoredCelestialDirectionalLight,
],
[RenderCapability.GpuTimestampQueries],
Resources(),
Passes(),
SceneReplays(),
PipelineVariants(),
QualityPresets(),
Settings(),
AtmospherePolicy())
{
FeatureSummary = "Filmic HDR atmosphere, moving sun-and-moon shadows from terrain, "
+ "trees, buildings, players, and monsters, plus optional volumetric shafts.",
};
internal static IRenderPackAssets CreateAssets(string shaderDirectory) =>
new DirectoryRenderPackAssets(shaderDirectory);
private static IReadOnlyList<RenderResourceDeclaration> Resources() =>
[
Image("world-hdr", RenderResourceSemantic.MainWorldHdr,
RenderFormatClass.HdrColor, 1.0, 1.0, 32L * 1024 * 1024),
Image("bloom-a", RenderResourceSemantic.BloomPing,
RenderFormatClass.HdrColor, 0.5, 0.5, 8L * 1024 * 1024),
Image("bloom-b", RenderResourceSemantic.BloomPong,
RenderFormatClass.HdrColor, 0.5, 0.5, 8L * 1024 * 1024),
Image("sun-mask", RenderResourceSemantic.SunOcclusionMask,
RenderFormatClass.SingleChannel, 0.25, 0.25, 2L * 1024 * 1024),
Image("sun-rays", RenderResourceSemantic.SunRays,
RenderFormatClass.HdrColor, 0.25, 0.25, 2L * 1024 * 1024),
new RenderResourceDeclaration(
"directional-shadow-depth",
RenderResourceKind.Image2DArray,
RenderFormatClass.DirectionalDepth,
new RenderExtentDeclaration(RenderExtentMode.AbsolutePixels, 1024, 1024, Layers: 2),
SizeBytes: 0,
RenderResourceUsage.Sampled | RenderResourceUsage.DepthAttachment,
RenderResourceLifetime.ActivePack,
EstimatedResidentBytes: 8L * 1024 * 1024)
with { Semantic = RenderResourceSemantic.DirectionalShadowDepth },
Image("volumetric", RenderResourceSemantic.VolumetricShafts,
RenderFormatClass.HdrColor, 0.25, 0.25, 2L * 1024 * 1024),
];
private static IReadOnlyList<RenderPassDeclaration> Passes() =>
[
Pass(
"directional-shadow-depth",
RenderPassSemantic.DirectionalShadowDepth,
RenderPassHook.ShadowDepthBeforeWorld,
"directional_shadow_world_opaque.vert.spv",
"directional_shadow_world_opaque.frag.spv",
[RenderSemanticInput.CameraMatrices,
RenderSemanticInput.SelectedCelestialDirectionalLight,
RenderSemanticInput.ShadowCasterTransforms, RenderSemanticInput.ActiveDayGroup,
RenderSemanticInput.Weather],
[],
["directional-shadow-depth"]),
Pass(
"sun-occlusion",
RenderPassSemantic.SunOcclusion,
RenderPassHook.AtmosphereBeforeToneMap,
"atmospheric_sun_occlusion.vert.spv",
"atmospheric_sun_occlusion.frag.spv",
[RenderSemanticInput.SceneDepth, RenderSemanticInput.SunScreenPosition,
RenderSemanticInput.ActiveDayGroup, RenderSemanticInput.Weather],
[],
["sun-mask"]),
Pass(
"sun-rays",
RenderPassSemantic.SunRays,
RenderPassHook.AtmosphereBeforeToneMap,
"atmospheric_sun_rays.vert.spv",
"atmospheric_sun_rays.frag.spv",
[RenderSemanticInput.SunScreenPosition, RenderSemanticInput.FrameTime],
["sun-mask"],
["sun-rays"]),
Pass(
"volumetric-shafts",
RenderPassSemantic.VolumetricShafts,
RenderPassHook.AtmosphereBeforeToneMap,
"atmospheric_volumetric.vert.spv",
"atmospheric_volumetric.frag.spv",
[RenderSemanticInput.SceneDepth, RenderSemanticInput.CameraMatrices,
RenderSemanticInput.SunDirection, RenderSemanticInput.DirectionalShadowMaps,
RenderSemanticInput.ActiveDayGroup, RenderSemanticInput.Weather],
["directional-shadow-depth"],
["volumetric"]),
Pass(
"bloom-downsample",
RenderPassSemantic.BloomDownsample,
RenderPassHook.AtmosphereBeforeToneMap,
"atmospheric_bloom_downsample.vert.spv",
"atmospheric_bloom_downsample.frag.spv",
[RenderSemanticInput.WorldColor],
["sun-rays", "volumetric"],
["bloom-a"]),
Pass(
"bloom-blur-horizontal",
RenderPassSemantic.BloomBlurHorizontal,
RenderPassHook.AtmosphereBeforeToneMap,
"atmospheric_bloom_blur.vert.spv",
"atmospheric_bloom_blur.frag.spv",
[RenderSemanticInput.FrameTime],
["bloom-a"],
["bloom-b"]),
Pass(
"bloom-blur-vertical",
RenderPassSemantic.BloomBlurVertical,
RenderPassHook.AtmosphereBeforeToneMap,
"atmospheric_bloom_blur.vert.spv",
"atmospheric_bloom_blur.frag.spv",
[RenderSemanticInput.FrameTime],
["bloom-b"],
["bloom-a"]),
Pass(
"filmic-composite",
RenderPassSemantic.FilmicComposite,
RenderPassHook.ToneMap,
"atmospheric_filmic.vert.spv",
"atmospheric_filmic.frag.spv",
[RenderSemanticInput.WorldColor, RenderSemanticInput.FrameTime],
["bloom-a", "sun-rays", "volumetric"],
[]),
];
private static IReadOnlyList<SceneReplayDeclaration> SceneReplays() =>
[
new SceneReplayDeclaration(
"outdoor-directional-shadow-casters",
RenderSceneReplaySemantic.OutdoorDirectionalShadowCasters,
RenderCasterClass.Terrain
| RenderCasterClass.OpaqueWorld
| RenderCasterClass.AlphaCutoutWorld
| RenderCasterClass.AnimatedOpaque
| RenderCasterClass.AnimatedAlphaCutout,
ViewCount: 4),
];
private static IReadOnlyList<PipelineVariantDeclaration> PipelineVariants() =>
[
Variant("terrain-shadow-caster", RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster,
RenderPipelineBaseSemantic.Terrain,
"directional_shadow_terrain.vert.spv", "directional_shadow_terrain.frag.spv",
RenderMaterialClass.Opaque,
[RenderSemanticInput.CameraMatrices]),
Variant("world-shadow-opaque", RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster,
RenderPipelineBaseSemantic.WorldMesh,
"directional_shadow_world_opaque.vert.spv", "directional_shadow_world_opaque.frag.spv",
RenderMaterialClass.Opaque | RenderMaterialClass.AnimatedOpaque,
[RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]),
Variant("world-shadow-cutout", RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster,
RenderPipelineBaseSemantic.WorldMesh,
"directional_shadow_world_cutout.vert.spv", "directional_shadow_world_cutout.frag.spv",
RenderMaterialClass.AlphaCutout | RenderMaterialClass.AnimatedAlphaCutout,
[RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]),
Variant("terrain-shadow-caster-multiview", RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster,
RenderPipelineBaseSemantic.Terrain,
"directional_shadow_terrain_multiview.vert.spv", "directional_shadow_terrain_multiview.frag.spv",
RenderMaterialClass.Opaque,
[RenderSemanticInput.CameraMatrices]),
Variant("world-shadow-opaque-multiview", RenderPipelineVariantSemantic.WorldOpaqueMultiviewDirectionalShadowCaster,
RenderPipelineBaseSemantic.WorldMesh,
"directional_shadow_world_opaque_multiview.vert.spv", "directional_shadow_world_opaque_multiview.frag.spv",
RenderMaterialClass.Opaque | RenderMaterialClass.AnimatedOpaque,
[RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]),
Variant("world-shadow-cutout-multiview", RenderPipelineVariantSemantic.WorldAlphaCutoutMultiviewDirectionalShadowCaster,
RenderPipelineBaseSemantic.WorldMesh,
"directional_shadow_world_cutout_multiview.vert.spv", "directional_shadow_world_cutout_multiview.frag.spv",
RenderMaterialClass.AlphaCutout | RenderMaterialClass.AnimatedAlphaCutout,
[RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]),
Variant("terrain-shadow-receiver", RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver,
RenderPipelineBaseSemantic.Terrain,
"terrain_atmospheric.vert.spv", "terrain_atmospheric.frag.spv",
RenderMaterialClass.Opaque,
[RenderSemanticInput.DirectionalShadowMaps,
RenderSemanticInput.SelectedCelestialDirectionalLight]),
Variant("world-shadow-receiver", RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver,
RenderPipelineBaseSemantic.WorldMesh,
"mesh_atmospheric.vert.spv", "mesh_atmospheric.frag.spv",
RenderMaterialClass.Opaque | RenderMaterialClass.AlphaCutout
| RenderMaterialClass.AnimatedOpaque | RenderMaterialClass.AnimatedAlphaCutout,
[RenderSemanticInput.DirectionalShadowMaps,
RenderSemanticInput.SelectedCelestialDirectionalLight]),
];
private static IReadOnlyList<RenderQualityPreset> QualityPresets() =>
[
Preset("low", "Low", RenderQualitySemantic.Low,
64, 2.0, 3.0, 0.15, 0.50, 768, 2, 72, 0.25) with
{
ExecutionHints =
RenderQualityExecutionHints.MultiviewDirectionalShadowCascades,
},
Preset("medium", "Medium", RenderQualitySemantic.Medium,
128, 3.25, 4.50, 0.25, 0.75, 1536, 3, 144, 0.5),
Preset("high", "High", RenderQualitySemantic.High,
256, 4.50, 6.00, 0.35, 1.00, 2048, 4, 240, 0.5),
Preset("auto", "Auto", RenderQualitySemantic.Automatic,
128, 3.25, 4.50, 0.25, 0.75, 1536, 3, 144, 0.5)
with
{
SettingOverrides =
[
new RenderQualitySettingOverride("automatic-quality", "true"),
new RenderQualitySettingOverride("volumetric-strength", "0.35"),
new RenderQualitySettingOverride("volumetric-ray-steps", "40"),
new RenderQualitySettingOverride("sun-shadow-strength", "0.72"),
new RenderQualitySettingOverride("sun-shadow-reach-metres", "144"),
new RenderQualitySettingOverride("sun-shadow-pcf-taps", "9"),
new RenderQualitySettingOverride("sun-ray-strength", "0.55"),
],
AutoEligible = false,
},
];
private static IReadOnlyList<RenderSettingDeclaration> Settings() =>
[
Float("bloom-strength", "Bloom strength", RenderSettingSemantic.BloomStrength,
0.65, 0, 2, 0.05),
Float("filmic-strength", "Filmic tonemap strength", RenderSettingSemantic.FilmicStrength,
1.0, 0, 1, 0.05),
Float("exposure", "Exposure", RenderSettingSemantic.Exposure,
0.80, 0.25, 4, 0.05),
Float("grade-saturation", "Colour saturation", RenderSettingSemantic.GradeSaturation,
1.0, 0, 2, 0.05),
Float("grade-contrast", "Colour contrast", RenderSettingSemantic.GradeContrast,
1.0, 0.5, 2, 0.05),
Float("vignette-strength", "Vignette strength", RenderSettingSemantic.VignetteStrength,
0.12, 0, 1, 0.01),
Float("sun-ray-strength", "Sun-ray strength", RenderSettingSemantic.SunRayStrength,
0.55, 0, 2, 0.05),
Float("sun-shadow-strength", "Directional-shadow strength",
RenderSettingSemantic.DirectionalShadowStrength, 0.72, 0, 1, 0.02),
Integer("sun-shadow-reach-metres", "Directional-shadow reach (metres)",
RenderSettingSemantic.DirectionalShadowReachMetres, 240, 16, 240, 1),
Choice("sun-shadow-pcf-taps", "Directional-shadow filter taps",
RenderSettingSemantic.DirectionalShadowPcfTaps, "9", ["1", "9", "25"]),
Float("volumetric-strength", "Volumetric-shaft strength",
RenderSettingSemantic.VolumetricStrength, 0.35, 0, 1, 0.01),
Integer("volumetric-ray-steps", "Volumetric ray-march steps",
RenderSettingSemantic.VolumetricRayMarchSteps, 40, 8, 64, 8),
new RenderSettingDeclaration(
"automatic-quality",
"Automatic quality",
RenderSettingKind.Boolean,
"false",
null,
null,
null,
[])
with { Semantic = RenderSettingSemantic.AutomaticQuality },
];
private static AtmospherePolicyDeclaration AtmospherePolicy() => new(
[
new SunElevationResponsePoint(-90, 0),
new SunElevationResponsePoint(-3, 0),
new SunElevationResponsePoint(4, 1),
new SunElevationResponsePoint(22, 0.75),
new SunElevationResponsePoint(55, 0),
new SunElevationResponsePoint(90, 0),
],
[
new ActiveDayGroupMultiplier(0, 1.0),
new ActiveDayGroupMultiplier(1, 0.35),
new ActiveDayGroupMultiplier(2, 0.20),
])
{
DirectionalShadowLightElevationResponse =
[
new SunElevationResponsePoint(-90, 0),
new SunElevationResponsePoint(1, 0),
new SunElevationResponsePoint(12, 1),
new SunElevationResponsePoint(90, 1),
],
VolumetricShaftSunElevationResponse =
[
new SunElevationResponsePoint(-90, 0),
new SunElevationResponsePoint(0, 0),
new SunElevationResponsePoint(6, 1),
new SunElevationResponsePoint(18, 1),
new SunElevationResponsePoint(70, 0),
new SunElevationResponsePoint(90, 0),
],
};
private static RenderResourceDeclaration Image(
string id,
RenderResourceSemantic semantic,
RenderFormatClass format,
double widthScale,
double heightScale,
long estimatedBytes) => new RenderResourceDeclaration(
id,
RenderResourceKind.Image2D,
format,
new RenderExtentDeclaration(
RenderExtentMode.RelativeToMainWorld,
widthScale,
heightScale),
SizeBytes: 0,
RenderResourceUsage.Sampled | RenderResourceUsage.ColorAttachment,
RenderResourceLifetime.ActivePack,
estimatedBytes)
{ Semantic = semantic };
private static RenderPassDeclaration Pass(
string id,
RenderPassSemantic semantic,
RenderPassHook hook,
string vertex,
string fragment,
IReadOnlyList<RenderSemanticInput> semantics,
IReadOnlyList<string> reads,
IReadOnlyList<string> writes) =>
new(id, hook, vertex, fragment, semantics, reads, writes)
{
Semantic = semantic,
};
private static PipelineVariantDeclaration Variant(
string id,
RenderPipelineVariantSemantic variantSemantic,
RenderPipelineBaseSemantic semantic,
string vertex,
string fragment,
RenderMaterialClass materials,
IReadOnlyList<RenderSemanticInput> inputs) =>
new(id, semantic, vertex, fragment, materials, inputs)
{
Semantic = variantSemantic,
};
private static RenderQualityPreset Preset(
string id,
string displayName,
RenderQualitySemantic semantic,
long maxMiB,
double gpuP50,
double gpuP99,
double cpuP50,
double cpuP99,
int shadowResolution,
int cascades,
int shadowReachMetres,
double postScale) => new RenderQualityPreset(
id,
displayName,
semantic == RenderQualitySemantic.Low
? [RenderCapability.DirectionalShadowMaps,
RenderCapability.MultiviewDirectionalShadowCascades]
: [RenderCapability.DirectionalShadowMaps],
[
Override("directional-shadow-depth", shadowResolution, shadowResolution, cascades,
4L * shadowResolution * shadowResolution * cascades),
RelativeOverride("bloom-a", postScale),
RelativeOverride("bloom-b", postScale),
RelativeOverride("sun-mask", id == "low" ? 0.25 : 0.5),
RelativeOverride("sun-rays", id == "low" ? 0.25 : 0.5),
RelativeOverride("volumetric", id == "high" ? 0.5 : 0.25),
],
[
new RenderQualitySettingOverride("automatic-quality", "false"),
new RenderQualitySettingOverride("volumetric-strength", id == "low" ? "0" : "0.35"),
new RenderQualitySettingOverride(
"volumetric-ray-steps",
semantic switch
{
RenderQualitySemantic.Low => "24",
RenderQualitySemantic.High => "56",
_ => "40",
}),
new RenderQualitySettingOverride("sun-shadow-strength", "0.72"),
new RenderQualitySettingOverride("sun-shadow-reach-metres", shadowReachMetres.ToString()),
new RenderQualitySettingOverride(
"sun-shadow-pcf-taps",
semantic switch
{
RenderQualitySemantic.Low => "1",
RenderQualitySemantic.High => "25",
_ => "9",
}),
// The renderer recognizes this bounded preset fact; it remains
// visible here instead of becoming a hidden cascade constant.
new RenderQualitySettingOverride("sun-ray-strength", id == "low" ? "0.4" : "0.55"),
],
maxMiB * 1024 * 1024,
gpuP50,
gpuP99,
cpuP50,
cpuP99)
{ Semantic = semantic };
private static RenderQualityResourceOverride Override(
string id,
int width,
int height,
int layers,
long bytes) => new(
id,
new RenderExtentDeclaration(RenderExtentMode.AbsolutePixels, width, height, layers),
SizeBytes: 0,
EstimatedResidentBytes: bytes);
private static RenderQualityResourceOverride RelativeOverride(string id, double scale) =>
new(
id,
new RenderExtentDeclaration(RenderExtentMode.RelativeToMainWorld, scale, scale),
SizeBytes: 0,
EstimatedResidentBytes: 0);
private static RenderSettingDeclaration Float(
string id,
string displayName,
RenderSettingSemantic semantic,
double defaultValue,
double min,
double max,
double step) => new RenderSettingDeclaration(
id,
displayName,
RenderSettingKind.Float,
defaultValue.ToString(System.Globalization.CultureInfo.InvariantCulture),
min,
max,
step,
[])
{ Semantic = semantic };
private static RenderSettingDeclaration Integer(
string id,
string displayName,
RenderSettingSemantic semantic,
int defaultValue,
int min,
int max,
int step) => new RenderSettingDeclaration(
id,
displayName,
RenderSettingKind.Integer,
defaultValue.ToString(System.Globalization.CultureInfo.InvariantCulture),
min,
max,
step,
[])
{ Semantic = semantic };
private static RenderSettingDeclaration Choice(
string id,
string displayName,
RenderSettingSemantic semantic,
string defaultValue,
IReadOnlyList<string> choices) => new RenderSettingDeclaration(
id,
displayName,
RenderSettingKind.Choice,
defaultValue,
null,
null,
null,
choices)
{ Semantic = semantic };
}
internal sealed class DirectoryRenderPackAssets : IRenderPackAssets
{
private readonly string _root;
internal DirectoryRenderPackAssets(string root)
{
ArgumentException.ThrowIfNullOrWhiteSpace(root);
_root = Path.GetFullPath(root);
}
public Stream OpenRead(string assetKey)
{
ArgumentException.ThrowIfNullOrWhiteSpace(assetKey);
string normalized = assetKey.Replace('/', Path.DirectorySeparatorChar);
string path = Path.GetFullPath(Path.Combine(_root, normalized));
string relative = Path.GetRelativePath(_root, path);
if (Path.IsPathRooted(relative)
|| relative == ".."
|| relative.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal))
throw new UnauthorizedAccessException("The asset key escapes the render-pack root.");
return File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
}
}

View file

@ -0,0 +1,965 @@
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Wb;
using AcDream.Core.World;
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.App.Rendering.Packs;
/// <summary>
/// API-v1 executor for declaration-only fullscreen graphs. It supports the
/// portable Tier-1 hooks/resources without recognizing a pack id or shader
/// filename. Scene replay and renderer-pipeline variants remain separate host
/// facilities and are rejected by the factory before this runtime is built.
/// </summary>
internal class DeclaredFullscreenRenderPackGraph :
IAtmosphericWorldGraphRuntime,
IRenderPackRuntimePerformanceSource,
IRenderPackRuntimeDiagnosticsSource
{
private readonly IGpuDevice _device;
private readonly IDisposable _hdrLease;
private readonly IGpuSampler _sampler;
private readonly Node[] _nodes;
private readonly IReadOnlyDictionary<string, RenderResourceDeclaration> _resources;
private readonly PackSettingsUniforms _settings;
private readonly DirectionalSunShadowRenderer? _directionalShadows;
private readonly DirectionalShadowCasterFrame _shadowCasters = new();
private readonly RenderPassDeclaration? _shadowPass;
private readonly float _shadowStrength;
private TargetSet? _targets;
private RenderPackResourceBudget _resourceBudget;
private long _resourceGeneration;
private long _residentGpuBudgetBytes;
private AtmosphericFrameInputs _lastInputs;
private DirectionalSunShadowDiagnostics _lastShadowDiagnostics;
private int _lastShadowCasterCount;
private int _lastShadowClassificationCalls;
private WbDrawDispatcher? _lastShadowWorldMeshes;
private long _lastShadowFrameSerial = -1;
private bool _renderedFrame;
private bool _disposed;
internal DeclaredFullscreenRenderPackGraph(
IGpuDevice device,
RenderPackDescriptor descriptor,
IRenderPackAssets assets,
RenderQualityPreset preset,
IReadOnlyDictionary<string, string> userSettingOverrides)
: this(
device,
descriptor,
RenderPackShaderAssets.Validate(descriptor, assets),
preset,
userSettingOverrides)
{
}
internal DeclaredFullscreenRenderPackGraph(
IGpuDevice device,
RenderPackDescriptor descriptor,
ValidatedRenderPackShaderAssets assets,
RenderQualityPreset preset,
IReadOnlyDictionary<string, string> userSettingOverrides)
{
_device = device ?? throw new ArgumentNullException(nameof(device));
Descriptor = descriptor ?? throw new ArgumentNullException(nameof(descriptor));
ArgumentNullException.ThrowIfNull(assets);
Preset = preset ?? throw new ArgumentNullException(nameof(preset));
ArgumentNullException.ThrowIfNull(userSettingOverrides);
if (device is not IGpuPipelineFormatVariantHost variants)
throw new NotSupportedException("The active RHI cannot build an HDR world intermediate.");
_resources = descriptor.Resources.ToDictionary(value => value.Id, StringComparer.OrdinalIgnoreCase);
RenderPassDeclaration[] passes = descriptor.Passes
.OrderBy(value => value.Hook)
.ToArray();
RenderPassDeclaration[] fullscreenPasses = passes
.Where(static value =>
value.Semantic != RenderPassSemantic.DirectionalShadowDepth)
.ToArray();
if (!fullscreenPasses.Any(value => value.Hook == RenderPassHook.ToneMap
&& value.ResourceWrites.Count == 0))
{
throw new NotSupportedException(
$"Fullscreen pack '{descriptor.Id}' must declare a ToneMap pass that writes the output surface.");
}
IDisposable? lease = null;
DirectionalSunShadowRenderer? directionalShadows = null;
var nodes = new List<Node>(fullscreenPasses.Length);
try
{
lease = variants.AcquirePipelineColorFormat(GpuTextureFormat.Rgba16FloatRenderTarget);
_sampler = device.CreateSampler(GpuSamplerDescription.WorldClamp);
foreach (RenderPassDeclaration pass in fullscreenPasses)
{
if (pass.Hook is not RenderPassHook.AtmosphereBeforeToneMap
and not RenderPassHook.ToneMap)
throw new NotSupportedException($"Fullscreen executor does not support hook '{pass.Hook}'.");
RenderSemanticInput? unsupported = pass.SemanticInputs.FirstOrDefault(value =>
value is RenderSemanticInput.SceneNormals
or RenderSemanticInput.ShadowCasterTransforms
or RenderSemanticInput.DirectionalShadowMaps);
if (unsupported is RenderSemanticInput.SceneNormals
or RenderSemanticInput.ShadowCasterTransforms
or RenderSemanticInput.DirectionalShadowMaps)
{
throw new NotSupportedException(
$"Tier-1 fullscreen pass '{pass.Id}' requires unsupported semantic '{unsupported}'.");
}
if (pass.ResourceWrites.Count > 1)
throw new NotSupportedException($"Pass '{pass.Id}' writes more than one colour target.");
GpuTextureFormat format = pass.ResourceWrites.Count == 0
? GpuTextureFormat.Rgba8UnormRenderTarget
: ValidateOutput(Resource(pass.ResourceWrites[0]));
var pipeline = device.CreatePipeline(new GpuPipelineDescription
{
Name = $"render-pack-{descriptor.Id}-{pass.Id}",
Shaders = RenderPackShaderAssets.LoadPass(descriptor, assets, pass),
VertexLayout = GpuVertexLayout.None,
Blend = GpuBlendMode.None,
Depth = GpuDepthState.Disabled,
Cull = GpuCullMode.None,
ColorFormat = format,
AllowColorFormatVariants = false,
SampleCount = 1,
UsesRenderPackShaderAbi = true,
});
string timerName = $"render-pack-{descriptor.Id}-{pass.Id}";
nodes.Add(new Node(
pass,
pipeline,
[.. RenderPackTextureBindingResolver.Resolve(pass, _resources)],
timerName));
}
_nodes = [.. nodes];
_settings = PackSettingsUniforms.Create(
descriptor,
preset,
userSettingOverrides);
_shadowPass = passes.SingleOrDefault(static value =>
value.Semantic == RenderPassSemantic.DirectionalShadowDepth);
_shadowStrength = _shadowPass is null
? 0f
: ReadSemanticSetting(
descriptor,
preset,
userSettingOverrides,
RenderSettingSemantic.DirectionalShadowStrength);
if (_shadowPass is not null)
{
directionalShadows = new DirectionalSunShadowRenderer(
device,
ResolveShadowQuality(
descriptor,
preset,
userSettingOverrides),
RenderPackAtmospherePolicyEvaluation.NeutralDirectionalShadowElevation,
LoadDirectionalShadowShaders(descriptor, assets),
multiviewCascades: (preset.ExecutionHints
& RenderQualityExecutionHints
.MultiviewDirectionalShadowCascades) != 0);
}
_directionalShadows = directionalShadows;
directionalShadows = null;
_hdrLease = lease;
lease = null;
}
catch
{
directionalShadows?.Dispose();
for (int i = nodes.Count - 1; i >= 0; i--)
nodes[i].Pipeline.Dispose();
lease?.Dispose();
throw;
}
}
public RenderPackDescriptor Descriptor { get; }
public RenderQualityPreset Preset { get; }
internal IDirectionalShadowReceiverSource DeclaredDirectionalShadowReceivers =>
_directionalShadows
?? throw new InvalidOperationException(
$"Pack '{Descriptor.Id}' has no declared directional-shadow executor.");
internal DirectionalSunShadowDiagnostics RenderDeclaredDirectionalShadows(
IGpuFrame frame,
in RenderFrameFoundation foundation,
in WorldRenderFrame world,
int activeDayGroup,
in RenderSceneQuery scene,
WbDrawDispatcher worldMeshes,
TerrainModernRenderer terrain)
{
ObjectDisposedException.ThrowIf(_disposed, this);
DirectionalSunShadowRenderer renderer = _directionalShadows
?? throw new InvalidOperationException(
$"Pack '{Descriptor.Id}' has no declared directional-shadow executor.");
_shadowCasters.Build(in scene);
AuthoredCelestialShadowSource source = world.CelestialShadowSource;
float elevationStrength = RenderPackAtmospherePolicyEvaluation
.DirectionalShadowFromSin(
Descriptor.AtmospherePolicy!.DirectionalShadowLightElevationResponse,
source.ElevationSin,
fallback: 0f);
var environment = new DirectionalShadowEnvironmentInput(
PackEnabled: true,
PortalOrLoginCoverVisible: foundation.PortalViewportVisible,
PlayerInsideCell: world.Roots.PlayerInsideCell
|| world.Roots.CameraInsideCell,
source,
foundation.Atmosphere,
ActiveDayGroupMultiplier: Math.Clamp(
EvaluateDayGroupPolicy(activeDayGroup)
* elevationStrength
* _shadowStrength,
0f,
1f));
var input = new DirectionalSunShadowRenderInput(
environment,
world.Camera.Camera.View,
world.Camera.Projection,
_shadowCasters,
ResidentMaximumReachMeters:
world.ResidentStreamingWindow.MaximumReachMeters);
_lastShadowCasterCount = _shadowCasters.Stats.Accepted;
_lastShadowClassificationCalls = _shadowCasters.Stats.TopologyRebuilt ? 1 : 0;
_lastShadowDiagnostics = renderer.Render(
frame,
in input,
worldMeshes,
terrain);
_lastShadowWorldMeshes = worldMeshes;
_lastShadowFrameSerial = frame.Serial;
RequireRetainedGpuBudget(renderer);
return _lastShadowDiagnostics;
}
public IGpuRenderTarget PrepareWorldTarget(int width, int height, int sampleCount)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_targets is { } current
&& current.Width == width
&& current.Height == height
&& current.SampleCount == sampleCount)
return current.World;
RenderPackHostCapabilities capabilities =
RenderPackCapabilityResolver.Resolve(_device.Capabilities);
RenderPackResourceBudget budget = RenderPackResourceBudgetPlanner.RequireWithinHost(
Descriptor,
Preset,
width,
height,
sampleCount,
capabilities);
TargetSet candidate = TargetSet.Create(
_device,
Descriptor,
Preset,
_sampler,
width,
height,
sampleCount);
TargetSet? prior = _targets;
_targets = candidate;
_resourceBudget = budget;
_residentGpuBudgetBytes = Math.Min(
Preset.MaxResidentGpuBytes,
capabilities.MaxPackResidentBytes);
_resourceGeneration = checked(_resourceGeneration + 1);
_renderedFrame = false;
prior?.Dispose();
return candidate.World;
}
public void RenderPostProcess(IGpuFrame frame, in AtmosphericFrameInputs inputs)
{
ObjectDisposedException.ThrowIf(_disposed, this);
TargetSet targets = _targets
?? throw new InvalidOperationException("PrepareWorldTarget must run before the fullscreen graph.");
if (inputs.ViewportWidth != targets.Width || inputs.ViewportHeight != targets.Height)
throw new InvalidOperationException("Fullscreen graph inputs and targets belong to different frames.");
float elevationPolicy = EvaluateSunElevationPolicy(inputs.SunElevationDegrees);
float dayGroupPolicy = EvaluateDayGroupPolicy(inputs.ActiveDayGroup);
IReadOnlyList<SunElevationResponsePoint> shadowCurve =
Descriptor.AtmospherePolicy?.DirectionalShadowLightElevationResponse ?? [];
IReadOnlyList<SunElevationResponsePoint> volumetricCurve =
Descriptor.AtmospherePolicy?.VolumetricShaftSunElevationResponse ?? [];
float shadowElevationPolicy = shadowCurve.Count == 0
? elevationPolicy
: RenderPackAtmospherePolicyEvaluation.DirectionalShadow(
shadowCurve,
inputs.SunElevationDegrees,
elevationPolicy);
float volumetricElevationPolicy = volumetricCurve.Count == 0
? 0f
: RenderPackAtmospherePolicyEvaluation.VolumetricShaft(
volumetricCurve,
inputs.SunElevationDegrees,
0f);
float sunPolicy = EvaluateSunPolicy(
inputs,
elevationPolicy,
dayGroupPolicy);
var frameValues = new AtmosphericFrameUniforms(
new Vector4(inputs.SunScreenUv, sunPolicy, inputs.SunElevationDegrees),
new Vector4(inputs.SunColor, sunPolicy),
new Vector4(targets.Width, targets.Height, 1f / targets.Width, 1f / targets.Height),
new Vector4((float)inputs.Weather, inputs.WeatherIntensity,
(float)Math.Clamp(inputs.DeltaSeconds, 0d, 1d), inputs.IsOutdoor ? 1f : 0f),
new Vector4(inputs.SunDirection, inputs.SunDirectionalBrightness),
new Vector4(
inputs.ActiveDayGroup,
dayGroupPolicy,
shadowElevationPolicy,
volumetricElevationPolicy),
inputs.InverseViewProjection);
GpuRingAllocation frameBlock = frame.AllocateRing(AtmosphericFrameUniforms.SizeInBytes, GpuRingUsage.Uniform);
MemoryMarshal.Write(frameBlock.Data, in frameValues);
GpuRingAllocation settingsBlock = frame.AllocateRing(PackSettingsUniforms.SizeInBytes, GpuRingUsage.Uniform);
PackSettingsUniforms settings = _settings;
MemoryMarshal.Write(settingsBlock.Data, in settings);
foreach (Node node in _nodes)
Draw(frame, node, targets, frameBlock, settingsBlock);
_lastInputs = inputs;
_renderedFrame = true;
}
public RenderPackRuntimeDiagnostics CaptureDiagnostics()
{
ObjectDisposedException.ThrowIf(_disposed, this);
TargetSet? targets = _targets;
if (!_renderedFrame || targets is null)
return RenderPackRuntimeDiagnostics.Empty(Preset.Id);
int shadowPassCount = _shadowPass is null ? 0 : 1;
var passes = new RenderPackPassDiagnostics[_nodes.Length + shadowPassCount];
int passIndex = 0;
if (_shadowPass is not null)
{
passes[passIndex++] = new RenderPackPassDiagnostics(
_shadowPass.Id,
_lastShadowDiagnostics.LastResolvedGpuMilliseconds,
_lastShadowDiagnostics.DrawCalls,
DispatchCalls: 0);
}
for (int i = 0; i < _nodes.Length; i++)
{
Node node = _nodes[i];
_device.Timers.TryResolve(node.TimerName, out double milliseconds);
passes[passIndex++] = new RenderPackPassDiagnostics(
node.Pass.Id,
milliseconds,
DrawCalls: 1,
DispatchCalls: 0);
}
return new RenderPackRuntimeDiagnostics(
Preset.Id,
checked(
_resourceBudget.RetainedGpuBytes
+ (_directionalShadows?.RetainedGpuBufferBytes ?? 0L)),
_resourceBudget.MultisampleGpuBytes,
targets.ImageCount + shadowPassCount,
BufferCount: _directionalShadows?.RetainedGpuBufferCount ?? 0,
DrawCalls: _nodes.Length + _lastShadowDiagnostics.DrawCalls,
DispatchCalls: 0,
ShadowCasterCount: _lastShadowCasterCount,
CascadeDrawCount: _lastShadowDiagnostics.CascadeCount,
CpuClassificationCalls: _lastShadowClassificationCalls,
_lastInputs.SunElevationDegrees,
_lastInputs.ActiveDayGroup,
_lastInputs.Weather.ToString(),
_lastInputs.WeatherIntensity,
_lastInputs.IsOutdoor,
DirectionalShadowStrength: _lastShadowDiagnostics.Strength,
passes)
{
DirectionalShadowSourceKind = _lastShadowDiagnostics.SourceKind,
DirectionalShadowSourceObjectIndex =
_lastShadowDiagnostics.SourceObjectIndex,
DirectionalShadowSourceGfxObjId =
_lastShadowDiagnostics.SourceGfxObjId,
DirectionalShadowSurfaceToLightDirection =
_lastShadowDiagnostics.SurfaceToLightDirection,
DirectionalShadowLightElevationSin =
_lastShadowDiagnostics.LightElevationSin,
ShadowTransformChurn = _lastShadowDiagnostics.TransformChurn,
SharedWorldTransformUsedInstances =
_lastShadowWorldMeshes is not null
&& _lastShadowWorldMeshes.HasDirectionalShadowTransformFrame(
_lastShadowFrameSerial)
? _lastShadowWorldMeshes
.DirectionalShadowTransformFrameUsedInstances
: 0u,
};
}
public RenderPackRuntimePerformanceMetrics CapturePerformanceMetrics()
{
ObjectDisposedException.ThrowIf(_disposed, this);
double gpuMilliseconds = 0d;
bool resolved = _targets is not null;
for (int i = 0; i < _nodes.Length; i++)
{
if (!_device.Timers.TryTakeResolved(
_nodes[i].TimerName,
out double milliseconds))
{
resolved = false;
}
else
{
gpuMilliseconds += milliseconds;
}
}
if (_directionalShadows is not null)
{
if (!_device.Timers.TryTakeResolved(
RenderPackPerformanceScopeNames.EnhancedWorldReceiver,
out double receiverMilliseconds))
{
resolved = false;
}
else
{
gpuMilliseconds += receiverMilliseconds;
}
int shadowTimerCount = _directionalShadows.MultiviewCascadesEnabled
&& _lastShadowDiagnostics.CascadeCount > 0
? 1
: _lastShadowDiagnostics.CascadeCount;
for (int i = 0; i < shadowTimerCount; i++)
{
if (!_device.Timers.TryTakeResolved(
_directionalShadows.MultiviewCascadesEnabled
? DirectionalSunShadowRenderer.MultiviewTimerName
: DirectionalSunShadowRenderer.TimerName(i),
out double milliseconds))
{
resolved = false;
}
else
{
gpuMilliseconds += milliseconds;
}
}
}
return new RenderPackRuntimePerformanceMetrics(
_resourceGeneration,
resolved,
resolved ? gpuMilliseconds : 0d,
checked(
_resourceBudget.RetainedGpuBytes
+ (_directionalShadows?.RetainedGpuBufferBytes ?? 0L)),
_resourceBudget.MultisampleGpuBytes);
}
private void RequireRetainedGpuBudget(
DirectionalSunShadowRenderer renderer)
{
long total = checked(
_resourceBudget.RetainedGpuBytes
+ renderer.RetainedGpuBufferBytes);
if (total <= _residentGpuBudgetBytes)
return;
throw new NotSupportedException(
$"Render pack preset '{Preset.Id}' needs {total} resident GPU bytes "
+ "after materializing its scene-dependent shadow command buffers; "
+ $"the active pack budget is {_residentGpuBudgetBytes} bytes.");
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_targets?.Dispose();
_directionalShadows?.Dispose();
for (int i = _nodes.Length - 1; i >= 0; i--)
_nodes[i].Pipeline.Dispose();
_hdrLease.Dispose();
}
private void Draw(
IGpuFrame frame,
Node node,
TargetSet targets,
GpuRingAllocation frameBlock,
GpuRingAllocation settingsBlock)
{
IGpuRenderTarget? output = node.Pass.ResourceWrites.Count == 0
? null
: targets.Resource(node.Pass.ResourceWrites[0]).Target;
using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription
{
Name = node.TimerName,
Color = new GpuColorAttachment(output, GpuLoadOp.Clear, GpuStoreOp.Store, Vector4.Zero),
Depth = null,
SampleCount = 1,
});
using IDisposable timer = encoder.BeginTimerScope(node.TimerName);
encoder.BindPipeline(node.Pipeline);
encoder.BindUniformBuffer(GpuBindingModel.UniformAtmosphericFrame,
frameBlock.Buffer, frameBlock.OffsetBytes, AtmosphericFrameUniforms.SizeInBytes);
GpuRingAllocation passBlock = frame.AllocateRing(
AtmosphericPackPassUniforms.SizeInBytes,
GpuRingUsage.Uniform);
var zero = AtmosphericPackPassUniforms.From(Vector4.Zero);
MemoryMarshal.Write(passBlock.Data, in zero);
encoder.BindUniformBuffer(GpuBindingModel.UniformPackPass,
passBlock.Buffer, passBlock.OffsetBytes, AtmosphericPackPassUniforms.SizeInBytes);
encoder.BindUniformBuffer(GpuBindingModel.UniformPackSettings,
settingsBlock.Buffer, settingsBlock.OffsetBytes, PackSettingsUniforms.SizeInBytes);
Span<GpuTextureSlot> slots = stackalloc GpuTextureSlot[4];
slots.Fill(GpuTextureSlot.Unassigned);
for (int i = 0; i < node.Inputs.Length; i++)
slots[i] = Resolve(node.Inputs[i], targets);
GpuPushConstants push = GpuPushConstants.Default;
push.TextureIndexA = slots[0].Index;
push.TextureIndexB = slots[1].Index;
push.ParamA = BitConverter.UInt32BitsToSingle(slots[2].Index);
push.ParamB = BitConverter.UInt32BitsToSingle(slots[3].Index);
encoder.SetPushConstants(in push);
encoder.Draw(3, 1, 0, 0);
}
private static GpuTextureSlot Resolve(RenderPackTextureInput input, TargetSet targets)
{
if (input.Semantic is { } semantic)
{
return semantic switch
{
RenderSemanticInput.WorldColor => targets.WorldColor,
RenderSemanticInput.SceneDepth => targets.WorldDepth,
_ => throw new NotSupportedException($"Texture semantic '{semantic}' is unsupported by Tier-1."),
};
}
return targets.Resource(input.ResourceId!).Slot;
}
private float EvaluateSunElevationPolicy(float elevation)
{
IReadOnlyList<SunElevationResponsePoint>? points =
Descriptor.AtmospherePolicy?.SunElevationResponse;
return RenderPackAtmospherePolicyEvaluation.Ray(points, elevation);
}
private float EvaluateDayGroupPolicy(int activeDayGroup)
{
ActiveDayGroupMultiplier? value = Descriptor.AtmospherePolicy?
.ActiveDayGroupMultipliers
.FirstOrDefault(entry => entry.ActiveDayGroup == activeDayGroup);
return value is null ? 1f : (float)value.Multiplier;
}
private static float EvaluateSunPolicy(
in AtmosphericFrameInputs inputs,
float elevationPolicy,
float dayGroupPolicy)
{
if (!inputs.IsOutdoor || !inputs.SunIsOnScreen)
return 0f;
return Math.Clamp(
elevationPolicy
* dayGroupPolicy
* EvaluateWeatherPolicy(inputs.Weather, inputs.WeatherIntensity),
0f,
4f);
}
private static float EvaluateWeatherPolicy(
AcDream.Core.World.WeatherKind weather,
float intensity)
{
float weatherTarget = weather switch
{
AcDream.Core.World.WeatherKind.Clear => 1f,
AcDream.Core.World.WeatherKind.Overcast => 0.18f,
AcDream.Core.World.WeatherKind.Rain => 0.10f,
AcDream.Core.World.WeatherKind.Snow => 0.16f,
AcDream.Core.World.WeatherKind.Storm => 0.06f,
_ => 0f,
};
return 1f + ((weatherTarget - 1f) * Math.Clamp(intensity, 0f, 1f));
}
private static DirectionalShadowPipelineShaders LoadDirectionalShadowShaders(
RenderPackDescriptor descriptor,
ValidatedRenderPackShaderAssets assets)
{
DirectionalShadowPipelineShaders shaders = new(
Variant(RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster),
Variant(RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster),
Variant(RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster),
Variant(RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver),
Variant(RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver));
if (descriptor.PipelineVariants.Any(value =>
value.Semantic == RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster))
{
shaders = shaders with
{
MultiviewCasters = new DirectionalShadowMultiviewPipelineShaders(
Variant(RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster),
Variant(RenderPipelineVariantSemantic.WorldOpaqueMultiviewDirectionalShadowCaster),
Variant(RenderPipelineVariantSemantic.WorldAlphaCutoutMultiviewDirectionalShadowCaster)),
};
}
return shaders;
GpuShaderSet Variant(RenderPipelineVariantSemantic semantic)
{
PipelineVariantDeclaration variant = descriptor.PipelineVariants
.Single(value => value.Semantic == semantic);
return RenderPackShaderAssets.LoadVariant(descriptor, assets, variant);
}
}
private static DirectionalShadowQuality ResolveShadowQuality(
RenderPackDescriptor descriptor,
RenderQualityPreset preset,
IReadOnlyDictionary<string, string> userSettingOverrides)
{
DirectionalShadowPreset shadowPreset = preset.Semantic switch
{
RenderQualitySemantic.Low => DirectionalShadowPreset.Low,
RenderQualitySemantic.High => DirectionalShadowPreset.High,
_ => DirectionalShadowPreset.Medium,
};
DirectionalShadowQuality quality = DirectionalShadowQuality.For(shadowPreset);
RenderResourceDeclaration resource = descriptor.Resources.Single(value =>
value.Semantic == RenderResourceSemantic.DirectionalShadowDepth);
RenderExtentDeclaration extent = preset.ResourceOverrides.FirstOrDefault(value =>
string.Equals(
value.ResourceId,
resource.Id,
StringComparison.OrdinalIgnoreCase))?.Extent
?? resource.Extent
?? throw new NotSupportedException(
"The DirectionalShadowDepth semantic resource has no image extent.");
if (extent.Mode != RenderExtentMode.AbsolutePixels
|| extent.Width != extent.Height
|| extent.Width != Math.Truncate(extent.Width)
|| extent.Width is < 1 or > 16_384
|| extent.Layers is < 1 or > 4)
{
throw new NotSupportedException(
"The DirectionalShadowDepth semantic resource must be a square "
+ "absolute 1..16384 image with 1..4 array layers.");
}
float reach = ReadSemanticSetting(
descriptor,
preset,
userSettingOverrides,
RenderSettingSemantic.DirectionalShadowReachMetres);
int taps = ReadShadowPcfTaps(descriptor, preset, userSettingOverrides);
int radius = taps switch
{
1 => 0,
9 => 1,
25 => 2,
_ => throw new NotSupportedException(
"DirectionalShadowPcfTaps must resolve to exactly 1, 9, or 25 samples."),
};
int resolution = checked((int)extent.Width);
int cascades = extent.Layers;
return quality with
{
CascadeCount = cascades,
MapResolution = resolution,
MaximumReachMeters = Math.Clamp(reach, 1f, 10_000f),
PcfRadiusTexels = radius,
ApproximateDepthMapBytes = checked(
(long)cascades * resolution * resolution * sizeof(float)),
IncrementalGpuP50BudgetMilliseconds = preset.MaxIncrementalGpuMillisecondsP50,
IncrementalGpuP99BudgetMilliseconds = preset.MaxIncrementalGpuMillisecondsP99,
IncrementalCpuP50BudgetMilliseconds = preset.MaxIncrementalCpuMillisecondsP50,
IncrementalCpuP99BudgetMilliseconds = preset.MaxIncrementalCpuMillisecondsP99,
PackResidentGpuByteBudget = preset.MaxResidentGpuBytes,
};
}
private static int ReadShadowPcfTaps(
RenderPackDescriptor descriptor,
RenderQualityPreset preset,
IReadOnlyDictionary<string, string> userSettingOverrides)
{
RenderSettingDeclaration setting = descriptor.Settings.Single(value =>
value.Semantic == RenderSettingSemantic.DirectionalShadowPcfTaps);
string value = RenderPackSettingResolution.Resolve(
setting,
preset,
userSettingOverrides);
return int.TryParse(
value,
System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture,
out int taps)
? taps
: throw new NotSupportedException(
"DirectionalShadowPcfTaps must resolve to an integer sample count.");
}
private static float ReadSemanticSetting(
RenderPackDescriptor descriptor,
RenderQualityPreset preset,
IReadOnlyDictionary<string, string> userSettingOverrides,
RenderSettingSemantic semantic)
{
RenderSettingDeclaration setting = descriptor.Settings.Single(value =>
value.Semantic == semantic);
string value = RenderPackSettingResolution.Resolve(
setting,
preset,
userSettingOverrides);
if (!RenderPackSettingValueCodec.TryEncode(setting, value, out float encoded)
|| !float.IsFinite(encoded))
{
throw new NotSupportedException(
$"Setting semantic '{semantic}' did not resolve to a finite value.");
}
return encoded;
}
private RenderResourceDeclaration Resource(string id) =>
_resources.TryGetValue(id, out RenderResourceDeclaration? value)
? value
: throw new InvalidOperationException($"Unknown render-pack resource '{id}'.");
private static GpuTextureFormat FormatOf(RenderResourceDeclaration resource) => resource.Format switch
{
RenderFormatClass.HdrColor => GpuTextureFormat.Rgba16FloatRenderTarget,
RenderFormatClass.LdrColor or RenderFormatClass.SingleChannel =>
GpuTextureFormat.Rgba8UnormRenderTarget,
_ => throw new NotSupportedException(
$"Fullscreen resource '{resource.Id}' has unsupported format '{resource.Format}'."),
};
private static GpuTextureFormat ValidateOutput(RenderResourceDeclaration resource)
{
if (resource.Kind != RenderResourceKind.Image2D
|| (resource.Usage & RenderResourceUsage.ColorAttachment) == 0
|| resource.Extent is null)
{
throw new NotSupportedException(
$"Fullscreen output '{resource.Id}' must be an extent-declared colour Image2D.");
}
return FormatOf(resource);
}
private sealed record Node(
RenderPassDeclaration Pass,
IGpuPipeline Pipeline,
RenderPackTextureInput[] Inputs,
string TimerName);
private sealed class TargetSet : IDisposable
{
private readonly IGpuDevice _device;
private readonly Dictionary<string, ResourceTarget> _resources;
private readonly GpuTextureSlot[] _slots;
private readonly string? _mainWorldResourceId;
private TargetSet(
IGpuDevice device,
int width,
int height,
int sampleCount,
IGpuRenderTarget world,
GpuTextureSlot worldColor,
GpuTextureSlot worldDepth,
Dictionary<string, ResourceTarget> resources,
GpuTextureSlot[] slots,
string? mainWorldResourceId)
{
_device = device;
Width = width;
Height = height;
SampleCount = sampleCount;
World = world;
WorldColor = worldColor;
WorldDepth = worldDepth;
_resources = resources;
_slots = slots;
_mainWorldResourceId = mainWorldResourceId;
}
internal int Width { get; }
internal int Height { get; }
internal int SampleCount { get; }
internal IGpuRenderTarget World { get; }
internal GpuTextureSlot WorldColor { get; }
internal GpuTextureSlot WorldDepth { get; }
internal int ImageCount => checked(
2
+ _resources.Count
+ (SampleCount > 1 ? (WorldDepth.IsAssigned ? 2 : 1) : 0));
internal ResourceTarget Resource(string id) =>
string.Equals(id, _mainWorldResourceId, StringComparison.OrdinalIgnoreCase)
? new ResourceTarget(World, WorldColor)
: _resources.TryGetValue(id, out ResourceTarget? value)
? value
: throw new InvalidOperationException($"Resource '{id}' has no produced image.");
internal static TargetSet Create(
IGpuDevice device,
RenderPackDescriptor descriptor,
RenderQualityPreset preset,
IGpuSampler sampler,
int width,
int height,
int samples)
{
var targets = new List<IGpuRenderTarget>();
var slots = new List<GpuTextureSlot>();
try
{
bool needsDepth = descriptor.Passes.Any(pass =>
pass.SemanticInputs.Contains(RenderSemanticInput.SceneDepth));
IGpuRenderTarget world = device.CreateRenderTarget(new GpuRenderTargetDescription(
$"render-pack-{descriptor.Id}-world-hdr", width, height,
GpuTextureFormat.Rgba16FloatRenderTarget,
GpuTextureFormat.Depth24Stencil8,
samples,
needsDepth));
targets.Add(world);
GpuTextureSlot worldColor = Register(device, world.ColorTexture, sampler, slots);
GpuTextureSlot worldDepth = needsDepth
? Register(device, world.DepthTexture!, sampler, slots)
: GpuTextureSlot.Unassigned;
var resources = new Dictionary<string, ResourceTarget>(StringComparer.OrdinalIgnoreCase);
string? mainWorldResourceId = descriptor.Resources.SingleOrDefault(resource =>
resource.Semantic == RenderResourceSemantic.MainWorldHdr)?.Id;
HashSet<string> written = descriptor.Passes
.SelectMany(pass => pass.ResourceWrites)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (RenderResourceDeclaration resource in descriptor.Resources)
{
if (!written.Contains(resource.Id)
|| resource.Semantic is RenderResourceSemantic.MainWorldHdr
or RenderResourceSemantic.DirectionalShadowDepth)
continue;
if (resource.Kind != RenderResourceKind.Image2D
|| (resource.Usage & RenderResourceUsage.ColorAttachment) == 0)
throw new NotSupportedException($"Fullscreen resource '{resource.Id}' is not a colour image.");
(int resourceWidth, int resourceHeight) = Extent(resource, preset, width, height);
IGpuRenderTarget target = device.CreateRenderTarget(new GpuRenderTargetDescription(
$"render-pack-{descriptor.Id}-{resource.Id}", resourceWidth, resourceHeight,
FormatOf(resource), null, 1));
targets.Add(target);
resources.Add(resource.Id, new ResourceTarget(
target,
Register(device, target.ColorTexture, sampler, slots)));
}
return new TargetSet(
device, width, height, samples, world, worldColor, worldDepth,
resources, [.. slots], mainWorldResourceId);
}
catch
{
for (int i = slots.Count - 1; i >= 0; i--)
device.ReleaseTextureSlot(slots[i]);
for (int i = targets.Count - 1; i >= 0; i--)
targets[i].Dispose();
throw;
}
}
public void Dispose()
{
for (int i = _slots.Length - 1; i >= 0; i--)
_device.ReleaseTextureSlot(_slots[i]);
foreach (ResourceTarget resource in _resources.Values.Reverse())
resource.Target.Dispose();
World.Dispose();
}
private static (int Width, int Height) Extent(
RenderResourceDeclaration resource,
RenderQualityPreset preset,
int width,
int height)
{
RenderExtentDeclaration extent = preset.ResourceOverrides.FirstOrDefault(value =>
string.Equals(value.ResourceId, resource.Id, StringComparison.OrdinalIgnoreCase))?.Extent
?? resource.Extent
?? throw new NotSupportedException($"Image resource '{resource.Id}' has no extent.");
return extent.Mode switch
{
RenderExtentMode.AbsolutePixels =>
(checked((int)extent.Width), checked((int)extent.Height)),
RenderExtentMode.RelativeToMainWorld or RenderExtentMode.RelativeToOutput =>
(Math.Max(1, (int)Math.Ceiling(width * extent.Width)),
Math.Max(1, (int)Math.Ceiling(height * extent.Height))),
_ => throw new NotSupportedException($"Resource '{resource.Id}' has unsupported extent mode."),
};
}
private static GpuTextureSlot Register(
IGpuDevice device,
IGpuTexture texture,
IGpuSampler sampler,
List<GpuTextureSlot> slots)
{
GpuTextureSlot slot = device.RegisterTexture(texture, sampler);
slots.Add(slot);
return slot;
}
}
internal sealed record ResourceTarget(IGpuRenderTarget Target, GpuTextureSlot Slot);
}
internal sealed class DeclaredDirectionalShadowRenderPackGraph :
DeclaredFullscreenRenderPackGraph,
IDirectionalShadowWorldGraphRuntime
{
internal DeclaredDirectionalShadowRenderPackGraph(
IGpuDevice device,
RenderPackDescriptor descriptor,
IRenderPackAssets assets,
RenderQualityPreset preset,
IReadOnlyDictionary<string, string> userSettingOverrides)
: base(device, descriptor, assets, preset, userSettingOverrides)
{
}
internal DeclaredDirectionalShadowRenderPackGraph(
IGpuDevice device,
RenderPackDescriptor descriptor,
ValidatedRenderPackShaderAssets assets,
RenderQualityPreset preset,
IReadOnlyDictionary<string, string> userSettingOverrides)
: base(device, descriptor, assets, preset, userSettingOverrides)
{
}
public IDirectionalShadowReceiverSource DirectionalShadowReceivers =>
DeclaredDirectionalShadowReceivers;
public DirectionalSunShadowDiagnostics RenderDirectionalShadows(
IGpuFrame frame,
in RenderFrameFoundation foundation,
in WorldRenderFrame world,
int activeDayGroup,
in RenderSceneQuery scene,
WbDrawDispatcher worldMeshes,
TerrainModernRenderer terrain) => RenderDeclaredDirectionalShadows(
frame,
in foundation,
in world,
activeDayGroup,
in scene,
worldMeshes,
terrain);
}

View file

@ -0,0 +1,87 @@
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.App.Rendering.Packs;
/// <summary>
/// Host evaluation for the public data-only atmosphere curves. Keeping the
/// three interpolation contracts here prevents a pack declaration from being
/// reinterpreted differently by the declared, shadow, and volumetric graphs.
/// </summary>
internal static class RenderPackAtmospherePolicyEvaluation
{
internal static DirectionalShadowAtmospherePolicy NeutralDirectionalShadowElevation { get; } =
DirectionalShadowAtmospherePolicy.BuiltIn with
{
MinimumLightElevationSin = -1.001f,
FullStrengthLightElevationSin = -1f,
};
internal static float Ray(
IReadOnlyList<SunElevationResponsePoint>? points,
float elevationDegrees,
float fallback = 1f) => Evaluate(
points,
elevationDegrees,
static value => (float)value,
static value => (float)value,
fallback);
internal static float DirectionalShadow(
IReadOnlyList<SunElevationResponsePoint>? points,
float elevationDegrees,
float fallback = 0f) => DirectionalShadowFromSin(
points,
MathF.Sin(elevationDegrees * (MathF.PI / 180f)),
fallback);
internal static float DirectionalShadowFromSin(
IReadOnlyList<SunElevationResponsePoint>? points,
float lightElevationSin,
float fallback = 0f) => Evaluate(
points,
Math.Clamp(lightElevationSin, -1f, 1f),
static degrees => MathF.Sin((float)degrees * (MathF.PI / 180f)),
static value => (float)value,
fallback);
internal static float VolumetricShaft(
IReadOnlyList<SunElevationResponsePoint>? points,
float elevationDegrees,
float fallback = 0f) => Evaluate(
points,
elevationDegrees,
static value => (float)value,
static value => value * value * (3f - (2f * value)),
fallback);
private static float Evaluate(
IReadOnlyList<SunElevationResponsePoint>? points,
float input,
Func<double, float> transformPoint,
Func<float, float> transformInterpolation,
float fallback)
{
if (points is null || points.Count == 0)
return fallback;
float first = transformPoint(points[0].ElevationDegrees);
if (input <= first)
return (float)points[0].Multiplier;
for (int i = 1; i < points.Count; i++)
{
SunElevationResponsePoint upper = points[i];
float upperInput = transformPoint(upper.ElevationDegrees);
if (input > upperInput)
continue;
SunElevationResponsePoint lower = points[i - 1];
float lowerInput = transformPoint(lower.ElevationDegrees);
float span = upperInput - lowerInput;
float t = span <= 0f
? 0f
: Math.Clamp((input - lowerInput) / span, 0f, 1f);
t = transformInterpolation(t);
return (float)(lower.Multiplier
+ ((upper.Multiplier - lower.Multiplier) * t));
}
return (float)points[^1].Multiplier;
}
}

View file

@ -0,0 +1,66 @@
using AcDream.App.Rendering.Gpu;
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.App.Rendering.Packs;
internal static class RenderPackCapabilityResolver
{
internal const long AbsoluteResidentByteCeiling = 256L * 1024 * 1024;
internal const long AbsoluteTransientByteCeiling = 512L * 1024 * 1024;
internal const int DeviceLocalShareDenominator = 8;
internal static RenderPackHostCapabilities Resolve(GpuCapabilityRecord gpu)
{
ArgumentNullException.ThrowIfNull(gpu);
var available = new HashSet<RenderCapability>
{
RenderCapability.FullscreenPasses,
RenderCapability.AuthoredSunDirection,
RenderCapability.AuthoredCelestialDirectionalLight,
RenderCapability.AuthoredSunScreenPosition,
RenderCapability.AuthoredWeather,
RenderCapability.OutdoorDirectionalShadowCasterReplay,
RenderCapability.AnimatedCasterTransforms,
RenderCapability.AlphaCutoutShadowCasters,
};
if (gpu.SupportsRgba16FloatRenderTargets)
available.Add(RenderCapability.MainWorldColorIntermediate);
if (gpu.SupportsSampledDepth)
{
available.Add(RenderCapability.SceneDepthSampling);
available.Add(RenderCapability.DirectionalShadowMaps);
}
if (gpu.SupportsTimestampQueries)
available.Add(RenderCapability.GpuTimestampQueries);
if (gpu.SupportsMultiview)
available.Add(RenderCapability.MultiviewDirectionalShadowCascades);
long residentBytes = DeviceLocalShare(
gpu.DeviceLocalMemoryBytes,
AbsoluteResidentByteCeiling);
long transientBytes = DeviceLocalShare(
gpu.DeviceLocalMemoryBytes,
AbsoluteTransientByteCeiling);
return new RenderPackHostCapabilities(
available,
MaxImageDimension2D: checked((int)Math.Min(
gpu.MaxImageDimension2D,
(uint)int.MaxValue)),
MaxImageArrayLayers: checked((int)Math.Min(
gpu.MaxImageArrayLayers,
(uint)int.MaxValue)),
MaxPackResidentBytes: residentBytes,
MaxPackTransientBytes: transientBytes,
MemoryPolicyDescription:
$"one eighth of {gpu.DeviceLocalMemoryBytes} device-local bytes, "
+ $"capped at {AbsoluteResidentByteCeiling} resident and "
+ $"{AbsoluteTransientByteCeiling} transient bytes");
}
private static long DeviceLocalShare(ulong deviceLocalBytes, long ceiling)
{
ulong share = deviceLocalBytes / DeviceLocalShareDenominator;
return (long)Math.Min(share, checked((ulong)ceiling));
}
}

View file

@ -0,0 +1,37 @@
using AcDream.App.Plugins;
namespace AcDream.App.Rendering.Packs;
/// <summary>
/// Production catalog authority shared by retained UI and the activation
/// controller. It deliberately retains no catalog snapshot (and therefore no
/// plugin asset source): withdrawal immediately releases the registry's last
/// catalog reference, while consumers rebuild only after the revision event or
/// an explicit UI interaction.
/// </summary>
internal sealed class RenderPackCatalogSource
{
private readonly BufferedRenderPackRegistry _registry;
private readonly RenderPackHostCapabilities _capabilities;
internal RenderPackCatalogSource(
BufferedRenderPackRegistry registry,
RenderPackHostCapabilities capabilities)
{
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
_capabilities = capabilities
?? throw new ArgumentNullException(nameof(capabilities));
}
internal long Revision => _registry.Revision;
internal event Action<long> Changed
{
add => _registry.Changed += value;
remove => _registry.Changed -= value;
}
internal RenderPackCatalog Snapshot() => RenderPackCatalog.Build(
_registry.Snapshot(),
_capabilities);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,284 @@
using System.Numerics;
namespace AcDream.App.Rendering.Packs;
internal readonly record struct RenderPackPassDiagnostics(
string PassId,
double GpuMilliseconds,
int DrawCalls,
int DispatchCalls);
/// <summary>
/// Pack-owned facts sampled after a successful frame. Implementations expose
/// already-resolved asynchronous timestamp results; capturing this value must
/// never wait for the GPU.
/// </summary>
internal sealed record RenderPackRuntimeDiagnostics(
string EffectiveQuality,
long RetainedGpuBytes,
long TransientGpuBytes,
int ImageCount,
int BufferCount,
int DrawCalls,
int DispatchCalls,
int ShadowCasterCount,
int CascadeDrawCount,
int CpuClassificationCalls,
double SunElevationDegrees,
int ActiveDayGroup,
string Weather,
double WeatherIntensity,
bool Outdoor,
double DirectionalShadowStrength,
IReadOnlyList<RenderPackPassDiagnostics> Passes)
{
/// <summary>
/// Number of matrices addressed through the one shared world-transform
/// binding after the enhanced world receiver has appended its ordinary
/// draws to the directional-shadow prefix. Zero means that no shared
/// directional-shadow frame was active for the sampled frame.
/// </summary>
public uint SharedWorldTransformUsedInstances { get; init; }
public IReadOnlyList<RenderPackCpuStageDiagnostics> CpuStages { get; init; } = [];
public AuthoredCelestialShadowSourceKind DirectionalShadowSourceKind
{
get;
init;
}
public int DirectionalShadowSourceObjectIndex { get; init; } = -1;
public uint DirectionalShadowSourceGfxObjId { get; init; }
public Vector3 DirectionalShadowSurfaceToLightDirection { get; init; }
public float DirectionalShadowLightElevationSin { get; init; }
public DirectionalShadowTransformChurnDiagnostics ShadowTransformChurn
{
get;
init;
}
internal static RenderPackRuntimeDiagnostics Empty(string quality) => new(
quality,
RetainedGpuBytes: 0,
TransientGpuBytes: 0,
ImageCount: 0,
BufferCount: 0,
DrawCalls: 0,
DispatchCalls: 0,
ShadowCasterCount: 0,
CascadeDrawCount: 0,
CpuClassificationCalls: 0,
SunElevationDegrees: 0,
ActiveDayGroup: -1,
Weather: "unknown",
WeatherIntensity: 0,
Outdoor: false,
DirectionalShadowStrength: 0,
Passes: []);
}
internal interface IRenderPackRuntimeDiagnosticsSource
{
RenderPackRuntimeDiagnostics CaptureDiagnostics();
}
internal sealed record RenderPackDiagnosticsSnapshot(
RenderPackActivationState State,
string PackId,
string? PackVersion,
string PresetId,
string EffectiveQuality,
string? FailureReason,
long ActivationGeneration,
long RetainedGpuBytes,
long TransientGpuBytes,
int ImageCount,
int BufferCount,
int DrawCalls,
int DispatchCalls,
int ShadowCasterCount,
int CascadeDrawCount,
int CpuClassificationCalls,
double SunElevationDegrees,
int ActiveDayGroup,
string Weather,
double WeatherIntensity,
bool Outdoor,
double DirectionalShadowStrength,
IReadOnlyList<RenderPackPassDiagnostics> Passes,
RenderPackPerformanceSnapshot Performance = default)
{
public uint SharedWorldTransformUsedInstances { get; init; }
public IReadOnlyList<RenderPackCpuStageDiagnostics> CpuStages { get; init; } = [];
public AuthoredCelestialShadowSourceKind DirectionalShadowSourceKind
{
get;
init;
}
public int DirectionalShadowSourceObjectIndex { get; init; } = -1;
public uint DirectionalShadowSourceGfxObjId { get; init; }
public Vector3 DirectionalShadowSurfaceToLightDirection { get; init; }
public float DirectionalShadowLightElevationSin { get; init; }
public DirectionalShadowTransformChurnDiagnostics ShadowTransformChurn
{
get;
init;
}
internal static RenderPackDiagnosticsSnapshot Retail { get; } = new(
RenderPackActivationState.Retail,
PackId: "retail",
PackVersion: null,
PresetId: "off",
EffectiveQuality: "off",
FailureReason: null,
ActivationGeneration: 0,
RetainedGpuBytes: 0,
TransientGpuBytes: 0,
ImageCount: 0,
BufferCount: 0,
DrawCalls: 0,
DispatchCalls: 0,
ShadowCasterCount: 0,
CascadeDrawCount: 0,
CpuClassificationCalls: 0,
SunElevationDegrees: 0,
ActiveDayGroup: -1,
Weather: "unknown",
WeatherIntensity: 0,
Outdoor: false,
DirectionalShadowStrength: 0,
Passes: []);
internal bool IsRetail =>
string.Equals(PackId, "retail", StringComparison.Ordinal);
}
internal interface IRenderPackDiagnosticsSnapshotSource
{
RenderPackDiagnosticsSnapshot CaptureDiagnostics();
}
/// <summary>
/// Construction-order bridge used by screenshot and retained-UI diagnostics.
/// Until the render-thread controller is composed, it reports the exact
/// resource-free retail selection.
/// </summary>
internal sealed class DeferredRenderPackDiagnosticsSource
: IRenderPackDiagnosticsSnapshotSource
{
private IRenderPackDiagnosticsSnapshotSource? _target;
public RenderPackDiagnosticsSnapshot CaptureDiagnostics() =>
_target?.CaptureDiagnostics() ?? RenderPackDiagnosticsSnapshot.Retail;
internal IDisposable BindOwned(IRenderPackDiagnosticsSnapshotSource target)
{
ArgumentNullException.ThrowIfNull(target);
if (_target is not null && !ReferenceEquals(_target, target))
throw new InvalidOperationException("Render-pack diagnostics are already bound.");
_target = target;
return new Binding(this, target);
}
private void Unbind(IRenderPackDiagnosticsSnapshotSource target)
{
if (ReferenceEquals(_target, target))
_target = null;
}
private sealed class Binding(
DeferredRenderPackDiagnosticsSource owner,
IRenderPackDiagnosticsSnapshotSource target) : IDisposable
{
private DeferredRenderPackDiagnosticsSource? _owner = owner;
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unbind(target);
}
}
internal static class RenderPackDiagnosticsFormatter
{
internal static string Format(RenderPackDiagnosticsSnapshot value) =>
$"[render-pack] state={value.State} "
+ $"pack={value.PackId}@{value.PackVersion ?? "(missing)"} "
+ $"preset={value.PresetId} effective={value.EffectiveQuality} "
+ $"generation={value.ActivationGeneration} "
+ $"gpuBytes={value.RetainedGpuBytes}/{value.TransientGpuBytes} "
+ $"resources={value.ImageCount}i/{value.BufferCount}b "
+ $"submit={value.DrawCalls}d/{value.DispatchCalls}c "
+ $"worldTransforms={value.SharedWorldTransformUsedInstances}used "
+ $"shadow={value.ShadowCasterCount}casters/{value.CascadeDrawCount}cascadeDraws/"
+ $"{value.CpuClassificationCalls}classify "
+ $"shadowSource={value.DirectionalShadowSourceKind}/"
+ $"obj{value.DirectionalShadowSourceObjectIndex}/"
+ $"0x{value.DirectionalShadowSourceGfxObjId:X8}/"
+ $"dir({Invariant(value.DirectionalShadowSurfaceToLightDirection.X, "F4")},"
+ $"{Invariant(value.DirectionalShadowSurfaceToLightDirection.Y, "F4")},"
+ $"{Invariant(value.DirectionalShadowSurfaceToLightDirection.Z, "F4")})/"
+ $"elevSin={Invariant(value.DirectionalShadowLightElevationSin, "F4")} "
+ $"atmosphere={Invariant(value.SunElevationDegrees, "F2")}deg/day{value.ActiveDayGroup}/"
+ $"{value.Weather}:{Invariant(value.WeatherIntensity, "F3")}/outdoor={value.Outdoor}/"
+ $"shadowStrength={Invariant(value.DirectionalShadowStrength, "F3")} "
+ $"perf=cpu-added:{Invariant(value.Performance.IncrementalCpuMillisecondsP50, "F3")}/"
+ $"{Invariant(value.Performance.IncrementalCpuMillisecondsP95, "F3")}/"
+ $"{Invariant(value.Performance.IncrementalCpuMillisecondsP99, "F3")}ms,"
+ $"receiver-cpu-absolute:{Invariant(value.Performance.AbsoluteReceiverCpuMillisecondsP50, "F3")}/"
+ $"{Invariant(value.Performance.AbsoluteReceiverCpuMillisecondsP95, "F3")}/"
+ $"{Invariant(value.Performance.AbsoluteReceiverCpuMillisecondsP99, "F3")}ms,"
+ $"gpu-inclusive:{Invariant(value.Performance.InclusiveGpuMillisecondsP50, "F3")}/"
+ $"{Invariant(value.Performance.InclusiveGpuMillisecondsP95, "F3")}/"
+ $"{Invariant(value.Performance.InclusiveGpuMillisecondsP99, "F3")}ms "
+ $"passes={FormatPasses(value.Passes)} "
+ $"cpuStages={FormatCpuStages(value.CpuStages)} "
+ $"shadowTransformChurn={FormatShadowTransformChurn(value.ShadowTransformChurn)} "
+ $"reason={value.FailureReason ?? "none"}";
private static string FormatShadowTransformChurn(
DirectionalShadowTransformChurnDiagnostics value) =>
$"scene={value.CopiedSceneChanges}[transform={value.UpdateTransformChanges},"
+ $"appearance={value.UpdateAppearanceChanges},sync={value.DynamicSynchronizationChanges};"
+ $"animated={value.ActiveAnimatedStaticChanges},live={value.LiveDynamicRootChanges},"
+ $"equipped={value.EquippedChildChanges}]/"
+ $"casters={value.DedupedCasterSlots}/sceneFallback={value.SceneJournalFullRefresh}/"
+ $"densityBulk={value.DensityBulkRefresh}/batchCopies={value.BatchedProjectionCopyCalls}/"
+ $"matrices={value.ChangedMatrixSlots}/flightCurrent={value.FlightCurrentChangedMatrices}/"
+ $"flightReplay={value.FlightPendingReplayMatrices}/uploaded={value.FlightUploadedMatrices}/"
+ $"ranges={value.FlightUploadRanges}/bytes={value.FlightBytesWritten}/"
+ $"flightFallback={value.FlightFullDynamicFallback}/denseDirect={value.DenseDirectUpload}/"
+ $"denseReplay={value.DenseFlightReplay}/"
+ $"classes=[terrain={value.CasterClasses.TerrainCommands},"
+ $"outdoorStatic={value.CasterClasses.OutdoorStatics},"
+ $"building={value.CasterClasses.Buildings},"
+ $"animated={value.CasterClasses.AnimatedStatics},"
+ $"localPlayer={value.CasterClasses.LocalPlayers},"
+ $"remotePlayer={value.CasterClasses.RemotePlayers},"
+ $"nonPlayerCreature={value.CasterClasses.NonPlayerCreatures},"
+ $"otherLive={value.CasterClasses.OtherLiveDynamics},"
+ $"equipped={value.CasterClasses.EquippedChildren}]";
private static string FormatPasses(IReadOnlyList<RenderPackPassDiagnostics> passes) =>
passes.Count == 0
? "none"
: string.Join(
',',
passes.Select(static pass =>
$"{pass.PassId}:{Invariant(pass.GpuMilliseconds, "F3")}ms/"
+ $"{pass.DrawCalls}d/{pass.DispatchCalls}c"));
private static string FormatCpuStages(
IReadOnlyList<RenderPackCpuStageDiagnostics> stages) =>
stages.Count == 0
? "none"
: string.Join(
',',
stages.Select(static stage =>
$"{stage.Stage}:{stage.SampleCount}n/"
+ $"{Invariant(stage.CpuMillisecondsP50, "F3")}/"
+ $"{Invariant(stage.CpuMillisecondsP95, "F3")}/"
+ $"{Invariant(stage.CpuMillisecondsP99, "F3")}ms"));
private static string Invariant(double value, string format) =>
value.ToString(format, System.Globalization.CultureInfo.InvariantCulture);
}

View file

@ -0,0 +1,166 @@
using AcDream.App.Diagnostics;
namespace AcDream.App.Rendering.Packs;
internal readonly record struct RenderPackPerformanceSnapshot(
int CpuSampleCount,
int AbsoluteReceiverCpuSampleCount,
int GpuSampleCount,
double IncrementalCpuMillisecondsP50,
double IncrementalCpuMillisecondsP95,
double IncrementalCpuMillisecondsP99,
double AbsoluteReceiverCpuMillisecondsP50,
double AbsoluteReceiverCpuMillisecondsP95,
double AbsoluteReceiverCpuMillisecondsP99,
double InclusiveGpuMillisecondsP50,
double InclusiveGpuMillisecondsP95,
double InclusiveGpuMillisecondsP99,
long ResidentGpuBytes,
long TransientGpuBytes)
{
internal bool HasStableAutoWindow(int minimumSamples) =>
minimumSamples > 0
&& CpuSampleCount >= minimumSamples
&& GpuSampleCount >= minimumSamples;
}
/// <summary>
/// Allocation-free facts captured from the active runtime after it has
/// submitted one complete frame. GPU time is the inclusive sum of already-
/// resolved asynchronous pack timers, including the enhanced-world receiver
/// pass; this contract never waits for the device.
/// </summary>
internal readonly record struct RenderPackRuntimePerformanceMetrics(
long ResourceGeneration,
bool HasResolvedGpuMeasurement,
double InclusiveResolvedGpuMilliseconds,
long RetainedGpuBytes,
long TransientGpuBytes);
internal interface IRenderPackRuntimePerformanceSource
{
RenderPackRuntimePerformanceMetrics CapturePerformanceMetrics();
}
internal readonly record struct RenderPackFramePerformanceObservation(
double PackAddedCpuMilliseconds,
bool StableFrameBoundary,
int ViewportWidth,
int ViewportHeight,
int SampleCount,
double AbsoluteEnhancedWorldReceiverCpuMilliseconds = 0d);
internal static class RenderPackPerformanceScopeNames
{
/// <summary>
/// The enhanced main-world pass uses the pack's receiver pipelines. Its
/// timestamp is intentionally part of the same total consumed by
/// diagnostics and Auto; measuring only the extra shadow/post passes would
/// hide the receiver shader's GPU cost.
/// </summary>
internal const string EnhancedWorldReceiver = "atmospheric-world-receiver";
}
/// <summary>
/// Allocation-free rolling evidence for one active pack runtime. Incremental
/// CPU samples bracket only work added by the pack. The complete enhanced-world
/// receiver recording is retained as a separate absolute diagnostic because it
/// is not an incremental delta and must never be compared with the pack's
/// incremental CPU budget. GPU samples are the already-resolved asynchronous
/// total including the receiver pass for the frame that issued them. The owner
/// resets this window on activation or quality generation changes so Auto can
/// never compare measurements from mixed resource layouts.
/// </summary>
internal sealed class RenderPackPerformanceWindow
{
internal const int DefaultCapacity = 2048;
private readonly FrameStatsBuffer _cpuMicroseconds;
private readonly FrameStatsBuffer _absoluteReceiverCpuMicroseconds;
private readonly FrameStatsBuffer _gpuMicroseconds;
private long _residentGpuBytes;
private long _transientGpuBytes;
internal RenderPackPerformanceWindow(int capacity = DefaultCapacity)
{
if (capacity <= 0)
throw new ArgumentOutOfRangeException(nameof(capacity));
_cpuMicroseconds = new FrameStatsBuffer(capacity);
_absoluteReceiverCpuMicroseconds = new FrameStatsBuffer(capacity);
_gpuMicroseconds = new FrameStatsBuffer(capacity);
}
internal void Observe(
double incrementalCpuMilliseconds,
double absoluteReceiverCpuMilliseconds,
bool hasResolvedGpuMeasurement,
double inclusiveResolvedGpuMilliseconds,
long residentGpuBytes,
long transientGpuBytes)
{
if (!double.IsFinite(incrementalCpuMilliseconds) || incrementalCpuMilliseconds < 0d)
throw new ArgumentOutOfRangeException(nameof(incrementalCpuMilliseconds));
if (!double.IsFinite(absoluteReceiverCpuMilliseconds)
|| absoluteReceiverCpuMilliseconds < 0d)
{
throw new ArgumentOutOfRangeException(nameof(absoluteReceiverCpuMilliseconds));
}
if (hasResolvedGpuMeasurement
&& (!double.IsFinite(inclusiveResolvedGpuMilliseconds)
|| inclusiveResolvedGpuMilliseconds < 0d))
{
throw new ArgumentOutOfRangeException(nameof(inclusiveResolvedGpuMilliseconds));
}
if (residentGpuBytes < 0)
throw new ArgumentOutOfRangeException(nameof(residentGpuBytes));
if (transientGpuBytes < 0)
throw new ArgumentOutOfRangeException(nameof(transientGpuBytes));
_cpuMicroseconds.Push(ToMicroseconds(incrementalCpuMilliseconds));
_absoluteReceiverCpuMicroseconds.Push(
ToMicroseconds(absoluteReceiverCpuMilliseconds));
if (hasResolvedGpuMeasurement)
_gpuMicroseconds.Push(ToMicroseconds(inclusiveResolvedGpuMilliseconds));
_residentGpuBytes = residentGpuBytes;
_transientGpuBytes = transientGpuBytes;
}
internal RenderPackPerformanceSnapshot Snapshot() => new(
_cpuMicroseconds.Count,
_absoluteReceiverCpuMicroseconds.Count,
_gpuMicroseconds.Count,
ToMilliseconds(_cpuMicroseconds.Percentile(0.50)),
ToMilliseconds(_cpuMicroseconds.Percentile(0.95)),
ToMilliseconds(_cpuMicroseconds.Percentile(0.99)),
ToMilliseconds(_absoluteReceiverCpuMicroseconds.Percentile(0.50)),
ToMilliseconds(_absoluteReceiverCpuMicroseconds.Percentile(0.95)),
ToMilliseconds(_absoluteReceiverCpuMicroseconds.Percentile(0.99)),
ToMilliseconds(_gpuMicroseconds.Percentile(0.50)),
ToMilliseconds(_gpuMicroseconds.Percentile(0.95)),
ToMilliseconds(_gpuMicroseconds.Percentile(0.99)),
_residentGpuBytes,
_transientGpuBytes);
internal int MinimumSampleCount => Math.Min(
_cpuMicroseconds.Count,
Math.Min(
_absoluteReceiverCpuMicroseconds.Count,
_gpuMicroseconds.Count));
internal void Reset()
{
_cpuMicroseconds.Reset();
_absoluteReceiverCpuMicroseconds.Reset();
_gpuMicroseconds.Reset();
_residentGpuBytes = 0;
_transientGpuBytes = 0;
}
private static long ToMicroseconds(double milliseconds) =>
checked((long)Math.Round(
milliseconds * 1000d,
MidpointRounding.AwayFromZero));
private static double ToMilliseconds(long microseconds) =>
microseconds / 1000d;
}

View file

@ -0,0 +1,49 @@
namespace AcDream.App.Rendering.Packs;
/// <summary>
/// Schedules one complete, unpublished render-pack candidate preparation.
/// Production uses the worker scheduler so shader I/O/validation and Vulkan
/// resource creation cannot block the render-frame boundary. Tests can inject
/// a deterministic scheduler without adding sleeps or timing races.
/// </summary>
internal interface IRenderPackPreparationScheduler
{
Task Schedule(Action preparation);
}
internal sealed class ThreadPoolRenderPackPreparationScheduler :
IRenderPackPreparationScheduler
{
internal static ThreadPoolRenderPackPreparationScheduler Instance { get; } = new();
private ThreadPoolRenderPackPreparationScheduler()
{
}
public Task Schedule(Action preparation)
{
ArgumentNullException.ThrowIfNull(preparation);
return Task.Run(preparation);
}
}
/// <summary>
/// Synchronous fixture scheduler. Production composition must use
/// <see cref="ThreadPoolRenderPackPreparationScheduler"/>.
/// </summary>
internal sealed class InlineRenderPackPreparationScheduler :
IRenderPackPreparationScheduler
{
internal static InlineRenderPackPreparationScheduler Instance { get; } = new();
private InlineRenderPackPreparationScheduler()
{
}
public Task Schedule(Action preparation)
{
ArgumentNullException.ThrowIfNull(preparation);
preparation();
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,124 @@
using AcDream.App.Rendering.Wb;
namespace AcDream.App.Rendering.Packs;
/// <summary>
/// One complete, unpublished receiver-pipeline product. Candidate resources
/// stay owned here until the render-pack controller commits them at a stable
/// frame boundary.
/// </summary>
internal interface IRenderPackReceiverPipelineCandidate : IDisposable
{
}
internal interface IRenderPackReceiverPipelineCoordinator
{
IRenderPackReceiverPipelineCandidate Prepare(
IDirectionalShadowReceiverSource? source,
int sampleCount);
void Publish(IRenderPackReceiverPipelineCandidate candidate);
void Clear();
}
/// <summary>
/// Couples terrain and world-mesh receiver pipelines into the same activation
/// transaction as their producing render-pack runtime. Preparation may compile
/// pipelines, publication only swaps already-complete state objects, and old
/// pipelines retire after both renderer owners point at the new generation.
/// </summary>
internal sealed class RenderPackReceiverPipelineCoordinator(
TerrainModernRenderer terrain,
WbDrawDispatcher worldMeshes) : IRenderPackReceiverPipelineCoordinator
{
private readonly TerrainModernRenderer _terrain = terrain
?? throw new ArgumentNullException(nameof(terrain));
private readonly WbDrawDispatcher _worldMeshes = worldMeshes
?? throw new ArgumentNullException(nameof(worldMeshes));
public IRenderPackReceiverPipelineCandidate Prepare(
IDirectionalShadowReceiverSource? source,
int sampleCount)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sampleCount);
TerrainModernRenderer.DirectionalShadowReceiverPipelineState? terrainState =
_terrain.PrepareDirectionalShadowReceiver(source, sampleCount);
try
{
WbDrawDispatcher.DirectionalShadowReceiverPipelineState? worldState =
_worldMeshes.PrepareDirectionalShadowReceiver(source, sampleCount);
return new Candidate(this, terrainState, worldState);
}
catch
{
terrainState?.Dispose();
throw;
}
}
public void Publish(IRenderPackReceiverPipelineCandidate candidate)
{
ArgumentNullException.ThrowIfNull(candidate);
if (candidate is not Candidate prepared || !ReferenceEquals(prepared.Owner, this))
throw new ArgumentException("Receiver candidate belongs to another coordinator.", nameof(candidate));
(TerrainModernRenderer.DirectionalShadowReceiverPipelineState? terrainState,
WbDrawDispatcher.DirectionalShadowReceiverPipelineState? worldState) = prepared.Take();
TerrainModernRenderer.DirectionalShadowReceiverPipelineState? oldTerrain =
_terrain.SwapDirectionalShadowReceiver(terrainState);
WbDrawDispatcher.DirectionalShadowReceiverPipelineState? oldWorld =
_worldMeshes.SwapDirectionalShadowReceiver(worldState);
// Vulkan pipeline disposal is flight-fence retirement. Do this only
// after both owners publish the complete new generation.
oldTerrain?.Dispose();
oldWorld?.Dispose();
}
public void Clear()
{
TerrainModernRenderer.DirectionalShadowReceiverPipelineState? oldTerrain =
_terrain.SwapDirectionalShadowReceiver(null);
WbDrawDispatcher.DirectionalShadowReceiverPipelineState? oldWorld =
_worldMeshes.SwapDirectionalShadowReceiver(null);
oldTerrain?.Dispose();
oldWorld?.Dispose();
}
private sealed class Candidate(
RenderPackReceiverPipelineCoordinator owner,
TerrainModernRenderer.DirectionalShadowReceiverPipelineState? terrain,
WbDrawDispatcher.DirectionalShadowReceiverPipelineState? world) :
IRenderPackReceiverPipelineCandidate
{
private TerrainModernRenderer.DirectionalShadowReceiverPipelineState? _terrain = terrain;
private WbDrawDispatcher.DirectionalShadowReceiverPipelineState? _world = world;
private bool _taken;
internal RenderPackReceiverPipelineCoordinator Owner { get; } = owner;
internal (TerrainModernRenderer.DirectionalShadowReceiverPipelineState?,
WbDrawDispatcher.DirectionalShadowReceiverPipelineState?) Take()
{
ObjectDisposedException.ThrowIf(_taken, this);
_taken = true;
TerrainModernRenderer.DirectionalShadowReceiverPipelineState? terrainState = _terrain;
WbDrawDispatcher.DirectionalShadowReceiverPipelineState? worldState = _world;
_terrain = null;
_world = null;
return (terrainState, worldState);
}
public void Dispose()
{
if (_taken)
return;
_taken = true;
_terrain?.Dispose();
_world?.Dispose();
_terrain = null;
_world = null;
}
}
}

View file

@ -0,0 +1,270 @@
using AcDream.Plugin.Abstractions.Rendering;
using AcDream.App.Rendering.Wb;
namespace AcDream.App.Rendering.Packs;
internal readonly record struct RenderPackResourceBudget(
long RetainedGpuBytes,
long MultisampleGpuBytes,
int LargestImageWidth,
int LargestImageHeight,
int LargestImageLayerCount)
{
internal long TotalGpuBytes => checked(RetainedGpuBytes + MultisampleGpuBytes);
}
/// <summary>
/// Resolves declaration extents against the real main-world size before an
/// executor allocates any size-dependent image. Declared byte estimates are
/// useful during discovery, but cannot prove a 1080p/1440p/4K preset ceiling.
/// This is the allocation-time authority for the images API-v1 executors
/// actually keep alive.
/// </summary>
internal static class RenderPackResourceBudgetPlanner
{
private const int HdrColorBytesPerPixel = 8;
private const int LdrColorBytesPerPixel = 4;
private const int DirectionalDepthBytesPerPixel = 4;
private const int MainWorldDepthBytesPerPixel = 4;
// Production Vulkan owns two frame-flight slots. Directional shadows
// materialize one shared demand-growth N.5 transform arena in each slot before an
// ordinary world frame can consume the pack, so admission must include
// those mandatory buffers rather than discovering them after the first
// shadow pass has already published a borrow.
private const int DirectionalShadowTransformFlightSlots = 2;
internal static RenderPackResourceBudget Resolve(
RenderPackDescriptor descriptor,
RenderQualityPreset preset,
int mainWorldWidth,
int mainWorldHeight,
int sampleCount)
{
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentNullException.ThrowIfNull(preset);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(mainWorldWidth);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(mainWorldHeight);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sampleCount);
// Every executable graph replaces the main world attachment with one
// RGBA16F colour image and one D24S8 depth image. The resolve images
// remain alive for the complete active target set.
long mainPixels = checked((long)mainWorldWidth * mainWorldHeight);
long retained = checked(mainPixels
* (HdrColorBytesPerPixel + MainWorldDepthBytesPerPixel));
long multisample = sampleCount > 1
? checked(mainPixels
* (HdrColorBytesPerPixel + MainWorldDepthBytesPerPixel)
* sampleCount)
: 0L;
int largestWidth = mainWorldWidth;
int largestHeight = mainWorldHeight;
int largestLayers = 1;
HashSet<string> writtenResources = descriptor.Passes
.SelectMany(static pass => pass.ResourceWrites)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (RenderResourceDeclaration resource in descriptor.Resources)
{
if (resource.Semantic == RenderResourceSemantic.MainWorldHdr
|| !writtenResources.Contains(resource.Id))
{
continue;
}
if (UsesFusedAtmosphericPostProcess(preset)
&& resource.Semantic is RenderResourceSemantic.BloomPing
or RenderResourceSemantic.BloomPong)
{
// The fused Low filmic shader evaluates the declared bloom
// extraction/filter directly from world colour + sun rays.
// These ping/pong images have no executing writer or reader.
continue;
}
if (resource.Kind is not RenderResourceKind.Image2D
and not RenderResourceKind.Image2DArray)
{
throw new NotSupportedException(
$"Resource '{resource.Id}' is not an API-v1 image resource.");
}
RenderQualityResourceOverride? resourceOverride = preset.ResourceOverrides
.FirstOrDefault(value => string.Equals(
value.ResourceId,
resource.Id,
StringComparison.OrdinalIgnoreCase));
RenderExtentDeclaration extent = resourceOverride?.Extent
?? resource.Extent
?? throw new NotSupportedException(
$"Image resource '{resource.Id}' has no extent.");
(int width, int height) = ResolveExtent(
resource.Id,
extent,
mainWorldWidth,
mainWorldHeight);
int layers = extent.Layers;
if (layers <= 0)
{
throw new NotSupportedException(
$"Image resource '{resource.Id}' has no image layers.");
}
int bytesPerPixel = resource.Format switch
{
RenderFormatClass.HdrColor => HdrColorBytesPerPixel,
RenderFormatClass.LdrColor or RenderFormatClass.SingleChannel =>
LdrColorBytesPerPixel,
RenderFormatClass.DirectionalDepth => DirectionalDepthBytesPerPixel,
_ => throw new NotSupportedException(
$"Image resource '{resource.Id}' has unsupported format "
+ $"'{resource.Format}'."),
};
retained = checked(retained
+ ((long)width * height * layers * bytesPerPixel));
largestWidth = Math.Max(largestWidth, width);
largestHeight = Math.Max(largestHeight, height);
largestLayers = Math.Max(largestLayers, layers);
}
if (descriptor.Passes.Any(pass =>
pass.Semantic == RenderPassSemantic.DirectionalShadowDepth))
{
retained = checked(
retained
+ DirectionalShadowTransformFlightSlots
* WorldTransformCapacityPolicy.InitialBindingSizeBytes);
}
return new RenderPackResourceBudget(
retained,
multisample,
largestWidth,
largestHeight,
largestLayers);
}
private static bool UsesFusedAtmosphericPostProcess(
RenderQualityPreset preset) =>
(preset.ExecutionHints
& RenderQualityExecutionHints.FusedAtmosphericPostProcess) != 0;
internal static RenderPackResourceBudget RequireWithinPreset(
RenderPackDescriptor descriptor,
RenderQualityPreset preset,
int mainWorldWidth,
int mainWorldHeight,
int sampleCount)
{
RenderPackResourceBudget budget = Resolve(
descriptor,
preset,
mainWorldWidth,
mainWorldHeight,
sampleCount);
if (budget.RetainedGpuBytes > preset.MaxResidentGpuBytes)
{
throw new NotSupportedException(
$"Render pack preset '{preset.Id}' needs "
+ $"{budget.RetainedGpuBytes} resident GPU bytes at "
+ $"{mainWorldWidth}x{mainWorldHeight}; its declared ceiling is "
+ $"{preset.MaxResidentGpuBytes}. Select a compatible preset or "
+ "reduce the main-world resolution.");
}
return budget;
}
/// <summary>
/// Allocation-time gate against the selected adapter and the host's
/// explicit optional-memory share. Catalog checks can reject absolute
/// preset extents, but only this point knows the resolved viewport-relative
/// sizes and multisample attachment bytes.
/// </summary>
internal static RenderPackResourceBudget RequireWithinHost(
RenderPackDescriptor descriptor,
RenderQualityPreset preset,
int mainWorldWidth,
int mainWorldHeight,
int sampleCount,
RenderPackHostCapabilities capabilities)
{
ArgumentNullException.ThrowIfNull(capabilities);
RenderPackResourceBudget budget = RequireWithinPreset(
descriptor,
preset,
mainWorldWidth,
mainWorldHeight,
sampleCount);
if (budget.LargestImageWidth > capabilities.MaxImageDimension2D
|| budget.LargestImageHeight > capabilities.MaxImageDimension2D)
{
throw new NotSupportedException(
$"Render pack preset '{preset.Id}' resolves an image to "
+ $"{budget.LargestImageWidth}x{budget.LargestImageHeight} at "
+ $"{mainWorldWidth}x{mainWorldHeight}; this device's maximum "
+ $"2-D image edge is {capabilities.MaxImageDimension2D}.");
}
if (budget.LargestImageLayerCount > capabilities.MaxImageArrayLayers)
{
throw new NotSupportedException(
$"Render pack preset '{preset.Id}' needs "
+ $"{budget.LargestImageLayerCount} image-array layers; this "
+ $"device provides {capabilities.MaxImageArrayLayers}.");
}
if (budget.RetainedGpuBytes > capabilities.MaxPackResidentBytes)
{
throw new NotSupportedException(
$"Render pack preset '{preset.Id}' needs "
+ $"{budget.RetainedGpuBytes} resident GPU bytes at "
+ $"{mainWorldWidth}x{mainWorldHeight}; this host permits "
+ $"{capabilities.MaxPackResidentBytes} under its "
+ $"{capabilities.MemoryPolicyDescription} policy.");
}
if (budget.MultisampleGpuBytes > capabilities.MaxPackTransientBytes)
{
throw new NotSupportedException(
$"Render pack preset '{preset.Id}' needs "
+ $"{budget.MultisampleGpuBytes} transient multisample GPU bytes "
+ $"at {mainWorldWidth}x{mainWorldHeight} x{sampleCount}; this "
+ $"host permits {capabilities.MaxPackTransientBytes} under its "
+ $"{capabilities.MemoryPolicyDescription} policy.");
}
return budget;
}
private static (int Width, int Height) ResolveExtent(
string resourceId,
RenderExtentDeclaration extent,
int mainWorldWidth,
int mainWorldHeight)
{
if (!double.IsFinite(extent.Width)
|| !double.IsFinite(extent.Height)
|| extent.Width <= 0d
|| extent.Height <= 0d)
{
throw new NotSupportedException(
$"Image resource '{resourceId}' has an invalid extent.");
}
try
{
return extent.Mode switch
{
RenderExtentMode.AbsolutePixels =>
(checked((int)extent.Width), checked((int)extent.Height)),
RenderExtentMode.RelativeToMainWorld or RenderExtentMode.RelativeToOutput =>
(Math.Max(1, checked((int)Math.Ceiling(mainWorldWidth * extent.Width))),
Math.Max(1, checked((int)Math.Ceiling(mainWorldHeight * extent.Height)))),
_ => throw new NotSupportedException(
$"Image resource '{resourceId}' has unsupported extent mode "
+ $"'{extent.Mode}'."),
};
}
catch (OverflowException error)
{
throw new NotSupportedException(
$"Image resource '{resourceId}' extent overflows the host image range.",
error);
}
}
}

View file

@ -0,0 +1,85 @@
using AcDream.App.Settings;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Rendering.Packs;
/// <summary>
/// Bridges committed Display settings to the render-thread controller. The
/// controller performs all GPU work at the explicit frame boundary; this
/// binding only queues stable logical selections and persists a safe retail
/// fallback once per failed activation generation.
/// </summary>
internal sealed class RenderPackSelectionBinding : IDisposable
{
private readonly RuntimeSettingsController _settings;
private readonly RenderPackController _controller;
private readonly Action<string> _log;
private long _fallbackPersistedGeneration = -1;
private bool _suppressDisplayEdge;
private bool _disposed;
internal RenderPackSelectionBinding(
RuntimeSettingsController settings,
RenderPackController controller,
Action<string>? log = null)
{
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
_controller = controller ?? throw new ArgumentNullException(nameof(controller));
_log = log ?? (_ => { });
_settings.DisplayChanged += OnDisplayChanged;
_controller.Request(_settings.Display.RenderPack);
}
internal RenderPackActivationSnapshot ApplyAtFrameBoundary(
RenderPackActivationExtent extent)
{
ObjectDisposedException.ThrowIf(_disposed, this);
RenderPackActivationSnapshot snapshot = _controller.ApplyAtFrameBoundary(extent);
if (snapshot.State != RenderPackActivationState.FailedToRetail
|| snapshot.ActivationGeneration == _fallbackPersistedGeneration
|| _settings.Display.RenderPack.IsRetail)
return snapshot;
_fallbackPersistedGeneration = snapshot.ActivationGeneration;
_suppressDisplayEdge = true;
try
{
_settings.SaveDisplay(_settings.Display with
{
RenderPack = RenderPackSelectionSettings.Retail,
});
}
finally
{
_suppressDisplayEdge = false;
}
if (_settings.Display.RenderPack.IsRetail)
{
_log(
$"[render-pack] selection failed; persisted acdream default (retail-faithful): "
+ snapshot.Reason);
}
else
{
_log(
$"[render-pack] selection failed and retail fallback could not be persisted: "
+ snapshot.Reason);
}
return snapshot;
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_settings.DisplayChanged -= OnDisplayChanged;
}
private void OnDisplayChanged(DisplaySettings display)
{
if (!_disposed && !_suppressDisplayEdge)
_controller.Request(display.RenderPack);
}
}

View file

@ -0,0 +1,77 @@
using AcDream.Plugin.Abstractions.Rendering;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Rendering.Packs;
internal static class RenderPackSettingResolution
{
internal static RenderPackValidationResult ValidateUserOverrides(
RenderPackDescriptor descriptor,
RenderPackSettingOverrides overrides)
{
ArgumentNullException.ThrowIfNull(descriptor);
if (overrides is null)
return Invalid($"Render pack '{descriptor.Id}' has a null user-setting override map.");
Dictionary<string, RenderSettingDeclaration> settings = descriptor.Settings
.ToDictionary(setting => setting.Id, StringComparer.OrdinalIgnoreCase);
foreach ((string id, string value) in overrides)
{
if (!settings.TryGetValue(id, out RenderSettingDeclaration? setting))
{
return Invalid(
$"Render pack '{descriptor.Id}' has a user override for unknown "
+ $"setting '{id}'.");
}
if (!RenderPackSettingValueCodec.TryEncode(setting, value, out _))
{
return Invalid(
$"Render pack '{descriptor.Id}' user override '{id}' has invalid "
+ $"{setting.Kind} value '{value}'.");
}
}
return RenderPackValidationResult.Valid();
}
internal static string Resolve(
RenderSettingDeclaration setting,
RenderQualityPreset preset,
IReadOnlyDictionary<string, string>? userOverrides)
{
ArgumentNullException.ThrowIfNull(setting);
ArgumentNullException.ThrowIfNull(preset);
if (TryGet(userOverrides, setting.Id, out string? user))
return user;
RenderQualitySettingOverride? presetValue = preset.SettingOverrides
.FirstOrDefault(value => string.Equals(
value.SettingId,
setting.Id,
StringComparison.OrdinalIgnoreCase));
return presetValue?.Value ?? setting.DefaultValue;
}
private static bool TryGet(
IReadOnlyDictionary<string, string>? values,
string id,
out string value)
{
if (values is not null && values.TryGetValue(id, out value!))
return true;
if (values is not null)
{
foreach ((string key, string candidate) in values)
{
if (string.Equals(key, id, StringComparison.OrdinalIgnoreCase))
{
value = candidate;
return true;
}
}
}
value = string.Empty;
return false;
}
private static RenderPackValidationResult Invalid(string reason) =>
RenderPackValidationResult.Invalid(reason);
}

View file

@ -0,0 +1,71 @@
using System.Collections.Immutable;
using AcDream.App.Rendering.Gpu;
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.App.Rendering.Packs;
internal static class RenderPackShaderAssets
{
internal static ValidatedRenderPackShaderAssets Validate(
RenderPackDescriptor descriptor,
IRenderPackAssets assets)
{
RenderPackValidationResult result = RenderPackValidator.ValidateSelectedAssets(
descriptor,
assets,
out ValidatedRenderPackShaderAssets? validated);
if (!result.Success)
throw new InvalidDataException(result.Reason);
return validated!;
}
internal static GpuShaderSet LoadPass(
RenderPackDescriptor descriptor,
ValidatedRenderPackShaderAssets assets,
RenderPassDeclaration pass) => new(
$"{descriptor.Id}:{pass.Id}",
assets.Copy(pass.VertexShaderAsset),
assets.Copy(pass.FragmentShaderAsset));
internal static GpuShaderSet LoadVariant(
RenderPackDescriptor descriptor,
ValidatedRenderPackShaderAssets assets,
PipelineVariantDeclaration variant) => new(
$"{descriptor.Id}:{variant.Id}",
assets.Copy(variant.VertexShaderAsset),
assets.Copy(variant.FragmentShaderAsset));
}
/// <summary>
/// Candidate-owned immutable shader snapshot. The plugin asset provider is
/// read exactly once during selected-candidate validation; pipeline creation
/// only copies bytes from this snapshot and cannot reopen a mutable plugin
/// stream or resolve a second path.
/// </summary>
internal sealed class ValidatedRenderPackShaderAssets
{
private readonly IReadOnlyDictionary<string, ImmutableArray<byte>> _assets;
internal ValidatedRenderPackShaderAssets(
IReadOnlyDictionary<string, byte[]> assets)
{
ArgumentNullException.ThrowIfNull(assets);
var owned = new Dictionary<string, ImmutableArray<byte>>(
assets.Count,
StringComparer.Ordinal);
foreach ((string key, byte[] bytes) in assets)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
ArgumentNullException.ThrowIfNull(bytes);
owned.Add(key, [.. bytes]);
}
_assets = owned;
}
internal byte[] Copy(string key)
{
if (!_assets.TryGetValue(key, out ImmutableArray<byte> bytes))
throw new InvalidDataException($"Validated render-pack shader '{key}' is missing.");
return [.. bytes];
}
}

View file

@ -0,0 +1,54 @@
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.App.Rendering.Packs;
internal readonly record struct RenderPackTextureInput(
RenderSemanticInput? Semantic,
string? ResourceId)
{
internal static RenderPackTextureInput FromSemantic(RenderSemanticInput value) =>
new(value, null);
internal static RenderPackTextureInput FromResource(string value) =>
new(null, value);
}
/// <summary>
/// Binary API-v1 texture-slot rule. Ordinary sampled inputs occupy push
/// TextureIndexA..D in declaration order: sampled semantic inputs first, then
/// declared resource reads. Directional depth uses its dedicated binding-6
/// texture slot and therefore does not consume A..D.
/// </summary>
internal static class RenderPackTextureBindingResolver
{
internal static IReadOnlyList<RenderPackTextureInput> Resolve(
RenderPassDeclaration pass,
IReadOnlyDictionary<string, RenderResourceDeclaration> resources)
{
ArgumentNullException.ThrowIfNull(pass);
ArgumentNullException.ThrowIfNull(resources);
var result = new List<RenderPackTextureInput>(4);
foreach (RenderSemanticInput semantic in pass.SemanticInputs)
{
if (semantic is RenderSemanticInput.WorldColor
or RenderSemanticInput.SceneDepth
or RenderSemanticInput.SceneNormals)
result.Add(RenderPackTextureInput.FromSemantic(semantic));
}
foreach (string resourceId in pass.ResourceReads)
{
if (!resources.TryGetValue(resourceId, out RenderResourceDeclaration? resource))
throw new InvalidOperationException($"Unknown render-pack resource '{resourceId}'.");
if (resource.Format == RenderFormatClass.DirectionalDepth
&& pass.SemanticInputs.Contains(RenderSemanticInput.DirectionalShadowMaps))
continue;
result.Add(RenderPackTextureInput.FromResource(resourceId));
}
if (result.Count > 4)
{
throw new InvalidOperationException(
$"Render-pack pass '{pass.Id}' exceeds the four API-v1 texture slots.");
}
return result;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,471 @@
using System.Diagnostics;
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.App.Rendering.Packs;
internal enum VolumetricShaftGateReason : byte
{
Rendered,
DisabledByPreset,
NoCurrentDirectionalShadow,
NoSceneDepth,
Indoor,
SunOffScreen,
SunBelowHorizon,
AtmosphereSuppressed,
}
internal readonly record struct VolumetricShaftDiagnostics(
VolumetricShaftGateReason GateReason,
int Width,
int Height,
int RayMarchSteps,
float Density,
float Strength,
long RetainedGpuBytes,
double LastResolvedGpuMilliseconds,
bool HasResolvedGpuMeasurement,
int DrawCalls);
internal readonly record struct VolumetricShaftOutput(
GpuTextureSlot TextureSlot,
VolumetricShaftDiagnostics Diagnostics)
{
internal bool HasTexture => TextureSlot.IsAssigned;
}
/// <summary>
/// Tier-2+ shadow-map volumetric producer. It consumes only the current frame's
/// b5/b6/b8 facts and scene depth, and owns one preset-scaled HDR result. It has
/// no clock, weather state, caster traversal, or independent sun policy.
/// </summary>
internal sealed class VolumetricShaftRenderer : IDisposable
{
internal const string TimerName = "atmospheric-volumetric-shafts";
private readonly IGpuDevice _device;
private readonly VolumetricShaftQuality _quality;
private readonly float _declaredStrength;
private readonly AtmospherePolicyDeclaration _atmospherePolicy;
private readonly IReadOnlyDictionary<int, float> _dayGroupMultipliers;
private readonly IGpuSampler _sampler;
private readonly IGpuPipeline _pipeline;
private readonly PackSettingsUniforms _settings;
private readonly RenderPackPerformanceWindow _performance = new();
private Target? _target;
private bool _disposed;
internal VolumetricShaftRenderer(
IGpuDevice device,
RenderPackDescriptor descriptor,
IRenderPackAssets assets,
RenderQualityPreset preset,
IReadOnlyDictionary<string, string>? userSettingOverrides = null)
: this(
device,
descriptor,
RenderPackShaderAssets.Validate(descriptor, assets),
preset,
userSettingOverrides)
{
}
internal VolumetricShaftRenderer(
IGpuDevice device,
RenderPackDescriptor descriptor,
ValidatedRenderPackShaderAssets assets,
RenderQualityPreset preset,
IReadOnlyDictionary<string, string>? userSettingOverrides = null)
{
_device = device ?? throw new ArgumentNullException(nameof(device));
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentNullException.ThrowIfNull(assets);
ArgumentNullException.ThrowIfNull(preset);
_quality = ResolveQuality(
descriptor,
preset,
userSettingOverrides);
_declaredStrength = ReadSetting(
descriptor,
preset,
userSettingOverrides,
RenderSettingSemantic.VolumetricStrength,
0.35f);
_atmospherePolicy = descriptor.AtmospherePolicy
?? throw new NotSupportedException(
$"Pack '{descriptor.Id}' declares no atmosphere policy.");
if (_atmospherePolicy.VolumetricShaftSunElevationResponse.Count < 2)
{
throw new NotSupportedException(
$"Pack '{descriptor.Id}' declares no volumetric-shaft elevation curve.");
}
_dayGroupMultipliers = _atmospherePolicy.ActiveDayGroupMultipliers
.ToDictionary(value => value.ActiveDayGroup, value => (float)value.Multiplier);
_settings = PackSettingsUniforms.Create(descriptor, preset, userSettingOverrides);
RenderPassDeclaration pass = descriptor.Passes.FirstOrDefault(value =>
value.Semantic == RenderPassSemantic.VolumetricShafts)
?? throw new NotSupportedException(
$"Pack '{descriptor.Id}' declares no VolumetricShafts pass semantic.");
_sampler = device.CreateSampler(GpuSamplerDescription.WorldClamp);
_pipeline = device.CreatePipeline(new GpuPipelineDescription
{
Name = $"render-pack-{descriptor.Id}-volumetric-shafts",
Shaders = RenderPackShaderAssets.LoadPass(descriptor, assets, pass),
VertexLayout = GpuVertexLayout.None,
Blend = GpuBlendMode.None,
Depth = GpuDepthState.Disabled,
Cull = GpuCullMode.None,
ColorFormat = GpuTextureFormat.Rgba16FloatRenderTarget,
AllowColorFormatVariants = false,
SampleCount = 1,
UsesRenderPackShaderAbi = true,
});
LastDiagnostics = Disabled(VolumetricShaftGateReason.DisabledByPreset);
}
internal VolumetricShaftDiagnostics LastDiagnostics { get; private set; }
internal VolumetricShaftQuality Quality => _quality;
internal RenderPackPerformanceSnapshot Performance => _performance.Snapshot();
/// <summary>
/// Builds the selected preset's optional shaft target during off-side pack
/// activation/resize. A disabled preset owns no target; enabling it later
/// through a user override is reflected in <see cref="_declaredStrength"/>.
/// </summary>
internal void PrepareTarget(int outputWidth, int outputHeight)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(outputWidth);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(outputHeight);
if (_declaredStrength > 0f)
_ = Prepare(outputWidth, outputHeight);
}
internal VolumetricShaftOutput Render(
IGpuFrame frame,
in AtmosphericFrameInputs inputs,
in DirectionalShadowFrameBinding shadow,
GpuTextureSlot sceneDepth)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(frame);
VolumetricShaftGateReason reason = Gate(frame, inputs, shadow, sceneDepth);
if (reason != VolumetricShaftGateReason.Rendered)
{
LastDiagnostics = Disabled(reason);
return new VolumetricShaftOutput(GpuTextureSlot.Unassigned, LastDiagnostics);
}
(float density, float strength) = Parameters(inputs);
if (strength <= 1e-4f)
{
LastDiagnostics = Disabled(VolumetricShaftGateReason.AtmosphereSuppressed);
return new VolumetricShaftOutput(GpuTextureSlot.Unassigned, LastDiagnostics);
}
Target target = Prepare(inputs.ViewportWidth, inputs.ViewportHeight);
long started = Stopwatch.GetTimestamp();
AtmosphericFrameUniforms atmospheric = FrameUniforms(inputs, strength);
GpuRingAllocation frameBlock = frame.AllocateRing(
AtmosphericFrameUniforms.SizeInBytes,
GpuRingUsage.Uniform);
MemoryMarshal.Write(frameBlock.Data, in atmospheric);
GpuRingAllocation passBlock = frame.AllocateRing(
AtmosphericPackPassUniforms.SizeInBytes,
GpuRingUsage.Uniform);
var passValues = new AtmosphericPackPassUniforms(
new Vector4(density, strength, _quality.RayMarchSteps, 1f),
Vector4.Zero,
Vector4.Zero,
Vector4.Zero);
MemoryMarshal.Write(passBlock.Data, in passValues);
GpuRingAllocation settingsBlock = frame.AllocateRing(
PackSettingsUniforms.SizeInBytes,
GpuRingUsage.Uniform);
PackSettingsUniforms settings = _settings;
MemoryMarshal.Write(settingsBlock.Data, in settings);
using (IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription
{
Name = TimerName,
Color = new GpuColorAttachment(
target.RenderTarget,
GpuLoadOp.Clear,
GpuStoreOp.Store,
Vector4.Zero),
Depth = null,
SampleCount = 1,
}))
using (encoder.BeginTimerScope(TimerName))
{
encoder.BindPipeline(_pipeline);
encoder.BindUniformBuffer(
GpuBindingModel.UniformAtmosphericFrame,
frameBlock.Buffer,
frameBlock.OffsetBytes,
AtmosphericFrameUniforms.SizeInBytes);
encoder.BindUniformBuffer(
GpuBindingModel.UniformDirectionalShadow,
shadow.Buffer!,
shadow.OffsetBytes,
shadow.SizeBytes);
encoder.BindUniformBuffer(
GpuBindingModel.UniformPackPass,
passBlock.Buffer,
passBlock.OffsetBytes,
AtmosphericPackPassUniforms.SizeInBytes);
encoder.BindUniformBuffer(
GpuBindingModel.UniformPackSettings,
settingsBlock.Buffer,
settingsBlock.OffsetBytes,
PackSettingsUniforms.SizeInBytes);
GpuPushConstants push = GpuPushConstants.Default;
push.TextureIndexA = sceneDepth.Index;
push.TextureIndexB = GpuTextureSlot.Unassigned.Index;
push.ParamA = BitConverter.UInt32BitsToSingle(GpuTextureSlot.Unassigned.Index);
push.ParamB = BitConverter.UInt32BitsToSingle(GpuTextureSlot.Unassigned.Index);
encoder.SetPushConstants(in push);
encoder.Draw(3, 1, 0, 0);
}
bool hasGpu = _device.Timers.TryResolve(TimerName, out double milliseconds);
LastDiagnostics = new VolumetricShaftDiagnostics(
VolumetricShaftGateReason.Rendered,
target.RenderTarget.Description.Width,
target.RenderTarget.Description.Height,
_quality.RayMarchSteps,
density,
strength,
target.RetainedBytes,
milliseconds,
hasGpu,
DrawCalls: 1);
_performance.Observe(
Stopwatch.GetElapsedTime(started).TotalMilliseconds,
absoluteReceiverCpuMilliseconds: 0d,
hasGpu,
milliseconds,
target.RetainedBytes,
transientGpuBytes: 0);
return new VolumetricShaftOutput(target.TextureSlot, LastDiagnostics);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_target?.Dispose();
_target = null;
_pipeline.Dispose();
}
private Target Prepare(int outputWidth, int outputHeight)
{
int width = Math.Max(1, (int)MathF.Ceiling(outputWidth * _quality.ResolutionScale));
int height = Math.Max(1, (int)MathF.Ceiling(outputHeight * _quality.ResolutionScale));
if (_target is { } current
&& current.RenderTarget.Description.Width == width
&& current.RenderTarget.Description.Height == height)
return current;
IGpuRenderTarget? renderTarget = null;
GpuTextureSlot slot = GpuTextureSlot.Unassigned;
try
{
renderTarget = _device.CreateRenderTarget(new GpuRenderTargetDescription(
"atmospheric-volumetric",
width,
height,
GpuTextureFormat.Rgba16FloatRenderTarget,
DepthFormat: null,
SampleCount: 1));
slot = _device.RegisterTexture(renderTarget.ColorTexture, _sampler);
var candidate = new Target(_device, renderTarget, slot);
renderTarget = null;
slot = GpuTextureSlot.Unassigned;
Target? prior = _target;
_target = candidate;
prior?.Dispose();
_performance.Reset();
return candidate;
}
catch
{
if (slot.IsAssigned)
_device.ReleaseTextureSlot(slot);
renderTarget?.Dispose();
throw;
}
}
private VolumetricShaftGateReason Gate(
IGpuFrame frame,
in AtmosphericFrameInputs inputs,
in DirectionalShadowFrameBinding shadow,
GpuTextureSlot sceneDepth)
{
if (_declaredStrength <= 0f)
return VolumetricShaftGateReason.DisabledByPreset;
if (!shadow.IsValidFor(frame))
return VolumetricShaftGateReason.NoCurrentDirectionalShadow;
if (!sceneDepth.IsAssigned)
return VolumetricShaftGateReason.NoSceneDepth;
if (!inputs.IsOutdoor)
return VolumetricShaftGateReason.Indoor;
if (!inputs.SunIsOnScreen)
return VolumetricShaftGateReason.SunOffScreen;
return VolumetricShaftGateReason.Rendered;
}
private (float Density, float Strength) Parameters(in AtmosphericFrameInputs inputs)
{
float weatherTarget = inputs.Weather switch
{
AcDream.Core.World.WeatherKind.Clear => 1f,
AcDream.Core.World.WeatherKind.Overcast => 0.18f,
AcDream.Core.World.WeatherKind.Rain => 0.10f,
AcDream.Core.World.WeatherKind.Snow => 0.16f,
AcDream.Core.World.WeatherKind.Storm => 0.06f,
_ => 0f,
};
float weatherBlend = Math.Clamp(inputs.WeatherIntensity, 0f, 1f);
float weather = 1f + ((weatherTarget - 1f) * weatherBlend);
float elevation = RenderPackAtmospherePolicyEvaluation.VolumetricShaft(
_atmospherePolicy.VolumetricShaftSunElevationResponse,
inputs.SunElevationDegrees);
float authoredEnergy = Math.Clamp(inputs.SunDirectionalBrightness, 0f, 4f);
float dayGroup = _dayGroupMultipliers.TryGetValue(
inputs.ActiveDayGroup,
out float declaredDayGroup)
? Math.Clamp(declaredDayGroup, 0f, 4f)
: 1f;
float strength = Math.Clamp(
_declaredStrength * weather * elevation * authoredEnergy * dayGroup,
0f,
1f);
return (0.035f * strength, strength);
}
private AtmosphericFrameUniforms FrameUniforms(
in AtmosphericFrameInputs inputs,
float strength) => new(
new Vector4(inputs.SunScreenUv, strength, inputs.SunElevationDegrees),
new Vector4(inputs.SunColor, strength),
new Vector4(inputs.ViewportWidth, inputs.ViewportHeight,
1f / inputs.ViewportWidth, 1f / inputs.ViewportHeight),
new Vector4((float)inputs.Weather, inputs.WeatherIntensity,
(float)Math.Clamp(inputs.DeltaSeconds, 0d, 1d), inputs.IsOutdoor ? 1f : 0f),
new Vector4(inputs.SunDirection, inputs.SunDirectionalBrightness),
new Vector4(
inputs.ActiveDayGroup,
_dayGroupMultipliers.TryGetValue(inputs.ActiveDayGroup, out float dayGroup)
? dayGroup
: 1f,
RenderPackAtmospherePolicyEvaluation.DirectionalShadow(
_atmospherePolicy.DirectionalShadowLightElevationResponse,
inputs.SunElevationDegrees),
RenderPackAtmospherePolicyEvaluation.VolumetricShaft(
_atmospherePolicy.VolumetricShaftSunElevationResponse,
inputs.SunElevationDegrees)),
inputs.InverseViewProjection);
private VolumetricShaftDiagnostics Disabled(VolumetricShaftGateReason reason) => new(
reason,
0,
0,
_quality.RayMarchSteps,
0f,
0f,
_target?.RetainedBytes ?? 0L,
0d,
false,
0);
private static DirectionalShadowPreset PresetOf(RenderQualityPreset preset) =>
preset.Semantic switch
{
RenderQualitySemantic.Low => DirectionalShadowPreset.Low,
RenderQualitySemantic.High => DirectionalShadowPreset.High,
_ => DirectionalShadowPreset.Medium,
};
private static VolumetricShaftQuality ResolveQuality(
RenderPackDescriptor descriptor,
RenderQualityPreset preset,
IReadOnlyDictionary<string, string>? userSettingOverrides)
{
VolumetricShaftQuality quality = VolumetricShaftQuality.For(PresetOf(preset));
RenderResourceDeclaration resource = descriptor.Resources.Single(value =>
value.Semantic == RenderResourceSemantic.VolumetricShafts);
RenderQualityResourceOverride? resourceOverride = preset.ResourceOverrides
.FirstOrDefault(value => string.Equals(
value.ResourceId,
resource.Id,
StringComparison.OrdinalIgnoreCase));
RenderExtentDeclaration extent = resourceOverride?.Extent
?? resource.Extent
?? throw new NotSupportedException(
"The VolumetricShafts semantic resource has no image extent.");
if (extent.Mode is not RenderExtentMode.RelativeToMainWorld
and not RenderExtentMode.RelativeToOutput)
{
throw new NotSupportedException(
"The VolumetricShafts semantic resource must use a relative extent.");
}
int steps = checked((int)MathF.Round(ReadSetting(
descriptor,
preset,
userSettingOverrides,
RenderSettingSemantic.VolumetricRayMarchSteps,
quality.RayMarchSteps)));
return quality with
{
ResolutionScale = (float)Math.Clamp(extent.Width, 0.0625, 1.0),
RayMarchSteps = Math.Clamp(steps, 8, 64),
};
}
private static float ReadSetting(
RenderPackDescriptor descriptor,
RenderQualityPreset preset,
IReadOnlyDictionary<string, string>? userSettingOverrides,
RenderSettingSemantic semantic,
float fallback)
{
RenderSettingDeclaration? setting = descriptor.Settings.FirstOrDefault(candidate =>
candidate.Semantic == semantic);
if (setting is null)
return fallback;
string value = RenderPackSettingResolution.Resolve(
setting,
preset,
userSettingOverrides);
return RenderPackSettingValueCodec.TryEncode(setting, value, out float encoded)
? Math.Max(0f, encoded)
: fallback;
}
private sealed class Target(
IGpuDevice device,
IGpuRenderTarget renderTarget,
GpuTextureSlot textureSlot) : IDisposable
{
internal IGpuRenderTarget RenderTarget { get; } = renderTarget;
internal GpuTextureSlot TextureSlot { get; } = textureSlot;
internal long RetainedBytes => checked(
(long)RenderTarget.Description.Width * RenderTarget.Description.Height * 8L);
public void Dispose()
{
device.ReleaseTextureSlot(TextureSlot);
RenderTarget.Dispose();
}
}
}

View file

@ -1,4 +1,5 @@
using AcDream.Core.World;
using AcDream.App.Rendering.Packs;
namespace AcDream.App.Rendering;
@ -153,6 +154,7 @@ internal sealed class RenderFrameDiagnosticsController :
private readonly IRenderFrameResourceDiagnosticsSource? _resources;
private readonly IRenderFrameDiagnosticLog _log;
private readonly bool _publishResourceDiagnostics;
private readonly IRenderPackDiagnosticsSnapshotSource? _renderPack;
private double _elapsedSeconds;
private int _frameCount;
@ -165,7 +167,8 @@ internal sealed class RenderFrameDiagnosticsController :
IRenderFrameTitleSink titleSink,
IRenderFrameDiagnosticLog log,
bool publishResourceDiagnostics,
IRenderFrameResourceDiagnosticsSource? resources = null)
IRenderFrameResourceDiagnosticsSource? resources = null,
IRenderPackDiagnosticsSnapshotSource? renderPack = null)
{
_titleFacts = titleFacts ?? throw new ArgumentNullException(nameof(titleFacts));
_titleSink = titleSink ?? throw new ArgumentNullException(nameof(titleSink));
@ -174,6 +177,7 @@ internal sealed class RenderFrameDiagnosticsController :
_resources = publishResourceDiagnostics
? resources ?? throw new ArgumentNullException(nameof(resources))
: resources;
_renderPack = renderPack;
}
public void Publish(RenderFrameInput input, RenderFrameOutcome outcome)
@ -201,6 +205,11 @@ internal sealed class RenderFrameDiagnosticsController :
{
RenderFrameResourceDiagnosticsSnapshot resources = _resources!.Capture();
_log.WriteLine(FormatGpuStream(resources));
if (_renderPack is not null)
{
_log.WriteLine(RenderPackDiagnosticsFormatter.Format(
_renderPack.CaptureDiagnostics()));
}
}
Snapshot = new RenderFrameDiagnosticsSnapshot(

View file

@ -0,0 +1,47 @@
using System.Numerics;
using AcDream.App.Rendering.Gpu;
namespace AcDream.App.Rendering;
/// <summary>
/// Testable CPU statement of retail's detail-pass gate and pixel math. The
/// production pixels are produced by <c>mesh_detail</c>; keeping these facts in
/// one small contract makes the setting, distance units, neutral point, and
/// intentional brightening independently assertable without a GPU.
/// </summary>
internal static class RetailDetailTextureContract
{
internal const float FullDetailDistanceMetres = 10f;
internal const float ZeroDetailDistanceMetres = 50f;
internal static bool ShouldRender(
bool settingEnabled,
TerrainAtlas.RetailDetailTextureBinding binding) =>
settingEnabled && binding.IsAvailable;
/// <summary>
/// Opaque detail must compare equal against the depth written by its exact
/// base geometry. On an MSAA target that inherits the base pass's per-sample
/// alpha-to-coverage mask without applying A2C to the detail alpha itself.
/// Transparent bases do not write depth, so their adjacent detail uses the
/// accepted less-or-equal comparison instead.
/// </summary>
internal static GpuCompareOp DetailDepthCompare(bool transparent) =>
transparent ? GpuCompareOp.LessOrEqual : GpuCompareOp.Equal;
internal static float FadeForPositiveViewDepthMetres(float depthMetres) =>
Math.Clamp(
(ZeroDetailDistanceMetres - depthMetres)
/ (ZeroDetailDistanceMetres - FullDetailDistanceMetres),
0f,
1f);
/// <summary>
/// Effective multiplier on the existing framebuffer after the shader
/// scales both detail RGB and alpha by fade and the pipeline applies
/// <c>DstColor + OneMinusSrcAlpha</c>.
/// </summary>
internal static Vector3 FramebufferFactor(Vector4 detail, float fade) =>
Vector3.One + fade * (new Vector3(detail.X, detail.Y, detail.Z)
- new Vector3(detail.W));
}

View file

@ -1,4 +1,6 @@
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using AcDream.App.Rendering;
using Arch.Core;
using ArchWorld = Arch.Core.World;
@ -37,6 +39,12 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
private RenderProjectionCounts _counts;
private ulong _lastAppliedJournalSequence;
private ulong _indexRevision = 1;
private ulong _directionalShadowTopologyRevision = 1;
private DirectionalShadowTransformChange[]? _directionalShadowTransformChanges;
private Dictionary<RenderProjectionId, DirectionalShadowPartPoseSnapshot>?
_directionalShadowPartPoses;
private ulong _directionalShadowTransformRevision;
private int _directionalShadowTransformChangeCount;
private bool _disposed;
public ArchRenderScene(RenderSceneGeneration initialGeneration)
@ -76,6 +84,23 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
long lookupBytes =
(long)lookupCapacity * Unsafe.SizeOf<ProjectionLookupSlotEstimate>();
long indexBytes = EstimateIndexBytes();
long directionalShadowJournalBytes =
_directionalShadowTransformChanges is null
? 0
: checked((long)_directionalShadowTransformChanges.Length
* Unsafe.SizeOf<DirectionalShadowTransformChange>());
if (_directionalShadowPartPoses is not null)
{
directionalShadowJournalBytes = checked(
directionalShadowJournalBytes
+ (long)_directionalShadowPartPoses.EnsureCapacity(0)
* (sizeof(int)
+ Unsafe.SizeOf<KeyValuePair<
RenderProjectionId,
DirectionalShadowPartPoseSnapshot>>())
+ _directionalShadowPartPoses.Values.Sum(static pose =>
(long)pose.Count * Unsafe.SizeOf<Matrix4x4>()));
}
return new RenderSceneMemoryAccounting(
EntityCount: _world.Size,
@ -86,7 +111,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
ProjectionLookupCapacity: lookupCapacity,
EstimatedProjectionLookupBytes: lookupBytes,
EstimatedIndexBytes: indexBytes,
EstimatedJournalBufferBytes: 0,
EstimatedJournalBufferBytes: directionalShadowJournalBytes,
EstimatedSynchronizationSourceBytes: 0);
}
}
@ -163,7 +188,8 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
ref _world.Get<RenderTransform>(entry.Entity);
ref RenderWorldBounds bounds =
ref _world.Get<RenderWorldBounds>(entry.Entity);
if (current == update.Transform && bounds == update.Bounds)
bool transformChanged = !TransformBitsEqual(current, update.Transform);
if (!transformChanged && bounds == update.Bounds)
continue;
_world.Set(
@ -171,6 +197,15 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
new PreviousRenderTransform(current.LocalToWorld));
_world.Set(entry.Entity, update.Transform);
_world.Set(entry.Entity, update.Bounds);
if (transformChanged
&& HasRefreshableDirectionalShadowTransforms(entry.ProjectionClass)
&& _directionalShadowTransformChanges is not null)
{
RenderProjectionRecord currentRecord = ReadRecord(in entry);
PublishDirectionalShadowTransformChange(
in currentRecord,
DirectionalShadowTransformChangeKind.DynamicSynchronization);
}
ref RenderDirtyMask dirty =
ref _world.Get<RenderDirtyMask>(entry.Entity);
@ -226,8 +261,10 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
ClearIndices();
_counts = default;
_lastAppliedJournalSequence = 0;
ResetDirectionalShadowTransformChanges();
Generation = replacementGeneration;
AdvanceIndexRevision();
AdvanceDirectionalShadowTopologyRevision();
}
public void Dispose()
@ -239,6 +276,10 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
ArchWorld.Destroy(_world);
_entries.Clear();
ClearIndices();
_directionalShadowTransformChanges = null;
_directionalShadowPartPoses = null;
_directionalShadowTransformRevision = 0;
_directionalShadowTransformChangeCount = 0;
_counts = default;
_disposed = true;
}
@ -284,6 +325,99 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
return _indexRevision;
}
ulong IRenderSceneQuerySource.GetDirectionalShadowTopologyRevision(
RenderSceneGeneration generation)
{
EnsureQueryGeneration(generation);
return _directionalShadowTopologyRevision;
}
ulong IRenderSceneQuerySource.GetDirectionalShadowTransformRevision(
RenderSceneGeneration generation)
{
EnsureQueryGeneration(generation);
EnsureDirectionalShadowTransformJournal();
return _directionalShadowTransformRevision;
}
DirectionalShadowTransformChanges
IRenderSceneQuerySource.CopyDirectionalShadowTransformChanges(
RenderSceneGeneration generation,
ulong afterRevision,
Span<DirectionalShadowTransformSnapshot> destination)
{
EnsureQueryGeneration(generation);
EnsureDirectionalShadowTransformJournal();
ulong latest = _directionalShadowTransformRevision;
if (afterRevision == latest)
return new DirectionalShadowTransformChanges(latest, 0, false);
if (afterRevision == 0
|| afterRevision > latest
|| latest - afterRevision
> checked((ulong)_directionalShadowTransformChangeCount))
{
return new DirectionalShadowTransformChanges(latest, 0, true);
}
int count = checked((int)(latest - afterRevision));
if (destination.Length < count)
return new DirectionalShadowTransformChanges(latest, 0, true);
DirectionalShadowTransformChange[] journal =
_directionalShadowTransformChanges!;
int updateTransformCount = 0;
int updateAppearanceCount = 0;
int dynamicSynchronizationCount = 0;
int activeAnimatedStaticCount = 0;
int liveDynamicRootCount = 0;
int equippedChildCount = 0;
for (int index = 0; index < count; index++)
{
ulong revision = checked(afterRevision + (ulong)index + 1UL);
DirectionalShadowTransformChange change =
journal[(int)(revision % (ulong)journal.Length)];
if (change.Revision != revision)
return new DirectionalShadowTransformChanges(latest, 0, true);
destination[index] = change.Projection;
switch (change.Kind)
{
case DirectionalShadowTransformChangeKind.UpdateTransform:
updateTransformCount++;
break;
case DirectionalShadowTransformChangeKind.UpdateAppearance:
updateAppearanceCount++;
break;
case DirectionalShadowTransformChangeKind.DynamicSynchronization:
dynamicSynchronizationCount++;
break;
default:
throw new InvalidOperationException(
$"Unknown directional-shadow change kind {change.Kind}.");
}
switch (change.Projection.ProjectionClass)
{
case RenderProjectionClass.ActiveAnimatedStatic:
activeAnimatedStaticCount++;
break;
case RenderProjectionClass.LiveDynamicRoot:
liveDynamicRootCount++;
break;
case RenderProjectionClass.EquippedChild:
equippedChildCount++;
break;
}
}
return new DirectionalShadowTransformChanges(
latest,
count,
false,
updateTransformCount,
updateAppearanceCount,
dynamicSynchronizationCount,
activeAnimatedStaticCount,
liveDynamicRootCount,
equippedChildCount);
}
bool IRenderSceneQuerySource.TryGet(
RenderSceneGeneration generation,
RenderProjectionId id,
@ -300,6 +434,30 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
return false;
}
int IRenderSceneQuerySource.CopyById(
RenderSceneGeneration generation,
ReadOnlySpan<RenderProjectionId> ids,
Span<RenderProjectionRecord> destination)
{
EnsureQueryGeneration(generation);
if (destination.Length < ids.Length)
{
throw new ArgumentException(
"The render-scene ID-copy destination is too small.",
nameof(destination));
}
for (int index = 0; index < ids.Length; index++)
{
if (!_entries.TryGetValue(ids[index], out SceneEntry entry))
{
throw new InvalidOperationException(
$"Render-scene projection {ids[index]} disappeared during a batched copy.");
}
destination[index] = ReadRecord(in entry);
}
return ids.Length;
}
int IRenderSceneQuerySource.CopyTo(
RenderSceneGeneration generation,
RenderProjectionClass? projectionClass,
@ -391,6 +549,16 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
RenderProjectionRecord prior = ReadRecord(in existing);
WriteRecord(existing.Entity, in record);
UpdateIndices(in prior, in record);
if (HasRefreshableDirectionalShadowTransforms(record.ProjectionClass))
{
if (!TransformBitsEqual(prior.Transform, record.Transform))
{
PublishDirectionalShadowTransformChange(
in record,
DirectionalShadowTransformChangeKind.UpdateTransform);
}
PublishDirectionalShadowPartPoseChangeIfNeeded(in record);
}
result.Applied++;
result.Updated++;
return;
@ -407,6 +575,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
record.ProjectionClass);
IncrementCount(record.ProjectionClass);
AddToIndices(in record);
SynchronizeDirectionalShadowPartPose(in record);
result.Applied++;
result.Registered++;
}
@ -465,6 +634,20 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
RenderProjectionRecord current = ReadRecord(in entry);
UpdateIndices(in prior, in current);
if (HasRefreshableDirectionalShadowTransforms(current.ProjectionClass))
{
if (kind is RenderProjectionDeltaKind.UpdateTransform
&& !TransformBitsEqual(prior.Transform, current.Transform))
{
PublishDirectionalShadowTransformChange(
in current,
DirectionalShadowTransformChangeKind.UpdateTransform);
}
else if (kind is RenderProjectionDeltaKind.UpdateAppearance)
{
PublishDirectionalShadowPartPoseChangeIfNeeded(in current);
}
}
result.Applied++;
result.Updated++;
}
@ -582,6 +765,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
private void Destroy(in SceneEntry entry)
{
RenderProjectionRecord record = ReadRecord(in entry);
_directionalShadowPartPoses?.Remove(record.Id);
RemoveFromIndices(in record);
_world.Destroy(entry.Entity);
DecrementCount(entry.ProjectionClass);
@ -603,6 +787,8 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
{
if (IndexMembershipEquals(in prior, in current))
{
if (!DirectionalShadowTopologyEquals(in prior, in current))
AdvanceDirectionalShadowTopologyRevision();
SynchronizeDirtyIndex(in current);
return;
}
@ -652,6 +838,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
if (record.DirtyMask != RenderDirtyMask.None)
_dirty.Add(record.Id);
AdvanceIndexRevision();
AdvanceDirectionalShadowTopologyRevision();
}
private void RemoveFromIndices(in RenderProjectionRecord record)
@ -668,6 +855,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
RemoveCell(_cellStatics, record.Residency.FullCellId, record.Id);
RemoveCell(_cellDynamics, record.Residency.FullCellId, record.Id);
AdvanceIndexRevision();
AdvanceDirectionalShadowTopologyRevision();
}
private static bool IndexMembershipEquals(
@ -689,6 +877,235 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
&& left.SortKey == right.SortKey;
}
private static bool DirectionalShadowTopologyEquals(
in RenderProjectionRecord left,
in RenderProjectionRecord right)
{
const RenderProjectionFlags eligibilityFlags =
RenderProjectionFlags.Draw
| RenderProjectionFlags.SpatiallyResident
| RenderProjectionFlags.Translucent;
if (left.ProjectionClass != right.ProjectionClass
|| left.OwnerIncarnation != right.OwnerIncarnation
|| left.Source.ParentCellId != right.Source.ParentCellId
|| (left.Flags & eligibilityFlags) != (right.Flags & eligibilityFlags)
|| left.SortKey != right.SortKey
|| left.MeshSet.MeshCount != right.MeshSet.MeshCount
|| left.Material != right.Material
|| left.DegradeState != right.DegradeState
|| left.Source.AppearanceFingerprint
!= right.Source.AppearanceFingerprint
|| left.Source.DirectionalShadowTopologyFingerprint
!= right.Source.DirectionalShadowTopologyFingerprint
|| left.EntityPayload.IsBuildingShell
!= right.EntityPayload.IsBuildingShell
|| left.EntityPayload.CasterIdentity
!= right.EntityPayload.CasterIdentity
|| !PaletteEquals(
left.EntityPayload.PaletteOverride,
right.EntityPayload.PaletteOverride))
{
return false;
}
bool refreshableTransforms =
HasRefreshableDirectionalShadowTransforms(left.ProjectionClass);
if (!refreshableTransforms
&& (left.Transform != right.Transform
|| left.MeshSet != right.MeshSet
|| left.Source.GeometryFingerprint
!= right.Source.GeometryFingerprint))
{
return false;
}
IReadOnlyList<AcDream.Core.World.MeshRef>? leftMeshes =
left.EntityPayload.MeshRefs;
IReadOnlyList<AcDream.Core.World.MeshRef>? rightMeshes =
right.EntityPayload.MeshRefs;
if (ReferenceEquals(leftMeshes, rightMeshes))
return true;
if (leftMeshes is null
|| rightMeshes is null
|| leftMeshes.Count != rightMeshes.Count)
{
return false;
}
for (int meshIndex = 0; meshIndex < leftMeshes.Count; meshIndex++)
{
AcDream.Core.World.MeshRef leftMesh = leftMeshes[meshIndex];
AcDream.Core.World.MeshRef rightMesh = rightMeshes[meshIndex];
if (leftMesh.GfxObjId != rightMesh.GfxObjId
|| !SurfaceOverridesEqual(
leftMesh.SurfaceOverrides,
rightMesh.SurfaceOverrides)
|| (!refreshableTransforms
&& leftMesh.PartTransform != rightMesh.PartTransform))
{
return false;
}
}
return true;
}
private static bool HasRefreshableDirectionalShadowTransforms(
RenderProjectionClass projectionClass) =>
projectionClass is RenderProjectionClass.ActiveAnimatedStatic
or RenderProjectionClass.LiveDynamicRoot
or RenderProjectionClass.EquippedChild;
private static bool TransformBitsEqual(
in RenderTransform left,
in RenderTransform right)
{
Matrix4x4 leftMatrix = left.LocalToWorld;
Matrix4x4 rightMatrix = right.LocalToWorld;
ReadOnlySpan<Matrix4x4> leftSpan = MemoryMarshal.CreateReadOnlySpan(
in leftMatrix,
1);
ReadOnlySpan<Matrix4x4> rightSpan = MemoryMarshal.CreateReadOnlySpan(
in rightMatrix,
1);
return MemoryMarshal.AsBytes(leftSpan).SequenceEqual(
MemoryMarshal.AsBytes(rightSpan));
}
private void EnsureDirectionalShadowTransformJournal()
{
if (_directionalShadowTransformChanges is not null)
return;
_directionalShadowTransformChanges = new DirectionalShadowTransformChange[
DirectionalShadowTransformChangeJournal.Capacity];
_directionalShadowTransformRevision = 1;
_directionalShadowTransformChangeCount = 0;
_directionalShadowPartPoses = new Dictionary<
RenderProjectionId,
DirectionalShadowPartPoseSnapshot>();
foreach (SceneEntry entry in _entries.Values)
{
if (!HasRefreshableDirectionalShadowTransforms(entry.ProjectionClass))
continue;
RenderProjectionRecord record = ReadRecord(in entry);
SynchronizeDirectionalShadowPartPose(in record);
}
}
private void PublishDirectionalShadowTransformChange(
in RenderProjectionRecord projection,
DirectionalShadowTransformChangeKind kind)
{
DirectionalShadowTransformChange[]? journal =
_directionalShadowTransformChanges;
if (journal is null)
return;
if (_directionalShadowTransformRevision == ulong.MaxValue)
{
throw new InvalidOperationException(
"Directional-shadow transform revision space was exhausted.");
}
ulong revision = ++_directionalShadowTransformRevision;
journal[(int)(revision % (ulong)journal.Length)] =
new DirectionalShadowTransformChange(
revision,
DirectionalShadowTransformSnapshot.Capture(in projection),
kind);
if (_directionalShadowTransformChangeCount < journal.Length)
_directionalShadowTransformChangeCount++;
}
private void ResetDirectionalShadowTransformChanges()
{
if (_directionalShadowTransformChanges is null)
return;
_directionalShadowTransformRevision = 1;
_directionalShadowTransformChangeCount = 0;
_directionalShadowPartPoses!.Clear();
}
private void SynchronizeDirectionalShadowPartPose(
in RenderProjectionRecord record)
{
Dictionary<RenderProjectionId, DirectionalShadowPartPoseSnapshot>?
poses = _directionalShadowPartPoses;
if (poses is null
|| !HasRefreshableDirectionalShadowTransforms(record.ProjectionClass))
{
return;
}
if (!poses.TryGetValue(record.Id, out DirectionalShadowPartPoseSnapshot? pose))
{
poses.Add(record.Id, DirectionalShadowPartPoseSnapshot.Capture(in record));
return;
}
pose.CaptureCurrent(in record);
}
private void PublishDirectionalShadowPartPoseChangeIfNeeded(
in RenderProjectionRecord record)
{
Dictionary<RenderProjectionId, DirectionalShadowPartPoseSnapshot>?
poses = _directionalShadowPartPoses;
if (poses is null)
return;
if (!poses.TryGetValue(record.Id, out DirectionalShadowPartPoseSnapshot? pose))
{
poses.Add(record.Id, DirectionalShadowPartPoseSnapshot.Capture(in record));
return;
}
if (!pose.CaptureCurrent(in record))
return;
PublishDirectionalShadowTransformChange(
in record,
DirectionalShadowTransformChangeKind.UpdateAppearance);
}
private static bool PaletteEquals(
AcDream.Core.World.PaletteOverride? left,
AcDream.Core.World.PaletteOverride? right)
{
if (ReferenceEquals(left, right))
return true;
if (left is null
|| right is null
|| left.BasePaletteId != right.BasePaletteId
|| left.SubPalettes.Count != right.SubPalettes.Count)
{
return false;
}
for (int index = 0; index < left.SubPalettes.Count; index++)
{
if (left.SubPalettes[index] != right.SubPalettes[index])
return false;
}
return true;
}
private static bool SurfaceOverridesEqual(
IReadOnlyDictionary<uint, uint>? left,
IReadOnlyDictionary<uint, uint>? right)
{
if (ReferenceEquals(left, right))
return true;
if (left is null || right is null || left.Count != right.Count)
return false;
foreach ((uint surfaceId, uint textureId) in left)
{
if (!right.TryGetValue(surfaceId, out uint candidate)
|| candidate != textureId)
{
return false;
}
}
return true;
}
private void SynchronizeDirtyIndex(
in RenderProjectionRecord record)
{
@ -709,6 +1126,17 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
_indexRevision++;
}
private void AdvanceDirectionalShadowTopologyRevision()
{
if (_directionalShadowTopologyRevision == ulong.MaxValue)
{
throw new InvalidOperationException(
"Directional-shadow topology revision space was exhausted.");
}
_directionalShadowTopologyRevision++;
}
private static bool IsDynamic(RenderProjectionClass projectionClass) =>
projectionClass is RenderProjectionClass.LiveDynamicRoot
or RenderProjectionClass.EquippedChild;
@ -925,10 +1353,71 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
hash.Add(record.Source.AppearanceFingerprint.Low);
hash.Add(record.Source.AppearanceFingerprint.High);
hash.Add(record.Source.CurrentProjectionFlags);
hash.Add((byte)record.EntityPayload.CasterIdentity);
}
private readonly record struct ProjectionIdentity(RenderProjectionId Id);
private readonly record struct DirectionalShadowTransformChange(
ulong Revision,
DirectionalShadowTransformSnapshot Projection,
DirectionalShadowTransformChangeKind Kind);
private sealed class DirectionalShadowPartPoseSnapshot
{
private Matrix4x4[] _parts;
private DirectionalShadowPartPoseSnapshot(Matrix4x4[] parts) =>
_parts = parts;
internal int Count => _parts.Length;
internal static DirectionalShadowPartPoseSnapshot Capture(
in RenderProjectionRecord record)
{
IReadOnlyList<AcDream.Core.World.MeshRef>? meshes =
record.EntityPayload.MeshRefs;
var parts = new Matrix4x4[meshes?.Count ?? 0];
for (int index = 0; index < parts.Length; index++)
parts[index] = meshes![index].PartTransform;
return new DirectionalShadowPartPoseSnapshot(parts);
}
internal bool CaptureCurrent(in RenderProjectionRecord record)
{
IReadOnlyList<AcDream.Core.World.MeshRef>? meshes =
record.EntityPayload.MeshRefs;
int count = meshes?.Count ?? 0;
bool changed = _parts.Length != count;
if (changed)
_parts = new Matrix4x4[count];
for (int index = 0; index < count; index++)
{
Matrix4x4 current = meshes![index].PartTransform;
if (!MatrixBitsEqual(in _parts[index], in current))
{
_parts[index] = current;
changed = true;
}
}
return changed;
}
private static bool MatrixBitsEqual(
in Matrix4x4 left,
in Matrix4x4 right)
{
ReadOnlySpan<Matrix4x4> leftSpan = MemoryMarshal.CreateReadOnlySpan(
in left,
1);
ReadOnlySpan<Matrix4x4> rightSpan = MemoryMarshal.CreateReadOnlySpan(
in right,
1);
return MemoryMarshal.AsBytes(leftSpan).SequenceEqual(
MemoryMarshal.AsBytes(rightSpan));
}
}
private readonly record struct OutdoorStaticTag;
private readonly record struct IndoorCellStaticTag;

View file

@ -834,6 +834,22 @@ internal sealed class CurrentRenderSceneOracle :
geometry.Add(fingerprint.High);
}
internal static RenderSceneHash128
CreateDirectionalShadowTopologyFingerprint(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
StableRenderHash128 topology = StableRenderHash128.Create();
topology.Add(entity.MeshRefs.Count);
for (int meshIndex = 0; meshIndex < entity.MeshRefs.Count; meshIndex++)
{
MeshRef mesh = entity.MeshRefs[meshIndex];
topology.Add(mesh.GfxObjId);
AddSurfaceOverrides(ref topology, mesh.SurfaceOverrides);
}
return topology.Finish();
}
internal static RenderSceneHash128 CreateSurfaceOverrideFingerprint(
IReadOnlyDictionary<uint, uint>? overrides)
{

View file

@ -0,0 +1,542 @@
namespace AcDream.App.Rendering.Scene;
/// <summary>
/// Projection-level membership. Opaque versus alpha-cutout remains an exact
/// mesh-batch decision in the dispatcher; this product deliberately does not
/// guess from an entity's texture set.
/// </summary>
internal enum DirectionalShadowCasterKind : byte
{
OutdoorStatic,
Building,
AnimatedStatic,
LiveDynamic,
EquippedChild,
}
internal readonly record struct DirectionalShadowCaster(
RenderProjectionRecord Projection,
DirectionalShadowCasterKind Kind)
{
public bool UsesCurrentAnimatedTransforms =>
Projection.ProjectionClass
is RenderProjectionClass.ActiveAnimatedStatic
or RenderProjectionClass.LiveDynamicRoot
or RenderProjectionClass.EquippedChild;
}
internal readonly struct DirectionalShadowChangedPose
{
internal DirectionalShadowChangedPose(
int casterIndex,
in DirectionalShadowTransformSnapshot snapshot)
{
CasterIndex = casterIndex;
Snapshot = snapshot;
}
internal readonly int CasterIndex;
internal readonly DirectionalShadowTransformSnapshot Snapshot;
}
/// <summary>
/// Accepted caster counts by the strongest class proven at render publication.
/// TerrainCommands is populated by the terrain command producer. OutdoorStatics
/// includes trees and all other outdoor DAT scenery; NonPlayerCreatures includes
/// hostile monsters and non-hostile NPC creatures because neither source carries
/// a narrower authoritative render-only discriminator.
/// </summary>
internal readonly record struct DirectionalShadowCasterClassDiagnostics(
int TerrainCommands,
int OutdoorStatics,
int Buildings,
int AnimatedStatics,
int LocalPlayers,
int RemotePlayers,
int NonPlayerCreatures,
int OtherLiveDynamics,
int EquippedChildren);
internal readonly record struct DirectionalShadowCasterBuildStats(
int SourceOutdoorStatics,
int SourceOutdoorDynamics,
int Accepted,
int RejectedNotDrawable,
int RejectedNotResident,
int RejectedTransparent,
int RejectedIndoor,
int RejectedMissingMesh,
int IndexCopies,
int Classifications,
int DynamicTransformRefreshes,
bool TopologyRebuilt,
int CopiedTransformChanges = 0,
int DedupedChangedCasterSlots = 0,
bool TransformJournalFullRefresh = false,
int UpdateTransformChanges = 0,
int UpdateAppearanceChanges = 0,
int DynamicSynchronizationChanges = 0,
int ActiveAnimatedStaticChanges = 0,
int LiveDynamicRootChanges = 0,
int EquippedChildChanges = 0,
bool DensityBulkRefresh = false,
int BatchedProjectionCopyCalls = 0)
{
public DirectionalShadowCasterClassDiagnostics CasterClasses { get; init; }
}
/// <summary>
/// Reusable, streaming-bounded caster product. It copies and classifies the
/// render scene's two resident outdoor indices only when the scene's shadow
/// topology revision changes. Stable frames retain those topology records and
/// emit only deduplicated slim root/part pose changes for prepared matrix slots.
/// </summary>
internal sealed class DirectionalShadowCasterFrame
{
private RenderProjectionRecord[] _outdoorStaticScratch = [];
private RenderProjectionRecord[] _outdoorDynamicScratch = [];
private DirectionalShadowCaster[] _casters = [];
private int[] _refreshCasterSlots = [];
private DirectionalShadowChangedPose[] _changedCasterPoses = [];
private bool[] _changedCasterFlags = [];
private RenderProjectionId[] _casterIds = [];
private RenderProjectionClass[] _casterClasses = [];
private RenderProjectionId[] _denseIdScratch = [];
private RenderProjectionRecord[] _denseRecordScratch = [];
private readonly DirectionalShadowTransformSnapshot[] _transformChangeScratch =
new DirectionalShadowTransformSnapshot[
DirectionalShadowTransformChangeJournal.Capacity];
private readonly Dictionary<RenderProjectionId, int> _refreshCasterSlotById = [];
private int _casterCount;
private int _refreshCasterSlotCount;
private int _changedCasterPoseCount;
private ulong _topologyRevision;
private ulong _transformRevision;
private DirectionalShadowTransformChanges _lastTransformChanges;
private bool _lastDensityBulkRefresh;
private int _lastBatchedProjectionCopyCalls;
public RenderSceneGeneration Generation { get; private set; }
public ulong BuildSequence { get; private set; }
public ReadOnlySpan<DirectionalShadowCaster> Casters =>
_casters.AsSpan(0, _casterCount);
internal ReadOnlySpan<int> RefreshCasterSlots =>
_refreshCasterSlots.AsSpan(0, _refreshCasterSlotCount);
internal ReadOnlySpan<DirectionalShadowChangedPose> ChangedCasterPoses =>
_changedCasterPoses.AsSpan(0, _changedCasterPoseCount);
internal ulong TransformRevision => _transformRevision;
public DirectionalShadowCasterBuildStats Stats { get; private set; }
public long RetainedScratchBytes =>
checked(
(long)_outdoorStaticScratch.Length
* System.Runtime.CompilerServices.Unsafe.SizeOf<RenderProjectionRecord>()
+ (long)_outdoorDynamicScratch.Length
* System.Runtime.CompilerServices.Unsafe.SizeOf<RenderProjectionRecord>()
+ (long)_casters.Length
* System.Runtime.CompilerServices.Unsafe.SizeOf<DirectionalShadowCaster>()
+ (long)_refreshCasterSlots.Length * sizeof(int)
+ (long)_changedCasterPoses.Length
* System.Runtime.CompilerServices.Unsafe.SizeOf<
DirectionalShadowChangedPose>()
+ _changedCasterFlags.Length
+ (long)_casterIds.Length
* System.Runtime.CompilerServices.Unsafe.SizeOf<
RenderProjectionId>()
+ (long)_casterClasses.Length
* System.Runtime.CompilerServices.Unsafe.SizeOf<
RenderProjectionClass>()
+ (long)_denseIdScratch.Length
* System.Runtime.CompilerServices.Unsafe.SizeOf<RenderProjectionId>()
+ (long)_denseRecordScratch.Length
* System.Runtime.CompilerServices.Unsafe.SizeOf<RenderProjectionRecord>()
+ (long)_transformChangeScratch.Length
* System.Runtime.CompilerServices.Unsafe.SizeOf<
DirectionalShadowTransformSnapshot>()
+ (long)_refreshCasterSlotById.EnsureCapacity(0)
* (sizeof(int)
+ System.Runtime.CompilerServices.Unsafe.SizeOf<
KeyValuePair<RenderProjectionId, int>>()));
public void Build(in RenderSceneQuery query)
{
ulong topologyRevision = query.DirectionalShadowTopologyRevision;
if (BuildSequence != 0
&& Generation == query.Generation
&& _topologyRevision == topologyRevision)
{
int refreshes = RefreshChangedTransforms(in query);
Stats = Stats with
{
IndexCopies = 0,
Classifications = 0,
DynamicTransformRefreshes = refreshes,
TopologyRebuilt = false,
CopiedTransformChanges = _lastTransformChanges.Count,
DedupedChangedCasterSlots = _changedCasterPoseCount,
TransformJournalFullRefresh =
_lastTransformChanges.RequiresFullRefresh,
DensityBulkRefresh = _lastDensityBulkRefresh,
BatchedProjectionCopyCalls = _lastBatchedProjectionCopyCalls,
UpdateTransformChanges =
_lastTransformChanges.UpdateTransformCount,
UpdateAppearanceChanges =
_lastTransformChanges.UpdateAppearanceCount,
DynamicSynchronizationChanges =
_lastTransformChanges.DynamicSynchronizationCount,
ActiveAnimatedStaticChanges =
_lastTransformChanges.ActiveAnimatedStaticCount,
LiveDynamicRootChanges =
_lastTransformChanges.LiveDynamicRootCount,
EquippedChildChanges =
_lastTransformChanges.EquippedChildCount,
};
return;
}
RenderSceneIndexCounts counts = query.IndexCounts;
EnsureCapacity(ref _outdoorStaticScratch, counts.OutdoorStatic);
EnsureCapacity(ref _outdoorDynamicScratch, counts.OutdoorDynamic);
int staticCount = query.CopyIndexTo(
RenderSceneIndex.OutdoorStatic,
_outdoorStaticScratch.AsSpan(0, counts.OutdoorStatic));
int dynamicCount = query.CopyIndexTo(
RenderSceneIndex.OutdoorDynamic,
_outdoorDynamicScratch.AsSpan(0, counts.OutdoorDynamic));
EnsureCapacity(ref _casters, checked(staticCount + dynamicCount));
_casterCount = 0;
int rejectedNotDrawable = 0;
int rejectedNotResident = 0;
int rejectedTransparent = 0;
int rejectedIndoor = 0;
int rejectedMissingMesh = 0;
int outdoorStatics = 0;
int buildings = 0;
int animatedStatics = 0;
int localPlayers = 0;
int remotePlayers = 0;
int nonPlayerCreatures = 0;
int otherLiveDynamics = 0;
int equippedChildren = 0;
for (int i = 0; i < staticCount; i++)
Add(_outdoorStaticScratch[i]);
for (int i = 0; i < dynamicCount; i++)
Add(_outdoorDynamicScratch[i]);
Array.Sort(
_casters,
0,
_casterCount,
DirectionalShadowCasterComparer.Instance);
int refreshCasterCount = 0;
for (int casterIndex = 0; casterIndex < _casterCount; casterIndex++)
{
if (_casters[casterIndex].UsesCurrentAnimatedTransforms)
refreshCasterCount++;
}
EnsureCapacity(ref _refreshCasterSlots, refreshCasterCount);
EnsureCapacity(ref _changedCasterPoses, refreshCasterCount);
EnsureCapacity(ref _changedCasterFlags, _casterCount);
EnsureCapacity(ref _casterIds, _casterCount);
EnsureCapacity(ref _casterClasses, _casterCount);
EnsureCapacity(ref _denseIdScratch, refreshCasterCount);
EnsureCapacity(ref _denseRecordScratch, refreshCasterCount);
_refreshCasterSlotCount = 0;
_changedCasterPoseCount = 0;
_refreshCasterSlotById.Clear();
_refreshCasterSlotById.EnsureCapacity(refreshCasterCount);
for (int casterIndex = 0; casterIndex < _casterCount; casterIndex++)
{
_casterIds[casterIndex] = _casters[casterIndex].Projection.Id;
_casterClasses[casterIndex] =
_casters[casterIndex].Projection.ProjectionClass;
if (_casters[casterIndex].UsesCurrentAnimatedTransforms)
{
_refreshCasterSlots[_refreshCasterSlotCount++] = casterIndex;
_refreshCasterSlotById.Add(
_casterIds[casterIndex],
casterIndex);
}
}
Generation = query.Generation;
_topologyRevision = topologyRevision;
_transformRevision = query.DirectionalShadowTransformRevision;
_lastTransformChanges = default;
_lastDensityBulkRefresh = false;
_lastBatchedProjectionCopyCalls = 0;
BuildSequence = checked(BuildSequence + 1);
Stats = new DirectionalShadowCasterBuildStats(
staticCount,
dynamicCount,
_casterCount,
rejectedNotDrawable,
rejectedNotResident,
rejectedTransparent,
rejectedIndoor,
rejectedMissingMesh,
IndexCopies: 2,
Classifications: _casterCount,
DynamicTransformRefreshes: 0,
TopologyRebuilt: true)
{
CasterClasses = new DirectionalShadowCasterClassDiagnostics(
TerrainCommands: 0,
outdoorStatics,
buildings,
animatedStatics,
localPlayers,
remotePlayers,
nonPlayerCreatures,
otherLiveDynamics,
equippedChildren),
};
return;
void Add(in RenderProjectionRecord projection)
{
if ((projection.Flags & RenderProjectionFlags.Draw) == 0)
{
rejectedNotDrawable++;
return;
}
if ((projection.Flags & RenderProjectionFlags.SpatiallyResident) == 0)
{
rejectedNotResident++;
return;
}
// Transparent means a true blended projection. ClipMap/foliage is
// retained here and separated from opaque batches later.
if ((projection.Flags & RenderProjectionFlags.Translucent) != 0)
{
rejectedTransparent++;
return;
}
if (projection.Source.ParentCellId != 0
&& InteriorEntityPartition.IsIndoorCellId(
projection.Source.ParentCellId))
{
rejectedIndoor++;
return;
}
if (projection.MeshSet.MeshCount <= 0
|| projection.EntityPayload.MeshRefs is null
|| projection.EntityPayload.MeshRefs.Count == 0)
{
rejectedMissingMesh++;
return;
}
DirectionalShadowCasterKind kind = Classify(in projection);
_casters[_casterCount++] = new DirectionalShadowCaster(
projection,
kind);
switch (kind)
{
case DirectionalShadowCasterKind.OutdoorStatic:
outdoorStatics++;
break;
case DirectionalShadowCasterKind.Building:
buildings++;
break;
case DirectionalShadowCasterKind.AnimatedStatic:
animatedStatics++;
break;
case DirectionalShadowCasterKind.EquippedChild:
equippedChildren++;
break;
case DirectionalShadowCasterKind.LiveDynamic:
switch (projection.EntityPayload.CasterIdentity)
{
case RenderCasterIdentityKind.LocalPlayer:
localPlayers++;
break;
case RenderCasterIdentityKind.RemotePlayer:
remotePlayers++;
break;
case RenderCasterIdentityKind.NonPlayerCreature:
nonPlayerCreatures++;
break;
default:
otherLiveDynamics++;
break;
}
break;
default:
throw new ArgumentOutOfRangeException(
nameof(kind), kind, "Unknown shadow caster kind.");
}
}
}
private int RefreshChangedTransforms(in RenderSceneQuery query)
{
_changedCasterPoseCount = 0;
ulong latest = query.DirectionalShadowTransformRevision;
if (latest == _transformRevision)
{
_lastTransformChanges = new DirectionalShadowTransformChanges(
latest,
0,
false);
_lastDensityBulkRefresh = false;
_lastBatchedProjectionCopyCalls = 0;
return 0;
}
DirectionalShadowTransformChanges changes =
query.CopyDirectionalShadowTransformChanges(
_transformRevision,
_transformChangeScratch);
_lastTransformChanges = changes;
if (changes.RequiresFullRefresh)
{
_lastBatchedProjectionCopyCalls = 1;
for (int index = 0; index < _refreshCasterSlotCount; index++)
{
int casterIndex = _refreshCasterSlots[index];
_denseIdScratch[index] = _casters[casterIndex].Projection.Id;
}
int copied = query.CopyById(
_denseIdScratch.AsSpan(0, _refreshCasterSlotCount),
_denseRecordScratch.AsSpan(0, _refreshCasterSlotCount));
if (copied != _refreshCasterSlotCount)
{
throw new InvalidOperationException(
"Dense directional-shadow refresh returned an incomplete record batch.");
}
for (int index = 0; index < _refreshCasterSlotCount; index++)
{
int casterIndex = _refreshCasterSlots[index];
RefreshOne(in _denseRecordScratch[index], casterIndex);
DirectionalShadowTransformSnapshot snapshot =
DirectionalShadowTransformSnapshot.Capture(
in _denseRecordScratch[index]);
_changedCasterPoses[_changedCasterPoseCount++] =
new DirectionalShadowChangedPose(casterIndex, in snapshot);
}
_lastDensityBulkRefresh = false;
_transformRevision = changes.LatestRevision;
return _changedCasterPoseCount;
}
_lastBatchedProjectionCopyCalls = 0;
try
{
ReadOnlySpan<DirectionalShadowTransformSnapshot> records =
_transformChangeScratch.AsSpan(0, changes.Count);
// Newest-first makes repeated publications of the same projection
// resolve to the latest exact root/part payload without an ECS read.
for (int index = records.Length - 1; index >= 0; index--)
{
if (!_refreshCasterSlotById.TryGetValue(
records[index].Id,
out int casterIndex)
|| _changedCasterFlags[casterIndex])
{
continue;
}
_changedCasterFlags[casterIndex] = true;
ValidateStablePose(in records[index], casterIndex);
_changedCasterPoses[_changedCasterPoseCount++] =
new DirectionalShadowChangedPose(
casterIndex,
in records[index]);
}
}
finally
{
for (int index = 0; index < _changedCasterPoseCount; index++)
{
_changedCasterFlags[
_changedCasterPoses[index].CasterIndex] = false;
}
}
_lastDensityBulkRefresh = _refreshCasterSlotCount >= 64
&& _changedCasterPoseCount
>= checked((_refreshCasterSlotCount * 3) / 4);
_transformRevision = changes.LatestRevision;
return _changedCasterPoseCount;
}
private void ValidateStablePose(
in DirectionalShadowTransformSnapshot current,
int casterIndex)
{
if (current.Id != _casterIds[casterIndex]
|| current.ProjectionClass != _casterClasses[casterIndex])
{
throw new InvalidOperationException(
$"Stable directional-shadow topology changed caster "
+ $"{_casterIds[casterIndex]} identity or class.");
}
}
private void RefreshOne(
in RenderProjectionRecord current,
int casterIndex)
{
DirectionalShadowCaster retained = _casters[casterIndex];
if (current.Id != retained.Projection.Id
|| current.ProjectionClass != retained.Projection.ProjectionClass)
{
throw new InvalidOperationException(
$"Stable directional-shadow topology changed caster "
+ $"{retained.Projection.Id} identity or class.");
}
_casters[casterIndex] = retained with { Projection = current };
}
private static DirectionalShadowCasterKind Classify(
in RenderProjectionRecord projection)
{
if (projection.EntityPayload.IsBuildingShell)
return DirectionalShadowCasterKind.Building;
return projection.ProjectionClass switch
{
RenderProjectionClass.OutdoorStatic =>
DirectionalShadowCasterKind.OutdoorStatic,
RenderProjectionClass.ActiveAnimatedStatic =>
DirectionalShadowCasterKind.AnimatedStatic,
RenderProjectionClass.LiveDynamicRoot =>
DirectionalShadowCasterKind.LiveDynamic,
RenderProjectionClass.EquippedChild =>
DirectionalShadowCasterKind.EquippedChild,
_ => throw new InvalidOperationException(
$"Outdoor shadow index carried unsupported {projection.ProjectionClass}."),
};
}
private static void EnsureCapacity<T>(ref T[] values, int required)
{
if (required < 0)
throw new ArgumentOutOfRangeException(nameof(required));
if (values.Length >= required)
return;
int capacity = values.Length == 0 ? 4 : values.Length;
while (capacity < required)
capacity = checked(capacity * 2);
Array.Resize(ref values, capacity);
}
private sealed class DirectionalShadowCasterComparer
: IComparer<DirectionalShadowCaster>
{
public static DirectionalShadowCasterComparer Instance { get; } = new();
public int Compare(DirectionalShadowCaster left, DirectionalShadowCaster right)
{
int order = left.Projection.SortKey.Value.CompareTo(
right.Projection.SortKey.Value);
return order != 0
? order
: left.Projection.Id.CompareTo(right.Projection.Id);
}
}
}

View file

@ -1,5 +1,7 @@
using AcDream.App.Input;
using AcDream.App.Update;
using AcDream.App.World;
using AcDream.Core.Items;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
@ -26,6 +28,7 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
private readonly LiveEntityRuntime _runtime;
private readonly RenderProjectionJournal _journal;
private readonly IRenderTraversalOrderSource _traversalOrder;
private readonly ILocalPlayerIdentitySource? _localPlayer;
private readonly Dictionary<RuntimeEntityKey, TrackedProjection> _byKey = [];
private readonly List<LiveEntityRecord> _activeRootScratch = [];
private readonly List<TrackedProjection> _activeScratch = [];
@ -33,12 +36,14 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
public LiveRenderProjectionJournal(
LiveEntityRuntime runtime,
RenderProjectionJournal journal,
IRenderTraversalOrderSource traversalOrder)
IRenderTraversalOrderSource traversalOrder,
ILocalPlayerIdentitySource? localPlayer = null)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_journal = journal ?? throw new ArgumentNullException(nameof(journal));
_traversalOrder = traversalOrder
?? throw new ArgumentNullException(nameof(traversalOrder));
_localPlayer = localPlayer;
}
public int ProjectionCount => _byKey.Count;
@ -286,7 +291,14 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
ownerLandblockId,
fullCellId,
entity,
spatiallyVisible);
spatiallyVisible,
record.ProjectionKind is LiveEntityProjectionKind.Attached
? RenderCasterIdentityKind.EquippedChild
: RenderCasterIdentityClassifier.Classify(
record.Snapshot.Guid,
record.Snapshot.ItemType,
record.Snapshot.ObjectDescriptionFlags,
_localPlayer?.ServerGuid ?? 0u));
if (_traversalOrder.TryGetTraversalSortKey(
entity,
out RenderSortKey sortKey))
@ -336,6 +348,31 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
}
}
internal static class RenderCasterIdentityClassifier
{
private const uint PlayerDescriptionFlag = 0x8u;
private const uint PlayerGuidPrefix = 0x50000000u;
internal static RenderCasterIdentityKind Classify(
uint serverGuid,
uint? itemType,
uint? objectDescriptionFlags,
uint localPlayerGuid)
{
if (localPlayerGuid != 0 && serverGuid == localPlayerGuid)
return RenderCasterIdentityKind.LocalPlayer;
if ((objectDescriptionFlags.GetValueOrDefault()
& PlayerDescriptionFlag) != 0
|| (serverGuid & 0xFF000000u) == PlayerGuidPrefix)
{
return RenderCasterIdentityKind.RemotePlayer;
}
if ((itemType.GetValueOrDefault() & (uint)ItemType.Creature) != 0)
return RenderCasterIdentityKind.NonPlayerCreature;
return RenderCasterIdentityKind.OtherLiveDynamic;
}
}
internal sealed class LiveRenderProjectionResourceLifecycle(
ILiveRenderProjectionSink sink) : ILiveEntityResourceLifecycle
{

View file

@ -19,7 +19,9 @@ internal static class RenderProjectionRecordFactory
uint ownerLandblockId,
uint fullCellId,
WorldEntity entity,
bool spatiallyVisible)
bool spatiallyVisible,
RenderCasterIdentityKind casterIdentity =
RenderCasterIdentityKind.Unclassified)
{
ArgumentNullException.ThrowIfNull(entity);
CurrentRenderProjectionFingerprint fingerprint =
@ -80,11 +82,14 @@ internal static class RenderProjectionRecordFactory
fingerprint.Transform,
fingerprint.Geometry,
fingerprint.Appearance,
fingerprint.Flags),
fingerprint.Flags,
CurrentRenderSceneOracle
.CreateDirectionalShadowTopologyFingerprint(entity)),
new RenderEntityPayload(
entity.MeshRefs,
entity.PaletteOverride,
entity.IsBuildingShell));
entity.IsBuildingShell,
casterIdentity));
}
private static (Vector3 Minimum, Vector3 Maximum) CalculateBounds(

View file

@ -222,7 +222,26 @@ internal readonly record struct RenderSourceMetadata(
RenderSceneHash128 TransformFingerprint,
RenderSceneHash128 GeometryFingerprint,
RenderSceneHash128 AppearanceFingerprint,
uint CurrentProjectionFlags = 0);
uint CurrentProjectionFlags = 0,
RenderSceneHash128 DirectionalShadowTopologyFingerprint = default);
/// <summary>
/// Render-only identity facts retained from the authoritative publication edge.
/// Outdoor DAT scenery has no tree discriminator, and the create-object payload
/// does not distinguish hostile monsters from other non-player creatures, so
/// neither narrower category is guessed here.
/// </summary>
internal enum RenderCasterIdentityKind : byte
{
Unclassified,
OutdoorStatic,
Building,
LocalPlayer,
RemotePlayer,
NonPlayerCreature,
OtherLiveDynamic,
EquippedChild,
}
/// <summary>
/// Borrowed immutable presentation payload captured at a scene publication
@ -234,7 +253,9 @@ internal readonly record struct RenderSourceMetadata(
internal readonly record struct RenderEntityPayload(
IReadOnlyList<MeshRef> MeshRefs,
PaletteOverride? PaletteOverride,
bool IsBuildingShell);
bool IsBuildingShell,
RenderCasterIdentityKind CasterIdentity =
RenderCasterIdentityKind.Unclassified);
internal readonly record struct RenderProjectionRecord(
RenderProjectionId Id,
@ -449,6 +470,66 @@ internal readonly record struct RenderSceneDigest(
RenderProjectionCounts Counts,
RenderSceneHash128 Hash);
internal static class DirectionalShadowTransformChangeJournal
{
// Larger than the measured 9,498-caster dense-Arwic row so one complete
// changed-pose publication fits without truncation. Overflow is explicit
// and makes the consumer take its exact full-refresh fallback.
internal const int Capacity = 16_384;
}
internal readonly struct DirectionalShadowTransformSnapshot
{
internal DirectionalShadowTransformSnapshot(
RenderProjectionId id,
RenderProjectionClass projectionClass,
RenderTransform transform,
RenderEntityPayload entityPayload)
{
Id = id;
ProjectionClass = projectionClass;
Transform = transform;
EntityPayload = entityPayload;
}
internal readonly RenderProjectionId Id;
internal readonly RenderProjectionClass ProjectionClass;
internal readonly RenderTransform Transform;
internal readonly RenderEntityPayload EntityPayload;
internal static DirectionalShadowTransformSnapshot Capture(
in RenderProjectionRecord projection) =>
new(
projection.Id,
projection.ProjectionClass,
projection.Transform,
projection.EntityPayload);
}
internal readonly record struct DirectionalShadowTransformChanges(
ulong LatestRevision,
int Count,
bool RequiresFullRefresh,
int UpdateTransformCount = 0,
int UpdateAppearanceCount = 0,
int DynamicSynchronizationCount = 0,
int ActiveAnimatedStaticCount = 0,
int LiveDynamicRootCount = 0,
int EquippedChildCount = 0);
// Transform-journal records carry the producer's already-current projection.
// Root matrices are values; MeshRef payloads are borrowed under the render
// publication ordering rule: a part-pose mutation must be followed by its
// UpdateAppearance publication before the frame opens a scene query. Consumers
// read newest-to-oldest, so repeated IDs always select the latest publication.
internal enum DirectionalShadowTransformChangeKind : byte
{
UpdateTransform,
UpdateAppearance,
DynamicSynchronization,
}
internal sealed class RenderSceneDigestBuffer
{
internal List<RenderProjectionRecord> Records { get; } = [];
@ -461,12 +542,26 @@ internal interface IRenderSceneQuerySource
RenderProjectionCounts GetCounts(RenderSceneGeneration generation);
RenderSceneIndexCounts GetIndexCounts(RenderSceneGeneration generation);
ulong GetIndexRevision(RenderSceneGeneration generation);
ulong GetDirectionalShadowTopologyRevision(
RenderSceneGeneration generation);
ulong GetDirectionalShadowTransformRevision(
RenderSceneGeneration generation);
DirectionalShadowTransformChanges CopyDirectionalShadowTransformChanges(
RenderSceneGeneration generation,
ulong afterRevision,
Span<DirectionalShadowTransformSnapshot> destination);
bool TryGet(
RenderSceneGeneration generation,
RenderProjectionId id,
out RenderProjectionRecord record);
int CopyById(
RenderSceneGeneration generation,
ReadOnlySpan<RenderProjectionId> ids,
Span<RenderProjectionRecord> destination);
int CopyTo(
RenderSceneGeneration generation,
RenderProjectionClass? projectionClass,
@ -512,11 +607,30 @@ internal readonly struct RenderSceneQuery
public ulong IndexRevision =>
Source.GetIndexRevision(Generation);
public ulong DirectionalShadowTopologyRevision =>
Source.GetDirectionalShadowTopologyRevision(Generation);
public ulong DirectionalShadowTransformRevision =>
Source.GetDirectionalShadowTransformRevision(Generation);
public DirectionalShadowTransformChanges CopyDirectionalShadowTransformChanges(
ulong afterRevision,
Span<DirectionalShadowTransformSnapshot> destination) =>
Source.CopyDirectionalShadowTransformChanges(
Generation,
afterRevision,
destination);
public bool TryGet(
RenderProjectionId id,
out RenderProjectionRecord record) =>
Source.TryGet(Generation, id, out record);
public int CopyById(
ReadOnlySpan<RenderProjectionId> ids,
Span<RenderProjectionRecord> destination) =>
Source.CopyById(Generation, ids, destination);
public int CopyTo(Span<RenderProjectionRecord> destination) =>
Source.CopyTo(Generation, null, destination);

View file

@ -1,5 +1,6 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using AcDream.App.Input;
using AcDream.App.Rendering.Scene.Arch;
using AcDream.App.World;
using AcDream.Core.World;
@ -95,7 +96,8 @@ internal sealed class RenderSceneShadowRuntime : IDisposable
public LiveRenderProjectionJournal BindLiveRuntime(
LiveEntityRuntime runtime,
IRenderTraversalOrderSource traversalOrder)
IRenderTraversalOrderSource traversalOrder,
ILocalPlayerIdentitySource? localPlayer = null)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(runtime);
@ -109,7 +111,8 @@ internal sealed class RenderSceneShadowRuntime : IDisposable
_live = new LiveRenderProjectionJournal(
runtime,
_journal,
traversalOrder);
traversalOrder,
localPlayer);
return _live;
}

View file

@ -122,7 +122,9 @@ internal sealed class StaticRenderProjectionJournal :
&& accepted.Source.AppearanceFingerprint
== retained.Record.Source.AppearanceFingerprint
&& accepted.EntityPayload.IsBuildingShell
== retained.Record.EntityPayload.IsBuildingShell)
== retained.Record.EntityPayload.IsBuildingShell
&& accepted.EntityPayload.CasterIdentity
== retained.Record.EntityPayload.CasterIdentity)
{
accepted = accepted with
{
@ -245,7 +247,10 @@ internal sealed class StaticRenderProjectionJournal :
tracked.Record.Residency.OwnerLandblockId,
tracked.Record.Residency.FullCellId,
entity,
spatiallyVisible: true) with
spatiallyVisible: true,
casterIdentity: entity.IsBuildingShell
? RenderCasterIdentityKind.Building
: RenderCasterIdentityKind.OutdoorStatic) with
{
PreviousTransform = new PreviousRenderTransform(
tracked.Record.Transform.LocalToWorld),
@ -317,7 +322,10 @@ internal sealed class StaticRenderProjectionJournal :
landblockId,
fullCellId,
entity,
spatiallyVisible: true) with
spatiallyVisible: true,
casterIdentity: entity.IsBuildingShell
? RenderCasterIdentityKind.Building
: RenderCasterIdentityKind.OutdoorStatic) with
{
SortKey = sortKey,
};

View file

@ -12,7 +12,7 @@ namespace AcDream.App.Rendering.Selection;
/// </summary>
internal interface IWorldSceneSelectionFrame
{
void BeginFrame();
void BeginFrame(FrustumPlanes? preparedViewFrustum = null);
void CompleteFrame();
@ -76,7 +76,7 @@ internal sealed class RetailSelectionScene :
_lightingPulse.Clear();
}
public void BeginFrame()
public void BeginFrame(FrustumPlanes? preparedViewFrustum = null)
{
if (_frameOpen)
{
@ -87,7 +87,7 @@ internal sealed class RetailSelectionScene :
_frameOpen = true;
_building.Clear();
_buildingKeys.Clear();
_viewFrustum = null;
_viewFrustum = preparedViewFrustum;
_currentRenderSceneObserver?.BeginSelectionFrame();
}

View file

@ -0,0 +1,17 @@
#version 430 core
layout(location = 0) in vec2 vUv;
layout(location = 0) out vec4 oColor;
#include "atmospheric_common.glsl"
void main()
{
vec2 stepUv = uPackParams0.xy;
vec3 value = ACDREAM_SAMPLE_2D(uTextureIndexA, vUv).rgb * 0.227027;
value += ACDREAM_SAMPLE_2D(uTextureIndexA, vUv + stepUv * 1.384615).rgb * 0.316216;
value += ACDREAM_SAMPLE_2D(uTextureIndexA, vUv - stepUv * 1.384615).rgb * 0.316216;
value += ACDREAM_SAMPLE_2D(uTextureIndexA, vUv + stepUv * 3.230769).rgb * 0.070270;
value += ACDREAM_SAMPLE_2D(uTextureIndexA, vUv - stepUv * 3.230769).rgb * 0.070270;
oColor = vec4(value, 1.0);
}

View file

@ -0,0 +1,10 @@
#version 430 core
layout(location = 0) out vec2 vUv;
void main()
{
vec2 triangle = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
gl_Position = vec4(triangle * 2.0 - 1.0, 0.0, 1.0);
vUv = vec2(triangle.x, 1.0 - triangle.y);
}

View file

@ -0,0 +1,23 @@
#version 430 core
layout(location = 0) in vec2 vUv;
layout(location = 0) out vec4 oColor;
#include "atmospheric_common.glsl"
void main()
{
vec3 scene = ACDREAM_SAMPLE_2D(uTextureIndexA, vUv).rgb
+ ACDREAM_SAMPLE_2D(uTextureIndexB, vUv).rgb;
if (uPackParams0.w > 0.5)
scene += ACDREAM_SAMPLE_2D(uTextureIndexC, vUv).rgb;
float brightness = dot(scene, vec3(0.2126, 0.7152, 0.0722));
float threshold = uPackParams0.y;
float knee = max(uPackParams0.z, 0.0001);
float soft = clamp((brightness - threshold + knee) / (2.0 * knee), 0.0, 1.0);
soft = soft * soft;
float contribution = max(brightness - threshold, 0.0) + soft * knee;
contribution /= max(brightness, 0.0001);
vec3 bloom = scene * contribution * uPackParams0.x;
oColor = vec4(bloom, 1.0);
}

View file

@ -0,0 +1,10 @@
#version 430 core
layout(location = 0) out vec2 vUv;
void main()
{
vec2 triangle = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
gl_Position = vec4(triangle * 2.0 - 1.0, 0.0, 1.0);
vUv = vec2(triangle.x, 1.0 - triangle.y);
}

View file

@ -0,0 +1,34 @@
#ifndef ACDREAM_ATMOSPHERIC_COMMON_GLSL
#define ACDREAM_ATMOSPHERIC_COMMON_GLSL
// Render-pack shader ABI v1. These declarations are the byte-level SSOT for
// AtmosphericFrameUniforms (set 3/binding 5, 160 bytes) and
// AtmosphericPackPassUniforms (set 3/binding 7). Binding 6 is intentionally
// reserved for directional-shadow data in directional_shadow_common.glsl.
layout(std140, ACDREAM_PACK_UBO_SET binding = 5) uniform AtmosphericFrame {
vec4 uAtmosphereSunScreen; // 0: uv.xy, ray strength, elevation degrees
vec4 uAtmosphereSunColor; // 16: authored linear rgb, policy multiplier
vec4 uAtmosphereViewport; // 32: width, height, reciprocal width/height
vec4 uAtmosphereWeather; // 48: kind, intensity, delta seconds, outdoor
vec4 uAtmosphereSunDirection; // 64: surface-to-sun xyz, authored brightness
vec4 uAtmospherePolicy; // 80: day group, group factor, shadow/shaft elevation factors
mat4 uAtmosphereInverseViewProjection; // 96: screen/depth to world
};
layout(std140, ACDREAM_PACK_UBO_SET binding = 7) uniform PackPass {
vec4 uPackParams0; // 0
vec4 uPackParams1; // 16
vec4 uPackParams2; // 32
vec4 uPackParams3; // 48
};
// FusedAtmosphericPostProcess PackPass ABI (opt-in Low preset only):
// sun-rays: Params1 = (enabled, logical mask width, mask height, 0)
// filmic: Params1.z = enabled; Params2 = bloom extraction parameters;
// Params3.xy = logical bloom texel step
layout(std140, ACDREAM_PACK_UBO_SET binding = 8) uniform PackSettings {
vec4 uPackSettings[16]; // 64 declaration-order scalar setting slots
};
#endif

View file

@ -0,0 +1,88 @@
#version 430 core
layout(location = 0) in vec2 vUv;
layout(location = 0) out vec4 oColor;
#include "atmospheric_common.glsl"
vec3 acesFitted(vec3 value)
{
const float a = 2.51;
const float b = 0.03;
const float c = 2.43;
const float d = 0.59;
const float e = 0.14;
return clamp((value * (a * value + b)) / (value * (c * value + d) + e), 0.0, 1.0);
}
vec3 sampleBloom(vec2 uv)
{
return ACDREAM_SAMPLE_2D(uTextureIndexB, uv).rgb;
}
vec3 lowFusedScene(vec2 uv)
{
vec3 scene = ACDREAM_SAMPLE_2D(uTextureIndexA, uv).rgb
+ ACDREAM_SAMPLE_2D(uTextureIndexB, uv).rgb;
if (uPackParams2.w > 0.5)
scene += ACDREAM_SAMPLE_2D(uTextureIndexC, uv).rgb;
return scene;
}
vec3 lowFusedBloomExtract(vec3 scene)
{
float brightness = dot(scene, vec3(0.2126, 0.7152, 0.0722));
float threshold = uPackParams2.y;
float knee = max(uPackParams2.z, 0.0001);
float soft = clamp((brightness - threshold + knee) / (2.0 * knee), 0.0, 1.0);
soft = soft * soft;
float contribution = max(brightness - threshold, 0.0) + soft * knee;
contribution /= max(brightness, 0.0001);
return scene * contribution * uPackParams2.x;
}
vec3 lowFusedBloom(vec3 centerScene)
{
const float offsets[5] = float[5](
-3.230769, -1.384615, 0.0, 1.384615, 3.230769);
const float weights[5] = float[5](
0.070270, 0.316216, 0.227027, 0.316216, 0.070270);
vec3 bloom = vec3(0.0);
for (int y = 0; y < 5; ++y) {
for (int x = 0; x < 5; ++x) {
vec3 scene = x == 2 && y == 2
? centerScene
: lowFusedScene(vUv + vec2(offsets[x], offsets[y]) * uPackParams3.xy);
bloom += lowFusedBloomExtract(scene) * (weights[x] * weights[y]);
}
}
return bloom;
}
void main()
{
vec3 hdr;
if (uPackParams1.z > 0.5) {
vec3 scene = lowFusedScene(vUv);
hdr = scene + lowFusedBloom(scene);
}
else {
hdr = ACDREAM_SAMPLE_2D(uTextureIndexA, vUv).rgb
+ sampleBloom(vUv)
+ ACDREAM_SAMPLE_2D(uTextureIndexC, vUv).rgb;
if (uPackParams1.y > 0.5)
hdr += ACDREAM_SAMPLE_2D(uTextureIndexD, vUv).rgb;
}
vec3 exposed = max(hdr * uPackParams0.x, vec3(0.0));
vec3 linearClamped = clamp(exposed, 0.0, 1.0);
vec3 color = mix(linearClamped, acesFitted(exposed), clamp(uPackParams1.x, 0.0, 1.0));
float luminance = dot(color, vec3(0.2126, 0.7152, 0.0722));
color = mix(vec3(luminance), color, uPackParams0.y);
color = (color - 0.5) * uPackParams0.z + 0.5;
vec2 centered = vUv * 2.0 - 1.0;
float vignette = smoothstep(1.25, 0.25, dot(centered, centered));
color *= mix(1.0, vignette, clamp(uPackParams0.w, 0.0, 1.0));
oColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}

View file

@ -0,0 +1,10 @@
#version 430 core
layout(location = 0) out vec2 vUv;
void main()
{
vec2 triangle = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
gl_Position = vec4(triangle * 2.0 - 1.0, 0.0, 1.0);
vUv = vec2(triangle.x, 1.0 - triangle.y);
}

View file

@ -0,0 +1,14 @@
#version 430 core
layout(location = 0) in vec2 vUv;
layout(location = 0) out vec4 oColor;
#include "atmospheric_common.glsl"
void main()
{
float depth = ACDREAM_SAMPLE_2D(uTextureIndexA, vUv).r;
float unobstructedSky = smoothstep(0.9975, 0.99995, depth);
float enabled = uAtmosphereSunScreen.z * uAtmosphereWeather.w;
oColor = vec4(vec3(unobstructedSky * enabled), 1.0);
}

View file

@ -0,0 +1,12 @@
#version 430 core
layout(location = 0) out vec2 vUv;
void main()
{
vec2 triangle = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
gl_Position = vec4(triangle * 2.0 - 1.0, 0.0, 1.0);
// Vulkan's negative viewport preserves GL world winding; flip the sampled
// image coordinate once here so row zero remains the screen top.
vUv = vec2(triangle.x, 1.0 - triangle.y);
}

Some files were not shown because too many files have changed in this diff Show more